attackmap 0.6.4__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.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +449 -0
- attackmap/analyzer_contracts.py +194 -0
- attackmap/analyzers.py +1003 -0
- attackmap/anomalies.py +612 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +133 -0
- attackmap/auth_protocol.py +702 -0
- attackmap/authz.py +1695 -0
- attackmap/bench.py +467 -0
- attackmap/cli.py +1474 -0
- attackmap/config_scanner.py +389 -0
- attackmap/context_pack.py +82 -0
- attackmap/contracts.py +201 -0
- attackmap/control_model.py +280 -0
- attackmap/crossrepo.py +356 -0
- attackmap/crypto.py +240 -0
- attackmap/cve.py +717 -0
- attackmap/defensive_review.py +647 -0
- attackmap/detection_opportunities.py +1221 -0
- attackmap/detections/__init__.py +491 -0
- attackmap/detections/bola.yml +57 -0
- attackmap/detections/cloud.yml +609 -0
- attackmap/detections/injection.yml +167 -0
- attackmap/detections/secrets.yml +48 -0
- attackmap/detections/sigma.py +511 -0
- attackmap/detections/ssrf.yml +98 -0
- attackmap/detections/weakness.yml +62 -0
- attackmap/detections/webhook.yml +45 -0
- attackmap/detections/workflow.yml +121 -0
- attackmap/diagrams.py +237 -0
- attackmap/diff.py +301 -0
- attackmap/exploitability.py +493 -0
- attackmap/fingerprint.py +91 -0
- attackmap/fleet.py +370 -0
- attackmap/gitref.py +129 -0
- attackmap/graph.py +26 -0
- attackmap/handler_auth.py +639 -0
- attackmap/handler_span.py +62 -0
- attackmap/hunt_harness.py +505 -0
- attackmap/insights.py +701 -0
- attackmap/llm_review.py +846 -0
- attackmap/lockfiles.py +521 -0
- attackmap/mass_assignment.py +543 -0
- attackmap/md.py +175 -0
- attackmap/merge.py +333 -0
- attackmap/models.py +1284 -0
- attackmap/plugins_lock.py +114 -0
- attackmap/popular_packages.py +193 -0
- attackmap/progress.py +258 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +149 -0
- attackmap/redact.py +220 -0
- attackmap/report.py +424 -0
- attackmap/review_eval.py +335 -0
- attackmap/review_json.py +308 -0
- attackmap/review_prompts.py +796 -0
- attackmap/route_auth_fusion.py +474 -0
- attackmap/safe_fs.py +285 -0
- attackmap/sarif.py +373 -0
- attackmap/sbom.py +418 -0
- attackmap/scanner.py +1379 -0
- attackmap/sdk/__init__.py +278 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/fs.py +156 -0
- attackmap/sdk/models.py +59 -0
- attackmap/secrets_history.py +161 -0
- attackmap/security_overlay.py +71 -0
- attackmap/srcpaths.py +301 -0
- attackmap/suggest.py +394 -0
- attackmap/supply_chain.py +688 -0
- attackmap/suppress.py +740 -0
- attackmap/taint.py +1540 -0
- attackmap/taint_flow.py +1419 -0
- attackmap/taint_sinks.py +431 -0
- attackmap/taint_sinks.yaml +308 -0
- attackmap/taint_sources.py +396 -0
- attackmap/taxonomy.py +465 -0
- attackmap/threat_model.py +3441 -0
- attackmap/topology.py +316 -0
- attackmap/triage.py +151 -0
- attackmap/weaknesses.py +365 -0
- attackmap/webhardening.py +479 -0
- attackmap/webhook_verification.py +1067 -0
- attackmap/workflow_scanner.py +964 -0
- attackmap-0.6.4.dist-info/METADATA +226 -0
- attackmap-0.6.4.dist-info/RECORD +91 -0
- attackmap-0.6.4.dist-info/WHEEL +5 -0
- attackmap-0.6.4.dist-info/entry_points.txt +2 -0
- attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
- attackmap-0.6.4.dist-info/top_level.txt +1 -0
attackmap/fleet.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Fleet (multi-repo) container + summary rendering — cross-repo phase 1 (#146a).
|
|
2
|
+
|
|
3
|
+
The lowest-blast-radius shape for cross-repo analysis (epic #150): rather than
|
|
4
|
+
merge N repos into one `ScanResult` (which would force a repo id onto every
|
|
5
|
+
signal model and rewrite the scanner's path relativization), each repo is
|
|
6
|
+
scanned independently into its own `ScanResult` — its own root, its own
|
|
7
|
+
relative paths, no collisions — and the results are collected into a `FleetScan`
|
|
8
|
+
over which later phases (#146b–#146d: contract linking, cross-boundary taint,
|
|
9
|
+
trust-gap) will reason.
|
|
10
|
+
|
|
11
|
+
This module is deliberately pure: the CLI does the per-repo scanning (reusing
|
|
12
|
+
the same building blocks a single-repo run uses) and hands the results here for
|
|
13
|
+
identity assignment and summary rendering, so the logic stays unit-testable
|
|
14
|
+
without a real scan. Phase 1 adds **no** cross-repo detection — it is the
|
|
15
|
+
foundation the seam-analysis phases build on.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from .contracts import ContractLink
|
|
26
|
+
from .crossrepo import CrossBoundaryFlow, CrossRepoAnomaly, TrustGap
|
|
27
|
+
from .diagrams import _mermaid_text
|
|
28
|
+
from .md import md_code, md_text
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from .models import AttackPath, Finding, ScanResult
|
|
32
|
+
|
|
33
|
+
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
|
34
|
+
|
|
35
|
+
_SEVERITIES = ("high", "medium", "low")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _slug(name: str) -> str:
|
|
39
|
+
"""A filesystem- and link-safe slug for a repo directory name."""
|
|
40
|
+
s = _SLUG_RE.sub("-", name.lower()).strip("-")
|
|
41
|
+
return s or "repo"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def fleet_repo_ids(paths: list[Path]) -> list[str]:
|
|
45
|
+
"""Stable, unique per-repo identifiers derived from the directory names.
|
|
46
|
+
|
|
47
|
+
Two repos with the same basename (``a/service`` and ``b/service``) would
|
|
48
|
+
collide into one report subdirectory, so collisions are disambiguated by
|
|
49
|
+
appending ``-2``, ``-3``, … in input order. Each candidate is checked against
|
|
50
|
+
**every id already emitted** — not just a per-base counter — so a suffix that
|
|
51
|
+
coincides with a later input's own name (``api``, ``api``, ``api-2`` →
|
|
52
|
+
``api``, ``api-2``, ``api-2-2``) still stays globally unique. Deterministic.
|
|
53
|
+
"""
|
|
54
|
+
ids: list[str] = []
|
|
55
|
+
used: set[str] = set()
|
|
56
|
+
for path in paths:
|
|
57
|
+
base = _slug(path.name)
|
|
58
|
+
candidate = base
|
|
59
|
+
n = 1
|
|
60
|
+
while candidate in used:
|
|
61
|
+
n += 1
|
|
62
|
+
candidate = f"{base}-{n}"
|
|
63
|
+
used.add(candidate)
|
|
64
|
+
ids.append(candidate)
|
|
65
|
+
return ids
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class FleetRepoResult:
|
|
70
|
+
"""One repo's analysis within a fleet run."""
|
|
71
|
+
|
|
72
|
+
repo_id: str
|
|
73
|
+
root: str # absolute path scanned
|
|
74
|
+
report_dir: str # where this repo's reports were written (output/<repo_id>)
|
|
75
|
+
scan: "ScanResult"
|
|
76
|
+
findings: list["Finding"]
|
|
77
|
+
attack_paths: list["AttackPath"] = field(default_factory=list)
|
|
78
|
+
suppressed_count: int = 0
|
|
79
|
+
|
|
80
|
+
def severity_counts(self) -> dict[str, int]:
|
|
81
|
+
counts = {sev: 0 for sev in _SEVERITIES}
|
|
82
|
+
for f in self.findings:
|
|
83
|
+
if f.severity in counts:
|
|
84
|
+
counts[f.severity] += 1
|
|
85
|
+
return counts
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class FleetScan:
|
|
90
|
+
"""A multi-repo analysis run — the input surface for cross-repo phases."""
|
|
91
|
+
|
|
92
|
+
results: list[FleetRepoResult] = field(default_factory=list)
|
|
93
|
+
# Cross-repo client→server contract links (#146b), computed once all repos
|
|
94
|
+
# are scanned. Empty until the linker runs.
|
|
95
|
+
links: list[ContractLink] = field(default_factory=list)
|
|
96
|
+
# Cross-boundary trust flows (#146c) — confused-deputy leads over the links.
|
|
97
|
+
cross_boundary: list[CrossBoundaryFlow] = field(default_factory=list)
|
|
98
|
+
# Trust-assumption gaps + cross-repo anomalies (#146d / #149b).
|
|
99
|
+
trust_gaps: list[TrustGap] = field(default_factory=list)
|
|
100
|
+
cross_repo_anomalies: list[CrossRepoAnomaly] = field(default_factory=list)
|
|
101
|
+
# Repos whose analysis raised (#217): {repo_id, root, error}. The run keeps
|
|
102
|
+
# going without them and exits non-zero at the end.
|
|
103
|
+
failed: list[dict[str, str]] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def repo_count(self) -> int:
|
|
107
|
+
return len(self.results)
|
|
108
|
+
|
|
109
|
+
def total_findings(self) -> int:
|
|
110
|
+
return sum(len(r.findings) for r in self.results)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _top_findings(result: FleetRepoResult, limit: int = 3) -> list["Finding"]:
|
|
114
|
+
order = {sev: i for i, sev in enumerate(_SEVERITIES)}
|
|
115
|
+
ranked = sorted(
|
|
116
|
+
result.findings,
|
|
117
|
+
key=lambda f: (order.get(f.severity, 99), -(f.score or 0), f.title),
|
|
118
|
+
)
|
|
119
|
+
return ranked[:limit]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def render_fleet_summary(fleet: FleetScan) -> str:
|
|
123
|
+
"""A Markdown fleet index: one row per repo with a severity breakdown, plus
|
|
124
|
+
each repo's top findings and a link to its per-repo report directory."""
|
|
125
|
+
lines: list[str] = ["# AttackMap fleet summary", ""]
|
|
126
|
+
lines.append(
|
|
127
|
+
f"{fleet.repo_count} repositories analyzed · "
|
|
128
|
+
f"{fleet.total_findings()} finding(s) total."
|
|
129
|
+
)
|
|
130
|
+
lines.append("")
|
|
131
|
+
if fleet.failed:
|
|
132
|
+
lines.append(f"**{len(fleet.failed)} repositor{'y' if len(fleet.failed) == 1 else 'ies'} failed and "
|
|
133
|
+
"are not included below:**")
|
|
134
|
+
for f in fleet.failed:
|
|
135
|
+
lines.append(f"- {md_code(f['repo_id'])} — {md_text(f['error'])}")
|
|
136
|
+
lines.append("")
|
|
137
|
+
lines.append("| Repository | HIGH | MED | LOW | Total | Report |")
|
|
138
|
+
lines.append("|---|---:|---:|---:|---:|---|")
|
|
139
|
+
for r in fleet.results:
|
|
140
|
+
c = r.severity_counts()
|
|
141
|
+
total = len(r.findings)
|
|
142
|
+
lines.append(
|
|
143
|
+
f"| {md_code(r.repo_id)} | {c['high']} | {c['medium']} | {c['low']} | "
|
|
144
|
+
f"{total} | [{r.repo_id}/]({r.repo_id}/) |"
|
|
145
|
+
)
|
|
146
|
+
lines.append("")
|
|
147
|
+
|
|
148
|
+
for r in fleet.results:
|
|
149
|
+
lines.append(f"## {md_code(r.repo_id)}")
|
|
150
|
+
lines.append("")
|
|
151
|
+
lines.append(f"- Path: {md_code(r.root)}")
|
|
152
|
+
lines.append(f"- Findings: {len(r.findings)} ({r.suppressed_count} suppressed)")
|
|
153
|
+
top = _top_findings(r)
|
|
154
|
+
if top:
|
|
155
|
+
lines.append("- Top findings:")
|
|
156
|
+
for f in top:
|
|
157
|
+
lines.append(f" - **[{f.severity.upper()}]** {md_text(f.title)}")
|
|
158
|
+
lines.append("")
|
|
159
|
+
|
|
160
|
+
lines.append("## Cross-repo links (#146b)")
|
|
161
|
+
lines.append("")
|
|
162
|
+
if fleet.links:
|
|
163
|
+
loc = lambda f, ln: md_text(f"{f}:{ln}" if ln else f) # noqa: E731
|
|
164
|
+
lines.append(
|
|
165
|
+
f"{len(fleet.links)} client→server contract link(s) — an outbound call "
|
|
166
|
+
"in one repo matched to a route another repo serves:"
|
|
167
|
+
)
|
|
168
|
+
lines.append("")
|
|
169
|
+
lines.append("| Client | Call | → | Server | Route |")
|
|
170
|
+
lines.append("|---|---|---|---|---|")
|
|
171
|
+
for lk in fleet.links:
|
|
172
|
+
lines.append(
|
|
173
|
+
f"| {md_code(lk.client_repo)} | {md_code(f'{lk.method} {lk.client_target}')} "
|
|
174
|
+
f"[{loc(lk.client_file, lk.client_line)}] | → | {md_code(lk.server_repo)} | "
|
|
175
|
+
f"{md_code(f'{lk.method} {lk.server_route_path}')} [{loc(lk.server_file, lk.server_line)}] |"
|
|
176
|
+
)
|
|
177
|
+
lines.append("")
|
|
178
|
+
else:
|
|
179
|
+
lines.append("_No cross-repo client→server HTTP links detected._")
|
|
180
|
+
lines.append("")
|
|
181
|
+
|
|
182
|
+
lines.append("## Cross-boundary trust (#146c)")
|
|
183
|
+
lines.append("")
|
|
184
|
+
if fleet.cross_boundary:
|
|
185
|
+
loc = lambda f, ln: md_text(f"{f}:{ln}" if ln else f) # noqa: E731
|
|
186
|
+
lines.append(
|
|
187
|
+
f"{len(fleet.cross_boundary)} SPECULATIVE confused-deputy flow(s) — a "
|
|
188
|
+
"value forwarded across a link that the callee trusts into a sink or "
|
|
189
|
+
"unguarded object access. Adjudicate before acting:"
|
|
190
|
+
)
|
|
191
|
+
lines.append("")
|
|
192
|
+
for cb in fleet.cross_boundary:
|
|
193
|
+
what = (
|
|
194
|
+
f"reaches a {md_text(cb.detail)} sink"
|
|
195
|
+
if cb.basis == "taint"
|
|
196
|
+
else f"is an unguarded {md_text(cb.detail)}"
|
|
197
|
+
)
|
|
198
|
+
lines.append(
|
|
199
|
+
f"- **[{cb.severity.upper()}, SPECULATIVE]** {md_code(cb.client_repo)} calls "
|
|
200
|
+
f"{md_code(f'{cb.method} {cb.route}')} served by {md_code(cb.server_repo)}, which {what} — "
|
|
201
|
+
f"the caller's value is trusted without the callee re-validating. "
|
|
202
|
+
f"Caller: {md_code(cb.client_target)} [{loc(cb.client_file, cb.client_line)}]; "
|
|
203
|
+
f"callee: [{loc(cb.server_file, cb.server_line)}]."
|
|
204
|
+
)
|
|
205
|
+
lines.append("")
|
|
206
|
+
else:
|
|
207
|
+
lines.append("_No cross-boundary trust flows detected._")
|
|
208
|
+
lines.append("")
|
|
209
|
+
|
|
210
|
+
loc = lambda f, ln: md_text(f"{f}:{ln}" if ln else f) # noqa: E731
|
|
211
|
+
|
|
212
|
+
lines.append("## Trust-assumption gaps (#146d)")
|
|
213
|
+
lines.append("")
|
|
214
|
+
if fleet.trust_gaps:
|
|
215
|
+
lines.append(
|
|
216
|
+
f"{len(fleet.trust_gaps)} SPECULATIVE gap(s) — a state-changing call "
|
|
217
|
+
"crosses a link to a route the callee serves with no auth control "
|
|
218
|
+
"(if each side assumes the other enforces, nobody does):"
|
|
219
|
+
)
|
|
220
|
+
lines.append("")
|
|
221
|
+
for g in fleet.trust_gaps:
|
|
222
|
+
lines.append(
|
|
223
|
+
f"- **[{g.severity.upper()}, SPECULATIVE]** {md_code(g.client_repo)} calls "
|
|
224
|
+
f"{md_code(f'{g.method} {g.route}')} on {md_code(g.server_repo)}, which enforces no "
|
|
225
|
+
f"authentication. Caller: {md_code(g.client_target)} "
|
|
226
|
+
f"[{loc(g.client_file, g.client_line)}]; callee route: "
|
|
227
|
+
f"[{loc(g.server_file, g.server_line)}]."
|
|
228
|
+
)
|
|
229
|
+
lines.append("")
|
|
230
|
+
else:
|
|
231
|
+
lines.append("_No trust-assumption gaps detected._")
|
|
232
|
+
lines.append("")
|
|
233
|
+
|
|
234
|
+
lines.append("## Cross-repo anomalies (#149b)")
|
|
235
|
+
lines.append("")
|
|
236
|
+
if fleet.cross_repo_anomalies:
|
|
237
|
+
lines.append(
|
|
238
|
+
f"{len(fleet.cross_repo_anomalies)} SPECULATIVE outlier(s) — a service "
|
|
239
|
+
"that omits an auth control its siblings enforce on the same route:"
|
|
240
|
+
)
|
|
241
|
+
lines.append("")
|
|
242
|
+
for a in fleet.cross_repo_anomalies:
|
|
243
|
+
peers = ", ".join(md_code(p) for p in a.peers)
|
|
244
|
+
lines.append(
|
|
245
|
+
f"- **[{a.severity.upper()}, SPECULATIVE]** {md_code(a.repo)} serves "
|
|
246
|
+
f"{md_code(f'{a.method} {a.route}')} with no auth, but {peers} guard the same "
|
|
247
|
+
f"{md_code(f'/{a.template}')} route."
|
|
248
|
+
)
|
|
249
|
+
lines.append("")
|
|
250
|
+
else:
|
|
251
|
+
lines.append("_No cross-repo control anomalies detected._")
|
|
252
|
+
lines.append("")
|
|
253
|
+
|
|
254
|
+
return "\n".join(lines)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def render_fleet_graph_mermaid(fleet: FleetScan) -> str:
|
|
258
|
+
"""A Mermaid flowchart of the fleet: one node per repo, one edge per distinct
|
|
259
|
+
client→server route link (labeled with the served contract)."""
|
|
260
|
+
lines = ["```mermaid", "flowchart LR"]
|
|
261
|
+
ids = {r.repo_id for r in fleet.results}
|
|
262
|
+
node_id = {rid: f"R{i}" for i, rid in enumerate(sorted(ids))}
|
|
263
|
+
for rid in sorted(ids):
|
|
264
|
+
lines.append(f' {node_id[rid]}["{_mermaid_text(rid)}"]')
|
|
265
|
+
# Collapse multiple links between the same pair to one edge per route.
|
|
266
|
+
seen: set[tuple[str, str, str, str]] = set()
|
|
267
|
+
for lk in fleet.links:
|
|
268
|
+
edge = (lk.client_repo, lk.server_repo, lk.method, lk.path_template)
|
|
269
|
+
if edge in seen or lk.client_repo not in node_id or lk.server_repo not in node_id:
|
|
270
|
+
continue
|
|
271
|
+
seen.add(edge)
|
|
272
|
+
label = _mermaid_text(f"{lk.method} /{lk.path_template}")
|
|
273
|
+
lines.append(f' {node_id[lk.client_repo]} -->|"{label}"| {node_id[lk.server_repo]}')
|
|
274
|
+
lines.append("```")
|
|
275
|
+
return "\n".join(lines)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def fleet_summary_json(fleet: FleetScan) -> dict:
|
|
279
|
+
"""Machine-readable fleet index (for GUI / tooling front-ends)."""
|
|
280
|
+
return {
|
|
281
|
+
"repo_count": fleet.repo_count,
|
|
282
|
+
"total_findings": fleet.total_findings(),
|
|
283
|
+
"failed": list(fleet.failed),
|
|
284
|
+
"repos": [
|
|
285
|
+
{
|
|
286
|
+
"repo_id": r.repo_id,
|
|
287
|
+
"root": r.root,
|
|
288
|
+
"report_dir": r.report_dir,
|
|
289
|
+
"findings": len(r.findings),
|
|
290
|
+
"suppressed": r.suppressed_count,
|
|
291
|
+
"severity_counts": r.severity_counts(),
|
|
292
|
+
}
|
|
293
|
+
for r in fleet.results
|
|
294
|
+
],
|
|
295
|
+
"cross_repo_links": [
|
|
296
|
+
{
|
|
297
|
+
"client_repo": lk.client_repo,
|
|
298
|
+
"server_repo": lk.server_repo,
|
|
299
|
+
"method": lk.method,
|
|
300
|
+
"path_template": lk.path_template,
|
|
301
|
+
"client_target": lk.client_target,
|
|
302
|
+
"client_location": f"{lk.client_file}:{lk.client_line}"
|
|
303
|
+
if lk.client_line
|
|
304
|
+
else lk.client_file,
|
|
305
|
+
"server_route_path": lk.server_route_path,
|
|
306
|
+
"server_location": f"{lk.server_file}:{lk.server_line}"
|
|
307
|
+
if lk.server_line
|
|
308
|
+
else lk.server_file,
|
|
309
|
+
}
|
|
310
|
+
for lk in fleet.links
|
|
311
|
+
],
|
|
312
|
+
"cross_boundary_flows": [
|
|
313
|
+
{
|
|
314
|
+
"client_repo": cb.client_repo,
|
|
315
|
+
"server_repo": cb.server_repo,
|
|
316
|
+
"method": cb.method,
|
|
317
|
+
"route": cb.route,
|
|
318
|
+
"basis": cb.basis,
|
|
319
|
+
"detail": cb.detail,
|
|
320
|
+
"severity": cb.severity,
|
|
321
|
+
"speculative": True,
|
|
322
|
+
"client_location": f"{cb.client_file}:{cb.client_line}"
|
|
323
|
+
if cb.client_line
|
|
324
|
+
else cb.client_file,
|
|
325
|
+
"server_location": f"{cb.server_file}:{cb.server_line}"
|
|
326
|
+
if cb.server_line
|
|
327
|
+
else cb.server_file,
|
|
328
|
+
}
|
|
329
|
+
for cb in fleet.cross_boundary
|
|
330
|
+
],
|
|
331
|
+
"trust_gaps": [
|
|
332
|
+
{
|
|
333
|
+
"client_repo": g.client_repo,
|
|
334
|
+
"server_repo": g.server_repo,
|
|
335
|
+
"method": g.method,
|
|
336
|
+
"route": g.route,
|
|
337
|
+
"severity": g.severity,
|
|
338
|
+
"speculative": True,
|
|
339
|
+
"client_location": f"{g.client_file}:{g.client_line}"
|
|
340
|
+
if g.client_line
|
|
341
|
+
else g.client_file,
|
|
342
|
+
"server_location": f"{g.server_file}:{g.server_line}"
|
|
343
|
+
if g.server_line
|
|
344
|
+
else g.server_file,
|
|
345
|
+
}
|
|
346
|
+
for g in fleet.trust_gaps
|
|
347
|
+
],
|
|
348
|
+
"cross_repo_anomalies": [
|
|
349
|
+
{
|
|
350
|
+
"repo": a.repo,
|
|
351
|
+
"method": a.method,
|
|
352
|
+
"route": a.route,
|
|
353
|
+
"template": a.template,
|
|
354
|
+
"peers": list(a.peers),
|
|
355
|
+
"severity": a.severity,
|
|
356
|
+
"speculative": True,
|
|
357
|
+
}
|
|
358
|
+
for a in fleet.cross_repo_anomalies
|
|
359
|
+
],
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
__all__ = [
|
|
364
|
+
"FleetRepoResult",
|
|
365
|
+
"FleetScan",
|
|
366
|
+
"fleet_repo_ids",
|
|
367
|
+
"fleet_summary_json",
|
|
368
|
+
"render_fleet_graph_mermaid",
|
|
369
|
+
"render_fleet_summary",
|
|
370
|
+
]
|
attackmap/gitref.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Read files and changed lines from a git ref (#238).
|
|
2
|
+
|
|
3
|
+
Used to trust only suppressions that already exist on a reference branch (the
|
|
4
|
+
PR's base): a pull request must not be able to suppress its own findings.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import difflib
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .safe_fs import UnsafePathError, read_repo_text
|
|
15
|
+
from .srcpaths import split_lines
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GitRefError(RuntimeError):
|
|
19
|
+
"""The ref can't be resolved, or the directory isn't a git checkout."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# The scanned repository is untrusted, and so is its `.git/config`: it can set
|
|
23
|
+
# `core.fsmonitor`, `diff.external`, `core.pager` or textconv drivers that run
|
|
24
|
+
# arbitrary commands when git reads history. Every git call AttackMap makes
|
|
25
|
+
# overrides those, ignores system/global config, never prompts, and never
|
|
26
|
+
# lazily fetches missing objects from a promisor remote (#252).
|
|
27
|
+
_HARDENED_CONFIG = (
|
|
28
|
+
"-c", "core.fsmonitor=false",
|
|
29
|
+
"-c", "core.hooksPath=" + os.devnull,
|
|
30
|
+
"-c", "core.pager=cat",
|
|
31
|
+
"-c", "diff.external=",
|
|
32
|
+
"-c", "core.attributesFile=" + os.devnull,
|
|
33
|
+
"-c", "protocol.allow=never",
|
|
34
|
+
"-c", "core.sshCommand=false",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def hardened_git_env() -> dict[str, str]:
|
|
39
|
+
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
|
40
|
+
env.update({
|
|
41
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
42
|
+
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
43
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
44
|
+
"GIT_NO_LAZY_FETCH": "1",
|
|
45
|
+
"GIT_PAGER": "cat",
|
|
46
|
+
"GIT_OPTIONAL_LOCKS": "0",
|
|
47
|
+
})
|
|
48
|
+
return env
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def hardened_git_command(root: Path, *args: str) -> list[str]:
|
|
52
|
+
"""``git -C root <hardening> --no-pager <args>`` (see ``_HARDENED_CONFIG``)."""
|
|
53
|
+
return ["git", "-C", str(root), *_HARDENED_CONFIG, "--no-pager", *args]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _git(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
57
|
+
return subprocess.run(
|
|
58
|
+
hardened_git_command(root, *args),
|
|
59
|
+
capture_output=True,
|
|
60
|
+
text=True,
|
|
61
|
+
check=False,
|
|
62
|
+
stdin=subprocess.DEVNULL,
|
|
63
|
+
env=hardened_git_env(),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_ref(root: Path, ref: str) -> None:
|
|
68
|
+
if not ref or ref.startswith("-"):
|
|
69
|
+
raise GitRefError(f"invalid git ref {ref!r}")
|
|
70
|
+
probe = _git(root, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}")
|
|
71
|
+
if probe.returncode != 0:
|
|
72
|
+
raise GitRefError(
|
|
73
|
+
f"git ref {ref!r} is not available in {root} (in CI, fetch the base branch first, "
|
|
74
|
+
"e.g. git fetch --depth=1 origin <base>)"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def show_file(root: Path, ref: str, rel: str) -> str | None:
|
|
79
|
+
"""Content of ``rel`` (relative to ``root``) at ``ref``; None if absent there.
|
|
80
|
+
|
|
81
|
+
`cat-file blob` returns the raw object: no textconv and no filter drivers,
|
|
82
|
+
which a hostile `.git/config` could point at arbitrary commands.
|
|
83
|
+
"""
|
|
84
|
+
result = _git(root, "cat-file", "blob", f"{ref}:./{rel}")
|
|
85
|
+
if result.returncode != 0:
|
|
86
|
+
return None
|
|
87
|
+
return result.stdout
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def added_lines(root: Path, ref: str, rel: str) -> set[int]:
|
|
91
|
+
"""Line numbers of ``rel`` (working tree) that are new or changed since ``ref``.
|
|
92
|
+
|
|
93
|
+
Computed in Python from the blob at ``ref`` and the working file, never by
|
|
94
|
+
`git diff <ref> -- <file>`: diffing against the working tree runs the
|
|
95
|
+
repo's `filter.<driver>.clean` commands, which `--no-textconv` and
|
|
96
|
+
`--no-ext-diff` don't disable, so a scanned repo could execute code.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
current = split_lines(read_repo_text(root, rel, errors="replace"))
|
|
100
|
+
except (OSError, UnsafePathError):
|
|
101
|
+
return set()
|
|
102
|
+
previous = show_file(root, ref, rel)
|
|
103
|
+
if previous is None:
|
|
104
|
+
return set(range(1, len(current) + 1)) # new since ref (or untracked)
|
|
105
|
+
matcher = difflib.SequenceMatcher(None, split_lines(previous), current, autojunk=False)
|
|
106
|
+
lines: set[int] = set()
|
|
107
|
+
for tag, _i1, _i2, j1, j2 in matcher.get_opcodes():
|
|
108
|
+
if tag in ("replace", "insert"):
|
|
109
|
+
lines.update(range(j1 + 1, j2 + 1))
|
|
110
|
+
return lines
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def untrusted_output_markers(root: Path, ref: str, *, keep_under: "list[Path] | tuple[Path, ...]" = ()) -> list[Path]:
|
|
114
|
+
"""Output-marker files under ``root`` that do not exist at ``ref``, as
|
|
115
|
+
paths relative to ``root``: ones a pull request added, which must not
|
|
116
|
+
exclude their directory from the scan (GHSA-j544-8x8g-c3mw). Markers
|
|
117
|
+
anywhere under a ``keep_under`` directory (the run's own output
|
|
118
|
+
directory, which carries markers in its subdirectories too) are trusted."""
|
|
119
|
+
from .safe_fs import output_marker_files # noqa: PLC0415 — local, so the module header is untouched
|
|
120
|
+
|
|
121
|
+
kept = [os.path.abspath(str(d)) for d in keep_under]
|
|
122
|
+
untrusted: list[Path] = []
|
|
123
|
+
for rel in output_marker_files(root):
|
|
124
|
+
marker = os.path.abspath(str(root / rel))
|
|
125
|
+
if any(marker == d or marker.startswith(d + os.sep) for d in kept):
|
|
126
|
+
continue
|
|
127
|
+
if show_file(root, ref, rel.as_posix()) is None:
|
|
128
|
+
untrusted.append(rel)
|
|
129
|
+
return untrusted
|
attackmap/graph.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import networkx as nx
|
|
4
|
+
|
|
5
|
+
from .models import ScanResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_graph(scan: ScanResult) -> nx.DiGraph:
|
|
9
|
+
graph = nx.DiGraph()
|
|
10
|
+
|
|
11
|
+
graph.add_node("repo", kind="system", label="Repository")
|
|
12
|
+
|
|
13
|
+
if scan.routes:
|
|
14
|
+
graph.add_node("web", kind="service", label="Web/API Layer")
|
|
15
|
+
graph.add_edge("repo", "web", relation="contains")
|
|
16
|
+
|
|
17
|
+
for db in sorted({d.kind for d in scan.databases}):
|
|
18
|
+
graph.add_node(db, kind="database", label=db)
|
|
19
|
+
graph.add_edge("web" if scan.routes else "repo", db, relation="uses")
|
|
20
|
+
|
|
21
|
+
for idx, call in enumerate(scan.external_calls, start=1):
|
|
22
|
+
node_id = f"external_{idx}"
|
|
23
|
+
graph.add_node(node_id, kind="external", label=call.target)
|
|
24
|
+
graph.add_edge("web" if scan.routes else "repo", node_id, relation="calls")
|
|
25
|
+
|
|
26
|
+
return graph
|