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
|
@@ -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"]
|
attackmap/fingerprint.py
ADDED
|
@@ -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))
|