ubuntils 1.5.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.
- ubuntils/__init__.py +1 -0
- ubuntils/cli.py +195 -0
- ubuntils/collectors/__init__.py +19 -0
- ubuntils/collectors/base.py +11 -0
- ubuntils/collectors/cron.py +73 -0
- ubuntils/collectors/environment.py +86 -0
- ubuntils/collectors/network.py +58 -0
- ubuntils/collectors/processes.py +48 -0
- ubuntils/collectors/ssh.py +52 -0
- ubuntils/collectors/sudoers.py +75 -0
- ubuntils/collectors/systemd.py +77 -0
- ubuntils/collectors/users.py +74 -0
- ubuntils/detectors/__init__.py +3 -0
- ubuntils/detectors/engine.py +46 -0
- ubuntils/detectors/finding.py +38 -0
- ubuntils/detectors/rules.py +269 -0
- ubuntils/formatters/__init__.py +3 -0
- ubuntils/formatters/json_formatter.py +73 -0
- ubuntils/remediators/__init__.py +12 -0
- ubuntils/remediators/base.py +136 -0
- ubuntils/remediators/cron.py +34 -0
- ubuntils/remediators/environment.py +37 -0
- ubuntils/remediators/ssh.py +30 -0
- ubuntils/remediators/sudoers.py +42 -0
- ubuntils/timeline/__init__.py +1 -0
- ubuntils/timeline/builder.py +114 -0
- ubuntils/tui/__init__.py +6 -0
- ubuntils/tui/app.py +128 -0
- ubuntils/tui/confirm_modal.py +63 -0
- ubuntils/tui/findings_panel.py +166 -0
- ubuntils/tui/results_screen.py +106 -0
- ubuntils/tui/scan_screen.py +77 -0
- ubuntils/tui/stats_panel.py +44 -0
- ubuntils/tui/summary_screen.py +41 -0
- ubuntils/tui/timeline_panel.py +35 -0
- ubuntils/utils/__init__.py +0 -0
- ubuntils/utils/config.py +56 -0
- ubuntils/utils/logging.py +26 -0
- ubuntils/utils/shell.py +9 -0
- ubuntils/utils/since_parser.py +27 -0
- ubuntils/utils/validators.py +23 -0
- ubuntils-1.5.0.dist-info/METADATA +562 -0
- ubuntils-1.5.0.dist-info/RECORD +47 -0
- ubuntils-1.5.0.dist-info/WHEEL +5 -0
- ubuntils-1.5.0.dist-info/entry_points.txt +2 -0
- ubuntils-1.5.0.dist-info/licenses/LICENSE +21 -0
- ubuntils-1.5.0.dist-info/top_level.txt +1 -0
ubuntils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.5.0"
|
ubuntils/cli.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
import socket
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
from ubuntils import __version__
|
|
12
|
+
from ubuntils.collectors import ALL_COLLECTORS
|
|
13
|
+
from ubuntils.detectors.engine import DetectionEngine
|
|
14
|
+
from ubuntils.detectors.finding import Severity
|
|
15
|
+
from ubuntils.formatters.json_formatter import JSONFormatter
|
|
16
|
+
from ubuntils.remediators import REMEDIATOR_REGISTRY
|
|
17
|
+
from ubuntils.timeline.builder import TimelineBuilder
|
|
18
|
+
from ubuntils.tui.app import UbuntilsApp
|
|
19
|
+
from ubuntils.tui.stats_panel import get_ubuntu_version
|
|
20
|
+
from ubuntils.utils.config import load_allowlist
|
|
21
|
+
from ubuntils.utils.logging import configure_logging
|
|
22
|
+
from ubuntils.utils.since_parser import parse_since
|
|
23
|
+
|
|
24
|
+
logger = structlog.get_logger()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ensure_root() -> None:
|
|
28
|
+
"""Re-exec under sudo if not running as root, preserving the active venv/conda PATH."""
|
|
29
|
+
if os.geteuid() == 0:
|
|
30
|
+
return
|
|
31
|
+
current_path = os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")
|
|
32
|
+
# Pass PATH through so sudo finds this venv's Python and ubuntils script.
|
|
33
|
+
args = ["sudo", "env", f"PATH={current_path}", sys.argv[0]] + sys.argv[1:]
|
|
34
|
+
print("ubuntils requires root — re-invoking with sudo…", file=sys.stderr)
|
|
35
|
+
try:
|
|
36
|
+
os.execvp("sudo", args)
|
|
37
|
+
except FileNotFoundError:
|
|
38
|
+
print("Error: sudo not found. Please run as root.", file=sys.stderr)
|
|
39
|
+
sys.exit(1)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _run_pipeline(remediate: bool, confirm: bool, allowlist=None, since=None) -> tuple:
|
|
43
|
+
"""Run collectors → detection → timeline → optional remediation.
|
|
44
|
+
|
|
45
|
+
Returns (findings, timeline, stats, scan_metadata, artifact_counts, remediation_results).
|
|
46
|
+
"""
|
|
47
|
+
start = time.monotonic()
|
|
48
|
+
artifacts: dict = {}
|
|
49
|
+
failures = 0
|
|
50
|
+
artifact_counts: dict = {}
|
|
51
|
+
collectors = [C() for C in ALL_COLLECTORS]
|
|
52
|
+
|
|
53
|
+
for collector in collectors:
|
|
54
|
+
name = type(collector).__name__
|
|
55
|
+
try:
|
|
56
|
+
result = collector.collect()
|
|
57
|
+
artifacts.update(result)
|
|
58
|
+
artifact_counts[name] = sum(
|
|
59
|
+
len(v) if isinstance(v, (list, dict)) else 1
|
|
60
|
+
for v in result.values()
|
|
61
|
+
)
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
logger.error("collector_failed", name=name, error=str(exc))
|
|
64
|
+
failures += 1
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
findings = DetectionEngine(allowlist=allowlist).run(artifacts)
|
|
68
|
+
timeline = TimelineBuilder().build()
|
|
69
|
+
if since is not None:
|
|
70
|
+
timeline = [e for e in timeline if e.timestamp >= since]
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
logger.error("scan_engine_failed", error=str(exc))
|
|
73
|
+
findings = []
|
|
74
|
+
timeline = []
|
|
75
|
+
|
|
76
|
+
duration = time.monotonic() - start
|
|
77
|
+
|
|
78
|
+
remediation_results = []
|
|
79
|
+
if remediate:
|
|
80
|
+
for finding in findings:
|
|
81
|
+
remediator_cls = REMEDIATOR_REGISTRY.get(finding.rule_id)
|
|
82
|
+
if remediator_cls is None:
|
|
83
|
+
continue
|
|
84
|
+
result = remediator_cls().remediate(finding, dry_run=not confirm)
|
|
85
|
+
remediation_results.append(result)
|
|
86
|
+
logger.info(
|
|
87
|
+
"remediation",
|
|
88
|
+
rule_id=finding.rule_id,
|
|
89
|
+
status=result.status.value,
|
|
90
|
+
message=result.message,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
ubuntu_version = get_ubuntu_version()
|
|
94
|
+
arch = platform.machine()
|
|
95
|
+
|
|
96
|
+
stats = {
|
|
97
|
+
"ubuntu_version": ubuntu_version,
|
|
98
|
+
"architecture": arch,
|
|
99
|
+
"duration_s": duration,
|
|
100
|
+
"collector_count": len(collectors),
|
|
101
|
+
"collector_failures": failures,
|
|
102
|
+
"finding_counts": {
|
|
103
|
+
"HIGH": sum(1 for f in findings if f.severity == Severity.HIGH),
|
|
104
|
+
"MEDIUM": sum(1 for f in findings if f.severity == Severity.MEDIUM),
|
|
105
|
+
"LOW": sum(1 for f in findings if f.severity == Severity.LOW),
|
|
106
|
+
},
|
|
107
|
+
"timeline_count": len(timeline),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
scan_metadata = {
|
|
111
|
+
"tool_version": __version__,
|
|
112
|
+
"hostname": socket.gethostname(),
|
|
113
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
114
|
+
"ubuntu_version": ubuntu_version,
|
|
115
|
+
"architecture": arch,
|
|
116
|
+
"duration_s": duration,
|
|
117
|
+
"collector_failures": failures,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return findings, timeline, stats, scan_metadata, artifact_counts, remediation_results
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@click.group()
|
|
124
|
+
def main():
|
|
125
|
+
"""ubuntils - Ubuntu incident response tool."""
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@main.command()
|
|
130
|
+
@click.option("--json", "output_json", is_flag=True, help="Output JSON instead of launching TUI")
|
|
131
|
+
@click.option("--remediate", is_flag=True, help="Run remediation engine after detection")
|
|
132
|
+
@click.option("--confirm", is_flag=True, help="Required with --remediate to apply changes")
|
|
133
|
+
@click.option("--config", "config_path", type=click.Path(exists=True, dir_okay=False),
|
|
134
|
+
help="YAML config with false-positive allowlist (rules/paths to suppress)")
|
|
135
|
+
@click.option("--output", "output_path", type=click.Path(dir_okay=False),
|
|
136
|
+
help="Write JSON report to FILE instead of stdout (implies --json)")
|
|
137
|
+
@click.option("--since", "since_value",
|
|
138
|
+
help="Limit timeline to events since this time (e.g. '24h', '7d', '2026-05-20')")
|
|
139
|
+
@click.option("--verbose", is_flag=True, help="Enable verbose logging")
|
|
140
|
+
def scan(output_json, remediate, confirm, config_path, output_path, since_value, verbose):
|
|
141
|
+
"""Scan the system for forensic artifacts and suspicious activity."""
|
|
142
|
+
_ensure_root()
|
|
143
|
+
if output_path:
|
|
144
|
+
output_json = True
|
|
145
|
+
configure_logging(json_mode=output_json, verbose=verbose)
|
|
146
|
+
|
|
147
|
+
since = None
|
|
148
|
+
if since_value:
|
|
149
|
+
try:
|
|
150
|
+
since = parse_since(since_value)
|
|
151
|
+
except ValueError as exc:
|
|
152
|
+
raise click.ClickException(str(exc))
|
|
153
|
+
|
|
154
|
+
allowlist = None
|
|
155
|
+
if config_path:
|
|
156
|
+
try:
|
|
157
|
+
allowlist = load_allowlist(config_path)
|
|
158
|
+
except (ValueError, OSError) as exc:
|
|
159
|
+
raise click.ClickException(f"Invalid config {config_path}: {exc}")
|
|
160
|
+
|
|
161
|
+
if output_json or remediate:
|
|
162
|
+
findings, timeline, stats, scan_metadata, artifact_counts, remediation_results = \
|
|
163
|
+
_run_pipeline(remediate=remediate, confirm=confirm, allowlist=allowlist, since=since)
|
|
164
|
+
|
|
165
|
+
if output_json:
|
|
166
|
+
report = JSONFormatter().format(
|
|
167
|
+
scan_metadata, artifact_counts, findings, timeline, remediation_results
|
|
168
|
+
)
|
|
169
|
+
if output_path:
|
|
170
|
+
with open(output_path, "w") as f:
|
|
171
|
+
f.write(report + "\n")
|
|
172
|
+
click.echo(f"Report written to {output_path}", err=True)
|
|
173
|
+
else:
|
|
174
|
+
click.echo(report)
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
# --remediate without --json: launch TUI with pre-computed results
|
|
178
|
+
def _override():
|
|
179
|
+
return (findings, timeline, stats)
|
|
180
|
+
|
|
181
|
+
UbuntilsApp(verbose=verbose, _scan_override=_override).run()
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
# Plain TUI mode: let the app run its own scan with live progress
|
|
185
|
+
UbuntilsApp(verbose=verbose, allowlist=allowlist, since=since).run()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@main.command()
|
|
189
|
+
def version():
|
|
190
|
+
"""Print version and exit."""
|
|
191
|
+
click.echo(__version__)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
main()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from ubuntils.collectors.processes import ProcessCollector
|
|
2
|
+
from ubuntils.collectors.network import NetworkCollector
|
|
3
|
+
from ubuntils.collectors.users import UserCollector
|
|
4
|
+
from ubuntils.collectors.cron import CronCollector
|
|
5
|
+
from ubuntils.collectors.systemd import SystemdCollector
|
|
6
|
+
from ubuntils.collectors.ssh import SSHCollector
|
|
7
|
+
from ubuntils.collectors.sudoers import SudoersCollector
|
|
8
|
+
from ubuntils.collectors.environment import EnvironmentCollector
|
|
9
|
+
|
|
10
|
+
ALL_COLLECTORS = [
|
|
11
|
+
ProcessCollector,
|
|
12
|
+
NetworkCollector,
|
|
13
|
+
UserCollector,
|
|
14
|
+
CronCollector,
|
|
15
|
+
SystemdCollector,
|
|
16
|
+
SSHCollector,
|
|
17
|
+
SudoersCollector,
|
|
18
|
+
EnvironmentCollector,
|
|
19
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from ubuntils.collectors.base import BaseCollector
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CronCollector(BaseCollector):
|
|
8
|
+
def collect(self) -> dict:
|
|
9
|
+
entries = []
|
|
10
|
+
|
|
11
|
+
# System crontab (7-field: min hour dom mon dow user command)
|
|
12
|
+
for path in ["/etc/crontab"] + glob.glob("/etc/cron.d/*"):
|
|
13
|
+
entries.extend(self._parse_system_crontab(path))
|
|
14
|
+
|
|
15
|
+
# User crontabs (5-field: min hour dom mon dow command)
|
|
16
|
+
for path in glob.glob("/var/spool/cron/crontabs/*"):
|
|
17
|
+
owner = os.path.basename(path)
|
|
18
|
+
entries.extend(self._parse_user_crontab(path, owner))
|
|
19
|
+
|
|
20
|
+
return {"cron_entries": entries}
|
|
21
|
+
|
|
22
|
+
def _parse_system_crontab(self, path: str) -> list:
|
|
23
|
+
entries = []
|
|
24
|
+
try:
|
|
25
|
+
with open(path) as f:
|
|
26
|
+
for line in f:
|
|
27
|
+
line = line.strip()
|
|
28
|
+
if not line or line.startswith("#"):
|
|
29
|
+
continue
|
|
30
|
+
if "=" in line and not line[0].isdigit() and not line[0] == "*":
|
|
31
|
+
continue # skip variable assignments
|
|
32
|
+
parts = line.split(None, 6)
|
|
33
|
+
if len(parts) < 7:
|
|
34
|
+
continue
|
|
35
|
+
schedule = " ".join(parts[:5])
|
|
36
|
+
run_as = parts[5]
|
|
37
|
+
command = parts[6]
|
|
38
|
+
entries.append({
|
|
39
|
+
"owner": "root",
|
|
40
|
+
"run_as": run_as,
|
|
41
|
+
"schedule": schedule,
|
|
42
|
+
"command": command,
|
|
43
|
+
"source": path,
|
|
44
|
+
})
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
return entries
|
|
48
|
+
|
|
49
|
+
def _parse_user_crontab(self, path: str, owner: str) -> list:
|
|
50
|
+
entries = []
|
|
51
|
+
try:
|
|
52
|
+
with open(path) as f:
|
|
53
|
+
for line in f:
|
|
54
|
+
line = line.strip()
|
|
55
|
+
if not line or line.startswith("#"):
|
|
56
|
+
continue
|
|
57
|
+
if "=" in line and not line[0].isdigit() and not line[0] == "*":
|
|
58
|
+
continue
|
|
59
|
+
parts = line.split(None, 5)
|
|
60
|
+
if len(parts) < 6:
|
|
61
|
+
continue
|
|
62
|
+
schedule = " ".join(parts[:5])
|
|
63
|
+
command = parts[5]
|
|
64
|
+
entries.append({
|
|
65
|
+
"owner": owner,
|
|
66
|
+
"run_as": owner,
|
|
67
|
+
"schedule": schedule,
|
|
68
|
+
"command": command,
|
|
69
|
+
"source": path,
|
|
70
|
+
})
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
return entries
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from ubuntils.collectors.base import BaseCollector
|
|
5
|
+
from ubuntils.utils.validators import is_login_shell
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EnvironmentCollector(BaseCollector):
|
|
9
|
+
def collect(self) -> dict:
|
|
10
|
+
definitions = []
|
|
11
|
+
|
|
12
|
+
definitions.extend(self._read_env_file("/etc/environment", "system"))
|
|
13
|
+
definitions.extend(self._read_shell_init("/etc/profile", "system"))
|
|
14
|
+
for path in glob.glob("/etc/profile.d/*.sh"):
|
|
15
|
+
definitions.extend(self._read_shell_init(path, "system"))
|
|
16
|
+
|
|
17
|
+
for username, home in self._get_login_users():
|
|
18
|
+
for filename in (".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile"):
|
|
19
|
+
path = f"{home}/{filename}"
|
|
20
|
+
if os.path.exists(path):
|
|
21
|
+
definitions.extend(self._read_shell_init(path, username))
|
|
22
|
+
|
|
23
|
+
return {"env_definitions": definitions}
|
|
24
|
+
|
|
25
|
+
def _get_login_users(self):
|
|
26
|
+
try:
|
|
27
|
+
with open("/etc/passwd") as f:
|
|
28
|
+
for line in f:
|
|
29
|
+
line = line.strip()
|
|
30
|
+
if not line or line.startswith("#"):
|
|
31
|
+
continue
|
|
32
|
+
parts = line.split(":")
|
|
33
|
+
if len(parts) < 7:
|
|
34
|
+
continue
|
|
35
|
+
username, home, shell = parts[0], parts[5], parts[6]
|
|
36
|
+
if is_login_shell(shell) and home:
|
|
37
|
+
yield username, home
|
|
38
|
+
except Exception:
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
def _read_env_file(self, path: str, owner: str) -> list:
|
|
42
|
+
entries = []
|
|
43
|
+
try:
|
|
44
|
+
with open(path) as f:
|
|
45
|
+
for line in f:
|
|
46
|
+
raw = line.strip()
|
|
47
|
+
if not raw or raw.startswith("#"):
|
|
48
|
+
continue
|
|
49
|
+
if "=" in raw:
|
|
50
|
+
var, _, val = raw.partition("=")
|
|
51
|
+
entries.append({
|
|
52
|
+
"owner": owner,
|
|
53
|
+
"source": path,
|
|
54
|
+
"variable": var.strip(),
|
|
55
|
+
"value": val.strip().strip("\"'"),
|
|
56
|
+
"raw_line": raw,
|
|
57
|
+
})
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
return entries
|
|
61
|
+
|
|
62
|
+
def _read_shell_init(self, path: str, owner: str) -> list:
|
|
63
|
+
entries = []
|
|
64
|
+
try:
|
|
65
|
+
with open(path) as f:
|
|
66
|
+
for line in f:
|
|
67
|
+
raw = line.strip()
|
|
68
|
+
if not raw or raw.startswith("#"):
|
|
69
|
+
continue
|
|
70
|
+
actual = raw
|
|
71
|
+
if actual.startswith("export "):
|
|
72
|
+
actual = actual[7:].strip()
|
|
73
|
+
if "=" in actual:
|
|
74
|
+
var, _, val = actual.partition("=")
|
|
75
|
+
var = var.strip()
|
|
76
|
+
if var and all(c.isalnum() or c == "_" for c in var):
|
|
77
|
+
entries.append({
|
|
78
|
+
"owner": owner,
|
|
79
|
+
"source": path,
|
|
80
|
+
"variable": var,
|
|
81
|
+
"value": val.strip().strip("\"'"),
|
|
82
|
+
"raw_line": raw,
|
|
83
|
+
})
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
return entries
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from ubuntils.collectors.base import BaseCollector
|
|
2
|
+
from ubuntils.utils.shell import run_command
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class NetworkCollector(BaseCollector):
|
|
6
|
+
def collect(self) -> dict:
|
|
7
|
+
stdout, _, returncode = run_command(["ss", "-tunap"])
|
|
8
|
+
if returncode != 0:
|
|
9
|
+
stdout, _, returncode = run_command(["netstat", "-tunap"])
|
|
10
|
+
if returncode != 0:
|
|
11
|
+
return {}
|
|
12
|
+
|
|
13
|
+
known_protos = {"tcp", "tcp6", "udp", "udp6", "raw", "raw6"}
|
|
14
|
+
connections = []
|
|
15
|
+
for line in stdout.splitlines():
|
|
16
|
+
parts = line.split()
|
|
17
|
+
if not parts or parts[0].lower() not in known_protos:
|
|
18
|
+
continue
|
|
19
|
+
if len(parts) < 5:
|
|
20
|
+
continue
|
|
21
|
+
proto = parts[0]
|
|
22
|
+
state = parts[1] if len(parts) > 5 else ""
|
|
23
|
+
local = parts[4] if len(parts) > 5 else parts[3]
|
|
24
|
+
remote = parts[5] if len(parts) > 5 else parts[4]
|
|
25
|
+
|
|
26
|
+
local_addr, local_port = self._split_addr(local)
|
|
27
|
+
remote_addr, remote_port = self._split_addr(remote)
|
|
28
|
+
|
|
29
|
+
pid = self._extract_pid(line)
|
|
30
|
+
|
|
31
|
+
connections.append({
|
|
32
|
+
"proto": proto,
|
|
33
|
+
"local_addr": local_addr,
|
|
34
|
+
"local_port": local_port,
|
|
35
|
+
"remote_addr": remote_addr,
|
|
36
|
+
"remote_port": remote_port,
|
|
37
|
+
"state": state,
|
|
38
|
+
"pid": pid,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
return {"connections": connections}
|
|
42
|
+
|
|
43
|
+
def _split_addr(self, addr_port: str):
|
|
44
|
+
if addr_port in ("*", "0.0.0.0:*", ":::*"):
|
|
45
|
+
return addr_port, "*"
|
|
46
|
+
addr, _, port = addr_port.rpartition(":")
|
|
47
|
+
return addr or addr_port, port or "*"
|
|
48
|
+
|
|
49
|
+
def _extract_pid(self, line: str) -> str:
|
|
50
|
+
if "pid=" in line:
|
|
51
|
+
start = line.index("pid=") + 4
|
|
52
|
+
end = line.find(",", start)
|
|
53
|
+
return line[start:end] if end != -1 else line[start:].rstrip(")")
|
|
54
|
+
parts = line.split()
|
|
55
|
+
last = parts[-1] if parts else ""
|
|
56
|
+
if "/" in last:
|
|
57
|
+
return last.split("/")[0]
|
|
58
|
+
return ""
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from ubuntils.collectors.base import BaseCollector
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ProcessCollector(BaseCollector):
|
|
8
|
+
def collect(self) -> dict:
|
|
9
|
+
processes = []
|
|
10
|
+
try:
|
|
11
|
+
status_paths = glob.glob("/proc/*/status")
|
|
12
|
+
except Exception:
|
|
13
|
+
return {}
|
|
14
|
+
|
|
15
|
+
for status_path in status_paths:
|
|
16
|
+
try:
|
|
17
|
+
pid = int(status_path.split("/")[2])
|
|
18
|
+
name, uid = self._parse_status(status_path)
|
|
19
|
+
exe = os.readlink(f"/proc/{pid}/exe")
|
|
20
|
+
cmdline = self._read_cmdline(f"/proc/{pid}/cmdline")
|
|
21
|
+
processes.append({
|
|
22
|
+
"pid": pid,
|
|
23
|
+
"name": name,
|
|
24
|
+
"uid": uid,
|
|
25
|
+
"exe": exe,
|
|
26
|
+
"cmdline": cmdline,
|
|
27
|
+
})
|
|
28
|
+
except FileNotFoundError:
|
|
29
|
+
continue
|
|
30
|
+
except Exception:
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
return {"processes": processes}
|
|
34
|
+
|
|
35
|
+
def _parse_status(self, path: str):
|
|
36
|
+
name, uid = "", 0
|
|
37
|
+
with open(path) as f:
|
|
38
|
+
for line in f:
|
|
39
|
+
if line.startswith("Name:"):
|
|
40
|
+
name = line.split(":", 1)[1].strip()
|
|
41
|
+
elif line.startswith("Uid:"):
|
|
42
|
+
uid = int(line.split(":", 1)[1].split()[0])
|
|
43
|
+
return name, uid
|
|
44
|
+
|
|
45
|
+
def _read_cmdline(self, path: str) -> str:
|
|
46
|
+
with open(path, "rb") as f:
|
|
47
|
+
data = f.read()
|
|
48
|
+
return data.replace(b"\x00", b" ").decode(errors="replace").strip()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from ubuntils.collectors.base import BaseCollector
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SSHCollector(BaseCollector):
|
|
7
|
+
def collect(self) -> dict:
|
|
8
|
+
entries = []
|
|
9
|
+
try:
|
|
10
|
+
with open("/etc/passwd") as f:
|
|
11
|
+
passwd_lines = f.readlines()
|
|
12
|
+
except Exception:
|
|
13
|
+
return {}
|
|
14
|
+
|
|
15
|
+
for line in passwd_lines:
|
|
16
|
+
line = line.strip()
|
|
17
|
+
if not line or line.startswith("#"):
|
|
18
|
+
continue
|
|
19
|
+
parts = line.split(":")
|
|
20
|
+
if len(parts) < 6:
|
|
21
|
+
continue
|
|
22
|
+
username, home = parts[0], parts[5]
|
|
23
|
+
if not home:
|
|
24
|
+
continue
|
|
25
|
+
auth_keys_path = f"{home}/.ssh/authorized_keys"
|
|
26
|
+
if not os.path.exists(auth_keys_path):
|
|
27
|
+
continue
|
|
28
|
+
try:
|
|
29
|
+
file_mtime = os.stat(auth_keys_path).st_mtime
|
|
30
|
+
with open(auth_keys_path) as f:
|
|
31
|
+
for key_line in f:
|
|
32
|
+
key_line = key_line.strip()
|
|
33
|
+
if not key_line or key_line.startswith("#"):
|
|
34
|
+
continue
|
|
35
|
+
key_parts = key_line.split(None, 2)
|
|
36
|
+
if len(key_parts) < 2:
|
|
37
|
+
continue
|
|
38
|
+
key_type = key_parts[0]
|
|
39
|
+
key_data = key_parts[1]
|
|
40
|
+
comment = key_parts[2] if len(key_parts) > 2 else ""
|
|
41
|
+
entries.append({
|
|
42
|
+
"username": username,
|
|
43
|
+
"home": home,
|
|
44
|
+
"key_type": key_type,
|
|
45
|
+
"key_data": key_data,
|
|
46
|
+
"comment": comment,
|
|
47
|
+
"file_mtime": float(file_mtime),
|
|
48
|
+
})
|
|
49
|
+
except Exception:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
return {"authorized_keys": entries}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
|
|
3
|
+
from ubuntils.collectors.base import BaseCollector
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SudoersCollector(BaseCollector):
|
|
7
|
+
def collect(self) -> dict:
|
|
8
|
+
rules = []
|
|
9
|
+
paths = ["/etc/sudoers"] + glob.glob("/etc/sudoers.d/*")
|
|
10
|
+
for path in paths:
|
|
11
|
+
rules.extend(self._parse_sudoers_file(path))
|
|
12
|
+
return {"sudoers_rules": rules}
|
|
13
|
+
|
|
14
|
+
def _parse_sudoers_file(self, path: str) -> list:
|
|
15
|
+
rules = []
|
|
16
|
+
try:
|
|
17
|
+
with open(path) as f:
|
|
18
|
+
for line in f:
|
|
19
|
+
raw = line.strip()
|
|
20
|
+
if not raw or raw.startswith("#") or raw.startswith("@"):
|
|
21
|
+
continue
|
|
22
|
+
if raw.startswith("#include") or raw.startswith("@include"):
|
|
23
|
+
continue
|
|
24
|
+
if "=" not in raw and raw.startswith("Defaults"):
|
|
25
|
+
continue
|
|
26
|
+
parsed = self._parse_rule_line(raw, path)
|
|
27
|
+
if parsed:
|
|
28
|
+
rules.append(parsed)
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
return rules
|
|
32
|
+
|
|
33
|
+
def _parse_rule_line(self, line: str, source: str):
|
|
34
|
+
# Basic sudoers rule: user host=(run_as) [options:] commands
|
|
35
|
+
# Skip Defaults lines
|
|
36
|
+
if line.startswith("Defaults"):
|
|
37
|
+
return None
|
|
38
|
+
parts = line.split(None, 2)
|
|
39
|
+
if len(parts) < 3:
|
|
40
|
+
return None
|
|
41
|
+
user = parts[0]
|
|
42
|
+
host_runas_cmds = parts[2] if len(parts) > 2 else parts[1]
|
|
43
|
+
|
|
44
|
+
# Extract run_as from (...)
|
|
45
|
+
run_as = ""
|
|
46
|
+
if "(" in host_runas_cmds and ")" in host_runas_cmds:
|
|
47
|
+
start = host_runas_cmds.index("(")
|
|
48
|
+
end = host_runas_cmds.index(")")
|
|
49
|
+
run_as = host_runas_cmds[start + 1:end]
|
|
50
|
+
rest = host_runas_cmds[end + 1:].strip()
|
|
51
|
+
else:
|
|
52
|
+
rest = host_runas_cmds
|
|
53
|
+
|
|
54
|
+
# Extract options (NOPASSWD, PASSWD, etc.)
|
|
55
|
+
options = ""
|
|
56
|
+
if ":" in rest:
|
|
57
|
+
maybe_options, _, commands = rest.partition(":")
|
|
58
|
+
maybe_options = maybe_options.strip()
|
|
59
|
+
if any(kw in maybe_options for kw in ("NOPASSWD", "PASSWD", "NOEXEC", "EXEC")):
|
|
60
|
+
options = maybe_options
|
|
61
|
+
commands = commands.strip()
|
|
62
|
+
else:
|
|
63
|
+
commands = rest
|
|
64
|
+
else:
|
|
65
|
+
commands = rest
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
"source": source,
|
|
69
|
+
"user": user,
|
|
70
|
+
"host": parts[1],
|
|
71
|
+
"run_as": run_as,
|
|
72
|
+
"options": options,
|
|
73
|
+
"commands": commands.strip(),
|
|
74
|
+
"raw_line": line,
|
|
75
|
+
}
|