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.
Files changed (47) hide show
  1. ubuntils/__init__.py +1 -0
  2. ubuntils/cli.py +195 -0
  3. ubuntils/collectors/__init__.py +19 -0
  4. ubuntils/collectors/base.py +11 -0
  5. ubuntils/collectors/cron.py +73 -0
  6. ubuntils/collectors/environment.py +86 -0
  7. ubuntils/collectors/network.py +58 -0
  8. ubuntils/collectors/processes.py +48 -0
  9. ubuntils/collectors/ssh.py +52 -0
  10. ubuntils/collectors/sudoers.py +75 -0
  11. ubuntils/collectors/systemd.py +77 -0
  12. ubuntils/collectors/users.py +74 -0
  13. ubuntils/detectors/__init__.py +3 -0
  14. ubuntils/detectors/engine.py +46 -0
  15. ubuntils/detectors/finding.py +38 -0
  16. ubuntils/detectors/rules.py +269 -0
  17. ubuntils/formatters/__init__.py +3 -0
  18. ubuntils/formatters/json_formatter.py +73 -0
  19. ubuntils/remediators/__init__.py +12 -0
  20. ubuntils/remediators/base.py +136 -0
  21. ubuntils/remediators/cron.py +34 -0
  22. ubuntils/remediators/environment.py +37 -0
  23. ubuntils/remediators/ssh.py +30 -0
  24. ubuntils/remediators/sudoers.py +42 -0
  25. ubuntils/timeline/__init__.py +1 -0
  26. ubuntils/timeline/builder.py +114 -0
  27. ubuntils/tui/__init__.py +6 -0
  28. ubuntils/tui/app.py +128 -0
  29. ubuntils/tui/confirm_modal.py +63 -0
  30. ubuntils/tui/findings_panel.py +166 -0
  31. ubuntils/tui/results_screen.py +106 -0
  32. ubuntils/tui/scan_screen.py +77 -0
  33. ubuntils/tui/stats_panel.py +44 -0
  34. ubuntils/tui/summary_screen.py +41 -0
  35. ubuntils/tui/timeline_panel.py +35 -0
  36. ubuntils/utils/__init__.py +0 -0
  37. ubuntils/utils/config.py +56 -0
  38. ubuntils/utils/logging.py +26 -0
  39. ubuntils/utils/shell.py +9 -0
  40. ubuntils/utils/since_parser.py +27 -0
  41. ubuntils/utils/validators.py +23 -0
  42. ubuntils-1.5.0.dist-info/METADATA +562 -0
  43. ubuntils-1.5.0.dist-info/RECORD +47 -0
  44. ubuntils-1.5.0.dist-info/WHEEL +5 -0
  45. ubuntils-1.5.0.dist-info/entry_points.txt +2 -0
  46. ubuntils-1.5.0.dist-info/licenses/LICENSE +21 -0
  47. 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,11 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class BaseCollector(ABC):
5
+ @abstractmethod
6
+ def collect(self) -> dict:
7
+ """
8
+ Collect forensic artifacts.
9
+ Returns a dict of collected data.
10
+ Must not raise — log exceptions and return {}.
11
+ """
@@ -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
+ }