privmap 1.0.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.
privmap/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """privmap — Linux privilege graph engine."""
2
+ __version__ = "1.0.0"
File without changes
@@ -0,0 +1,49 @@
1
+ """Path extraction, deduplication, and filtering."""
2
+ from __future__ import annotations
3
+
4
+ from typing import List, Optional
5
+
6
+ from privmap.graph.model import EscalationPath, PrivilegeGraph, Severity
7
+ from privmap.graph.traversal import find_escalation_paths
8
+ from privmap.analysis.scoring import score_path
9
+ from privmap.analysis.remediation import generate_remediation
10
+
11
+
12
+ def analyze_paths(
13
+ graph: PrivilegeGraph,
14
+ source_users: Optional[List[str]] = None,
15
+ max_depth: int = 10,
16
+ min_severity: Severity = Severity.INFO,
17
+ ) -> List[EscalationPath]:
18
+ """Full path analysis pipeline: find, score, remediate, filter, sort."""
19
+ # Find raw paths
20
+ raw_paths = find_escalation_paths(graph, source_users, max_depth)
21
+
22
+ # Score each path
23
+ for path in raw_paths:
24
+ score_path(path)
25
+
26
+ # Generate remediation advice
27
+ for path in raw_paths:
28
+ generate_remediation(path)
29
+
30
+ # Filter by minimum severity
31
+ filtered = [p for p in raw_paths if p.severity and p.severity >= min_severity]
32
+
33
+ # Sort: CRITICAL first, then by hop count (shorter = more exploitable)
34
+ filtered.sort(
35
+ key=lambda p: (-p.severity.rank if p.severity else 0, p.hop_count)
36
+ )
37
+
38
+ return filtered
39
+
40
+
41
+ def group_paths_by_user(
42
+ paths: List[EscalationPath],
43
+ ) -> dict:
44
+ """Group escalation paths by source user."""
45
+ groups = {}
46
+ for path in paths:
47
+ user = path.source.name
48
+ groups.setdefault(user, []).append(path)
49
+ return groups
@@ -0,0 +1,148 @@
1
+ """Generate per-path remediation suggestions."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from typing import List
6
+
7
+ from privmap.graph.model import EdgeType, EscalationPath, NodeType
8
+
9
+
10
+ def generate_remediation(path: EscalationPath) -> None:
11
+ """Generate a risk description and remediation for the path. Mutates in place."""
12
+ steps: List[str] = []
13
+ risks: List[str] = []
14
+
15
+ for i, edge in enumerate(path.edges):
16
+ source_node = None
17
+ target_node = None
18
+ for n in path.nodes:
19
+ if n.id == edge.source_id:
20
+ source_node = n
21
+ if n.id == edge.target_id:
22
+ target_node = n
23
+
24
+ if edge.edge_type == EdgeType.CAN_WRITE and target_node:
25
+ file_path = target_node.properties.get("path", target_node.name)
26
+ mode = target_node.properties.get("mode", "")
27
+ reason = edge.properties.get("reason", "")
28
+
29
+ if reason == "world-writable":
30
+ risks.append(
31
+ f"World-writable file {file_path} (mode: {mode})"
32
+ )
33
+ steps.append(
34
+ f"chmod o-w {file_path}"
35
+ )
36
+ owner = target_node.properties.get("owner", "root")
37
+ steps.append(
38
+ f"chown {owner}:root {file_path}"
39
+ )
40
+ elif reason == "ACL":
41
+ risks.append(
42
+ f"ACL grants write access to {file_path}"
43
+ )
44
+ user_name = source_node.name if source_node else "user"
45
+ steps.append(
46
+ f"setfacl -x u:{user_name} {file_path}"
47
+ )
48
+ else:
49
+ risks.append(
50
+ f"Writable file {file_path} (mode: {mode})"
51
+ )
52
+ steps.append(
53
+ f"chmod 644 {file_path}; chown root:root {file_path}"
54
+ )
55
+
56
+ elif edge.edge_type == EdgeType.GRANTS:
57
+ cmd = edge.properties.get("command", "")
58
+ nopasswd = edge.properties.get("nopasswd", False)
59
+
60
+ if cmd == "ALL":
61
+ risks.append(
62
+ f"Unrestricted sudo access"
63
+ f"{' (NOPASSWD)' if nopasswd else ''}"
64
+ )
65
+ if source_node:
66
+ steps.append(
67
+ f"Restrict sudo rules for {source_node.name} to specific commands"
68
+ )
69
+ elif target_node and target_node.node_type == NodeType.SUDO_RULE:
70
+ binary = target_node.properties.get("binary", cmd)
71
+ binary_name = os.path.basename(binary) if "/" in binary else binary
72
+ risks.append(
73
+ f"Sudo rule allows {binary_name}"
74
+ f"{' (NOPASSWD)' if nopasswd else ''} — may permit shell escape"
75
+ )
76
+ # Suggest sudoedit for editors
77
+ if binary_name in ("vim", "vi", "nano", "emacs"):
78
+ steps.append(
79
+ f"Replace sudo {binary_name} rule with sudoedit"
80
+ )
81
+ else:
82
+ steps.append(
83
+ f"Remove or restrict sudo rule for {binary_name}"
84
+ )
85
+
86
+ elif edge.edge_type == EdgeType.SUID_EXEC:
87
+ if target_node:
88
+ binary = target_node.properties.get("binary", target_node.name)
89
+ path_str = target_node.properties.get("path", target_node.name)
90
+ risks.append(
91
+ f"SUID binary {binary} ({path_str}) allows privilege escalation"
92
+ )
93
+ steps.append(
94
+ f"chmod u-s {path_str}"
95
+ )
96
+ steps.append(
97
+ f"Consider using capabilities instead: setcap cap_needed+ep {path_str}"
98
+ )
99
+
100
+ elif edge.edge_type == EdgeType.HAS_CAPABILITY:
101
+ if target_node:
102
+ cap = target_node.name
103
+ binary = target_node.properties.get("binary", "")
104
+ risks.append(
105
+ f"Binary {binary} has dangerous capability {cap}"
106
+ )
107
+ steps.append(
108
+ f"setcap -r {binary}"
109
+ )
110
+ steps.append(
111
+ f"Review if {cap} is strictly necessary for {os.path.basename(binary)}"
112
+ )
113
+
114
+ elif edge.edge_type == EdgeType.EXECUTES:
115
+ if source_node and target_node:
116
+ if source_node.node_type == NodeType.CRON_JOB:
117
+ run_as = source_node.properties.get("run_as", "root")
118
+ script = target_node.properties.get("path", target_node.name)
119
+ risks.append(
120
+ f"Cron job runs {script} as {run_as}"
121
+ )
122
+ steps.append(
123
+ f"Ensure {script} is owned by root and not writable by others"
124
+ )
125
+ elif source_node.node_type == NodeType.SYSTEMD_UNIT:
126
+ unit = source_node.name
127
+ script = target_node.properties.get("path", target_node.name)
128
+ risks.append(
129
+ f"Systemd unit {unit} executes {script}"
130
+ )
131
+ steps.append(
132
+ f"Ensure {script} is owned by root with mode 755 or stricter"
133
+ )
134
+
135
+ elif edge.edge_type == EdgeType.MEMBER_OF:
136
+ if target_node:
137
+ group = target_node.name
138
+ if group in ("docker", "lxd", "disk", "adm", "shadow"):
139
+ risks.append(
140
+ f"Membership in privileged group '{group}'"
141
+ )
142
+ if source_node:
143
+ steps.append(
144
+ f"Remove {source_node.name} from group {group} unless required"
145
+ )
146
+
147
+ path.risk_description = "; ".join(risks) if risks else "Privilege escalation path detected"
148
+ path.remediation = "; ".join(steps) if steps else "Review and restrict permissions along this path"
@@ -0,0 +1,138 @@
1
+ """Score escalation paths on exploitability and impact."""
2
+ from __future__ import annotations
3
+
4
+
5
+ from privmap.graph.model import EdgeType, EscalationPath, NodeType, Severity
6
+
7
+ # Sudo rules with arguments locked down are harder to exploit than unrestricted
8
+ # ones. We reduce exploitability when the command includes arguments, since the
9
+ # user can't freely choose what to run. This isn't foolproof (some argument
10
+ # patterns are still exploitable) but significantly reduces false-positive
11
+ # CRITICAL ratings on rules like: user ALL=(root) /usr/bin/systemctl restart nginx
12
+ _RESTRICTED_SUDO_PENALTY = 2.0
13
+
14
+
15
+ def score_path(path: EscalationPath) -> None:
16
+ """Score a path and assign severity. Mutates the path in place."""
17
+ exploitability = _compute_exploitability(path)
18
+ impact = _compute_impact(path)
19
+
20
+ path.exploitability_score = exploitability
21
+ path.impact_score = impact
22
+
23
+ combined = (exploitability * 0.6) + (impact * 0.4)
24
+
25
+ if combined >= 8.0:
26
+ path.severity = Severity.CRITICAL
27
+ elif combined >= 6.0:
28
+ path.severity = Severity.HIGH
29
+ elif combined >= 4.0:
30
+ path.severity = Severity.MEDIUM
31
+ elif combined >= 2.0:
32
+ path.severity = Severity.LOW
33
+ else:
34
+ path.severity = Severity.INFO
35
+
36
+
37
+ def _compute_exploitability(path: EscalationPath) -> float:
38
+ """Score 0-10 based on how easy the path is to exploit."""
39
+ score = 10.0
40
+
41
+ # Penalize long chains
42
+ hops = path.hop_count
43
+ if hops > 5:
44
+ score -= 5.0
45
+ elif hops > 3:
46
+ score -= 2.5
47
+ elif hops > 1:
48
+ score -= 0.5
49
+
50
+ # Check for timing dependencies (cron = must wait)
51
+ has_cron_dependency = False
52
+ has_sudo_nopasswd = False
53
+ has_suid = False
54
+ has_restricted_sudo = False
55
+
56
+ for edge in path.edges:
57
+ if edge.edge_type == EdgeType.EXECUTES:
58
+ # Check if source is a cron job
59
+ src_node = None
60
+ for n in path.nodes:
61
+ if n.id == edge.source_id:
62
+ src_node = n
63
+ break
64
+ if src_node and src_node.node_type == NodeType.CRON_JOB:
65
+ has_cron_dependency = True
66
+
67
+ if edge.edge_type == EdgeType.GRANTS:
68
+ if edge.properties.get("nopasswd"):
69
+ has_sudo_nopasswd = True
70
+
71
+ # Check for argument-restricted sudo rules. A command like
72
+ # "/usr/bin/systemctl restart nginx" has args after the binary,
73
+ # which limits what the user can actually do.
74
+ cmd = edge.properties.get("command", "")
75
+ if cmd and cmd != "ALL":
76
+ parts = cmd.strip().split()
77
+ if len(parts) > 1:
78
+ has_restricted_sudo = True
79
+
80
+ if edge.edge_type == EdgeType.SUID_EXEC:
81
+ has_suid = True
82
+
83
+ if has_cron_dependency:
84
+ score -= 2.0 # Must wait for cron trigger
85
+
86
+ if has_sudo_nopasswd:
87
+ score += 1.0 # No password needed is easier
88
+
89
+ if has_suid:
90
+ score += 0.5 # Direct execution, no waiting
91
+
92
+ if has_restricted_sudo:
93
+ score -= _RESTRICTED_SUDO_PENALTY
94
+
95
+ # Direct sudo ALL -> root is trivial
96
+ if hops <= 2 and any(
97
+ e.edge_type == EdgeType.GRANTS and
98
+ e.properties.get("command") == "ALL"
99
+ for e in path.edges
100
+ ):
101
+ score = 10.0
102
+
103
+ return max(0.0, min(10.0, score))
104
+
105
+
106
+ def _compute_impact(path: EscalationPath) -> float:
107
+ """Score 0-10 based on what the sink grants."""
108
+ sink = path.sink
109
+
110
+ # Root access = maximum impact
111
+ if sink.node_type == NodeType.USER and sink.properties.get("uid") == 0:
112
+ return 10.0
113
+
114
+ # Sudo ALL = equivalent to root
115
+ if sink.node_type == NodeType.SUDO_RULE:
116
+ cmd = sink.properties.get("command", "")
117
+ if cmd == "ALL":
118
+ return 10.0
119
+ # Argument-restricted sudo command — partial access
120
+ parts = cmd.strip().split()
121
+ if len(parts) > 1:
122
+ return 5.0
123
+ return 7.0 # Unrestricted single command, possible shell escape
124
+
125
+ # Dangerous capability
126
+ if sink.node_type == NodeType.CAPABILITY:
127
+ cap = sink.name.lower()
128
+ if cap in ("cap_sys_admin", "cap_dac_override", "cap_setuid"):
129
+ return 9.0
130
+ if cap in ("cap_sys_ptrace", "cap_sys_module", "cap_sys_rawio"):
131
+ return 8.0
132
+ return 6.0
133
+
134
+ # Lateral movement to another user
135
+ if sink.node_type == NodeType.USER:
136
+ return 5.0
137
+
138
+ return 3.0
privmap/cli.py ADDED
@@ -0,0 +1,195 @@
1
+ """privmap CLI — entry point and argument parsing."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import logging
7
+ import os
8
+ import sys
9
+ import tarfile
10
+ import tempfile
11
+ from typing import List, Optional
12
+
13
+ from privmap import __version__
14
+ from privmap.graph.builder import GraphBuilder
15
+ from privmap.graph.model import Severity
16
+ from privmap.analysis.paths import analyze_paths
17
+ from privmap.output.cli_output import render_cli
18
+ from privmap.output.json_export import export_json
19
+ from privmap.output.markdown_export import export_markdown
20
+
21
+
22
+ def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
23
+ parser = argparse.ArgumentParser(
24
+ prog="privmap",
25
+ description="Linux privilege graph engine — trace escalation paths.",
26
+ )
27
+ parser.add_argument(
28
+ "--version", action="version", version=f"privmap {__version__}"
29
+ )
30
+ parser.add_argument(
31
+ "--snapshot",
32
+ metavar="PATH",
33
+ help="Path to a snapshot archive (.tar.gz) for offline analysis.",
34
+ )
35
+ parser.add_argument(
36
+ "--user", "-u",
37
+ action="append",
38
+ dest="users",
39
+ metavar="USERNAME",
40
+ help="Analyse specific user(s). Can be repeated.",
41
+ )
42
+ parser.add_argument(
43
+ "--output", "-o",
44
+ choices=["cli", "json", "markdown"],
45
+ default="cli",
46
+ help="Output format (default: cli).",
47
+ )
48
+ parser.add_argument(
49
+ "--min-severity",
50
+ choices=["critical", "high", "medium", "low", "info"],
51
+ default="low",
52
+ help="Minimum severity to display (default: low).",
53
+ )
54
+ parser.add_argument(
55
+ "--max-depth",
56
+ type=int,
57
+ default=10,
58
+ help="Maximum traversal depth (default: 10).",
59
+ )
60
+ parser.add_argument(
61
+ "--scan-paths",
62
+ metavar="PATHS",
63
+ help="Comma-separated list of paths to scan (default: /etc,/usr,/opt,/tmp,/var).",
64
+ )
65
+ parser.add_argument(
66
+ "--export-graph",
67
+ metavar="FILE",
68
+ help="Export full graph as JSON to the specified file.",
69
+ )
70
+ parser.add_argument(
71
+ "--exit-code",
72
+ action="store_true",
73
+ help="Return non-zero exit code if paths at or above min-severity are found.",
74
+ )
75
+ parser.add_argument(
76
+ "-v", "--verbose",
77
+ action="count",
78
+ default=0,
79
+ help="Increase verbosity (-v info, -vv debug).",
80
+ )
81
+
82
+ return parser.parse_args(argv)
83
+
84
+
85
+ def setup_logging(verbosity: int) -> None:
86
+ if verbosity >= 2:
87
+ level = logging.DEBUG
88
+ elif verbosity >= 1:
89
+ level = logging.INFO
90
+ else:
91
+ level = logging.WARNING
92
+
93
+ logging.basicConfig(
94
+ level=level,
95
+ format="[%(levelname)s] %(name)s: %(message)s",
96
+ stream=sys.stderr,
97
+ )
98
+
99
+
100
+ def main(argv: Optional[List[str]] = None) -> int:
101
+ args = parse_args(argv)
102
+ setup_logging(args.verbose)
103
+ logger = logging.getLogger("privmap")
104
+
105
+ # Determine root path
106
+ root_path = "/"
107
+ snapshot_mode = False
108
+ temp_dir = None
109
+
110
+ if args.snapshot:
111
+ snapshot_mode = True
112
+ if not os.path.isfile(args.snapshot):
113
+ print(f"Error: Snapshot file not found: {args.snapshot}", file=sys.stderr)
114
+ return 1
115
+
116
+ # Extract snapshot to temp directory
117
+ temp_dir = tempfile.mkdtemp(prefix="privmap_snapshot_")
118
+ logger.info("Extracting snapshot to %s", temp_dir)
119
+ try:
120
+ with tarfile.open(args.snapshot, "r:gz") as tar:
121
+ tar.extractall(temp_dir)
122
+ except (tarfile.TarError, OSError) as e:
123
+ print(f"Error extracting snapshot: {e}", file=sys.stderr)
124
+ return 1
125
+
126
+ # Find the snapshot directory inside the extracted archive
127
+ entries = os.listdir(temp_dir)
128
+ if len(entries) == 1 and os.path.isdir(os.path.join(temp_dir, entries[0])):
129
+ root_path = os.path.join(temp_dir, entries[0])
130
+ else:
131
+ root_path = temp_dir
132
+
133
+ # Parse scan paths
134
+ scan_paths = None
135
+ if args.scan_paths:
136
+ scan_paths = [p.strip() for p in args.scan_paths.split(",")]
137
+
138
+ # Severity filter
139
+ min_severity = Severity(args.min_severity.upper())
140
+
141
+ try:
142
+ # Build graph
143
+ builder = GraphBuilder(
144
+ root_path=root_path,
145
+ scan_paths=scan_paths,
146
+ snapshot_mode=snapshot_mode,
147
+ )
148
+ graph = builder.build()
149
+
150
+ # Analyze paths
151
+ paths = analyze_paths(
152
+ graph,
153
+ source_users=args.users,
154
+ max_depth=args.max_depth,
155
+ min_severity=min_severity,
156
+ )
157
+
158
+ # Export full graph if requested
159
+ if args.export_graph:
160
+ graph_json = json.dumps(graph.to_dict(), indent=2, default=str)
161
+ with open(args.export_graph, "w") as f:
162
+ f.write(graph_json)
163
+ logger.info("Graph exported to %s", args.export_graph)
164
+
165
+ # Output
166
+ if args.output == "json":
167
+ print(export_json(paths, graph))
168
+ elif args.output == "markdown":
169
+ print(export_markdown(paths, graph))
170
+ else:
171
+ from rich.console import Console
172
+ console = Console(stderr=True) if args.exit_code else Console()
173
+ render_cli(paths, graph, console)
174
+
175
+ # Exit code
176
+ if args.exit_code and paths:
177
+ return 1
178
+ return 0
179
+
180
+ except KeyboardInterrupt:
181
+ print("\nInterrupted.", file=sys.stderr)
182
+ return 130
183
+ except Exception as e:
184
+ logger.exception("Unexpected error: %s", e)
185
+ print(f"Error: {e}", file=sys.stderr)
186
+ return 1
187
+ finally:
188
+ # Clean up temp directory
189
+ if temp_dir and os.path.isdir(temp_dir):
190
+ import shutil
191
+ shutil.rmtree(temp_dir, ignore_errors=True)
192
+
193
+
194
+ if __name__ == "__main__":
195
+ sys.exit(main())
File without changes
@@ -0,0 +1,86 @@
1
+ """Wires ingestion module output into the privilege graph."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import Optional
6
+
7
+ from privmap.graph.model import PrivilegeGraph
8
+ from privmap.ingestion.identity import IdentityIngester
9
+ from privmap.ingestion.filesystem import FilesystemIngester
10
+ from privmap.ingestion.execution import ExecutionIngester
11
+ from privmap.ingestion.capabilities import CapabilityIngester
12
+ from privmap.ingestion.processes import ProcessIngester
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class GraphBuilder:
18
+ """Coordinates all ingestion modules and builds the unified graph."""
19
+
20
+ def __init__(
21
+ self,
22
+ root_path: str = "/",
23
+ scan_paths: Optional[list] = None,
24
+ snapshot_mode: bool = False,
25
+ ) -> None:
26
+ self.root_path = root_path
27
+ self.scan_paths = scan_paths or ["/etc", "/usr", "/opt", "/tmp", "/var"]
28
+ self.snapshot_mode = snapshot_mode
29
+ self.graph = PrivilegeGraph()
30
+
31
+ def build(self) -> PrivilegeGraph:
32
+ logger.info("Starting graph construction (root=%s, snapshot=%s)",
33
+ self.root_path, self.snapshot_mode)
34
+
35
+ # Phase 1: Identity (users, groups, sudo)
36
+ logger.info("Phase 1: Ingesting identity data...")
37
+ identity = IdentityIngester(self.root_path, self.snapshot_mode)
38
+ identity.ingest(self.graph)
39
+ logger.info(
40
+ " Identity complete: %d nodes, %d edges",
41
+ self.graph.node_count, self.graph.edge_count,
42
+ )
43
+
44
+ # Phase 2: Filesystem permissions
45
+ logger.info("Phase 2: Ingesting filesystem permissions...")
46
+ filesystem = FilesystemIngester(
47
+ self.root_path, self.scan_paths, self.snapshot_mode
48
+ )
49
+ filesystem.ingest(self.graph)
50
+ logger.info(
51
+ " Filesystem complete: %d nodes, %d edges",
52
+ self.graph.node_count, self.graph.edge_count,
53
+ )
54
+
55
+ # Phase 3: Execution contexts (cron, systemd, init.d)
56
+ logger.info("Phase 3: Ingesting execution contexts...")
57
+ execution = ExecutionIngester(self.root_path, self.snapshot_mode)
58
+ execution.ingest(self.graph)
59
+ logger.info(
60
+ " Execution complete: %d nodes, %d edges",
61
+ self.graph.node_count, self.graph.edge_count,
62
+ )
63
+
64
+ # Phase 4: Capabilities
65
+ logger.info("Phase 4: Ingesting capabilities...")
66
+ caps = CapabilityIngester(self.root_path, self.snapshot_mode)
67
+ caps.ingest(self.graph)
68
+ logger.info(
69
+ " Capabilities complete: %d nodes, %d edges",
70
+ self.graph.node_count, self.graph.edge_count,
71
+ )
72
+
73
+ # Phase 5: Running processes (skip in snapshot mode unless /proc captured)
74
+ logger.info("Phase 5: Ingesting process data...")
75
+ procs = ProcessIngester(self.root_path, self.snapshot_mode)
76
+ procs.ingest(self.graph)
77
+ logger.info(
78
+ " Processes complete: %d nodes, %d edges",
79
+ self.graph.node_count, self.graph.edge_count,
80
+ )
81
+
82
+ logger.info(
83
+ "Graph construction complete: %d nodes, %d edges",
84
+ self.graph.node_count, self.graph.edge_count,
85
+ )
86
+ return self.graph