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/models.py
ADDED
|
@@ -0,0 +1,1284 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Literal, get_args
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field, model_validator
|
|
7
|
+
|
|
8
|
+
from .redact import redact_list, redact_text
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Provenance field. Present on every analyzer-emitted signal so downstream
|
|
12
|
+
# consumers can trace a signal back to the analyzer that produced it.
|
|
13
|
+
# Excluded from serialization by default (see #14) so JSON/CLI reports are
|
|
14
|
+
# byte-for-byte unchanged; access it via Python attribute for debugging,
|
|
15
|
+
# confidence scoring, or future finder logic.
|
|
16
|
+
_PROVENANCE_FIELD = Field(default=None, exclude=True, repr=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
RouteAuth = Literal["required", "anonymous", "unknown"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Package ecosystems a `DependencyHint` / `Vulnerability` may carry (#255).
|
|
23
|
+
# Exported from `attackmap.sdk`; `cve.py` maps each to its OSV.dev ecosystem
|
|
24
|
+
# (or marks it inventory-only when OSV has none).
|
|
25
|
+
DependencyEcosystem = Literal[
|
|
26
|
+
"pypi",
|
|
27
|
+
"npm",
|
|
28
|
+
"go",
|
|
29
|
+
"cargo",
|
|
30
|
+
"composer",
|
|
31
|
+
"swiftpm",
|
|
32
|
+
"maven",
|
|
33
|
+
"nuget",
|
|
34
|
+
"conan",
|
|
35
|
+
"vcpkg",
|
|
36
|
+
# CMake `FetchContent` / `find_package` dependencies (#191): no package
|
|
37
|
+
# registry behind them, so inventory only (never CVE-matched).
|
|
38
|
+
"cmake",
|
|
39
|
+
"gem",
|
|
40
|
+
"cocoapods",
|
|
41
|
+
# Carthage pins git repositories (`github "owner/repo" "1.2.3"`); CVE-matched
|
|
42
|
+
# like SwiftPM, by source URL (#188).
|
|
43
|
+
"carthage",
|
|
44
|
+
"hex",
|
|
45
|
+
"pub",
|
|
46
|
+
]
|
|
47
|
+
DEPENDENCY_ECOSYSTEMS: tuple[str, ...] = get_args(DependencyEcosystem)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Route(BaseModel):
|
|
51
|
+
path: str
|
|
52
|
+
method: str = "ANY"
|
|
53
|
+
file: str
|
|
54
|
+
line: int | None = None
|
|
55
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
56
|
+
# Route-level auth as the plugin resolved it (#256): `required` (a guard
|
|
57
|
+
# such as [Authorize] / @PreAuthorize / an auth middleware applies),
|
|
58
|
+
# `anonymous` (explicitly public, e.g. [AllowAnonymous]), or `unknown`
|
|
59
|
+
# (not determined — core falls back to its own resolution).
|
|
60
|
+
auth: RouteAuth = "unknown"
|
|
61
|
+
# Names of the guards that apply, e.g. ["[Authorize(Policy=Admin)]"].
|
|
62
|
+
guards: list[str] = Field(default_factory=list)
|
|
63
|
+
# Source text that established `auth` (redacted like other evidence).
|
|
64
|
+
guard_evidence: str | None = None
|
|
65
|
+
|
|
66
|
+
@model_validator(mode="after")
|
|
67
|
+
def _redact_guard_evidence(self) -> "Route":
|
|
68
|
+
if self.guard_evidence:
|
|
69
|
+
object.__setattr__(self, "guard_evidence", redact_text(self.guard_evidence))
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Fields that carry text copied out of the scanned repository. Every model
|
|
74
|
+
# with one of these redacts it at construction time (#235), so no detector —
|
|
75
|
+
# core or plugin — can put a raw credential into a report, SARIF, PR comment
|
|
76
|
+
# or LLM pack.
|
|
77
|
+
_EVIDENCE_TEXT_FIELDS = ("evidence_text", "db_evidence", "sanitizer_evidence", "target")
|
|
78
|
+
_EVIDENCE_LIST_FIELDS = ("evidence",)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _RedactedEvidence(BaseModel):
|
|
82
|
+
@model_validator(mode="after")
|
|
83
|
+
def _redact_evidence(self): # type: ignore[no-untyped-def]
|
|
84
|
+
fields = type(self).model_fields
|
|
85
|
+
for name in _EVIDENCE_TEXT_FIELDS:
|
|
86
|
+
if name in fields:
|
|
87
|
+
value = getattr(self, name)
|
|
88
|
+
if isinstance(value, str):
|
|
89
|
+
redacted = redact_text(value)
|
|
90
|
+
if redacted != value:
|
|
91
|
+
object.__setattr__(self, name, redacted)
|
|
92
|
+
for name in _EVIDENCE_LIST_FIELDS:
|
|
93
|
+
if name in fields:
|
|
94
|
+
value = getattr(self, name)
|
|
95
|
+
if isinstance(value, list) and value and all(isinstance(v, str) for v in value):
|
|
96
|
+
redacted_list = redact_list(value)
|
|
97
|
+
if redacted_list != value:
|
|
98
|
+
object.__setattr__(self, name, redacted_list)
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ExternalCall(_RedactedEvidence):
|
|
103
|
+
target: str
|
|
104
|
+
file: str
|
|
105
|
+
line: int | None = None
|
|
106
|
+
# HTTP verb of the outbound call, upper-cased (GET/POST/…), when the call
|
|
107
|
+
# syntax carries one (`requests.get`, `axios.post`). `None` when unknown
|
|
108
|
+
# (e.g. bare `fetch(url)`). Used by cross-repo contract linking (#146b) to
|
|
109
|
+
# match an outbound call to another repo's route by method + path.
|
|
110
|
+
method: str | None = None
|
|
111
|
+
evidence_text: str | None = None
|
|
112
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class DatabaseHint(_RedactedEvidence):
|
|
116
|
+
kind: str
|
|
117
|
+
file: str
|
|
118
|
+
line: int | None = None
|
|
119
|
+
evidence_text: str | None = None
|
|
120
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class AuthHint(_RedactedEvidence):
|
|
124
|
+
hint: str
|
|
125
|
+
file: str
|
|
126
|
+
line: int | None = None
|
|
127
|
+
evidence_text: str | None = None
|
|
128
|
+
confidence: float = 0.7
|
|
129
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ServiceHint(_RedactedEvidence):
|
|
133
|
+
hint: str
|
|
134
|
+
file: str
|
|
135
|
+
line: int | None = None
|
|
136
|
+
evidence_text: str | None = None
|
|
137
|
+
confidence: float = 0.7
|
|
138
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class EdgeHint(_RedactedEvidence):
|
|
142
|
+
hint: str
|
|
143
|
+
file: str
|
|
144
|
+
line: int | None = None
|
|
145
|
+
evidence_text: str | None = None
|
|
146
|
+
confidence: float = 0.7
|
|
147
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class EntrypointHint(_RedactedEvidence):
|
|
151
|
+
hint: str
|
|
152
|
+
file: str
|
|
153
|
+
line: int | None = None
|
|
154
|
+
evidence_text: str | None = None
|
|
155
|
+
confidence: float = 0.7
|
|
156
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ProtocolHint(_RedactedEvidence):
|
|
160
|
+
hint: str
|
|
161
|
+
file: str
|
|
162
|
+
line: int | None = None
|
|
163
|
+
evidence_text: str | None = None
|
|
164
|
+
confidence: float = 0.7
|
|
165
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class FrameworkHint(_RedactedEvidence):
|
|
169
|
+
hint: str
|
|
170
|
+
file: str
|
|
171
|
+
line: int | None = None
|
|
172
|
+
evidence_text: str | None = None
|
|
173
|
+
confidence: float = 0.7
|
|
174
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class SecretHint(_RedactedEvidence):
|
|
178
|
+
name: str
|
|
179
|
+
file: str
|
|
180
|
+
line: int | None = None
|
|
181
|
+
evidence_text: str | None = None
|
|
182
|
+
confidence: float = 0.85
|
|
183
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
184
|
+
# Optional classification. `env_reference` means we spotted a call
|
|
185
|
+
# like `os.getenv("API_KEY")` — the value is at runtime. Anything
|
|
186
|
+
# else identifies a literal secret pasted directly into code or
|
|
187
|
+
# config (#39). Consumers can elevate hardcoded-literal findings
|
|
188
|
+
# since exposure is broader than env references.
|
|
189
|
+
kind: str = "env_reference"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class SecretHistoryHit(_RedactedEvidence):
|
|
193
|
+
"""A hard-coded secret found in git history (#252, ``--secrets-history``).
|
|
194
|
+
|
|
195
|
+
``name`` is the masked value; ``introduced_in`` is the oldest scanned
|
|
196
|
+
commit that added it; ``still_in_head`` is False when a later commit
|
|
197
|
+
removed it from the file (it remains in every clone's history).
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
name: str
|
|
201
|
+
kind: str
|
|
202
|
+
file: str
|
|
203
|
+
line: int | None = None
|
|
204
|
+
introduced_in: str
|
|
205
|
+
still_in_head: bool
|
|
206
|
+
evidence_text: str | None = None
|
|
207
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class DependencyHint(_RedactedEvidence):
|
|
211
|
+
"""A single third-party dependency declared in a manifest or lockfile.
|
|
212
|
+
|
|
213
|
+
Manifests (#48) carry version *ranges* verbatim (``^4.16.0``, ``>=2,<3``,
|
|
214
|
+
``latest``) and only direct dependencies. Lockfiles (#143) carry *exact*
|
|
215
|
+
resolved versions and the full transitive tree — those hints set
|
|
216
|
+
``resolved=True`` and populate ``direct`` / ``via``.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
name: str
|
|
220
|
+
version: str
|
|
221
|
+
# One of `DEPENDENCY_ECOSYSTEMS`, typed `str` so a plugin built against a
|
|
222
|
+
# newer core still validates here: core drops a hint with an ecosystem it
|
|
223
|
+
# doesn't know (noted under `analyzer_errors`) and keeps the rest.
|
|
224
|
+
ecosystem: str = Field(min_length=1, max_length=32)
|
|
225
|
+
file: str
|
|
226
|
+
line: int | None = None
|
|
227
|
+
# dev / build-only dependency (``devDependencies``, ``require-dev``,
|
|
228
|
+
# PEP-621 ``optional-dependencies[dev]``). Runtime consumers weight
|
|
229
|
+
# these differently: a dev-time RCE still matters, but a dev-only
|
|
230
|
+
# dep isn't part of the shipped attack surface.
|
|
231
|
+
dev: bool = False
|
|
232
|
+
# Lockfile-sourced provenance (#143). ``resolved`` marks an exact pinned
|
|
233
|
+
# version (skip range-guessing in the CVE lookup). ``direct`` is False for
|
|
234
|
+
# a transitive dependency; ``via`` is its resolution path from a direct
|
|
235
|
+
# dependency, e.g. ``"express > body-parser > qs"``.
|
|
236
|
+
resolved: bool = False
|
|
237
|
+
direct: bool = True
|
|
238
|
+
via: str | None = None
|
|
239
|
+
evidence_text: str | None = None
|
|
240
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# Kinds of `CodeWeakness` this core turns into findings (#77, #189).
|
|
244
|
+
CodeWeaknessKind = Literal[
|
|
245
|
+
"prototype_pollution",
|
|
246
|
+
"mass_assignment",
|
|
247
|
+
"jwt_weakness",
|
|
248
|
+
"jwt_alg_confusion",
|
|
249
|
+
"jwt_unverified_decode",
|
|
250
|
+
"jwt_claims_unchecked",
|
|
251
|
+
"jwt_header_trust",
|
|
252
|
+
"jwt_weak_secret",
|
|
253
|
+
"oauth_missing_state",
|
|
254
|
+
"oauth_implicit_flow",
|
|
255
|
+
"oauth_redirect_uri_loose",
|
|
256
|
+
"oauth_missing_pkce",
|
|
257
|
+
"session_fixation",
|
|
258
|
+
"session_weak_secret",
|
|
259
|
+
"xxe",
|
|
260
|
+
"redos",
|
|
261
|
+
"insecure_upload",
|
|
262
|
+
"graphql_exposure",
|
|
263
|
+
"graphql_no_query_limits",
|
|
264
|
+
"graphql_batching",
|
|
265
|
+
"prompt_injection_attempt",
|
|
266
|
+
# Native (C/C++) memory-safety and command-exec sinks (#189), emitted
|
|
267
|
+
# by the C/C++ analyzer plugins. See `NATIVE_WEAKNESS_KINDS`.
|
|
268
|
+
"unbounded_copy",
|
|
269
|
+
"format_string",
|
|
270
|
+
"native_command_exec",
|
|
271
|
+
"tainted_copy_length",
|
|
272
|
+
"alloc_size_overflow",
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class CodeWeakness(_RedactedEvidence):
|
|
277
|
+
"""A novel vulnerability-class weakness in source (#77).
|
|
278
|
+
|
|
279
|
+
Emitted by the built-in `weaknesses` finder — bug classes beyond the
|
|
280
|
+
taint/crypto/web-hardening families, detected from concrete risky
|
|
281
|
+
constructs (not absence).
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
# One of `CODE_WEAKNESS_KINDS`, typed `str` so a plugin built against a
|
|
285
|
+
# newer core still validates here: core drops a record with a kind it
|
|
286
|
+
# doesn't know (noted under `analyzer_errors`) and keeps the rest (#189).
|
|
287
|
+
kind: str = Field(min_length=1, max_length=64)
|
|
288
|
+
file: str
|
|
289
|
+
line: int | None = None
|
|
290
|
+
evidence_text: str | None = None
|
|
291
|
+
severity: Literal["low", "medium", "high"] = "medium"
|
|
292
|
+
# Optional per-instance confidence (#189). When set, the finding takes the
|
|
293
|
+
# highest confidence among its instances instead of the default `medium`.
|
|
294
|
+
confidence: Literal["low", "medium", "high"] | None = None
|
|
295
|
+
# The input that reaches the weakness in the same function, when the
|
|
296
|
+
# emitter traced one (#189): `request`, `network`, `stdin`, `argv`, `env`,
|
|
297
|
+
# `file`. None means no source was traced.
|
|
298
|
+
# A value outside `NATIVE_SOURCE_KINDS` is kept as `other` (never as
|
|
299
|
+
# free text); a non-positive line is dropped.
|
|
300
|
+
source_kind: str | None = None
|
|
301
|
+
source_line: int | None = None
|
|
302
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
303
|
+
|
|
304
|
+
@model_validator(mode="after")
|
|
305
|
+
def _clean_source(self) -> "CodeWeakness":
|
|
306
|
+
if self.source_kind is not None and self.source_kind not in NATIVE_SOURCE_KINDS:
|
|
307
|
+
object.__setattr__(self, "source_kind", "other")
|
|
308
|
+
if self.source_line is not None and self.source_line < 1:
|
|
309
|
+
object.__setattr__(self, "source_line", None)
|
|
310
|
+
if self.source_kind is None:
|
|
311
|
+
object.__setattr__(self, "source_line", None)
|
|
312
|
+
return self
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
CODE_WEAKNESS_KINDS: tuple[str, ...] = get_args(CodeWeaknessKind)
|
|
316
|
+
# Kinds whose severity and confidence are set per instance by the emitter
|
|
317
|
+
# (they depend on whether an input source reaches the sink), so findings are
|
|
318
|
+
# grouped per severity tier instead of taking the kind's fixed severity.
|
|
319
|
+
# Inputs a native weakness's emitter may name in `source_kind`.
|
|
320
|
+
NATIVE_SOURCE_KINDS: frozenset[str] = frozenset({"request", "network", "stdin", "argv", "env", "file", "other"})
|
|
321
|
+
NATIVE_WEAKNESS_KINDS: tuple[str, ...] = (
|
|
322
|
+
"unbounded_copy",
|
|
323
|
+
"format_string",
|
|
324
|
+
"native_command_exec",
|
|
325
|
+
"tainted_copy_length",
|
|
326
|
+
"alloc_size_overflow",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class Anomaly(BaseModel):
|
|
331
|
+
"""A within-repo consistency outlier — the odd-one-out in a peer group (#78).
|
|
332
|
+
|
|
333
|
+
Emitted by the `anomalies` analyzer. Rather than matching a known-bad
|
|
334
|
+
signature, it flags a route that deviates from the norm its siblings
|
|
335
|
+
establish: peers in the same resource cohort carry an auth/validation
|
|
336
|
+
signal (or are read-only) and this one doesn't. The bigger and more
|
|
337
|
+
consistent the peer group, the more likely the deviation is a mistake —
|
|
338
|
+
so `confidence` scales with `consistent_peers`.
|
|
339
|
+
|
|
340
|
+
`invariant_violation` (#149a) is the mined-invariant variant: the cohort
|
|
341
|
+
is not a route-path prefix but the set of handlers that reach the same
|
|
342
|
+
dangerous sink kind, and the norm they establish is a *behavioural*
|
|
343
|
+
invariant ("guard the request before the sink") rather than a static
|
|
344
|
+
signal. When set, `invariant` states the mined rule as evidence.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
kind: Literal[
|
|
348
|
+
"auth_outlier",
|
|
349
|
+
"validation_outlier",
|
|
350
|
+
"method_outlier",
|
|
351
|
+
"invariant_violation",
|
|
352
|
+
]
|
|
353
|
+
route_path: str
|
|
354
|
+
route_method: str
|
|
355
|
+
route_file: str
|
|
356
|
+
route_line: int | None = None
|
|
357
|
+
peer_group: str # the shared path-prefix cohort key (e.g. "api/users")
|
|
358
|
+
peer_group_size: int # total sibling routes in the cohort
|
|
359
|
+
consistent_peers: int # siblings that exhibit the norm this route breaks
|
|
360
|
+
deviation: str # human-readable description of what is odd
|
|
361
|
+
# The mined invariant the flagged site violates (invariant_violation only) —
|
|
362
|
+
# e.g. "9 of 10 handlers that reach a database (SQL execute) sink apply an
|
|
363
|
+
# auth/validation guard before it". Cited as evidence.
|
|
364
|
+
invariant: str | None = None
|
|
365
|
+
peer_examples: list[str] = Field(default_factory=list) # sample "METHOD path" peers
|
|
366
|
+
severity: Literal["low", "medium", "high"] = "medium"
|
|
367
|
+
confidence: float = 0.5
|
|
368
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class WebHardeningIssue(_RedactedEvidence):
|
|
372
|
+
"""A web-hardening misconfiguration (#71).
|
|
373
|
+
|
|
374
|
+
Emitted by the built-in web-hardening finder. Detects
|
|
375
|
+
positively-present misconfigurations (wildcard CORS with credentials,
|
|
376
|
+
explicit CSRF disable, insecure cookie flags, unsafe-inline/eval CSP,
|
|
377
|
+
debug enabled) rather than hard-to-judge absences.
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
kind: Literal[
|
|
381
|
+
"cors_wildcard_credentials",
|
|
382
|
+
"cors_wildcard_origin",
|
|
383
|
+
"cors_untrusted_origin",
|
|
384
|
+
"csrf_disabled",
|
|
385
|
+
"csrf_unprotected_session",
|
|
386
|
+
"insecure_cookie",
|
|
387
|
+
"weak_csp",
|
|
388
|
+
"debug_enabled",
|
|
389
|
+
]
|
|
390
|
+
file: str
|
|
391
|
+
line: int | None = None
|
|
392
|
+
evidence_text: str | None = None
|
|
393
|
+
severity: Literal["low", "medium", "high"] = "medium"
|
|
394
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# Kinds of `AppSurfaceIssue` (#188, #190). Prefixed by platform so their
|
|
398
|
+
# rule ids (`apple-...`) never collide with a core detector family.
|
|
399
|
+
AppSurfaceIssueKind = Literal[
|
|
400
|
+
"apple_ats_insecure_loads",
|
|
401
|
+
"apple_custom_url_scheme",
|
|
402
|
+
"apple_webview_js_injection",
|
|
403
|
+
"apple_insecure_secret_storage",
|
|
404
|
+
"apple_pasteboard_secret",
|
|
405
|
+
"apple_hardened_runtime_exception",
|
|
406
|
+
"apple_debuggable_entitlement",
|
|
407
|
+
"apple_xpc_unvalidated_peer",
|
|
408
|
+
"apple_dynamic_dispatch",
|
|
409
|
+
]
|
|
410
|
+
APP_SURFACE_ISSUE_KINDS: tuple[str, ...] = get_args(AppSurfaceIssueKind)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
class AppSurfaceIssue(_RedactedEvidence):
|
|
414
|
+
"""A mobile/desktop app attack-surface issue emitted by a platform plugin
|
|
415
|
+
(#188, #190): the Apple analyzers (Swift, Objective-C) report App Transport
|
|
416
|
+
Security exceptions, custom URL-scheme handlers, weakening entitlements,
|
|
417
|
+
WKWebView script built from strings, secrets in UserDefaults or on the
|
|
418
|
+
pasteboard, unvalidated XPC peers, and classes, selectors or libraries
|
|
419
|
+
resolved from runtime strings. Native format-string and command-exec
|
|
420
|
+
constructs are `CodeWeakness` kinds shared with the C/C++ plugins.
|
|
421
|
+
|
|
422
|
+
Core owns the findings: one aggregated finding per kind (rule id = the
|
|
423
|
+
kind, dashed), at the most severe instance's severity. Like the other
|
|
424
|
+
issue families, every instance is a positively-present construct, never
|
|
425
|
+
an absence.
|
|
426
|
+
|
|
427
|
+
``kind`` is one of ``APP_SURFACE_ISSUE_KINDS`` but typed ``str``, so a
|
|
428
|
+
plugin built against a newer core still validates: core drops records
|
|
429
|
+
with a kind it doesn't know (noted under ``analyzer_errors``) and keeps
|
|
430
|
+
the rest of the plugin's result.
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
kind: str = Field(min_length=1, max_length=64)
|
|
434
|
+
file: str
|
|
435
|
+
line: int | None = None
|
|
436
|
+
evidence_text: str | None = None
|
|
437
|
+
severity: Literal["low", "medium", "high"] = "medium"
|
|
438
|
+
# How sure the emitter is (0-1). On a duplicate report of the same
|
|
439
|
+
# instance the merge keeps the more severe, then the more confident one.
|
|
440
|
+
confidence: float = Field(default=0.8, ge=0.0, le=1.0)
|
|
441
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class SupplyChainIssue(_RedactedEvidence):
|
|
445
|
+
"""A dependency supply-chain risk beyond CVEs (#247).
|
|
446
|
+
|
|
447
|
+
Emitted by the built-in, offline ``supply_chain`` pass over manifests,
|
|
448
|
+
lockfiles and package-manager configs: dependency confusion, typosquat
|
|
449
|
+
candidates, mutable VCS/URL dependencies, install scripts, unlocked
|
|
450
|
+
manifests and insecure registries.
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
kind: Literal[
|
|
454
|
+
"dependency_confusion",
|
|
455
|
+
"typosquat_candidate",
|
|
456
|
+
"mutable_vcs_dependency",
|
|
457
|
+
"install_script",
|
|
458
|
+
"unlocked_manifest",
|
|
459
|
+
"insecure_registry",
|
|
460
|
+
]
|
|
461
|
+
file: str
|
|
462
|
+
line: int | None = None
|
|
463
|
+
package: str | None = None
|
|
464
|
+
ecosystem: str | None = None
|
|
465
|
+
evidence_text: str | None = None
|
|
466
|
+
severity: Literal["low", "medium", "high"] = "medium"
|
|
467
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# Cloud / IaC / container-runtime misconfiguration kinds (`CloudMisconfig`).
|
|
471
|
+
# Exported from `attackmap.sdk`; each kind is a core rule id (dashed) with a
|
|
472
|
+
# finding spec in `threat_model` and a taxonomy entry in `taxonomy.py`.
|
|
473
|
+
CloudMisconfigKind = Literal[
|
|
474
|
+
# Cloud resources (Terraform)
|
|
475
|
+
"iac_module_unpinned",
|
|
476
|
+
"public_network_access",
|
|
477
|
+
"public_cluster_endpoint",
|
|
478
|
+
"plaintext_listener",
|
|
479
|
+
"api_no_authorizer",
|
|
480
|
+
"unencrypted_storage",
|
|
481
|
+
"public_data_access",
|
|
482
|
+
"public_invoker",
|
|
483
|
+
# Workloads (Kubernetes / Helm / compose / Dockerfile)
|
|
484
|
+
"privileged_container",
|
|
485
|
+
"privilege_escalation_allowed",
|
|
486
|
+
"container_runs_as_root",
|
|
487
|
+
"missing_security_context",
|
|
488
|
+
"host_path_mount",
|
|
489
|
+
"host_namespace_shared",
|
|
490
|
+
"dangerous_capability",
|
|
491
|
+
"security_profile_disabled",
|
|
492
|
+
"exposed_service",
|
|
493
|
+
"unpinned_container_image",
|
|
494
|
+
"dockerfile_remote_add",
|
|
495
|
+
"dockerfile_curl_pipe_shell",
|
|
496
|
+
# Kubernetes RBAC
|
|
497
|
+
"rbac_wildcard",
|
|
498
|
+
"rbac_escalation",
|
|
499
|
+
"rbac_secret_access",
|
|
500
|
+
"cluster_admin_binding",
|
|
501
|
+
]
|
|
502
|
+
CLOUD_MISCONFIG_KINDS: tuple[str, ...] = get_args(CloudMisconfigKind)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# Where a CloudMisconfig's resource lives. `terraform` is for module blocks.
|
|
506
|
+
CloudMisconfigPlatform = Literal["aws", "azure", "gcp", "kubernetes", "helm", "compose", "docker", "terraform"]
|
|
507
|
+
CLOUD_MISCONFIG_PLATFORMS: tuple[str, ...] = get_args(CloudMisconfigPlatform)
|
|
508
|
+
|
|
509
|
+
# Free-text fields are copied from the scanned repo: one line, capped.
|
|
510
|
+
_CLOUD_TEXT_CAPS = {"evidence_text": 300, "resource": 200, "context": 300, "escalation_path": 300}
|
|
511
|
+
_CLOUD_KIND_MAX = 64
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
class CloudMisconfig(_RedactedEvidence):
|
|
515
|
+
"""A cloud, IaC or container-runtime misconfiguration (terraform#4, iac#1).
|
|
516
|
+
|
|
517
|
+
Emitted by IaC plugins (Terraform, Kubernetes / Helm, compose,
|
|
518
|
+
Dockerfile). Like the other issue families, each one is a
|
|
519
|
+
positively-present setting (or a documented insecure default that the
|
|
520
|
+
file leaves in place) at a file and line. Core turns each `kind` into
|
|
521
|
+
findings, one per (kind, severity) tier, so a low `/var/log` mount never
|
|
522
|
+
inherits the severity of a `docker.sock` mount of the same kind.
|
|
523
|
+
|
|
524
|
+
- `kind`: one of `CLOUD_MISCONFIG_KINDS`. Typed `str` so a plugin built
|
|
525
|
+
against a newer core still validates here; core drops records with a
|
|
526
|
+
kind it doesn't know (noted under `analyzer_errors`) and keeps the rest
|
|
527
|
+
of the plugin's result.
|
|
528
|
+
- `resource`: what the setting belongs to, e.g. `aws_eks_cluster.main`,
|
|
529
|
+
`Deployment/web container app`, `module.vpc`.
|
|
530
|
+
- `platform`: one of `CLOUD_MISCONFIG_PLATFORMS`.
|
|
531
|
+
- `context`: how the resource is instantiated — `for_each`/`count`,
|
|
532
|
+
the calling module of a local module, a Helm template.
|
|
533
|
+
- `escalation_path`: what an attacker gets from it, in one line, e.g.
|
|
534
|
+
`hostPath / → node root → kubelet credentials`.
|
|
535
|
+
|
|
536
|
+
The free-text fields are redacted, folded to one line and capped.
|
|
537
|
+
"""
|
|
538
|
+
|
|
539
|
+
kind: str = Field(min_length=1, max_length=_CLOUD_KIND_MAX)
|
|
540
|
+
file: str
|
|
541
|
+
line: int | None = None
|
|
542
|
+
resource: str | None = None
|
|
543
|
+
platform: CloudMisconfigPlatform | None = None
|
|
544
|
+
context: str | None = None
|
|
545
|
+
escalation_path: str | None = None
|
|
546
|
+
evidence_text: str | None = None
|
|
547
|
+
severity: Literal["low", "medium", "high"] = "medium"
|
|
548
|
+
confidence: float = Field(default=0.8, ge=0.0, le=1.0)
|
|
549
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
550
|
+
|
|
551
|
+
@model_validator(mode="after")
|
|
552
|
+
def _clean_text_fields(self) -> "CloudMisconfig":
|
|
553
|
+
# Redact first (a module source URL can carry a token), then fold to
|
|
554
|
+
# one line and cap, so a cut never leaves half a secret unredacted.
|
|
555
|
+
for name, cap in _CLOUD_TEXT_CAPS.items():
|
|
556
|
+
value = getattr(self, name)
|
|
557
|
+
if not value:
|
|
558
|
+
continue
|
|
559
|
+
value = " ".join(redact_text(value).split())
|
|
560
|
+
if len(value) > cap:
|
|
561
|
+
value = value[: cap - 1] + "…"
|
|
562
|
+
object.__setattr__(self, name, value)
|
|
563
|
+
return self
|
|
564
|
+
|
|
565
|
+
@property
|
|
566
|
+
def known_kind(self) -> bool:
|
|
567
|
+
return self.kind in CLOUD_MISCONFIG_KINDS
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
class WorkflowIssue(_RedactedEvidence):
|
|
571
|
+
"""A CI-workflow security issue in a GitHub Actions file (#142).
|
|
572
|
+
|
|
573
|
+
Emitted by the built-in workflow scanner over ``.github/workflows/*.yml``.
|
|
574
|
+
Every issue is a positively-present misconfiguration (an unpinned action,
|
|
575
|
+
an untrusted-code checkout under ``pull_request_target``, a secret or an
|
|
576
|
+
attacker-controlled context interpolated into a shell step, ``write-all``
|
|
577
|
+
permissions, a self-hosted runner on a PR trigger) — a hardened workflow
|
|
578
|
+
produces nothing.
|
|
579
|
+
"""
|
|
580
|
+
|
|
581
|
+
kind: Literal[
|
|
582
|
+
"unpinned_action",
|
|
583
|
+
"pr_target_checkout",
|
|
584
|
+
"secret_in_run",
|
|
585
|
+
"script_injection",
|
|
586
|
+
"broad_permissions",
|
|
587
|
+
"self_hosted_pr",
|
|
588
|
+
# #246
|
|
589
|
+
"workflow_run_artifact_poisoning",
|
|
590
|
+
"issue_comment_pr_checkout",
|
|
591
|
+
"github_script_injection",
|
|
592
|
+
"github_env_injection",
|
|
593
|
+
"default_token_permissions",
|
|
594
|
+
"oidc_on_untrusted_trigger",
|
|
595
|
+
"secrets_inherit",
|
|
596
|
+
"checkout_persist_credentials",
|
|
597
|
+
"cache_poisoning_pr_target",
|
|
598
|
+
"docker_action_unpinned",
|
|
599
|
+
"curl_pipe_shell",
|
|
600
|
+
]
|
|
601
|
+
file: str
|
|
602
|
+
line: int | None = None
|
|
603
|
+
# Human-readable location within the workflow (job / step) so evidence
|
|
604
|
+
# points somewhere actionable even when a precise line isn't available.
|
|
605
|
+
context: str | None = None
|
|
606
|
+
evidence_text: str | None = None
|
|
607
|
+
severity: Literal["low", "medium", "high"] = "medium"
|
|
608
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
class CryptoWeakness(_RedactedEvidence):
|
|
612
|
+
"""An insecure-cryptography or weak-randomness usage (#70).
|
|
613
|
+
|
|
614
|
+
Emitted by the built-in crypto finder. Heuristic and regex-based;
|
|
615
|
+
the noisy families (weak hash, insecure RNG) are gated on a
|
|
616
|
+
security-context identifier to keep precision high.
|
|
617
|
+
"""
|
|
618
|
+
|
|
619
|
+
kind: Literal[
|
|
620
|
+
"weak_password_hash",
|
|
621
|
+
"weak_cipher",
|
|
622
|
+
"ecb_mode",
|
|
623
|
+
"static_iv_salt",
|
|
624
|
+
"insecure_random",
|
|
625
|
+
"insecure_tls",
|
|
626
|
+
]
|
|
627
|
+
file: str
|
|
628
|
+
line: int | None = None
|
|
629
|
+
evidence_text: str | None = None
|
|
630
|
+
severity: Literal["low", "medium", "high"] = "medium"
|
|
631
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
class BolaCandidate(_RedactedEvidence):
|
|
635
|
+
"""A route that may be missing object-level authorization (#69).
|
|
636
|
+
|
|
637
|
+
Emitted by the authz analyzer when a route takes a resource
|
|
638
|
+
identifier and reaches a datastore, but no ownership/authorization
|
|
639
|
+
check is visible near the handler. Heuristic — the ownership-marker
|
|
640
|
+
scan is the main false-positive reducer; confidence stays honest.
|
|
641
|
+
"""
|
|
642
|
+
|
|
643
|
+
route_path: str
|
|
644
|
+
route_method: str
|
|
645
|
+
route_file: str
|
|
646
|
+
route_line: int | None = None
|
|
647
|
+
id_param: str # the resource-id parameter/method detected
|
|
648
|
+
# Where the object identifier arrives (#139): a path template param, an
|
|
649
|
+
# id-bearing query parameter, an RPC method (XRPC/tRPC), or a GraphQL field.
|
|
650
|
+
surface: Literal["path_param", "query_param", "rpc_method", "graphql_field"] = "path_param"
|
|
651
|
+
reaches_db: bool = False
|
|
652
|
+
db_evidence: str = "" # how DB reachability was established
|
|
653
|
+
has_ownership_check: bool = False # True → suppressed (not a candidate)
|
|
654
|
+
# How the route is authenticated, "" when it isn't (#242). Authentication
|
|
655
|
+
# without an object check is the classic IDOR, so it raises confidence.
|
|
656
|
+
authn_evidence: str = ""
|
|
657
|
+
confidence: Literal["high", "medium", "low"] = "medium"
|
|
658
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
class TaintFlowStep(_RedactedEvidence):
|
|
662
|
+
"""One step of an intra-procedural flow (#239): where the tainted value
|
|
663
|
+
came from, each assignment it passed through, any sanitizer/guard bound to
|
|
664
|
+
it, and the sink. Rendered as SARIF ``codeFlows``. ``evidence_text`` is
|
|
665
|
+
the code at that step (redacted like other evidence)."""
|
|
666
|
+
|
|
667
|
+
file: str
|
|
668
|
+
line: int | None = None
|
|
669
|
+
kind: Literal["source", "propagation", "sanitizer", "guard", "sink"]
|
|
670
|
+
evidence_text: str | None = None
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
class TaintChain(_RedactedEvidence):
|
|
674
|
+
"""Cross-file data-flow evidence: a route reaches a sink via imports.
|
|
675
|
+
|
|
676
|
+
Emitted by the lite taint analyzer (see #45). Each entry names the
|
|
677
|
+
starting route, the sink kind and location, and the chain of files
|
|
678
|
+
the import-walk traversed. Confidence encodes that import-edge is a
|
|
679
|
+
heuristic proxy for call-edge — real dispatch may not hit the sink.
|
|
680
|
+
"""
|
|
681
|
+
|
|
682
|
+
route_path: str
|
|
683
|
+
route_method: str
|
|
684
|
+
route_file: str
|
|
685
|
+
sink_kind: Literal[
|
|
686
|
+
"sql_execute",
|
|
687
|
+
"subprocess_shell",
|
|
688
|
+
"eval",
|
|
689
|
+
"exec",
|
|
690
|
+
"dynamic_open",
|
|
691
|
+
"unsafe_deserialization",
|
|
692
|
+
"ssti",
|
|
693
|
+
"ssrf",
|
|
694
|
+
"nosql_injection",
|
|
695
|
+
"open_redirect",
|
|
696
|
+
# #240 — `dynamic_open` stays as the schema-compatible alias of
|
|
697
|
+
# `path_traversal` (#200).
|
|
698
|
+
"path_traversal",
|
|
699
|
+
"zip_slip",
|
|
700
|
+
"code_injection",
|
|
701
|
+
"expression_injection",
|
|
702
|
+
"jndi_injection",
|
|
703
|
+
"ldap_injection",
|
|
704
|
+
"xpath_injection",
|
|
705
|
+
"header_injection",
|
|
706
|
+
"regex_injection",
|
|
707
|
+
]
|
|
708
|
+
sink_file: str
|
|
709
|
+
sink_line: int | None = None
|
|
710
|
+
# Number of import edges walked. 0 = same file, 1 = direct import,
|
|
711
|
+
# 2 = transitive (imported module imports the sink module).
|
|
712
|
+
hops: int
|
|
713
|
+
# Chain of files walked, ordered [route_file, ..., sink_file].
|
|
714
|
+
files: list[str] = Field(default_factory=list)
|
|
715
|
+
evidence_text: str | None = None
|
|
716
|
+
confidence: float = 0.6
|
|
717
|
+
# Sanitizer awareness (#137). When a known neutralizer for this sink kind
|
|
718
|
+
# is present in the sink file (escaper/validator/parameter-binder/
|
|
719
|
+
# allow-lister), the chain is marked sanitized and its confidence is
|
|
720
|
+
# downgraded — the tainted value is likely neutralized before the sink.
|
|
721
|
+
# `sanitizer_evidence` names the neutralizer so the chain stays auditable.
|
|
722
|
+
sanitized: bool = False
|
|
723
|
+
sanitizer_evidence: str | None = None
|
|
724
|
+
# Recall mode (#148a). A chain surfaced only because an aggressive knob was
|
|
725
|
+
# relaxed — beyond the default hop depth, or through a gate the default pass
|
|
726
|
+
# keeps closed (a static-literal-looking sink argument) — is marked
|
|
727
|
+
# speculative with downgraded confidence. Speculative chains are discovery
|
|
728
|
+
# leads for the verifier to adjudicate, not asserted flows: they are kept
|
|
729
|
+
# out of the `--fail-on-new-high` gate and clearly labelled in reports.
|
|
730
|
+
speculative: bool = False
|
|
731
|
+
# Intra-procedural flow (#239). Set when a request-derived value is traced
|
|
732
|
+
# (through local assignments) into the sink's dangerous argument — or, for
|
|
733
|
+
# a sanitized chain, into the sanitizer/guard that neutralizes it.
|
|
734
|
+
# `source_kind` is the catalog kind (query, body, path_param, header,
|
|
735
|
+
# cookie, upload_filename, message); `source_line` is in `sink_file`.
|
|
736
|
+
# None when no flow was established (reachability-only chain).
|
|
737
|
+
source_kind: str | None = None
|
|
738
|
+
source_line: int | None = None
|
|
739
|
+
flow: list[TaintFlowStep] = Field(default_factory=list)
|
|
740
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
class Vulnerability(BaseModel):
|
|
744
|
+
"""A known CVE / advisory affecting a resolved dependency (#60).
|
|
745
|
+
|
|
746
|
+
One entry per (dep × advisory). ``package_version`` is the concrete
|
|
747
|
+
lower-bound the CVE lookup resolved from the manifest spec — it may
|
|
748
|
+
differ from the DependencyHint's raw ``version`` string.
|
|
749
|
+
"""
|
|
750
|
+
|
|
751
|
+
id: str # OSV entry id — CVE-2023-1234, GHSA-xxxx-xxxx, etc.
|
|
752
|
+
aliases: list[str] = Field(default_factory=list)
|
|
753
|
+
summary: str = ""
|
|
754
|
+
severity: Literal["low", "medium", "high"] = "medium"
|
|
755
|
+
cvss_score: float | None = None
|
|
756
|
+
references: list[str] = Field(default_factory=list)
|
|
757
|
+
affected_range: str = "" # human-readable "affected [lower, upper)" text
|
|
758
|
+
package_name: str
|
|
759
|
+
package_version: str
|
|
760
|
+
ecosystem: DependencyEcosystem
|
|
761
|
+
# Lockfile provenance (#143): False when the vulnerable package is a
|
|
762
|
+
# transitive dependency; ``resolution_path`` is its chain from a direct
|
|
763
|
+
# dependency (``"express > body-parser > qs"``) so the finding can say how
|
|
764
|
+
# the vulnerable code was pulled in.
|
|
765
|
+
direct: bool = True
|
|
766
|
+
resolution_path: str = ""
|
|
767
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
SignalKind = Literal[
|
|
771
|
+
"route",
|
|
772
|
+
"external_call",
|
|
773
|
+
"database",
|
|
774
|
+
"auth",
|
|
775
|
+
"service",
|
|
776
|
+
"edge",
|
|
777
|
+
"entrypoint",
|
|
778
|
+
"protocol",
|
|
779
|
+
"framework",
|
|
780
|
+
"secret",
|
|
781
|
+
"taint",
|
|
782
|
+
"dependency",
|
|
783
|
+
]
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
class Signal(_RedactedEvidence):
|
|
787
|
+
"""Unified view of a single static-analysis signal.
|
|
788
|
+
|
|
789
|
+
Synthesized from the typed hint lists on `ScanResult` via `all_signals()`.
|
|
790
|
+
Existing analyzer plugins keep populating the typed hint lists; downstream
|
|
791
|
+
consumers (insights, controls, asset detection, prompts) can iterate over
|
|
792
|
+
the unified Signal stream to reason uniformly about location, confidence,
|
|
793
|
+
and evidence.
|
|
794
|
+
"""
|
|
795
|
+
|
|
796
|
+
kind: SignalKind
|
|
797
|
+
label: str
|
|
798
|
+
file: str
|
|
799
|
+
line: int | None = None
|
|
800
|
+
confidence: float = 0.7
|
|
801
|
+
evidence_text: str | None = None
|
|
802
|
+
properties: dict[str, str] = Field(default_factory=dict)
|
|
803
|
+
|
|
804
|
+
def location(self) -> str:
|
|
805
|
+
"""Stable `file:line` reference if line known, else just file."""
|
|
806
|
+
return f"{self.file}:{self.line}" if self.line is not None else self.file
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
WebhookVerificationState = Literal["verified", "weak_compare", "no_replay_guard", "absent", "unknown"]
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
class WebhookVerification(BaseModel):
|
|
813
|
+
"""How a webhook handler authenticates the events it receives (#243, part B).
|
|
814
|
+
|
|
815
|
+
``verified``: a provider SDK verifier, or an HMAC checked with a
|
|
816
|
+
constant-time compare (and, for schemes that sign a timestamp, a freshness
|
|
817
|
+
check). ``weak_compare``: an HMAC compared with ``==``. ``no_replay_guard``:
|
|
818
|
+
a timestamped scheme whose timestamp is never checked. ``absent``: nothing
|
|
819
|
+
that verifies the payload. ``unknown``: the handler couldn't be read, or it
|
|
820
|
+
hands the signature to code we couldn't follow.
|
|
821
|
+
"""
|
|
822
|
+
|
|
823
|
+
state: WebhookVerificationState
|
|
824
|
+
provider: str | None = None # stripe, github, slack, twilio, shopify, svix, sns, hmac
|
|
825
|
+
evidence: str | None = None # file:line — the line that decided the state
|
|
826
|
+
notes: list[str] = Field(default_factory=list)
|
|
827
|
+
# The handler only answers 404/410 (a stub, e.g. a feature disabled in this
|
|
828
|
+
# edition): nothing is processed, so it isn't a live receiver (#366).
|
|
829
|
+
inert: bool = False
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
class AttackSurface(BaseModel):
|
|
833
|
+
route: str
|
|
834
|
+
method: str
|
|
835
|
+
file: str
|
|
836
|
+
category: Literal["webhook", "admin", "auth", "upload", "internal", "health", "public_api"]
|
|
837
|
+
exposure: Literal["public", "internal", "unknown"] = "public"
|
|
838
|
+
risk: Literal["low", "medium", "high"]
|
|
839
|
+
auth_signals: list[str] = Field(default_factory=list)
|
|
840
|
+
data_store_interaction: bool = False
|
|
841
|
+
outbound_integration: bool = False
|
|
842
|
+
rationale: list[str] = Field(default_factory=list)
|
|
843
|
+
line: int | None = None
|
|
844
|
+
# Set on webhook surfaces only (#243, part B).
|
|
845
|
+
webhook_verification: WebhookVerification | None = None
|
|
846
|
+
|
|
847
|
+
def location(self) -> str:
|
|
848
|
+
return f"{self.file}:{self.line}" if self.line is not None else self.file
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
class AttackTechnique(BaseModel):
|
|
852
|
+
"""Reference to a MITRE ATT&CK technique (Enterprise matrix)."""
|
|
853
|
+
|
|
854
|
+
technique_id: str # e.g., "T1190", "T1078.004"
|
|
855
|
+
name: str # e.g., "Exploit Public-Facing Application"
|
|
856
|
+
tactic: str # e.g., "Initial Access"
|
|
857
|
+
url: str | None = None # https://attack.mitre.org/techniques/T1190/
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
ExploitabilityTier = Literal["critical", "high", "medium", "low"]
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
class ExploitabilityFactor(BaseModel):
|
|
864
|
+
"""One named, signed contribution to an exploitability score (#79).
|
|
865
|
+
|
|
866
|
+
Kept explicit so every score is explainable — the report shows exactly
|
|
867
|
+
which signals raised (or, at 0 points, notably did not raise) the number.
|
|
868
|
+
"""
|
|
869
|
+
|
|
870
|
+
name: str # e.g. "public exposure", "no auth at entry", "reaches sql_execute sink"
|
|
871
|
+
points: int
|
|
872
|
+
detail: str = "" # concrete evidence for this factor
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
class ExploitabilityScore(BaseModel):
|
|
876
|
+
"""A fused 'exploitable now' score for one route→sink combination (#79).
|
|
877
|
+
|
|
878
|
+
Deterministic: the same scan always yields the same score, and `factors`
|
|
879
|
+
sums (before the 0–100 clamp) to `raw_score`, so nothing is hidden.
|
|
880
|
+
"""
|
|
881
|
+
|
|
882
|
+
subject: str # human label, e.g. "GET /search → sql_execute"
|
|
883
|
+
route: str
|
|
884
|
+
method: str
|
|
885
|
+
sink_kind: str
|
|
886
|
+
location: str # sink file:line
|
|
887
|
+
score: int # 0–100 (clamped)
|
|
888
|
+
raw_score: int # pre-clamp sum of factor points
|
|
889
|
+
tier: ExploitabilityTier
|
|
890
|
+
factors: list[ExploitabilityFactor] = Field(default_factory=list)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def title_slug(title: str) -> str:
|
|
894
|
+
"""Legacy rule id: the slugified finding title (pre-#223)."""
|
|
895
|
+
import re as _re
|
|
896
|
+
|
|
897
|
+
slug = _re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
|
|
898
|
+
return slug or "finding"
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def finding_rule_id(finding: "Finding") -> str:
|
|
902
|
+
"""The finding's stable rule id, or its title slug when it has none."""
|
|
903
|
+
return finding.rule_id or title_slug(finding.title)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
class FindingLocation(BaseModel):
|
|
907
|
+
"""A file (and line, when known) a finding applies to (#214). Carried
|
|
908
|
+
structurally so SARIF, suppression and diffing don't have to parse
|
|
909
|
+
evidence prose."""
|
|
910
|
+
|
|
911
|
+
file: str
|
|
912
|
+
line: int | None = None
|
|
913
|
+
# Instance identity across runs (#222): rule + file + normalized source
|
|
914
|
+
# line, without the line number. Set by `fingerprint.assign_fingerprints`.
|
|
915
|
+
fingerprint: str | None = None
|
|
916
|
+
# What tells this instance apart from others on the same line (a
|
|
917
|
+
# severity-tiered record's resource and evidence). Set only by tiered
|
|
918
|
+
# findings; when set it is mixed into the fingerprint, so an instance's
|
|
919
|
+
# fingerprint doesn't depend on its position among same-line instances
|
|
920
|
+
# (adding one, or re-tiering one, doesn't move dismissals). Not serialized.
|
|
921
|
+
instance: str | None = Field(default=None, exclude=True, repr=False)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
class FindingTaxonomy(BaseModel):
|
|
925
|
+
"""Standard ids for a finding's rule (#250), looked up by `rule_id` in
|
|
926
|
+
`attackmap.taxonomy` — never inferred from title or evidence text."""
|
|
927
|
+
|
|
928
|
+
cwe: list[str] = Field(default_factory=list) # ["CWE-918"]
|
|
929
|
+
owasp: list[str] = Field(default_factory=list) # ["A10:2021", "API7:2023"]
|
|
930
|
+
asvs: list[str] = Field(default_factory=list) # ASVS 4.0.3, ["V12.6.1"]
|
|
931
|
+
attack: list[str] = Field(default_factory=list) # ["T1190", "T1552.005"]
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
class Finding(_RedactedEvidence):
|
|
935
|
+
title: str
|
|
936
|
+
severity: Literal["low", "medium", "high"]
|
|
937
|
+
# Stable, short detector id (#223) — SARIF ruleId and suppress `rule:`.
|
|
938
|
+
# Titles are prose and may be reworded; this never changes. `None` for
|
|
939
|
+
# findings from callers that don't set one (falls back to the title slug).
|
|
940
|
+
rule_id: str | None = None
|
|
941
|
+
evidence: list[str] = Field(default_factory=list)
|
|
942
|
+
# Every location the finding covers — not capped like `evidence` (#214).
|
|
943
|
+
locations: list[FindingLocation] = Field(default_factory=list)
|
|
944
|
+
mitigation: str
|
|
945
|
+
confidence: Literal["low", "medium", "high"] = "medium"
|
|
946
|
+
attack_techniques: list[AttackTechnique] = Field(default_factory=list)
|
|
947
|
+
# Optional categorization tags. Vocabulary (see #4): exposed-endpoint,
|
|
948
|
+
# auth-missing, data-risk, secret-exposure, integration-risk,
|
|
949
|
+
# framework-chain, service-chain, atproto-chain, weak-signal. Multiple
|
|
950
|
+
# tags may apply per finding.
|
|
951
|
+
tags: list[str] = Field(default_factory=list)
|
|
952
|
+
# Numeric prioritization score (higher = triage first within the same
|
|
953
|
+
# severity band). Computed from severity + confidence; used as a
|
|
954
|
+
# secondary sort key. `None` means "not scored" for backward compat.
|
|
955
|
+
score: int | None = None
|
|
956
|
+
# Fused 'exploitable now' score (#79), 0–100, with a tier. Set on
|
|
957
|
+
# findings that sit on a route→sink path; `None` when not applicable.
|
|
958
|
+
exploitability: int | None = None
|
|
959
|
+
exploitability_tier: ExploitabilityTier | None = None
|
|
960
|
+
# Source→sink steps behind a taint finding (#239), one list per traced
|
|
961
|
+
# flow; emitted as SARIF `codeFlows` on the matching sink result.
|
|
962
|
+
code_flows: list[list[TaintFlowStep]] = Field(default_factory=list)
|
|
963
|
+
# CWE / OWASP / ASVS / ATT&CK ids for `rule_id` (#250). Filled from the
|
|
964
|
+
# registry on construction; `None` for rules it doesn't know.
|
|
965
|
+
taxonomy: FindingTaxonomy | None = None
|
|
966
|
+
|
|
967
|
+
@model_validator(mode="after")
|
|
968
|
+
def _apply_rule_taxonomy(self) -> "Finding":
|
|
969
|
+
from .taxonomy import ATTACK_TECHNIQUES, attack_url, taxonomy_for
|
|
970
|
+
|
|
971
|
+
entry = taxonomy_for(self.rule_id)
|
|
972
|
+
if entry is None:
|
|
973
|
+
return self
|
|
974
|
+
if self.taxonomy is None:
|
|
975
|
+
object.__setattr__(
|
|
976
|
+
self,
|
|
977
|
+
"taxonomy",
|
|
978
|
+
FindingTaxonomy(
|
|
979
|
+
cwe=entry.cwe_ids, owasp=list(entry.owasp), asvs=list(entry.asvs), attack=list(entry.attack)
|
|
980
|
+
),
|
|
981
|
+
)
|
|
982
|
+
if entry.attack:
|
|
983
|
+
# The registry is authoritative for ATT&CK on registered rules.
|
|
984
|
+
object.__setattr__(
|
|
985
|
+
self,
|
|
986
|
+
"attack_techniques",
|
|
987
|
+
[
|
|
988
|
+
AttackTechnique(technique_id=tid, name=name, tactic=tactic, url=attack_url(tid))
|
|
989
|
+
for tid in entry.attack
|
|
990
|
+
for name, tactic in [ATTACK_TECHNIQUES[tid]]
|
|
991
|
+
],
|
|
992
|
+
)
|
|
993
|
+
return self
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
class AttackPath(BaseModel):
|
|
997
|
+
name: str
|
|
998
|
+
steps: list[str]
|
|
999
|
+
impact: str
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
AssetKind = Literal[
|
|
1003
|
+
"credentials",
|
|
1004
|
+
"session",
|
|
1005
|
+
"user_pii",
|
|
1006
|
+
"payment",
|
|
1007
|
+
"internal_secret",
|
|
1008
|
+
"audit_log",
|
|
1009
|
+
"business_data",
|
|
1010
|
+
"configuration",
|
|
1011
|
+
]
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
class Asset(_RedactedEvidence):
|
|
1015
|
+
id: str
|
|
1016
|
+
kind: AssetKind
|
|
1017
|
+
name: str
|
|
1018
|
+
criticality: Literal["critical", "high", "medium", "low"]
|
|
1019
|
+
locations: list[str] = Field(default_factory=list)
|
|
1020
|
+
evidence: list[str] = Field(default_factory=list)
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
ControlKind = Literal[
|
|
1024
|
+
"authentication",
|
|
1025
|
+
"authorization",
|
|
1026
|
+
"input_validation",
|
|
1027
|
+
"output_encoding",
|
|
1028
|
+
"rate_limiting",
|
|
1029
|
+
"csrf_protection",
|
|
1030
|
+
"encryption_at_rest",
|
|
1031
|
+
"encryption_in_transit",
|
|
1032
|
+
"audit_logging",
|
|
1033
|
+
"rbac",
|
|
1034
|
+
"mfa",
|
|
1035
|
+
"secret_management",
|
|
1036
|
+
"security_headers",
|
|
1037
|
+
]
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
ControlStrength = Literal["strong", "moderate", "weak", "absent"]
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
class Control(_RedactedEvidence):
|
|
1044
|
+
id: str
|
|
1045
|
+
kind: ControlKind
|
|
1046
|
+
name: str
|
|
1047
|
+
strength: ControlStrength
|
|
1048
|
+
scope: Literal["global", "module", "route", "service", "asset"]
|
|
1049
|
+
placements: list[str] = Field(default_factory=list)
|
|
1050
|
+
evidence: list[str] = Field(default_factory=list)
|
|
1051
|
+
notes: str | None = None
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
InsightKind = Literal[
|
|
1055
|
+
"shared_secret_blast_radius",
|
|
1056
|
+
"sensitive_asset_reachability",
|
|
1057
|
+
"control_bypass",
|
|
1058
|
+
"defense_gap_in_chain",
|
|
1059
|
+
"asymmetric_protection",
|
|
1060
|
+
"trust_boundary_violation",
|
|
1061
|
+
"audit_gap",
|
|
1062
|
+
"control_strength_mismatch",
|
|
1063
|
+
"single_point_of_failure",
|
|
1064
|
+
"stale_or_contradictory_signal",
|
|
1065
|
+
"admin_action_without_auth",
|
|
1066
|
+
]
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
class Insight(_RedactedEvidence):
|
|
1070
|
+
id: str
|
|
1071
|
+
kind: InsightKind
|
|
1072
|
+
title: str
|
|
1073
|
+
narrative: str
|
|
1074
|
+
severity: Literal["critical", "high", "medium", "low", "informational"]
|
|
1075
|
+
confidence: Literal["high", "medium", "low"]
|
|
1076
|
+
evidence: list[str] = Field(default_factory=list)
|
|
1077
|
+
related_assets: list[str] = Field(default_factory=list)
|
|
1078
|
+
related_controls: list[str] = Field(default_factory=list)
|
|
1079
|
+
related_routes: list[str] = Field(default_factory=list)
|
|
1080
|
+
suggested_action: str | None = None
|
|
1081
|
+
attack_techniques: list[AttackTechnique] = Field(default_factory=list)
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
class DetectionLogsource(BaseModel):
|
|
1085
|
+
"""Sigma `logsource` of a generated rule (#251)."""
|
|
1086
|
+
|
|
1087
|
+
product: str | None = None
|
|
1088
|
+
service: str | None = None
|
|
1089
|
+
category: str | None = None
|
|
1090
|
+
|
|
1091
|
+
def label(self) -> str:
|
|
1092
|
+
return self.category or "/".join(p for p in (self.product, self.service) if p) or "unknown"
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
class DetectionTestEvents(BaseModel):
|
|
1096
|
+
"""Sample log events for a generated rule (#251): `positive` events match
|
|
1097
|
+
it, `negative` ones don't."""
|
|
1098
|
+
|
|
1099
|
+
positive: list[dict[str, Any]] = Field(default_factory=list)
|
|
1100
|
+
negative: list[dict[str, Any]] = Field(default_factory=list)
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
class DetectionOpportunity(BaseModel):
|
|
1104
|
+
"""A defender-facing detection-engineering hint produced from an insight or finding.
|
|
1105
|
+
|
|
1106
|
+
Designed to answer: "given this static-analysis finding, what runtime
|
|
1107
|
+
detection signal would catch the same condition in production?"
|
|
1108
|
+
"""
|
|
1109
|
+
|
|
1110
|
+
id: str
|
|
1111
|
+
title: str
|
|
1112
|
+
rationale: str
|
|
1113
|
+
signal_kind: Literal["log", "metric", "trace", "network", "config_audit"]
|
|
1114
|
+
suggested_rule: str # human-readable rule sketch (Sigma/KQL/Splunk-style)
|
|
1115
|
+
related_insight_ids: list[str] = Field(default_factory=list)
|
|
1116
|
+
related_finding_titles: list[str] = Field(default_factory=list)
|
|
1117
|
+
# Rule ids of the linked findings (#250), linked via the taxonomy registry.
|
|
1118
|
+
related_rule_ids: list[str] = Field(default_factory=list)
|
|
1119
|
+
attack_techniques: list[AttackTechnique] = Field(default_factory=list)
|
|
1120
|
+
# Deployable rule (#251), set on opportunities generated per finding
|
|
1121
|
+
# class: a structurally valid Sigma rule (deterministic `id`, status
|
|
1122
|
+
# experimental), its log source, the log fields it reads, false-positive
|
|
1123
|
+
# notes and sample events (positives match the rule, negatives don't).
|
|
1124
|
+
# `sigma_correlation` is a Sigma correlation rule over `sigma` (BOLA
|
|
1125
|
+
# cardinality). All `None`/empty on prose-only opportunities.
|
|
1126
|
+
sigma: dict[str, Any] | None = None
|
|
1127
|
+
sigma_correlation: dict[str, Any] | None = None
|
|
1128
|
+
logsource: DetectionLogsource | None = None
|
|
1129
|
+
fields_required: list[str] = Field(default_factory=list)
|
|
1130
|
+
false_positive_notes: list[str] = Field(default_factory=list)
|
|
1131
|
+
test_events: DetectionTestEvents | None = None
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
class AnalyzerError(BaseModel):
|
|
1135
|
+
"""An analyzer whose ``analyze()`` raised or returned something unusable
|
|
1136
|
+
(#220). The scan carries on without it; this records the gap."""
|
|
1137
|
+
|
|
1138
|
+
analyzer: str
|
|
1139
|
+
error_type: str
|
|
1140
|
+
message: str = ""
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
class ScanResult(BaseModel):
|
|
1144
|
+
root: str
|
|
1145
|
+
languages: list[str] = Field(default_factory=list)
|
|
1146
|
+
routes: list[Route] = Field(default_factory=list)
|
|
1147
|
+
external_calls: list[ExternalCall] = Field(default_factory=list)
|
|
1148
|
+
databases: list[DatabaseHint] = Field(default_factory=list)
|
|
1149
|
+
auth_hints: list[AuthHint] = Field(default_factory=list)
|
|
1150
|
+
service_hints: list[ServiceHint] = Field(default_factory=list)
|
|
1151
|
+
edge_hints: list[EdgeHint] = Field(default_factory=list)
|
|
1152
|
+
entrypoint_hints: list[EntrypointHint] = Field(default_factory=list)
|
|
1153
|
+
protocol_hints: list[ProtocolHint] = Field(default_factory=list)
|
|
1154
|
+
framework_hints: list[FrameworkHint] = Field(default_factory=list)
|
|
1155
|
+
secret_hints: list[SecretHint] = Field(default_factory=list)
|
|
1156
|
+
taint_chains: list[TaintChain] = Field(default_factory=list)
|
|
1157
|
+
dependencies: list[DependencyHint] = Field(default_factory=list)
|
|
1158
|
+
vulnerabilities: list[Vulnerability] = Field(default_factory=list)
|
|
1159
|
+
authz_candidates: list[BolaCandidate] = Field(default_factory=list)
|
|
1160
|
+
crypto_weaknesses: list[CryptoWeakness] = Field(default_factory=list)
|
|
1161
|
+
web_hardening_issues: list[WebHardeningIssue] = Field(default_factory=list)
|
|
1162
|
+
code_weaknesses: list[CodeWeakness] = Field(default_factory=list)
|
|
1163
|
+
workflow_issues: list[WorkflowIssue] = Field(default_factory=list)
|
|
1164
|
+
supply_chain_issues: list[SupplyChainIssue] = Field(default_factory=list)
|
|
1165
|
+
cloud_misconfigs: list[CloudMisconfig] = Field(default_factory=list)
|
|
1166
|
+
# Plugin-emitted app attack surface (#188, #190).
|
|
1167
|
+
app_surface_issues: list[AppSurfaceIssue] = Field(default_factory=list)
|
|
1168
|
+
secret_history: list[SecretHistoryHit] = Field(default_factory=list)
|
|
1169
|
+
anomalies: list[Anomaly] = Field(default_factory=list)
|
|
1170
|
+
# What the scan deliberately did not analyze (e.g. symlinks that point out
|
|
1171
|
+
# of the repo, #234) — so a clean report isn't mistaken for full coverage.
|
|
1172
|
+
limitations: list[str] = Field(default_factory=list)
|
|
1173
|
+
# Analyzers that failed and were skipped (#220).
|
|
1174
|
+
analyzer_errors: list[AnalyzerError] = Field(default_factory=list)
|
|
1175
|
+
files_scanned: int = 0
|
|
1176
|
+
|
|
1177
|
+
@property
|
|
1178
|
+
def root_path(self) -> Path:
|
|
1179
|
+
return Path(self.root)
|
|
1180
|
+
|
|
1181
|
+
def all_signals(self) -> list[Signal]:
|
|
1182
|
+
"""Synthesize the unified Signal stream from typed hint lists.
|
|
1183
|
+
|
|
1184
|
+
Cheap O(n) view — does not mutate `self`. Order: routes, external calls,
|
|
1185
|
+
databases, then the hint families (auth, service, edge, entrypoint,
|
|
1186
|
+
protocol, framework, secret) in that order.
|
|
1187
|
+
"""
|
|
1188
|
+
signals: list[Signal] = []
|
|
1189
|
+
for r in self.routes:
|
|
1190
|
+
signals.append(
|
|
1191
|
+
Signal(
|
|
1192
|
+
kind="route",
|
|
1193
|
+
label=f"{r.method} {r.path}",
|
|
1194
|
+
file=r.file,
|
|
1195
|
+
line=r.line,
|
|
1196
|
+
properties={"method": r.method, "path": r.path},
|
|
1197
|
+
)
|
|
1198
|
+
)
|
|
1199
|
+
for ext in self.external_calls:
|
|
1200
|
+
signals.append(
|
|
1201
|
+
Signal(
|
|
1202
|
+
kind="external_call",
|
|
1203
|
+
label=ext.target,
|
|
1204
|
+
file=ext.file,
|
|
1205
|
+
line=ext.line,
|
|
1206
|
+
evidence_text=ext.evidence_text,
|
|
1207
|
+
)
|
|
1208
|
+
)
|
|
1209
|
+
for db in self.databases:
|
|
1210
|
+
signals.append(
|
|
1211
|
+
Signal(
|
|
1212
|
+
kind="database",
|
|
1213
|
+
label=db.kind,
|
|
1214
|
+
file=db.file,
|
|
1215
|
+
line=db.line,
|
|
1216
|
+
evidence_text=db.evidence_text,
|
|
1217
|
+
properties={"kind": db.kind},
|
|
1218
|
+
)
|
|
1219
|
+
)
|
|
1220
|
+
_hint_kind_pairs: tuple[tuple[SignalKind, list], ...] = (
|
|
1221
|
+
("auth", self.auth_hints),
|
|
1222
|
+
("service", self.service_hints),
|
|
1223
|
+
("edge", self.edge_hints),
|
|
1224
|
+
("entrypoint", self.entrypoint_hints),
|
|
1225
|
+
("protocol", self.protocol_hints),
|
|
1226
|
+
("framework", self.framework_hints),
|
|
1227
|
+
)
|
|
1228
|
+
for kind, hints in _hint_kind_pairs:
|
|
1229
|
+
for h in hints:
|
|
1230
|
+
signals.append(
|
|
1231
|
+
Signal(
|
|
1232
|
+
kind=kind,
|
|
1233
|
+
label=h.hint,
|
|
1234
|
+
file=h.file,
|
|
1235
|
+
line=getattr(h, "line", None),
|
|
1236
|
+
evidence_text=getattr(h, "evidence_text", None),
|
|
1237
|
+
confidence=getattr(h, "confidence", 0.7),
|
|
1238
|
+
)
|
|
1239
|
+
)
|
|
1240
|
+
for s in self.secret_hints:
|
|
1241
|
+
signals.append(
|
|
1242
|
+
Signal(
|
|
1243
|
+
kind="secret",
|
|
1244
|
+
label=s.name,
|
|
1245
|
+
file=s.file,
|
|
1246
|
+
line=s.line,
|
|
1247
|
+
evidence_text=s.evidence_text,
|
|
1248
|
+
confidence=getattr(s, "confidence", 0.85),
|
|
1249
|
+
)
|
|
1250
|
+
)
|
|
1251
|
+
for tc in self.taint_chains:
|
|
1252
|
+
signals.append(
|
|
1253
|
+
Signal(
|
|
1254
|
+
kind="taint",
|
|
1255
|
+
label=f"{tc.route_method} {tc.route_path} → {tc.sink_kind}",
|
|
1256
|
+
file=tc.sink_file,
|
|
1257
|
+
line=tc.sink_line,
|
|
1258
|
+
evidence_text=tc.evidence_text,
|
|
1259
|
+
confidence=tc.confidence,
|
|
1260
|
+
properties={
|
|
1261
|
+
"hops": str(tc.hops),
|
|
1262
|
+
"sink_kind": tc.sink_kind,
|
|
1263
|
+
"route_file": tc.route_file,
|
|
1264
|
+
},
|
|
1265
|
+
)
|
|
1266
|
+
)
|
|
1267
|
+
for dep in self.dependencies:
|
|
1268
|
+
signals.append(
|
|
1269
|
+
Signal(
|
|
1270
|
+
kind="dependency",
|
|
1271
|
+
label=f"{dep.ecosystem}:{dep.name}@{dep.version}",
|
|
1272
|
+
file=dep.file,
|
|
1273
|
+
line=dep.line,
|
|
1274
|
+
evidence_text=dep.evidence_text,
|
|
1275
|
+
confidence=0.9, # manifest declarations are hard evidence
|
|
1276
|
+
properties={
|
|
1277
|
+
"ecosystem": dep.ecosystem,
|
|
1278
|
+
"name": dep.name,
|
|
1279
|
+
"version": dep.version,
|
|
1280
|
+
"dev": "true" if dep.dev else "false",
|
|
1281
|
+
},
|
|
1282
|
+
)
|
|
1283
|
+
)
|
|
1284
|
+
return signals
|