Skip to content

Latest commit

 

History

History
307 lines (232 loc) · 12.6 KB

File metadata and controls

307 lines (232 loc) · 12.6 KB

Network Inventory Tool

A host-discovery and local-inventory tool that sweeps an authorized subnet for live hosts (ICMP/TCP) and enumerates the local machine's interfaces, addresses, and open connections with psutil.

Project Overview

Asset inventory is the foundation of both offense and defense: you cannot secure what you cannot see. This project does two things — it sweeps a CIDR subnet you own to find responsive hosts (via a ping helper and a TCP fallback), and it inventories the local machine (interfaces, IPs, MACs, and listening sockets) using psutil. The subnet sweep is threaded for speed and uses ipaddress to expand the range safely.

[!warning] Authorized use only Sweep only networks you own or are authorized to inventory. Host discovery is active reconnaissance. Examples default to your own local subnet and localhost.

Objectives

  • Enumerate local network interfaces, addresses, and routes.
  • List listening sockets and map them to owning processes.
  • Discover live hosts on an authorized local subnet.
  • Produce a repeatable inventory suitable for change detection.
  • Distinguish local introspection from active network probing.

Requirements

Item Detail
Python 3.9 or newer
Dependencies psutil (python3 -m pip install psutil); socket, ipaddress from the standard library
Privileges None for local inventory; root to see process names for other users' sockets
Target The local host, and a lab subnet you own for discovery
Note Local inventory is passive; host discovery is active

Architecture

  • CLIargparse subcommands: sweep <cidr> and local.
  • Range expanderipaddress.ip_network() yields host addresses.
  • Host prober — a TCP connect to a common port (fallback when ICMP needs root), threaded via ThreadPoolExecutor.
  • Local inventorypsutil.net_if_addrs() for interfaces and psutil.net_connections() for sockets.
  • Reporter — prints live hosts and a local summary table.
sweep:  CIDR ─▶ hosts ─▶ ThreadPool(tcp_alive) ─▶ live host list
local:  psutil ─▶ interfaces + addresses + listening sockets ─▶ table

Directory Structure

network-inventory/
├── netinv.py           # CLI (sweep / local)
└── requirements.txt    # psutil

Implementation Plan

  1. List interfaces. Use psutil.net_if_addrs() and net_if_stats() for addresses, netmasks, and link state.
  2. List listening sockets. psutil.net_connections(kind="inet"), filtering to LISTEN.
  3. Map to processes. Resolve each socket's PID to a process name and command line, handling processes that vanish mid-scan.
  4. Flag exposure. Highlight anything bound to 0.0.0.0 or :: rather than loopback — that is the finding that matters.
  5. Derive the subnet. Combine address and netmask with ipaddress.ip_network() to get the local network.
  6. Discover hosts. Optionally sweep the subnet with a bounded TCP connect check on common ports.
  7. Emit a stable inventory. Sort deterministically and write JSON so two runs can be diffed.
  8. Diff mode. Compare against a previous inventory and report what appeared or disappeared.

Complete Example Implementation

#!/usr/bin/env python3
"""netinv.py - Subnet host discovery + local network inventory (educational).

Sweep only networks you own or are authorized to inventory.
"""
import argparse
import ipaddress
import socket
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed

import psutil


def tcp_alive(ip: str, ports: tuple[int, ...], timeout: float) -> str | None:
    """Return ip if any probe port accepts a TCP connection, else None."""
    for port in ports:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
            sock.settimeout(timeout)
            if sock.connect_ex((ip, port)) == 0:
                return ip
    return None


def sweep(cidr: str, ports: tuple[int, ...], threads: int, timeout: float) -> list[str]:
    """Threaded TCP host discovery across a CIDR range."""
    net = ipaddress.ip_network(cidr, strict=False)
    hosts = [str(ip) for ip in net.hosts()]
    print(f"[*] Sweeping {cidr} ({len(hosts)} host(s)) on ports {ports}")
    live: list[str] = []
    with ThreadPoolExecutor(max_workers=threads) as pool:
        futures = [pool.submit(tcp_alive, ip, ports, timeout) for ip in hosts]
        for future in as_completed(futures):
            result = future.result()
            if result:
                live.append(result)
                print(f"[+] up: {result}")
    return sorted(live, key=lambda ip: ipaddress.ip_address(ip))


def local_inventory() -> None:
    """Print local interfaces, addresses, and listening sockets via psutil."""
    print("[*] Interfaces and addresses:")
    for name, addrs in psutil.net_if_addrs().items():
        for addr in addrs:
            fam = addr.family.name if hasattr(addr.family, "name") else addr.family
            print(f"    {name:<12} {str(fam):<10} {addr.address}")

    print("\n[*] Listening sockets:")
    for conn in psutil.net_connections(kind="inet"):
        if conn.status == psutil.CONN_LISTEN and conn.laddr:
            print(f"    {conn.laddr.ip}:{conn.laddr.port:<6} pid={conn.pid}")


def main() -> int:
    parser = argparse.ArgumentParser(description="Network inventory tool.")
    sub = parser.add_subparsers(dest="command", required=True)

    sweep_p = sub.add_parser("sweep", help="discover live hosts on a subnet")
    sweep_p.add_argument("cidr", help="target subnet, e.g. 192.168.1.0/24")
    sweep_p.add_argument("-t", "--threads", type=int, default=100)
    sweep_p.add_argument("--timeout", type=float, default=0.5)
    sweep_p.add_argument("--ports", default="22,80,443,445,3389",
                         help="probe ports (default: 22,80,443,445,3389)")

    sub.add_parser("local", help="inventory the local machine")

    args = parser.parse_args()
    if args.command == "sweep":
        ports = tuple(int(p) for p in args.ports.split(",") if p.strip())
        live = sweep(args.cidr, ports, args.threads, args.timeout)
        print(f"\n[*] {len(live)} live host(s).")
    elif args.command == "local":
        local_inventory()
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except ValueError as exc:
        print(f"[!] {exc}", file=sys.stderr)
        raise SystemExit(2)
pip install psutil
python netinv.py local
python netinv.py sweep 192.168.1.0/24 -t 200 --timeout 0.3

Output

[*] Interfaces and addresses:
    lo           AF_INET    127.0.0.1
    eth0         AF_INET    192.168.1.42
    eth0         AF_PACKET  02:42:ac:11:00:02

[*] Listening sockets:
    127.0.0.1:631    pid=812
    0.0.0.0:22       pid=749

Usage

# Local inventory only (passive)
python3 netinv.py --local

# Include listening sockets with process names (root shows all)
sudo python3 netinv.py --local --sockets

# Active discovery on an authorized lab subnet
python3 netinv.py --discover 192.168.56.0/24 --workers 32

# Save and diff
python3 netinv.py --local --json > today.json
python3 netinv.py --diff yesterday.json today.json

Example Output

$ python3 netinv.py --local --sockets

INTERFACES
lo        127.0.0.1/8          up    loopback
eth0      192.168.56.10/24     up    1000 Mbps

LISTENING SOCKETS
PROTO  LOCAL ADDRESS        PID    PROCESS       EXPOSURE
tcp    127.0.0.1:631        1204   cupsd         loopback only
tcp    0.0.0.0:22           980    sshd          ALL INTERFACES
tcp    0.0.0.0:8000         18342  python3       ALL INTERFACES
udp    0.0.0.0:68           742    dhclient      ALL INTERFACES

[!] 3 services bound to all interfaces - confirm each is intended
$ python3 netinv.py --diff yesterday.json today.json
[+] NEW LISTENER   0.0.0.0:8000  python3
[-] GONE           127.0.0.1:3306  mysqld

Error Handling

Condition Exception Response
Insufficient privileges for socket details psutil.AccessDenied Show the socket without the process name and note that root reveals more
Process exits mid-enumeration psutil.NoSuchProcess Skip that entry; the process table is live
psutil not installed ModuleNotFoundError Explain the install command; exit 2
Interface has no address KeyError Skip the address family rather than failing
Invalid CIDR supplied ValueError Report the malformed network and exit 2
Discovery target unreachable socket.timeout Treat as "no response", continue the sweep
Previous inventory missing for diff FileNotFoundError Report the path and exit 2

Security Considerations

[!warning] Local inventory is passive; discovery is not Enumerating your own host is safe. Sweeping a subnet is active scanning — only do it on a network you own or are explicitly authorized to test.

  • Services bound to 0.0.0.0 are the finding. A development server on all interfaces is reachable by everyone on the network; that is the most common real issue this tool surfaces.
  • Never run subnet discovery on a network you do not control — including shared office, hotel, or cloud networks where other tenants are present.
  • The inventory is highly sensitive. It lists every listening service, its version-bearing process, and the local topology. Treat it as confidential and store it accordingly.
  • Root reveals more, so run it deliberately. Elevated enumeration exposes other users' processes; only do it where you are authorized to see that.
  • Diffing is the real value. A new listener appearing between two runs is a strong signal — of a legitimate deployment or of something you did not expect.
  • Do not chain automatically into port scanning. Discovery and probing are separate authorization decisions.

Testing

from unittest.mock import MagicMock, patch

import netinv


def test_flags_all_interface_binding():
    assert netinv.exposure("0.0.0.0") == "ALL INTERFACES"
    assert netinv.exposure("127.0.0.1") == "loopback only"
    assert netinv.exposure("::") == "ALL INTERFACES"


def test_handles_access_denied():
    import psutil
    with patch("psutil.Process", side_effect=psutil.AccessDenied(pid=1)):
        assert netinv.process_name(1) == "unknown"


def test_handles_vanished_process():
    import psutil
    with patch("psutil.Process", side_effect=psutil.NoSuchProcess(pid=1)):
        assert netinv.process_name(1) == "unknown"


def test_subnet_derived_from_address_and_mask():
    assert str(netinv.subnet_of("192.168.56.10", "255.255.255.0")) == "192.168.56.0/24"


def test_diff_reports_new_and_gone():
    before = {"listeners": ["127.0.0.1:3306"]}
    after = {"listeners": ["0.0.0.0:8000"]}
    result = netinv.diff(before, after)
    assert result["new"] == ["0.0.0.0:8000"]
    assert result["gone"] == ["127.0.0.1:3306"]

Mock psutil throughout — tests must not depend on the host's actual network state.

Extension Ideas

  • True ICMP ping — use raw sockets (root) or shell out to ping for hosts that block all TCP.
  • ARP sweep — on the local L2 segment, use scapy ARP requests for faster, stealthier discovery.
  • MAC vendor lookup — resolve OUIs to vendor names for asset classification.
  • Port/service follow-up — feed discovered hosts into [[Port-Scanner]] and [[Banner-Grabber]].
  • Export — write inventory to CSV/JSON for a CMDB or diffing over time.

Troubleshooting

Symptom Likely cause Fix
Process names show as unknown Sockets owned by other users Run with sudo to see the full picture
ModuleNotFoundError: psutil Not installed in the active venv python3 -m pip install psutil
No interfaces listed Running in a restricted container Expected — container namespaces limit visibility
Discovery finds nothing Wrong subnet, or host firewalls dropping probes Verify the CIDR; test one host manually
Inventory differs between identical runs Ephemeral ports and short-lived processes Filter to listening sockets and sort deterministically
AccessDenied floods the output Enumerating without privileges Handle it per socket and summarise the count

References

Related

  • [[Port-Scanner]]
  • [[Banner-Grabber]]
  • [[Log-Analyzer]]
  • [[Socket-Programming-for-Networking]]
  • [[Mini-Projects/Readme|Mini-Projects]] — module index
  • [[Readme|Python for Security Professionals]] — course home