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
@@ -0,0 +1,493 @@
1
+ """Exploitability fusion — 'exploitable now' risk scoring (#79).
2
+
3
+ Everything AttackMap already knows about a route→sink path is fused into a
4
+ single 0–100 score so the highest-risk *combinations* rise to the top of the
5
+ report — the public, unauthenticated route whose request reaches a SQL sink
6
+ next to a secret is a different animal from an internal, authed route that
7
+ reaches the same sink two hops away, even though both are "SQL injection."
8
+
9
+ Design goals (both from the ticket):
10
+
11
+ - **Deterministic.** The same scan always produces the same score; no
12
+ randomness, no clock, no ordering-dependent state. Ties break on a stable
13
+ key.
14
+ - **Explainable.** The score is the clamped sum of named `ExploitabilityFactor`
15
+ contributions — every point is attributable, and a factor worth 0 points is
16
+ still recorded when it's decision-relevant (e.g. "auth present at entry").
17
+
18
+ The fusion unit is the taint chain (route → dangerous sink). Auth, exposure
19
+ and surface category come from the matching `AttackSurface`; data sensitivity
20
+ from datastores / secrets co-located with the sink; and insecure-crypto /
21
+ web-hardening gaps plus known-vulnerable dependencies imported on the path act
22
+ as amplifiers. CVE fusion (#104) resolves each path file's bare imports to
23
+ package names and matches them against the `--cve` advisory set, so a public
24
+ route reaching a sink through a known-RCE library version scores higher.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import re
30
+ from pathlib import Path
31
+
32
+ from .safe_fs import contained_file
33
+ from .models import (
34
+ AttackSurface,
35
+ ExploitabilityFactor,
36
+ ExploitabilityScore,
37
+ ExploitabilityTier,
38
+ ScanResult,
39
+ TaintChain,
40
+ )
41
+ from .srcpaths import JS_TS_SUFFIXES, is_infra_route
42
+ from .taint_sinks import kind_base_score, kind_label
43
+
44
+ # Human labels for sink kinds (kept in sync with threat_model._TAINT_SINK_LABEL).
45
+ _SINK_LABEL: dict[str, str] = {
46
+ "sql_execute": "database (SQL execute)",
47
+ "subprocess_shell": "command execution",
48
+ "eval": "code eval",
49
+ "exec": "code exec",
50
+ "dynamic_open": "filesystem open",
51
+ "unsafe_deserialization": "unsafe deserialization",
52
+ "ssti": "template injection",
53
+ "ssrf": "outbound request (SSRF)",
54
+ "nosql_injection": "NoSQL query",
55
+ "open_redirect": "redirect target",
56
+ }
57
+
58
+ # Base points by sink danger. RCE / SQLi / deserialization class sit at the
59
+ # top; redirect/SSRF are lower because impact is usually narrower.
60
+ _SINK_BASE: dict[str, int] = {
61
+ "sql_execute": 35,
62
+ "exec": 35,
63
+ "eval": 35,
64
+ "subprocess_shell": 35,
65
+ "unsafe_deserialization": 35,
66
+ "ssti": 30,
67
+ "nosql_injection": 25,
68
+ "dynamic_open": 22,
69
+ "ssrf": 20,
70
+ "open_redirect": 15,
71
+ }
72
+
73
+ _EXPOSURE_PTS: dict[str, int] = {"public": 20, "unknown": 10, "internal": 5}
74
+
75
+ # Closer sinks are more exploitable (import-edge ≠ call-edge, but a same-file
76
+ # sink is a stronger signal than a two-hop transitive one).
77
+ _HOPS_PTS: dict[int, int] = {0: 15, 1: 10, 2: 5}
78
+
79
+ _NO_AUTH_PTS = 20
80
+ _SECRET_AT_SINK_PTS = 15
81
+ _DATASTORE_AT_SINK_PTS = 10
82
+ _WEAKNESS_ON_PATH_PTS = 5
83
+ # A known-vulnerable dependency imported on the path amplifies by CVE severity.
84
+ _CVE_ON_PATH_PTS = {"high": 15, "medium": 8, "low": 3}
85
+ # A risky (not known-vulnerable) dependency on the path is a weaker signal.
86
+ _SUPPLY_CHAIN_ON_PATH_PTS = {"high": 8, "medium": 4, "low": 2}
87
+ _MAX_FILE_BYTES = 1_000_000
88
+
89
+ # Bare (non-relative) module specifiers imported by a source file.
90
+ _JS_IMPORT_PKG_RE = re.compile(
91
+ r"""(?:from|require\(\s*)\s*["'](?P<pkg>@?[a-z0-9._-]+(?:/[a-z0-9._-]+)*)["']""",
92
+ re.IGNORECASE,
93
+ )
94
+ # Single-line only (#226): `\s` used to swallow newlines, so `import os` ran on
95
+ # into the next statement and every later import was lost.
96
+ _PY_IMPORT_PKG_RE = re.compile(
97
+ r"^[ \t]*(?:from[ \t]+(?P<from>[\w.]+)[ \t]+import\b"
98
+ r"|import[ \t]+(?P<mod>[\w.]+(?:[ \t]+as[ \t]+\w+)?(?:[ \t]*,[ \t]*[\w.]+(?:[ \t]+as[ \t]+\w+)?)*))",
99
+ re.MULTILINE,
100
+ )
101
+ # Import name → PyPI distribution name where they differ, so CVE fusion
102
+ # matches `import yaml` against a PyYAML advisory (#226).
103
+ _PY_MODULE_TO_DIST = {
104
+ "yaml": "pyyaml",
105
+ "pil": "pillow",
106
+ "bs4": "beautifulsoup4",
107
+ "jwt": "pyjwt",
108
+ "crypto": "pycryptodome",
109
+ "cryptodome": "pycryptodomex",
110
+ "cv2": "opencv-python",
111
+ "sklearn": "scikit-learn",
112
+ "dateutil": "python-dateutil",
113
+ "dns": "dnspython",
114
+ "openssl": "pyopenssl",
115
+ "jose": "python-jose",
116
+ "multipart": "python-multipart",
117
+ "magic": "python-magic",
118
+ "docx": "python-docx",
119
+ "git": "gitpython",
120
+ "ldap": "python-ldap",
121
+ "serial": "pyserial",
122
+ "usb": "pyusb",
123
+ "zmq": "pyzmq",
124
+ "nacl": "pynacl",
125
+ "markdown": "markdown",
126
+ "attr": "attrs",
127
+ "google.protobuf": "protobuf",
128
+ }
129
+
130
+ # Tier thresholds on the clamped 0–100 score.
131
+ _TIERS: tuple[tuple[int, ExploitabilityTier], ...] = (
132
+ (75, "critical"),
133
+ (50, "high"),
134
+ (25, "medium"),
135
+ (0, "low"),
136
+ )
137
+
138
+
139
+ def score_exploitability(
140
+ scan: ScanResult, attack_surfaces: list[AttackSurface] | None = None
141
+ ) -> list[ExploitabilityScore]:
142
+ """Return one deterministic ExploitabilityScore per taint chain, ranked
143
+ most-exploitable first."""
144
+ surfaces = attack_surfaces or []
145
+ surface_index = _SurfaceIndex(surfaces)
146
+
147
+ secret_files = {_norm(h.file) for h in scan.secret_hints}
148
+ secret_modules = {_module(h.file) for h in scan.secret_hints}
149
+ db_files = {_norm(d.file) for d in scan.databases}
150
+ db_modules = {_module(d.file) for d in scan.databases}
151
+ weakness_files = {_norm(w.file) for w in scan.crypto_weaknesses}
152
+ weakness_files |= {_norm(w.file) for w in scan.web_hardening_issues}
153
+
154
+ # CVE → exploitability fusion (#104): a known-vulnerable dependency imported
155
+ # by a file on the route→sink path amplifies the score by CVE severity.
156
+ vuln_index = _vuln_index(scan)
157
+ # Supply-chain risk (#247): a typosquat / confusable / mutable / install-
158
+ # script dependency imported on the path amplifies like a CVE does.
159
+ risk_index = _supply_chain_index(scan)
160
+ root = Path(scan.root)
161
+ import_cache: dict[str, set[str]] = {}
162
+
163
+ scores: list[ExploitabilityScore] = []
164
+ for chain in scan.taint_chains:
165
+ # Sanitized chains (#137) are neutralized before the sink — not
166
+ # "exploitable now", so they don't earn an exploitability score.
167
+ # Speculative recall chains (#148a) are unconfirmed leads — likewise
168
+ # not scored as exploitable until the verifier confirms them.
169
+ if chain.sanitized or chain.speculative:
170
+ continue
171
+ # Skip conventional infra/static routes — a SQL/RCE sink "reachable"
172
+ # from robots.txt or /_health is over-linking, not an exploit.
173
+ if is_infra_route(chain.route_path):
174
+ continue
175
+ vuln_on_path = _vulns_on_path(chain, vuln_index, root, import_cache) if vuln_index else []
176
+ risk_on_path = _vulns_on_path(chain, risk_index, root, import_cache) if risk_index else []
177
+ scores.append(
178
+ _score_chain(
179
+ chain,
180
+ surface_index,
181
+ secret_files,
182
+ secret_modules,
183
+ db_files,
184
+ db_modules,
185
+ weakness_files,
186
+ vuln_on_path,
187
+ risk_on_path,
188
+ )
189
+ )
190
+
191
+ scores.sort(key=lambda s: (-s.score, s.route, s.location))
192
+ return scores
193
+
194
+
195
+ def best_by_sink_kind(scores: list[ExploitabilityScore]) -> dict[str, ExploitabilityScore]:
196
+ """Map each sink kind to its highest-scoring chain — used to attach a
197
+ single exploitability number to the aggregated per-kind taint finding."""
198
+ best: dict[str, ExploitabilityScore] = {}
199
+ for s in scores:
200
+ cur = best.get(s.sink_kind)
201
+ if cur is None or s.score > cur.score:
202
+ best[s.sink_kind] = s
203
+ return best
204
+
205
+
206
+ # --- Internals -------------------------------------------------------------
207
+
208
+
209
+ class _SurfaceIndex:
210
+ """Match a chain to the attack surface of its own entry route (#226).
211
+
212
+ Two services in a monorepo can both expose `POST /run`; keying on
213
+ (method, route) alone handed both chains whichever surface came first, so an
214
+ unauthenticated route inherited its authed twin's auth signals. Look up by
215
+ (method, route, file) first, and fall back only to an unambiguous match.
216
+ """
217
+
218
+ def __init__(self, surfaces: list[AttackSurface]) -> None:
219
+ self._exact: dict[tuple[str, str, str], AttackSurface] = {}
220
+ self._by_route: dict[tuple[str, str], list[AttackSurface]] = {}
221
+ self._by_file: dict[str, list[AttackSurface]] = {}
222
+ for s in surfaces:
223
+ self._exact.setdefault((s.method, s.route, _norm(s.file)), s)
224
+ self._by_route.setdefault((s.method, s.route), []).append(s)
225
+ self._by_file.setdefault(_norm(s.file), []).append(s)
226
+
227
+ def lookup(self, chain: TaintChain) -> AttackSurface | None:
228
+ route_file = _norm(chain.route_file)
229
+ exact = self._exact.get((chain.route_method, chain.route_path, route_file))
230
+ if exact is not None:
231
+ return exact
232
+ for candidates in (
233
+ self._by_route.get((chain.route_method, chain.route_path), []),
234
+ self._by_file.get(route_file, []),
235
+ ):
236
+ if len(candidates) == 1:
237
+ return candidates[0]
238
+ return None
239
+
240
+
241
+ def _vulns_on_path(
242
+ chain: TaintChain,
243
+ vuln_index: dict[str, tuple[str, list[str]]],
244
+ root: Path,
245
+ cache: dict[str, set[str]],
246
+ ) -> list[tuple[str, str, list[str]]]:
247
+ """Return (pkg, severity, ids) for known-vulnerable deps imported by any
248
+ file on the chain's route→sink path. Deterministic (sorted by pkg)."""
249
+ matches: dict[str, tuple[str, list[str]]] = {}
250
+ path_files = dict.fromkeys(_norm(f) for f in (chain.route_file, *chain.files, chain.sink_file))
251
+ for rel in path_files:
252
+ if rel not in cache:
253
+ cache[rel] = _read_external_imports(contained_file(root, rel))
254
+ for pkg in cache[rel]:
255
+ hit = vuln_index.get(pkg)
256
+ if hit is not None:
257
+ matches[pkg] = hit
258
+ return sorted((pkg, sev, ids) for pkg, (sev, ids) in matches.items())
259
+
260
+
261
+ def _read_external_imports(path: Path | None) -> set[str]:
262
+ try:
263
+ if path is not None and path.stat().st_size <= _MAX_FILE_BYTES:
264
+ return _external_imports(path.read_text(encoding="utf-8", errors="ignore"), path.suffix)
265
+ except (OSError, ValueError):
266
+ pass
267
+ return set()
268
+
269
+
270
+ def _score_chain(
271
+ chain: TaintChain,
272
+ surface_index: "_SurfaceIndex",
273
+ secret_files: set[str],
274
+ secret_modules: set[str],
275
+ db_files: set[str],
276
+ db_modules: set[str],
277
+ weakness_files: set[str],
278
+ vuln_on_path: list[tuple[str, str, list[str]]] | None = None,
279
+ risk_on_path: list[tuple[str, str, list[str]]] | None = None,
280
+ ) -> ExploitabilityScore:
281
+ factors: list[ExploitabilityFactor] = []
282
+ sink_label = _SINK_LABEL.get(chain.sink_kind) or kind_label(chain.sink_kind)
283
+
284
+ # 1. Sink danger (base).
285
+ # Kinds without a hand-tuned weight take the registry's base score (#240).
286
+ base = _SINK_BASE.get(chain.sink_kind) or kind_base_score(chain.sink_kind)
287
+ factors.append(
288
+ ExploitabilityFactor(
289
+ name=f"reaches {chain.sink_kind} sink",
290
+ points=base,
291
+ detail=f"request-derived data reaches a {sink_label} sink at "
292
+ f"{chain.sink_file}:{chain.sink_line}",
293
+ )
294
+ )
295
+
296
+ surface = surface_index.lookup(chain)
297
+
298
+ # 2. Exposure.
299
+ exposure = surface.exposure if surface else "unknown"
300
+ factors.append(
301
+ ExploitabilityFactor(
302
+ name=f"{exposure} exposure",
303
+ points=_EXPOSURE_PTS.get(exposure, 10),
304
+ detail=(
305
+ f"entry route {chain.route_method} {chain.route_path} is {exposure}"
306
+ + (f" ({surface.category})" if surface else " (no surface classification)")
307
+ ),
308
+ )
309
+ )
310
+
311
+ # 3. Auth at entry.
312
+ has_auth = bool(surface and surface.auth_signals)
313
+ if has_auth:
314
+ factors.append(
315
+ ExploitabilityFactor(
316
+ name="auth present at entry",
317
+ points=0,
318
+ detail=f"auth signals observed: {', '.join(surface.auth_signals)}",
319
+ )
320
+ )
321
+ else:
322
+ factors.append(
323
+ ExploitabilityFactor(
324
+ name="no auth at entry",
325
+ points=_NO_AUTH_PTS,
326
+ detail="no auth signal observed on the entry route",
327
+ )
328
+ )
329
+
330
+ # 4. Reachability (closer = higher).
331
+ factors.append(
332
+ ExploitabilityFactor(
333
+ name=f"{chain.hops}-hop reachability",
334
+ points=_HOPS_PTS.get(chain.hops, 3),
335
+ detail=f"{chain.hops} import edge(s) from route to sink",
336
+ )
337
+ )
338
+
339
+ # 5. Data sensitivity at the sink.
340
+ sink_file = _norm(chain.sink_file)
341
+ sink_mod = _module(chain.sink_file)
342
+ if sink_file in secret_files or sink_mod in secret_modules:
343
+ factors.append(
344
+ ExploitabilityFactor(
345
+ name="secret material at sink",
346
+ points=_SECRET_AT_SINK_PTS,
347
+ detail="a secret/credential is co-located with the sink",
348
+ )
349
+ )
350
+ elif sink_file in db_files or sink_mod in db_modules:
351
+ factors.append(
352
+ ExploitabilityFactor(
353
+ name="datastore at sink",
354
+ points=_DATASTORE_AT_SINK_PTS,
355
+ detail="a datastore is co-located with the sink",
356
+ )
357
+ )
358
+
359
+ # 6. Amplifiers on the path.
360
+ path_files = {_norm(f) for f in (*chain.files, chain.route_file, chain.sink_file)}
361
+ if path_files & weakness_files:
362
+ factors.append(
363
+ ExploitabilityFactor(
364
+ name="crypto/web-hardening gap on path",
365
+ points=_WEAKNESS_ON_PATH_PTS,
366
+ detail="an insecure-crypto or web-hardening issue sits on this path",
367
+ )
368
+ )
369
+
370
+ # 7. Known-vulnerable dependency imported on the path (#104).
371
+ for pkg, severity, ids in vuln_on_path or []:
372
+ factors.append(
373
+ ExploitabilityFactor(
374
+ name=f"vulnerable dependency on path ({pkg})",
375
+ points=_CVE_ON_PATH_PTS.get(severity, 8),
376
+ detail=f"{pkg} has a known {severity}-severity advisory "
377
+ f"({', '.join(ids[:3])}) and is imported on this path",
378
+ )
379
+ )
380
+
381
+ # 8. Supply-chain-risky dependency imported on the path (#247).
382
+ for pkg, severity, kinds in risk_on_path or []:
383
+ factors.append(
384
+ ExploitabilityFactor(
385
+ name=f"supply-chain-risky dependency on path ({pkg})",
386
+ points=_SUPPLY_CHAIN_ON_PATH_PTS.get(severity, 4),
387
+ detail=f"{pkg} carries supply-chain risk ({', '.join(k.replace('_', ' ') for k in kinds[:3])}) "
388
+ "and is imported on this path",
389
+ )
390
+ )
391
+
392
+ raw = sum(f.points for f in factors)
393
+ score = max(0, min(100, raw))
394
+ return ExploitabilityScore(
395
+ subject=f"{chain.route_method} {chain.route_path} → {chain.sink_kind}",
396
+ route=chain.route_path,
397
+ method=chain.route_method,
398
+ sink_kind=chain.sink_kind,
399
+ location=f"{chain.sink_file}:{chain.sink_line}",
400
+ score=score,
401
+ raw_score=raw,
402
+ tier=_tier(score),
403
+ factors=factors,
404
+ )
405
+
406
+
407
+ def _tier(score: int) -> ExploitabilityTier:
408
+ for threshold, tier in _TIERS:
409
+ if score >= threshold:
410
+ return tier
411
+ return "low"
412
+
413
+
414
+ def _vuln_index(scan: ScanResult) -> dict[str, tuple[str, list[str]]]:
415
+ """pkg-name (lowercased) → (worst severity, sorted advisory ids)."""
416
+ order = {"low": 0, "medium": 1, "high": 2}
417
+ out: dict[str, tuple[str, list[str]]] = {}
418
+ for v in scan.vulnerabilities:
419
+ key = _pep503(v.package_name) if v.ecosystem == "pypi" else v.package_name.lower()
420
+ sev = v.severity if v.severity in order else "medium"
421
+ prev = out.get(key)
422
+ if prev is None:
423
+ out[key] = (sev, [v.id])
424
+ else:
425
+ best = sev if order[sev] > order[prev[0]] else prev[0]
426
+ out[key] = (best, sorted({*prev[1], v.id}))
427
+ return out
428
+
429
+
430
+ def _supply_chain_index(scan: ScanResult) -> dict[str, tuple[str, list[str]]]:
431
+ """pkg-name → (worst severity, sorted issue kinds) for package-level
432
+ supply-chain issues (#247), keyed like ``_vuln_index``."""
433
+ order = {"low": 0, "medium": 1, "high": 2}
434
+ out: dict[str, tuple[str, list[str]]] = {}
435
+ for issue in scan.supply_chain_issues:
436
+ if not issue.package:
437
+ continue
438
+ key = _pep503(issue.package) if issue.ecosystem == "pypi" else issue.package.lower()
439
+ prev = out.get(key)
440
+ if prev is None:
441
+ out[key] = (issue.severity, [issue.kind])
442
+ else:
443
+ best = issue.severity if order[issue.severity] > order[prev[0]] else prev[0]
444
+ out[key] = (best, sorted({*prev[1], issue.kind}))
445
+ return out
446
+
447
+
448
+ def _normalize_pkg(spec: str) -> str:
449
+ """Reduce an import specifier to its package name: `lodash/merge` → lodash,
450
+ `@scope/pkg/sub` → @scope/pkg."""
451
+ spec = spec.strip().lower()
452
+ if spec.startswith("@"):
453
+ parts = spec.split("/")
454
+ return "/".join(parts[:2])
455
+ return spec.split("/", 1)[0]
456
+
457
+
458
+ def _external_imports(content: str, suffix: str) -> set[str]:
459
+ """Bare (non-relative) package names imported by a JS/TS or Python file."""
460
+ pkgs: set[str] = set()
461
+ if suffix in JS_TS_SUFFIXES:
462
+ for m in _JS_IMPORT_PKG_RE.finditer(content):
463
+ spec = m.group("pkg")
464
+ if not spec.startswith("."):
465
+ pkgs.add(_normalize_pkg(spec))
466
+ elif suffix == ".py":
467
+ for m in _PY_IMPORT_PKG_RE.finditer(content):
468
+ mod = m.group("from") or m.group("mod") or ""
469
+ for name in mod.split(","):
470
+ dotted = name.split()[0].lower() if name.strip() else ""
471
+ top = dotted.split(".")[0]
472
+ if not top: # relative import
473
+ continue
474
+ dist = _PY_MODULE_TO_DIST.get(dotted) or _PY_MODULE_TO_DIST.get(top)
475
+ pkgs.add(_pep503(dist or top))
476
+ return pkgs
477
+
478
+
479
+ def _pep503(name: str) -> str:
480
+ """PyPI name normalization: case- and `-`/`_`/`.`-insensitive."""
481
+ return re.sub(r"[-_.]+", "-", name).lower()
482
+
483
+
484
+ def _norm(rel: str) -> str:
485
+ return rel.replace("\\", "/")
486
+
487
+
488
+ def _module(rel: str) -> str:
489
+ norm = _norm(rel)
490
+ return norm.rsplit("/", 1)[0] if "/" in norm else ""
491
+
492
+
493
+ __all__ = ["score_exploitability", "best_by_sink_kind"]
@@ -0,0 +1,91 @@
1
+ """Per-instance fingerprints for finding locations (#222).
2
+
3
+ A ``Finding`` aggregates every site of one issue type, and its id hashes only
4
+ the title, so a PR that adds a *second* command-injection route used to diff as
5
+ "persisted" and pass ``--fail-on-new-high``. Each ``FindingLocation`` now gets
6
+ a fingerprint that identifies that instance across runs:
7
+
8
+ sha256(rule_id | file | normalized source line | occurrence)[:16]
9
+
10
+ The line *number* is deliberately excluded so unrelated edits above a site
11
+ (line drift) don't make it look new. The source line is normalized —
12
+ whitespace removed, string and number literals masked — so reformatting or
13
+ changing a literal value doesn't either, while a different call or a
14
+ different file does. Identical normalized lines in one file are told apart by
15
+ their occurrence index, counted per rule across all findings: one rule can
16
+ have several findings (cloud misconfigurations split by severity), and two
17
+ of its instances on one line must not share a fingerprint.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import re
24
+ from pathlib import Path
25
+
26
+ from .models import Finding, finding_rule_id
27
+ from .safe_fs import contained_file, is_oversized
28
+ from .srcpaths import split_lines
29
+
30
+ _STRING = re.compile(r"""(?:[rbuf]{0,2})("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)""", re.IGNORECASE)
31
+ _NUMBER = re.compile(r"\b\d+(?:\.\d+)?\b")
32
+ _SPACE = re.compile(r"\s+")
33
+
34
+
35
+ def normalize_line(line: str) -> str:
36
+ """Whitespace-insensitive, literal-insensitive form of a source line."""
37
+ text = _STRING.sub('"S"', line)
38
+ text = _NUMBER.sub("0", text)
39
+ # All whitespace goes, not just runs of it: `a + b` and `a+b` are the
40
+ # same code, and token boundaries don't matter for a hash.
41
+ return _SPACE.sub("", text)
42
+
43
+
44
+ def _digest(*parts: str) -> str:
45
+ return hashlib.sha256("\x1f".join(parts).encode("utf-8")).hexdigest()[:16]
46
+
47
+
48
+ def assign_fingerprints(findings: list[Finding], root: str | Path) -> None:
49
+ """Set ``fingerprint`` on every location of every finding (in place)."""
50
+ root_path = Path(root)
51
+ line_cache: dict[str, list[str] | None] = {}
52
+
53
+ def source_line(rel: str, line: int | None) -> str:
54
+ if line is None:
55
+ return ""
56
+ if rel not in line_cache:
57
+ path = contained_file(root_path, rel)
58
+ try:
59
+ line_cache[rel] = (
60
+ split_lines(path.read_text(encoding="utf-8", errors="replace"))
61
+ if path is not None and not is_oversized(path)
62
+ else None
63
+ )
64
+ except OSError:
65
+ line_cache[rel] = None
66
+ lines = line_cache[rel]
67
+ if not lines or not 1 <= line <= len(lines):
68
+ return f"#line-unavailable:{line}"
69
+ return normalize_line(lines[line - 1])
70
+
71
+ # Occurrences are counted per (rule, file, line content) across every
72
+ # finding, not per finding. A location that was the only one of its
73
+ # (rule, file, content) keeps occurrence 0, so its fingerprint is
74
+ # unchanged.
75
+ seen: dict[tuple[str, str, str], int] = {}
76
+ for finding in findings:
77
+ rule = finding_rule_id(finding)
78
+ for loc in finding.locations:
79
+ file = loc.file.replace("\\", "/")
80
+ content = source_line(file, loc.line)
81
+ if loc.instance is None:
82
+ occurrence = seen.get((rule, file, content), 0)
83
+ seen[(rule, file, content)] = occurrence + 1
84
+ loc.fingerprint = _digest(rule, file, content, str(occurrence))
85
+ else:
86
+ # Tiered records name themselves (resource + evidence), so
87
+ # their fingerprint doesn't depend on same-line neighbours.
88
+ key = (rule, file, content, loc.instance)
89
+ occurrence = seen.get(key, 0)
90
+ seen[key] = occurrence + 1
91
+ loc.fingerprint = _digest(rule, file, content, loc.instance, str(occurrence))