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 +2 -0
- privmap/analysis/__init__.py +0 -0
- privmap/analysis/paths.py +49 -0
- privmap/analysis/remediation.py +148 -0
- privmap/analysis/scoring.py +138 -0
- privmap/cli.py +195 -0
- privmap/graph/__init__.py +0 -0
- privmap/graph/builder.py +86 -0
- privmap/graph/model.py +275 -0
- privmap/graph/traversal.py +242 -0
- privmap/ingestion/__init__.py +0 -0
- privmap/ingestion/capabilities.py +310 -0
- privmap/ingestion/execution.py +405 -0
- privmap/ingestion/filesystem.py +336 -0
- privmap/ingestion/identity.py +355 -0
- privmap/ingestion/processes.py +136 -0
- privmap/output/__init__.py +0 -0
- privmap/output/cli_output.py +164 -0
- privmap/output/json_export.py +38 -0
- privmap/output/markdown_export.py +89 -0
- privmap-1.0.0.dist-info/METADATA +174 -0
- privmap-1.0.0.dist-info/RECORD +26 -0
- privmap-1.0.0.dist-info/WHEEL +5 -0
- privmap-1.0.0.dist-info/entry_points.txt +2 -0
- privmap-1.0.0.dist-info/licenses/LICENSE +21 -0
- privmap-1.0.0.dist-info/top_level.txt +1 -0
privmap/__init__.py
ADDED
|
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
|
privmap/graph/builder.py
ADDED
|
@@ -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
|