attackmap 0.6.4__py3-none-any.whl

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