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.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. 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