devaudit-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dev_audit/__init__.py ADDED
File without changes
dev_audit/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from dev_audit.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
dev_audit/audit.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def check_devtools(results: dict) -> list[str]:
5
+ findings: list[str] = []
6
+
7
+ devtools = results.get("devtools", {})
8
+ warnings = devtools.get("warnings", [])
9
+
10
+ findings.extend(warnings)
11
+
12
+ return findings
13
+
14
+
15
+ def check_network(results: dict) -> list[str]:
16
+ findings: list[str] = []
17
+
18
+ network = results.get("network", {})
19
+ data = network.get("data", {})
20
+
21
+ ports = data.get("listening_ports", [])
22
+
23
+ for port in ports:
24
+ protocol = port.get("protocol")
25
+ address = port.get("local_address", "")
26
+
27
+ if protocol == "tcp" and address.startswith("*:22"):
28
+ findings.append(
29
+ "SSH is listening on all interfaces."
30
+ )
31
+
32
+ if protocol == "tcp" and address.startswith("0.0.0.0:80"):
33
+ findings.append(
34
+ "HTTP service exposed on all IPv4 interfaces."
35
+ )
36
+
37
+ if protocol == "tcp" and address.startswith("[::]:80"):
38
+ findings.append(
39
+ "HTTP service exposed on all IPv6 interfaces."
40
+ )
41
+
42
+ return findings
43
+
44
+
45
+ def run_audit(results: dict) -> list[str]:
46
+ findings: list[str] = []
47
+
48
+ findings.extend(check_devtools(results))
49
+ findings.extend(check_network(results))
50
+
51
+ return findings
dev_audit/cli.py ADDED
@@ -0,0 +1,110 @@
1
+ import argparse
2
+
3
+ from dev_audit.collectors.devtools import collect_devtools_info
4
+ from dev_audit.collectors.network import collect_network_info
5
+ from dev_audit.collectors.resources import collect_resource_info
6
+ from dev_audit.collectors.system import collect_system_info
7
+ from dev_audit.output import (
8
+ print_devtools,
9
+ print_findings,
10
+ print_json,
11
+ print_network,
12
+ print_resources,
13
+ print_system,
14
+ )
15
+ from dev_audit.audit import run_audit
16
+ from dev_audit.status import determine_exit_code
17
+
18
+ VALID_SECTIONS = ("system", "resources", "network", "devtools")
19
+
20
+
21
+ def build_parser() -> argparse.ArgumentParser:
22
+ parser = argparse.ArgumentParser(
23
+ prog="dev-audit",
24
+ description="Inspect Linux system health and developer environment setup.",
25
+ )
26
+
27
+ parser.add_argument(
28
+ "section",
29
+ nargs="?",
30
+ choices=VALID_SECTIONS,
31
+ help="Audit section to run. If omitted, all sections run.",
32
+ )
33
+
34
+ parser.add_argument(
35
+ "--json",
36
+ action="store_true",
37
+ help="Output results as JSON.",
38
+ )
39
+
40
+ parser.add_argument(
41
+ "--verbose",
42
+ action="store_true",
43
+ help="Show detailed diagnostic information.",
44
+ )
45
+
46
+ return parser
47
+
48
+
49
+ def run_full_audit(
50
+ json_output: bool = False,
51
+ verbose: bool = False,
52
+ ) -> int:
53
+ results = {
54
+ "system": collect_system_info(),
55
+ "resources": collect_resource_info(),
56
+ "network": collect_network_info(),
57
+ "devtools": collect_devtools_info(),
58
+ }
59
+
60
+ findings = run_audit(results)
61
+
62
+ if json_output:
63
+ results["findings"] = findings
64
+ print_json(results)
65
+ return determine_exit_code(results, findings)
66
+
67
+ print_system(results["system"], verbose=verbose)
68
+ print()
69
+
70
+ print_resources(results["resources"], verbose=verbose)
71
+ print()
72
+
73
+ print_network(results["network"], verbose=verbose)
74
+ print()
75
+
76
+ print_devtools(results["devtools"], verbose=verbose)
77
+
78
+ print_findings(findings)
79
+ return determine_exit_code(results, findings)
80
+
81
+ def main() -> int:
82
+ parser = build_parser()
83
+ args = parser.parse_args()
84
+
85
+ if args.section is None:
86
+ return run_full_audit(
87
+ json_output=args.json,
88
+ verbose=args.verbose,
89
+ )
90
+ return
91
+
92
+ if args.section == "system":
93
+ result = collect_system_info()
94
+ print_json(result) if args.json else print_system(result, verbose=args.verbose)
95
+ return 0
96
+
97
+ if args.section == "resources":
98
+ result = collect_resource_info()
99
+ print_json(result) if args.json else print_resources(result, verbose=args.verbose)
100
+ return 0
101
+
102
+ if args.section == "network":
103
+ result = collect_network_info()
104
+ print_json(result) if args.json else print_network(result, verbose=args.verbose)
105
+ return 0
106
+
107
+ if args.section == "devtools":
108
+ result = collect_devtools_info()
109
+ print_json(result) if args.json else print_devtools(result, verbose=args.verbose)
110
+ return 0
File without changes
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+
5
+ from dev_audit.command import run_command
6
+
7
+
8
+ def inspect_tool(
9
+ name: str,
10
+ executable: str,
11
+ version_command: list[str],
12
+ ) -> dict[str, object]:
13
+ path = shutil.which(executable)
14
+
15
+ if path is None:
16
+ return {
17
+ "name": name,
18
+ "installed": False,
19
+ "version": None,
20
+ "path": None,
21
+ }
22
+
23
+ version_output = run_command(version_command)
24
+
25
+ version = None
26
+
27
+ if version_output["ok"]:
28
+ version = (
29
+ version_output.get("stdout")
30
+ or version_output.get("stderr")
31
+ )
32
+
33
+ if version:
34
+ version = version.strip()
35
+
36
+ return {
37
+ "name": name,
38
+ "installed": True,
39
+ "version": version,
40
+ "path": path,
41
+ }
42
+
43
+
44
+ def collect_devtools_info() -> dict[str, object]:
45
+ tools = [
46
+ inspect_tool(
47
+ name="Python",
48
+ executable="python3",
49
+ version_command=["python3", "--version"],
50
+ ),
51
+ inspect_tool(
52
+ name="pip",
53
+ executable="pip",
54
+ version_command=["pip", "--version"],
55
+ ),
56
+ inspect_tool(
57
+ name="Git",
58
+ executable="git",
59
+ version_command=["git", "--version"],
60
+ ),
61
+ inspect_tool(
62
+ name="Docker",
63
+ executable="docker",
64
+ version_command=["docker", "--version"],
65
+ ),
66
+ inspect_tool(
67
+ name="Docker Compose",
68
+ executable="docker",
69
+ version_command=["docker", "compose", "version"],
70
+ ),
71
+ inspect_tool(
72
+ name="Node.js",
73
+ executable="node",
74
+ version_command=["node", "--version"],
75
+ ),
76
+ inspect_tool(
77
+ name="npm",
78
+ executable="npm",
79
+ version_command=["npm", "--version"],
80
+ ),
81
+ ]
82
+
83
+ warnings: list[str] = []
84
+
85
+ node_installed = False
86
+ npm_installed = False
87
+
88
+ for tool in tools:
89
+ if tool["name"] == "Node.js":
90
+ node_installed = bool(tool["installed"])
91
+
92
+ if tool["name"] == "npm":
93
+ npm_installed = bool(tool["installed"])
94
+
95
+ path = tool.get("path")
96
+
97
+ if isinstance(path, str) and path.startswith("/mnt/c/"):
98
+ warnings.append(
99
+ "npm is being resolved from a Windows installation."
100
+ )
101
+
102
+ if npm_installed and not node_installed:
103
+ warnings.append(
104
+ "npm detected but Node.js executable not found."
105
+ )
106
+
107
+ return {
108
+ "success": True,
109
+ "data": {
110
+ "tools": tools,
111
+ },
112
+ "warnings": warnings,
113
+ }
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from dev_audit.command import command_exists, run_command
6
+
7
+
8
+ def read_dns_servers() -> list[str]:
9
+ path = Path("/etc/resolv.conf")
10
+ servers: list[str] = []
11
+
12
+ try:
13
+ for line in path.read_text().splitlines():
14
+ line = line.strip()
15
+
16
+ if line.startswith("nameserver"):
17
+ parts = line.split()
18
+ if len(parts) >= 2:
19
+ servers.append(parts[1])
20
+
21
+ except OSError:
22
+ return []
23
+
24
+ return servers
25
+
26
+
27
+ def parse_interfaces(ip_addr_output: str) -> list[dict[str, str]]:
28
+ interfaces: list[dict[str, str]] = []
29
+ current_interface: dict[str, str] | None = None
30
+
31
+ for line in ip_addr_output.splitlines():
32
+ line = line.strip()
33
+
34
+ if not line:
35
+ continue
36
+
37
+ if ": " in line and line[0].isdigit():
38
+ parts = line.split(":", 2)
39
+
40
+ if len(parts) < 2:
41
+ continue
42
+
43
+ name = parts[1].strip()
44
+
45
+ current_interface = {
46
+ "name": name,
47
+ "ipv4": "",
48
+ }
49
+
50
+ interfaces.append(current_interface)
51
+
52
+ elif line.startswith("inet ") and current_interface is not None:
53
+ ip = line.split()[1].split("/")[0]
54
+ current_interface["ipv4"] = ip
55
+
56
+ return interfaces
57
+
58
+
59
+ def parse_default_route(route_output: str) -> dict[str, str] | None:
60
+ for line in route_output.splitlines():
61
+ if not line.startswith("default"):
62
+ continue
63
+
64
+ parts = line.split()
65
+
66
+ try:
67
+ gateway = parts[2]
68
+ interface = parts[4]
69
+ except IndexError:
70
+ return None
71
+
72
+ return {
73
+ "gateway": gateway,
74
+ "interface": interface,
75
+ }
76
+
77
+ return None
78
+
79
+
80
+ def parse_listening_ports(ss_output: str) -> list[dict[str, str]]:
81
+ ports: list[dict[str, str]] = []
82
+
83
+ for line in ss_output.splitlines():
84
+ line = line.strip()
85
+
86
+ if not line:
87
+ continue
88
+
89
+ if line.startswith("Netid"):
90
+ continue
91
+
92
+ parts = line.split()
93
+
94
+ if len(parts) < 5:
95
+ continue
96
+
97
+ ports.append(
98
+ {
99
+ "protocol": parts[0],
100
+ "local_address": parts[4],
101
+ }
102
+ )
103
+
104
+ return ports
105
+
106
+ def collect_network_info() -> dict[str, object]:
107
+ warnings: list[str] = []
108
+
109
+ ip_addr = None
110
+ ip_route = None
111
+ listening_ports = None
112
+
113
+ if not command_exists("ip"):
114
+ warnings.append("Missing required command: ip")
115
+ else:
116
+ ip_addr = run_command(["ip", "addr"])
117
+ ip_route = run_command(["ip", "route"])
118
+
119
+ if not ip_addr["ok"]:
120
+ warnings.append("Failed to run: ip addr")
121
+
122
+ if not ip_route["ok"]:
123
+ warnings.append("Failed to run: ip route")
124
+
125
+ if not command_exists("ss"):
126
+ warnings.append("Missing required command: ss")
127
+ else:
128
+ listening_ports = run_command(["ss", "-tuln"])
129
+
130
+ if not listening_ports["ok"]:
131
+ warnings.append("Failed to run: ss -tuln")
132
+
133
+ dns_servers = read_dns_servers()
134
+
135
+ if not dns_servers:
136
+ warnings.append("Could not read DNS servers from /etc/resolv.conf")
137
+
138
+ interfaces = (
139
+ parse_interfaces(ip_addr["stdout"])
140
+ if ip_addr and ip_addr["stdout"]
141
+ else []
142
+ )
143
+
144
+ default_route = (
145
+ parse_default_route(ip_route["stdout"])
146
+ if ip_route and ip_route["stdout"]
147
+ else None
148
+ )
149
+
150
+ parsed_ports = (
151
+ parse_listening_ports(listening_ports["stdout"])
152
+ if listening_ports and listening_ports["stdout"]
153
+ else []
154
+ )
155
+
156
+ data = {
157
+ "interfaces": interfaces,
158
+ "default_route": default_route,
159
+ "dns_servers": dns_servers,
160
+ "listening_ports": parsed_ports,
161
+ }
162
+
163
+ return {
164
+ "success": bool(
165
+ interfaces
166
+ or default_route
167
+ or dns_servers
168
+ or parsed_ports
169
+ ),
170
+ "data": data,
171
+ "warnings": warnings,
172
+ }
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+
8
+ def read_load_average() -> dict[str, float] | None:
9
+ path = Path("/proc/loadavg")
10
+
11
+ try:
12
+ values = path.read_text().split()
13
+ return {
14
+ "1_min": float(values[0]),
15
+ "5_min": float(values[1]),
16
+ "15_min": float(values[2]),
17
+ }
18
+ except (OSError, IndexError, ValueError):
19
+ return None
20
+
21
+
22
+ def read_meminfo() -> dict[str, int]:
23
+ path = Path("/proc/meminfo")
24
+ data: dict[str, int] = {}
25
+
26
+ try:
27
+ for line in path.read_text().splitlines():
28
+ key, value = line.split(":", 1)
29
+ parts = value.strip().split()
30
+
31
+ if not parts:
32
+ continue
33
+
34
+ data[key] = int(parts[0]) * 1024
35
+
36
+ except (OSError, ValueError):
37
+ return {}
38
+
39
+ return data
40
+
41
+
42
+ def bytes_to_gib(value: int | None) -> float | None:
43
+ if value is None:
44
+ return None
45
+
46
+ return round(value / (1024**3), 2)
47
+
48
+
49
+ def get_memory_usage(meminfo: dict[str, int]) -> dict[str, float | None]:
50
+ total = meminfo.get("MemTotal")
51
+ available = meminfo.get("MemAvailable")
52
+
53
+ used = None
54
+ percent = None
55
+
56
+ if total is not None and available is not None:
57
+ used = total - available
58
+ percent = round((used / total) * 100, 1)
59
+
60
+ return {
61
+ "total_gib": bytes_to_gib(total),
62
+ "available_gib": bytes_to_gib(available),
63
+ "used_gib": bytes_to_gib(used),
64
+ "used_percent": percent,
65
+ }
66
+
67
+
68
+ def get_swap_usage(meminfo: dict[str, int]) -> dict[str, float | None]:
69
+ total = meminfo.get("SwapTotal")
70
+ free = meminfo.get("SwapFree")
71
+
72
+ used = None
73
+ percent = None
74
+
75
+ if total is not None and free is not None:
76
+ used = total - free
77
+ percent = round((used / total) * 100, 1) if total else 0.0
78
+
79
+ return {
80
+ "total_gib": bytes_to_gib(total),
81
+ "free_gib": bytes_to_gib(free),
82
+ "used_gib": bytes_to_gib(used),
83
+ "used_percent": percent,
84
+ }
85
+
86
+
87
+ def get_root_disk_usage() -> dict[str, float]:
88
+ usage = shutil.disk_usage("/")
89
+
90
+ return {
91
+ "total_gib": bytes_to_gib(usage.total),
92
+ "used_gib": bytes_to_gib(usage.used),
93
+ "free_gib": bytes_to_gib(usage.free),
94
+ "used_percent": round((usage.used / usage.total) * 100, 1),
95
+ }
96
+
97
+
98
+ def read_mounts() -> list[dict[str, str]]:
99
+ path = Path("/proc/mounts")
100
+ mounts: list[dict[str, str]] = []
101
+
102
+ try:
103
+ for line in path.read_text().splitlines():
104
+ parts = line.split()
105
+
106
+ if len(parts) < 3:
107
+ continue
108
+
109
+ device, mount_point, filesystem = parts[:3]
110
+
111
+ mounts.append(
112
+ {
113
+ "device": device,
114
+ "mount_point": mount_point,
115
+ "filesystem": filesystem,
116
+ }
117
+ )
118
+
119
+ except OSError:
120
+ return []
121
+
122
+ return mounts
123
+
124
+
125
+ def collect_resource_info() -> dict[str, object]:
126
+ warnings: list[str] = []
127
+
128
+ load_average = read_load_average()
129
+ meminfo = read_meminfo()
130
+ mounts = read_mounts()
131
+
132
+ if load_average is None:
133
+ warnings.append("Could not read /proc/loadavg")
134
+
135
+ if not meminfo:
136
+ warnings.append("Could not read /proc/meminfo")
137
+
138
+ if not mounts:
139
+ warnings.append("Could not read /proc/mounts")
140
+
141
+ data = {
142
+ "cpu_count": os.cpu_count(),
143
+ "load_average": load_average,
144
+ "memory": get_memory_usage(meminfo),
145
+ "swap": get_swap_usage(meminfo),
146
+ "root_disk": get_root_disk_usage(),
147
+ "mounts": mounts,
148
+ }
149
+
150
+ return {
151
+ "success": bool(meminfo) and load_average is not None,
152
+ "data": data,
153
+ "warnings": warnings,
154
+ }
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import getpass
4
+ import os
5
+ import platform
6
+ import socket
7
+ from pathlib import Path
8
+
9
+
10
+ def read_os_release() -> dict[str, str]:
11
+ os_release_path = Path("/etc/os-release")
12
+
13
+ if not os_release_path.exists():
14
+ return {}
15
+
16
+ data: dict[str, str] = {}
17
+
18
+ try:
19
+ for line in os_release_path.read_text().splitlines():
20
+ if "=" not in line:
21
+ continue
22
+
23
+ key, value = line.split("=", 1)
24
+ data[key] = value.strip().strip('"')
25
+
26
+ except OSError:
27
+ return {}
28
+
29
+ return data
30
+
31
+
32
+ def read_uptime_seconds() -> float | None:
33
+ uptime_path = Path("/proc/uptime")
34
+
35
+ if not uptime_path.exists():
36
+ return None
37
+
38
+ try:
39
+ uptime_text = uptime_path.read_text().split()[0]
40
+ return float(uptime_text)
41
+ except (IndexError, ValueError, OSError):
42
+ return None
43
+
44
+
45
+ def collect_system_info() -> dict[str, object]:
46
+ warnings: list[str] = []
47
+
48
+ os_release = read_os_release()
49
+ uptime_seconds = read_uptime_seconds()
50
+
51
+ if not os_release:
52
+ warnings.append("Could not read /etc/os-release")
53
+
54
+ if uptime_seconds is None:
55
+ warnings.append("Could not read /proc/uptime")
56
+
57
+ data = {
58
+ "hostname": socket.gethostname(),
59
+ "os_name": os_release.get("NAME", platform.system()),
60
+ "os_version": os_release.get("VERSION", "unknown"),
61
+ "kernel": platform.release(),
62
+ "architecture": platform.machine(),
63
+ "user": getpass.getuser(),
64
+ "shell": os.environ.get("SHELL", "unknown"),
65
+ "uptime_seconds": uptime_seconds,
66
+ }
67
+
68
+ return {
69
+ "success": uptime_seconds is not None and bool(os_release),
70
+ "data": data,
71
+ "warnings": warnings,
72
+ }