attackmap 0.4.15__tar.gz → 0.4.21__tar.gz
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.
Potentially problematic release.
This version of attackmap might be problematic. Click here for more details.
- {attackmap-0.4.15/src/attackmap.egg-info → attackmap-0.4.21}/PKG-INFO +59 -3
- {attackmap-0.4.15 → attackmap-0.4.21}/README.md +58 -2
- {attackmap-0.4.15 → attackmap-0.4.21}/pyproject.toml +1 -1
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/__init__.py +1 -1
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/analyzers.py +37 -18
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/anomalies.py +214 -8
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/authz.py +4 -1
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/cli.py +129 -39
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/exploitability.py +3 -1
- attackmap-0.4.21/src/attackmap/hunt_harness.py +451 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/llm_review.py +35 -8
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/models.py +23 -1
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/review_prompts.py +172 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/scanner.py +5 -2
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/taint.py +191 -20
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/threat_model.py +63 -2
- {attackmap-0.4.15 → attackmap-0.4.21/src/attackmap.egg-info}/PKG-INFO +59 -3
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap.egg-info/SOURCES.txt +3 -0
- attackmap-0.4.21/tests/test_anomalies.py +663 -0
- attackmap-0.4.21/tests/test_hunt_harness.py +558 -0
- attackmap-0.4.21/tests/test_recall.py +310 -0
- attackmap-0.4.15/tests/test_anomalies.py +0 -397
- {attackmap-0.4.15 → attackmap-0.4.21}/LICENSE +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/setup.cfg +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/analyzer.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/analyzer_contracts.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/asset_model.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/attack_taxonomy.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/config_scanner.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/context_pack.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/control_model.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/crypto.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/cve.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/defensive_review.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/detection_opportunities.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/diagrams.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/diff.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/graph.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/insights.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/lockfiles.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/merge.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/progress.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/recon_models.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/recon_to_analysis.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/report.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/review_eval.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/review_json.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/route_auth_fusion.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/sarif.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/sbom.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/sdk/__init__.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/sdk/contracts.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/sdk/models.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/security_overlay.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/srcpaths.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/suggest.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/suppress.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/topology.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/triage.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/weaknesses.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/webhardening.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/workflow_scanner.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap.egg-info/dependency_links.txt +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap.egg-info/entry_points.txt +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap.egg-info/requires.txt +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap.egg-info/top_level.txt +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_analyzer.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_analyzer_metadata_schema.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_analyzers.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_artifact_schemas.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_authz.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_config_scanner.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_context_pack.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_crypto.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_cve.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_defensive_review.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_diagrams.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_diff.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_exploitability.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_graph.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_hunt.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_injection_sinks.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_llm_review.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_lockfiles.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_merge.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_modules_cli.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_pr_comment.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_progress.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_provenance.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_recon_to_analysis.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_remediate.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_report.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_review_eval.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_review_json.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_review_prompts.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_route_auth_fusion.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_sarif.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_sbom.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_scanner.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_security_overlay.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_shared_contract_imports.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_signal_v2.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_srcpaths.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_suggest.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_suppress.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_taint.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_taint_sanitizer.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_threat_intel.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_threat_model.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_topology.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_triage.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_weaknesses.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_webhardening.py +0 -0
- {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_workflow_scanner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: attackmap
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.21
|
|
4
4
|
Summary: AI-assisted defensive security analyzer for codebases — scans a repository, models assets and controls, finds cross-cutting weaknesses, and generates an evidence-grounded review with MITRE ATT&CK mappings and detection-engineering hints.
|
|
5
5
|
Author: AttackMap Contributors
|
|
6
6
|
Author-email: Matthew Davis <matthewd@matthewd.xyz>
|
|
@@ -77,7 +77,7 @@ managers who need to triage an unfamiliar codebase.
|
|
|
77
77
|
[](https://www.python.org/)
|
|
78
78
|
[](https://pypi.org/project/attackmap/)
|
|
79
79
|
|
|
80
|
-
> **Status: beta (v0.4.
|
|
80
|
+
> **Status: beta (v0.4.18).** Core engine and 14 analyzer plugins are published
|
|
81
81
|
> to PyPI, Homebrew, and GHCR and validated against real-world codebases.
|
|
82
82
|
> AttackMap is heuristic by design — findings are confidence-tiered evidence,
|
|
83
83
|
> not proof. See [Project status](#project-status) for what's solid and what's
|
|
@@ -384,6 +384,27 @@ excluded from all the heuristic passes above by default, since dangerous
|
|
|
384
384
|
patterns in test scaffolding are rarely real exposure. Set
|
|
385
385
|
`ATTACKMAP_INCLUDE_TESTS=1` to scan them too (e.g. for test-quality reviews).
|
|
386
386
|
|
|
387
|
+
**Recall mode (`--recall`).** Aggressiveness is only useful *behind* a verifier —
|
|
388
|
+
so `--recall` widens taint discovery and marks the extra reach **speculative**
|
|
389
|
+
instead of asserting it. It:
|
|
390
|
+
|
|
391
|
+
- raises the max import-hop depth (2 → 4) and per-route visit budget;
|
|
392
|
+
- surfaces reaches the default pass conservatively hides, like a
|
|
393
|
+
static-literal-looking `eval`/`exec`/deserialize argument;
|
|
394
|
+
- **enumerates capability-reach**: every reach to a powerful capability —
|
|
395
|
+
network (`requests`/`httpx`/`axios`/`fetch`/`urlopen`), template
|
|
396
|
+
(`render_template_string`/`Template`), filesystem (`open`), redirect
|
|
397
|
+
(`redirect`/`res.redirect`) — even with **no known-bad pattern**, i.e. the
|
|
398
|
+
bare call without a request-derived argument (a genuine request-derived sink
|
|
399
|
+
still fires once, as a confirmed finding, not a duplicate).
|
|
400
|
+
|
|
401
|
+
Speculative chains carry downgraded confidence, get their own LOW-severity
|
|
402
|
+
finding (`SPECULATIVE (recall mode)…`), are excluded from every asserted
|
|
403
|
+
downstream pass (exploitability, BOLA, attack paths), and are kept **out of
|
|
404
|
+
`--fail-on-new-high`** — they are leads for `--hunt --verify` / `--triage` to
|
|
405
|
+
adjudicate, not detections. Default behavior is unchanged; pair `--recall` with
|
|
406
|
+
`--hunt --verify` to widen the net and then adjudicate what it catches.
|
|
407
|
+
|
|
387
408
|
### Web hardening gaps
|
|
388
409
|
|
|
389
410
|
Route- and config-level checks for common web misconfigurations, each an
|
|
@@ -516,6 +537,20 @@ within each cohort the odd-one-out is reported:
|
|
|
516
537
|
| `auth_outlier` | siblings carry an auth/authorization signal near the handler; this route doesn't | HIGH |
|
|
517
538
|
| `validation_outlier` | among a cohort's state-changing handlers, peers validate input and this one shows no validation marker | LOW |
|
|
518
539
|
| `method_outlier` | a lone state-changing method in an otherwise read-only cohort | MEDIUM |
|
|
540
|
+
| `invariant_violation` | a mined-invariant outlier: among handlers that reach the same dangerous sink kind, the majority guard the request *before* the sink and this one doesn't — evidence cites the mined rule | HIGH |
|
|
541
|
+
|
|
542
|
+
The `invariant_violation` kind is the **mined-invariant** pass (#149a): its
|
|
543
|
+
cohort is not a route-path prefix but the set of handlers that reach the same
|
|
544
|
+
dangerous sink (from the taint pass). When a strong majority guard the request
|
|
545
|
+
before the sink, that pattern is mined as an implicit invariant and the handler
|
|
546
|
+
that reaches the same sink kind with no preceding guard is flagged, citing the
|
|
547
|
+
mined rule (e.g. "9 of 10 handlers that reach a database (SQL execute) sink
|
|
548
|
+
apply an auth/validation guard before it"). It reasons only about **same-file**
|
|
549
|
+
flows, where guard-before-sink ordering is actually verifiable (a cross-file
|
|
550
|
+
sink could be called before an `authorize(...)` that textually follows it), and
|
|
551
|
+
counts one handler per declaration regardless of how many verbs it exposes.
|
|
552
|
+
Sanitized chains are excluded so only genuinely undefended flows form the cohort
|
|
553
|
+
— it measures the code against its own norm and needs no signature.
|
|
519
554
|
|
|
520
555
|
Everything is peer-relative and **confidence scales with how consistent the
|
|
521
556
|
cohort is** — a lone deviation among many agreeing siblings is likelier a mistake
|
|
@@ -664,6 +699,24 @@ to confirm, not detections.** The same grounding contract as the review applies
|
|
|
664
699
|
assignment, no exploit code, confidence-tiered, and each lead lists exactly what
|
|
665
700
|
a human must verify. Uses the same auth/backend resolution as `--llm`.
|
|
666
701
|
|
|
702
|
+
With `--verify`, each hypothesis is adjudicated against the actual source at its
|
|
703
|
+
cited locations. **`--verify-votes N`** (default 3) turns that into a jury: **N
|
|
704
|
+
independent skeptics** each adjudicate the same fixed hypothesis list. A lead is
|
|
705
|
+
**CONFIRMED only on a strict majority** (NEEDS_REVIEW only when a majority flags
|
|
706
|
+
the evidence as insufficient); ties and uncertainty are **REFUTED** — so a lead
|
|
707
|
+
only one skeptic would confirm is dropped. **`--hunt-lenses N`** additionally
|
|
708
|
+
fans generation out into N independent passes, each specialised in one failure
|
|
709
|
+
mode (auth-bypass, TOCTOU/race, IDOR, deserialization, SSRF-to-internal,
|
|
710
|
+
secret-misuse), and dedupes the leads across passes before verifying — more
|
|
711
|
+
unique novel leads than a single generalist pass. **`--hunt-rounds N`** loops
|
|
712
|
+
generation for up to N rounds, accumulating new (deduped) leads while a
|
|
713
|
+
completeness critic seeds each next round with untried angles, and stops early
|
|
714
|
+
once a round finds nothing new; **`--hunt-budget T`** caps the multi-round output
|
|
715
|
+
tokens.
|
|
716
|
+
This is the verifier the unknown-bug initiative
|
|
717
|
+
([`docs/unknown-bug-epic.md`](docs/unknown-bug-epic.md)) is built on;
|
|
718
|
+
`--verify-votes 1` is the classic single pass.
|
|
719
|
+
|
|
667
720
|
Layered on top: **MITRE ATT&CK technique mappings** on every insight and
|
|
668
721
|
**detection opportunities** (Sigma/KQL/Splunk-style hints) for each weakness.
|
|
669
722
|
|
|
@@ -722,6 +775,9 @@ attackmap analyze <path> --llm --llm-provider openai # use OpenAI/Codex (OPE
|
|
|
722
775
|
attackmap analyze <path> --llm --llm-provider openai --llm-model gpt-5.5 # any OpenAI model ID, passed through
|
|
723
776
|
attackmap analyze <path> --hunt # LLM vulnerability-hypothesis hunt (leads to confirm)
|
|
724
777
|
attackmap analyze <path> --hunt --verify # adjudicate each lead vs. source (confirmed/refuted/review)
|
|
778
|
+
attackmap analyze <path> --hunt --verify --verify-votes 3 # majority vote of N independent skeptics (default 3; 1 = single pass)
|
|
779
|
+
attackmap analyze <path> --hunt --verify --hunt-lenses 4 # N lens-specialised generation passes, deduped, then verified
|
|
780
|
+
attackmap analyze <path> --hunt --verify --hunt-rounds 4 # loop-until-dry generation (completeness critic seeds each round)
|
|
725
781
|
attackmap analyze <path> --remediate # LLM review-first fix suggestions (remediation.md)
|
|
726
782
|
attackmap analyze <path> --triage # cluster/dedupe/rank existing findings (triage.md; deterministic fallback)
|
|
727
783
|
attackmap analyze <path> --pr-comment pr.md # Markdown PR summary comment for CI
|
|
@@ -768,7 +824,7 @@ introduces a new HIGH finding.
|
|
|
768
824
|
|
|
769
825
|
## Project status
|
|
770
826
|
|
|
771
|
-
AttackMap is **beta** (v0.4.
|
|
827
|
+
AttackMap is **beta** (v0.4.18) — published and validated on real codebases, but
|
|
772
828
|
pre-1.0 and heuristic.
|
|
773
829
|
|
|
774
830
|
**Solid today:**
|
|
@@ -16,7 +16,7 @@ managers who need to triage an unfamiliar codebase.
|
|
|
16
16
|
[](https://www.python.org/)
|
|
17
17
|
[](https://pypi.org/project/attackmap/)
|
|
18
18
|
|
|
19
|
-
> **Status: beta (v0.4.
|
|
19
|
+
> **Status: beta (v0.4.18).** Core engine and 14 analyzer plugins are published
|
|
20
20
|
> to PyPI, Homebrew, and GHCR and validated against real-world codebases.
|
|
21
21
|
> AttackMap is heuristic by design — findings are confidence-tiered evidence,
|
|
22
22
|
> not proof. See [Project status](#project-status) for what's solid and what's
|
|
@@ -323,6 +323,27 @@ excluded from all the heuristic passes above by default, since dangerous
|
|
|
323
323
|
patterns in test scaffolding are rarely real exposure. Set
|
|
324
324
|
`ATTACKMAP_INCLUDE_TESTS=1` to scan them too (e.g. for test-quality reviews).
|
|
325
325
|
|
|
326
|
+
**Recall mode (`--recall`).** Aggressiveness is only useful *behind* a verifier —
|
|
327
|
+
so `--recall` widens taint discovery and marks the extra reach **speculative**
|
|
328
|
+
instead of asserting it. It:
|
|
329
|
+
|
|
330
|
+
- raises the max import-hop depth (2 → 4) and per-route visit budget;
|
|
331
|
+
- surfaces reaches the default pass conservatively hides, like a
|
|
332
|
+
static-literal-looking `eval`/`exec`/deserialize argument;
|
|
333
|
+
- **enumerates capability-reach**: every reach to a powerful capability —
|
|
334
|
+
network (`requests`/`httpx`/`axios`/`fetch`/`urlopen`), template
|
|
335
|
+
(`render_template_string`/`Template`), filesystem (`open`), redirect
|
|
336
|
+
(`redirect`/`res.redirect`) — even with **no known-bad pattern**, i.e. the
|
|
337
|
+
bare call without a request-derived argument (a genuine request-derived sink
|
|
338
|
+
still fires once, as a confirmed finding, not a duplicate).
|
|
339
|
+
|
|
340
|
+
Speculative chains carry downgraded confidence, get their own LOW-severity
|
|
341
|
+
finding (`SPECULATIVE (recall mode)…`), are excluded from every asserted
|
|
342
|
+
downstream pass (exploitability, BOLA, attack paths), and are kept **out of
|
|
343
|
+
`--fail-on-new-high`** — they are leads for `--hunt --verify` / `--triage` to
|
|
344
|
+
adjudicate, not detections. Default behavior is unchanged; pair `--recall` with
|
|
345
|
+
`--hunt --verify` to widen the net and then adjudicate what it catches.
|
|
346
|
+
|
|
326
347
|
### Web hardening gaps
|
|
327
348
|
|
|
328
349
|
Route- and config-level checks for common web misconfigurations, each an
|
|
@@ -455,6 +476,20 @@ within each cohort the odd-one-out is reported:
|
|
|
455
476
|
| `auth_outlier` | siblings carry an auth/authorization signal near the handler; this route doesn't | HIGH |
|
|
456
477
|
| `validation_outlier` | among a cohort's state-changing handlers, peers validate input and this one shows no validation marker | LOW |
|
|
457
478
|
| `method_outlier` | a lone state-changing method in an otherwise read-only cohort | MEDIUM |
|
|
479
|
+
| `invariant_violation` | a mined-invariant outlier: among handlers that reach the same dangerous sink kind, the majority guard the request *before* the sink and this one doesn't — evidence cites the mined rule | HIGH |
|
|
480
|
+
|
|
481
|
+
The `invariant_violation` kind is the **mined-invariant** pass (#149a): its
|
|
482
|
+
cohort is not a route-path prefix but the set of handlers that reach the same
|
|
483
|
+
dangerous sink (from the taint pass). When a strong majority guard the request
|
|
484
|
+
before the sink, that pattern is mined as an implicit invariant and the handler
|
|
485
|
+
that reaches the same sink kind with no preceding guard is flagged, citing the
|
|
486
|
+
mined rule (e.g. "9 of 10 handlers that reach a database (SQL execute) sink
|
|
487
|
+
apply an auth/validation guard before it"). It reasons only about **same-file**
|
|
488
|
+
flows, where guard-before-sink ordering is actually verifiable (a cross-file
|
|
489
|
+
sink could be called before an `authorize(...)` that textually follows it), and
|
|
490
|
+
counts one handler per declaration regardless of how many verbs it exposes.
|
|
491
|
+
Sanitized chains are excluded so only genuinely undefended flows form the cohort
|
|
492
|
+
— it measures the code against its own norm and needs no signature.
|
|
458
493
|
|
|
459
494
|
Everything is peer-relative and **confidence scales with how consistent the
|
|
460
495
|
cohort is** — a lone deviation among many agreeing siblings is likelier a mistake
|
|
@@ -603,6 +638,24 @@ to confirm, not detections.** The same grounding contract as the review applies
|
|
|
603
638
|
assignment, no exploit code, confidence-tiered, and each lead lists exactly what
|
|
604
639
|
a human must verify. Uses the same auth/backend resolution as `--llm`.
|
|
605
640
|
|
|
641
|
+
With `--verify`, each hypothesis is adjudicated against the actual source at its
|
|
642
|
+
cited locations. **`--verify-votes N`** (default 3) turns that into a jury: **N
|
|
643
|
+
independent skeptics** each adjudicate the same fixed hypothesis list. A lead is
|
|
644
|
+
**CONFIRMED only on a strict majority** (NEEDS_REVIEW only when a majority flags
|
|
645
|
+
the evidence as insufficient); ties and uncertainty are **REFUTED** — so a lead
|
|
646
|
+
only one skeptic would confirm is dropped. **`--hunt-lenses N`** additionally
|
|
647
|
+
fans generation out into N independent passes, each specialised in one failure
|
|
648
|
+
mode (auth-bypass, TOCTOU/race, IDOR, deserialization, SSRF-to-internal,
|
|
649
|
+
secret-misuse), and dedupes the leads across passes before verifying — more
|
|
650
|
+
unique novel leads than a single generalist pass. **`--hunt-rounds N`** loops
|
|
651
|
+
generation for up to N rounds, accumulating new (deduped) leads while a
|
|
652
|
+
completeness critic seeds each next round with untried angles, and stops early
|
|
653
|
+
once a round finds nothing new; **`--hunt-budget T`** caps the multi-round output
|
|
654
|
+
tokens.
|
|
655
|
+
This is the verifier the unknown-bug initiative
|
|
656
|
+
([`docs/unknown-bug-epic.md`](docs/unknown-bug-epic.md)) is built on;
|
|
657
|
+
`--verify-votes 1` is the classic single pass.
|
|
658
|
+
|
|
606
659
|
Layered on top: **MITRE ATT&CK technique mappings** on every insight and
|
|
607
660
|
**detection opportunities** (Sigma/KQL/Splunk-style hints) for each weakness.
|
|
608
661
|
|
|
@@ -661,6 +714,9 @@ attackmap analyze <path> --llm --llm-provider openai # use OpenAI/Codex (OPE
|
|
|
661
714
|
attackmap analyze <path> --llm --llm-provider openai --llm-model gpt-5.5 # any OpenAI model ID, passed through
|
|
662
715
|
attackmap analyze <path> --hunt # LLM vulnerability-hypothesis hunt (leads to confirm)
|
|
663
716
|
attackmap analyze <path> --hunt --verify # adjudicate each lead vs. source (confirmed/refuted/review)
|
|
717
|
+
attackmap analyze <path> --hunt --verify --verify-votes 3 # majority vote of N independent skeptics (default 3; 1 = single pass)
|
|
718
|
+
attackmap analyze <path> --hunt --verify --hunt-lenses 4 # N lens-specialised generation passes, deduped, then verified
|
|
719
|
+
attackmap analyze <path> --hunt --verify --hunt-rounds 4 # loop-until-dry generation (completeness critic seeds each round)
|
|
664
720
|
attackmap analyze <path> --remediate # LLM review-first fix suggestions (remediation.md)
|
|
665
721
|
attackmap analyze <path> --triage # cluster/dedupe/rank existing findings (triage.md; deterministic fallback)
|
|
666
722
|
attackmap analyze <path> --pr-comment pr.md # Markdown PR summary comment for CI
|
|
@@ -707,7 +763,7 @@ introduces a new HIGH finding.
|
|
|
707
763
|
|
|
708
764
|
## Project status
|
|
709
765
|
|
|
710
|
-
AttackMap is **beta** (v0.4.
|
|
766
|
+
AttackMap is **beta** (v0.4.18) — published and validated on real codebases, but
|
|
711
767
|
pre-1.0 and heuristic.
|
|
712
768
|
|
|
713
769
|
**Solid today:**
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "attackmap"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.21"
|
|
8
8
|
description = "AI-assisted defensive security analyzer for codebases — scans a repository, models assets and controls, finds cross-cutting weaknesses, and generates an evidence-grounded review with MITRE ATT&CK mappings and detection-engineering hints."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
__all__ = ["__version__"]
|
|
2
|
-
__version__ = "0.4.
|
|
2
|
+
__version__ = "0.4.21"
|
|
@@ -225,9 +225,14 @@ class DefaultAnalyzer:
|
|
|
225
225
|
def name(self) -> str:
|
|
226
226
|
return self.metadata.name
|
|
227
227
|
|
|
228
|
-
def analyze(
|
|
228
|
+
def analyze(
|
|
229
|
+
self, root: str | Path, progress: "ScanProgress | None" = None, recall: bool = False
|
|
230
|
+
) -> AnalyzerResult:
|
|
229
231
|
return scan_repo(
|
|
230
|
-
root,
|
|
232
|
+
root,
|
|
233
|
+
suffixes=set(CODE_EXTENSIONS) - self._CLAIMED_SUFFIXES,
|
|
234
|
+
progress=progress,
|
|
235
|
+
recall=recall,
|
|
231
236
|
)
|
|
232
237
|
|
|
233
238
|
|
|
@@ -249,8 +254,10 @@ class BuiltinPythonWebAnalyzer:
|
|
|
249
254
|
def name(self) -> str:
|
|
250
255
|
return self.metadata.name
|
|
251
256
|
|
|
252
|
-
def analyze(
|
|
253
|
-
|
|
257
|
+
def analyze(
|
|
258
|
+
self, root: str | Path, progress: "ScanProgress | None" = None, recall: bool = False
|
|
259
|
+
) -> AnalyzerResult:
|
|
260
|
+
return scan_repo(root, suffixes={".py"}, progress=progress, recall=recall)
|
|
254
261
|
|
|
255
262
|
|
|
256
263
|
class BuiltinJavaScriptWebAnalyzer:
|
|
@@ -323,8 +330,10 @@ class BuiltinJavaScriptWebAnalyzer:
|
|
|
323
330
|
return False
|
|
324
331
|
return False
|
|
325
332
|
|
|
326
|
-
def analyze(
|
|
327
|
-
|
|
333
|
+
def analyze(
|
|
334
|
+
self, root: str | Path, progress: "ScanProgress | None" = None, recall: bool = False
|
|
335
|
+
) -> AnalyzerResult:
|
|
336
|
+
return scan_repo(root, suffixes=self._JS_SUFFIXES, progress=progress, recall=recall)
|
|
328
337
|
|
|
329
338
|
|
|
330
339
|
class BuiltinConfigAnalyzer:
|
|
@@ -604,12 +613,13 @@ def analyze_repository(
|
|
|
604
613
|
root: str | Path,
|
|
605
614
|
analyzers: Iterable[Analyzer] | None = None,
|
|
606
615
|
progress: "ScanProgress | None" = None,
|
|
616
|
+
recall: bool = False,
|
|
607
617
|
) -> AnalyzerResult:
|
|
608
618
|
repo_root = Path(root).resolve()
|
|
609
619
|
active_analyzers = resolve_run_analyzers(repo_root, analyzers=analyzers)
|
|
610
620
|
results: list[AnalyzerResult] = []
|
|
611
621
|
for analyzer in active_analyzers:
|
|
612
|
-
result = _call_analyze(analyzer, repo_root, progress)
|
|
622
|
+
result = _call_analyze(analyzer, repo_root, progress, recall)
|
|
613
623
|
_stamp_provenance(result, analyzer.name)
|
|
614
624
|
results.append(result)
|
|
615
625
|
if not results:
|
|
@@ -617,17 +627,26 @@ def analyze_repository(
|
|
|
617
627
|
return merge_analyzer_results(results, root=repo_root)
|
|
618
628
|
|
|
619
629
|
|
|
620
|
-
def _call_analyze(
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
630
|
+
def _call_analyze(
|
|
631
|
+
analyzer: Analyzer,
|
|
632
|
+
root: Path,
|
|
633
|
+
progress: "ScanProgress | None",
|
|
634
|
+
recall: bool = False,
|
|
635
|
+
) -> AnalyzerResult:
|
|
636
|
+
"""Invoke ``analyzer.analyze``, forwarding ``progress``/``recall`` only to
|
|
637
|
+
analyzers that accept them. Keeps the plugin ``analyze(root)`` contract
|
|
638
|
+
intact — old plugins that don't know about these kwargs are called
|
|
639
|
+
unchanged, and ``recall`` is only ever passed when it's on."""
|
|
640
|
+
kwargs: dict[str, object] = {}
|
|
641
|
+
try:
|
|
642
|
+
params = inspect.signature(analyzer.analyze).parameters
|
|
643
|
+
if progress is not None and "progress" in params:
|
|
644
|
+
kwargs["progress"] = progress
|
|
645
|
+
if recall and "recall" in params:
|
|
646
|
+
kwargs["recall"] = recall
|
|
647
|
+
except (TypeError, ValueError):
|
|
648
|
+
pass
|
|
649
|
+
return analyzer.analyze(root, **kwargs) # type: ignore[call-arg]
|
|
631
650
|
|
|
632
651
|
|
|
633
652
|
def _stamp_provenance(result: AnalyzerResult, analyzer_name: str) -> None:
|
|
@@ -18,6 +18,14 @@ forgotten decorator or a copy-paste that dropped a guard.
|
|
|
18
18
|
validate their input and this one shows no validation marker.
|
|
19
19
|
- **method_outlier**: a lone state-changing method exposed in a cohort
|
|
20
20
|
that is otherwise read-only.
|
|
21
|
+
- **invariant_violation** (#149a): the *mined-invariant* pass. Its cohort is
|
|
22
|
+
not a route-path prefix but the set of handlers that reach the same
|
|
23
|
+
dangerous sink kind (from the taint pass). When a strong majority guard the
|
|
24
|
+
request — an auth or validation check *before* the sink — the pass mines
|
|
25
|
+
that as an implicit invariant and flags the handler that reaches the same
|
|
26
|
+
sink kind with no guard before it, citing the mined rule as evidence. This
|
|
27
|
+
is signature-free: it compares the code against itself, so it can surface a
|
|
28
|
+
forgotten guard on a sink pattern no rule anticipates.
|
|
21
29
|
|
|
22
30
|
## Precision
|
|
23
31
|
|
|
@@ -103,6 +111,26 @@ _MAX_FILE_BYTES = 1_000_000
|
|
|
103
111
|
# A cohort must have at least this many routes to reason about a "norm".
|
|
104
112
|
_MIN_GROUP = 3
|
|
105
113
|
|
|
114
|
+
# Invariant mining (#149a). A sink cohort must have at least this many distinct
|
|
115
|
+
# handlers before a "guard the request before the sink" invariant is credible —
|
|
116
|
+
# higher than `_MIN_GROUP` because a wrong call here points at a dangerous sink.
|
|
117
|
+
_MIN_INVARIANT_COHORT = 4
|
|
118
|
+
|
|
119
|
+
# Human labels for the taint sink kinds an invariant is mined over (mirrors
|
|
120
|
+
# threat_model._TAINT_SINK_LABEL; kept local so anomalies.py stays standalone).
|
|
121
|
+
_SINK_LABELS: dict[str, str] = {
|
|
122
|
+
"sql_execute": "database (SQL execute)",
|
|
123
|
+
"subprocess_shell": "command execution",
|
|
124
|
+
"eval": "code eval",
|
|
125
|
+
"exec": "code exec",
|
|
126
|
+
"dynamic_open": "filesystem open",
|
|
127
|
+
"unsafe_deserialization": "unsafe deserialization",
|
|
128
|
+
"ssti": "template injection",
|
|
129
|
+
"ssrf": "outbound request (SSRF)",
|
|
130
|
+
"nosql_injection": "NoSQL query",
|
|
131
|
+
"open_redirect": "redirect target",
|
|
132
|
+
}
|
|
133
|
+
|
|
106
134
|
# Leading segments that are transport scaffolding, not the resource itself
|
|
107
135
|
# (`/api`, `/v1`, `/rest`). Stripped so the cohort key lands on the resource
|
|
108
136
|
# root — /api/users, /api/users/<id> and /api/users/export all group as
|
|
@@ -159,6 +187,10 @@ def find_anomalies(
|
|
|
159
187
|
out.extend(_method_outliers(key, members))
|
|
160
188
|
out.extend(_validation_outliers(key, members, ctx))
|
|
161
189
|
|
|
190
|
+
# Mined-invariant pass (#149a): cohorts of handlers reaching the same
|
|
191
|
+
# dangerous sink, flagged when the majority guard the request before it.
|
|
192
|
+
out.extend(_invariant_violations(scan, ctx))
|
|
193
|
+
|
|
162
194
|
out.sort(key=lambda a: (-a.confidence, a.route_file, a.route_path))
|
|
163
195
|
return out
|
|
164
196
|
|
|
@@ -274,6 +306,180 @@ def _method_outliers(key: str, members: list[Route]) -> list[Anomaly]:
|
|
|
274
306
|
]
|
|
275
307
|
|
|
276
308
|
|
|
309
|
+
# --- Mined-invariant pass (#149a) ------------------------------------------
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass
|
|
313
|
+
class _InvHandler:
|
|
314
|
+
"""One handler in a sink cohort: its analysis file + declaration line, the
|
|
315
|
+
verbs it is registered under, and the same-file chains it reaches."""
|
|
316
|
+
|
|
317
|
+
file: str
|
|
318
|
+
line: int | None
|
|
319
|
+
path: str
|
|
320
|
+
methods: set[str]
|
|
321
|
+
chains: list
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _invariant_violations(scan: ScanResult, ctx: _SignalCtx) -> list[Anomaly]:
|
|
325
|
+
"""Mine a "guard the request before the sink" invariant per sink kind and
|
|
326
|
+
flag the handler that violates it.
|
|
327
|
+
|
|
328
|
+
The cohort is not a route-path prefix but the set of handlers that reach
|
|
329
|
+
the *same dangerous sink kind* (from ``scan.taint_chains``). When a strong
|
|
330
|
+
majority of those handlers apply an auth/validation guard before the sink,
|
|
331
|
+
that pattern is mined as an implicit invariant and every cohort member that
|
|
332
|
+
reaches the same sink kind with no preceding guard is flagged, citing the
|
|
333
|
+
mined rule. Signature-free: the code is measured against its own norm.
|
|
334
|
+
|
|
335
|
+
Scope decisions that keep the high-severity signal precise:
|
|
336
|
+
|
|
337
|
+
- **Same-file flows only** (``hops == 0``). "Guard before the sink" is a
|
|
338
|
+
statement about ordering, and ordering is only verifiable when the guard
|
|
339
|
+
and the sink live in one file. A downstream (cross-file) sink could be
|
|
340
|
+
called before an ``authorize(...)`` that textually follows it, so we
|
|
341
|
+
don't guess — cross-file chains are simply out of scope here.
|
|
342
|
+
- **Anchored on the chain's own file.** The taint pass reasons from the
|
|
343
|
+
module that *defines* the handler (#107), which for a central-registration
|
|
344
|
+
Node app is not the route's registration file. Analysis therefore anchors
|
|
345
|
+
on ``chain.route_file``; a matching ``Route`` supplies the declaration line
|
|
346
|
+
for a precise per-handler span, and a resolved handler module with no such
|
|
347
|
+
match falls back to a whole-file span.
|
|
348
|
+
- **Per handler, not per verb.** One handler registered for several methods
|
|
349
|
+
emits one ``Route`` per verb; cohort identity is the handler (file +
|
|
350
|
+
declaration line), so multi-verb handlers count once.
|
|
351
|
+
- **Sanitized chains excluded** (#137) — a sink-local neutralizer is a
|
|
352
|
+
different control from a handler guard, so dropping them keeps the cohort
|
|
353
|
+
to genuinely undefended flows.
|
|
354
|
+
"""
|
|
355
|
+
# (file, method, path) -> declaration line, for handlers whose defining file
|
|
356
|
+
# is the file the chain is anchored on (inline / decorator handlers).
|
|
357
|
+
line_by_key: dict[tuple[str, str, str], int] = {}
|
|
358
|
+
for route in scan.routes:
|
|
359
|
+
if is_test_file(route.file) or route.line is None:
|
|
360
|
+
continue
|
|
361
|
+
line_by_key.setdefault((route.file, route.method, route.path), route.line)
|
|
362
|
+
|
|
363
|
+
# sink_kind -> { handler_id -> _InvHandler }
|
|
364
|
+
per_kind: dict[str, dict[tuple[str, object], _InvHandler]] = {}
|
|
365
|
+
for chain in scan.taint_chains:
|
|
366
|
+
# Same-file, undefended flows only (see the scope notes above); for
|
|
367
|
+
# hops == 0 the sink file is the anchor file, so one test covers both.
|
|
368
|
+
# Speculative recall chains (#148a) are unconfirmed leads — don't mine
|
|
369
|
+
# an asserted invariant violation from them.
|
|
370
|
+
if chain.sanitized or chain.speculative or chain.hops != 0:
|
|
371
|
+
continue
|
|
372
|
+
anchor_file = chain.route_file
|
|
373
|
+
if is_test_file(anchor_file):
|
|
374
|
+
continue
|
|
375
|
+
line = line_by_key.get((anchor_file, chain.route_method, chain.route_path))
|
|
376
|
+
# Handler identity: (file, decl line) when known, else (file, path) so a
|
|
377
|
+
# resolved handler module's several verbs still collapse to one handler.
|
|
378
|
+
handler_id: tuple[str, object] = (
|
|
379
|
+
(anchor_file, line) if line is not None else (anchor_file, chain.route_path)
|
|
380
|
+
)
|
|
381
|
+
cohort = per_kind.setdefault(chain.sink_kind, {})
|
|
382
|
+
handler = cohort.get(handler_id)
|
|
383
|
+
if handler is None:
|
|
384
|
+
handler = _InvHandler(
|
|
385
|
+
file=anchor_file, line=line, path=chain.route_path, methods=set(), chains=[]
|
|
386
|
+
)
|
|
387
|
+
cohort[handler_id] = handler
|
|
388
|
+
handler.methods.add(chain.route_method)
|
|
389
|
+
handler.chains.append(chain)
|
|
390
|
+
|
|
391
|
+
out: list[Anomaly] = []
|
|
392
|
+
for kind, cohort in per_kind.items():
|
|
393
|
+
handlers = list(cohort.values())
|
|
394
|
+
if len(handlers) < _MIN_INVARIANT_COHORT:
|
|
395
|
+
continue
|
|
396
|
+
guarded: list[_InvHandler] = []
|
|
397
|
+
unguarded: list[_InvHandler] = []
|
|
398
|
+
for handler in handlers:
|
|
399
|
+
if all(
|
|
400
|
+
_guard_before_sink(handler.file, handler.line, c.sink_file, c.sink_line, ctx)
|
|
401
|
+
for c in handler.chains
|
|
402
|
+
):
|
|
403
|
+
guarded.append(handler)
|
|
404
|
+
else:
|
|
405
|
+
unguarded.append(handler)
|
|
406
|
+
if not _is_outlier_split(len(guarded), len(unguarded), len(handlers)):
|
|
407
|
+
continue
|
|
408
|
+
label = _SINK_LABELS.get(kind, kind)
|
|
409
|
+
invariant = (
|
|
410
|
+
f"{len(guarded)} of {len(handlers)} handlers that reach a {label} sink "
|
|
411
|
+
f"apply an auth/validation guard before it"
|
|
412
|
+
)
|
|
413
|
+
examples = _handler_examples(guarded)
|
|
414
|
+
for handler in unguarded:
|
|
415
|
+
out.append(
|
|
416
|
+
Anomaly(
|
|
417
|
+
kind="invariant_violation",
|
|
418
|
+
route_path=handler.path,
|
|
419
|
+
route_method="/".join(sorted(handler.methods)),
|
|
420
|
+
route_file=handler.file,
|
|
421
|
+
route_line=handler.line,
|
|
422
|
+
peer_group=f"sink:{kind}",
|
|
423
|
+
peer_group_size=len(handlers),
|
|
424
|
+
consistent_peers=len(guarded),
|
|
425
|
+
deviation=(
|
|
426
|
+
f"reaches a {label} sink with no auth/validation guard "
|
|
427
|
+
f"before it, unlike its sibling handlers"
|
|
428
|
+
),
|
|
429
|
+
invariant=invariant,
|
|
430
|
+
peer_examples=examples,
|
|
431
|
+
severity="high",
|
|
432
|
+
confidence=_confidence(len(guarded)),
|
|
433
|
+
source_analyzer="anomalies",
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
return out
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _handler_examples(handlers: list[_InvHandler], limit: int = 3) -> list[str]:
|
|
440
|
+
seen: list[str] = []
|
|
441
|
+
for h in handlers:
|
|
442
|
+
label = f"{'/'.join(sorted(h.methods))} {h.path}"
|
|
443
|
+
if label not in seen:
|
|
444
|
+
seen.append(label)
|
|
445
|
+
if len(seen) >= limit:
|
|
446
|
+
break
|
|
447
|
+
return seen
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _guard_before_sink(
|
|
451
|
+
file: str, line: int | None, sink_file: str, sink_line: int | None, ctx: _SignalCtx
|
|
452
|
+
) -> bool:
|
|
453
|
+
"""True if an auth/validation marker guards the handler *before* the sink.
|
|
454
|
+
|
|
455
|
+
The handler span is its declaration line down to the next route (``line``
|
|
456
|
+
known), or the whole file when the handler is a resolved module with no
|
|
457
|
+
matching route declaration. The sink is same-file (this pass only reasons
|
|
458
|
+
about ``hops == 0`` flows): when it sits inside the span the window is
|
|
459
|
+
tightened to strictly above the sink line — a guard placed *after* the sink
|
|
460
|
+
doesn't defend it — otherwise (a sink in another handler's body, reached via
|
|
461
|
+
same-file import fan-out) the whole span counts.
|
|
462
|
+
"""
|
|
463
|
+
lines = _file_lines(file, ctx)
|
|
464
|
+
if not lines:
|
|
465
|
+
return False
|
|
466
|
+
if line is None:
|
|
467
|
+
start, end = 0, len(lines)
|
|
468
|
+
else:
|
|
469
|
+
start, end = _span_bounds(line, lines, ctx.file_route_lines.get(file, []))
|
|
470
|
+
# sink_line is 1-based; index sink_line-1 is the sink itself. Only tighten
|
|
471
|
+
# the window to "before the sink" when the sink sits within this span.
|
|
472
|
+
if sink_file == file and sink_line is not None and start < sink_line - 1 < end:
|
|
473
|
+
end = sink_line - 1
|
|
474
|
+
if end <= start:
|
|
475
|
+
return False
|
|
476
|
+
window = "\n".join(lines[start:end])
|
|
477
|
+
return (
|
|
478
|
+
_AUTH_MARKERS.search(window) is not None
|
|
479
|
+
or _VALIDATION_MARKERS.search(window) is not None
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
277
483
|
# --- Internals -------------------------------------------------------------
|
|
278
484
|
|
|
279
485
|
|
|
@@ -364,23 +570,23 @@ def _route_has_signal(route: Route, ctx: _SignalCtx, marker: re.Pattern[str]) ->
|
|
|
364
570
|
return False
|
|
365
571
|
if route.line is None:
|
|
366
572
|
return marker.search("\n".join(lines)) is not None
|
|
367
|
-
start, end = _span_bounds(route, lines, ctx.file_route_lines.get(route.file, []))
|
|
573
|
+
start, end = _span_bounds(route.line, lines, ctx.file_route_lines.get(route.file, []))
|
|
368
574
|
return marker.search("\n".join(lines[start:end])) is not None
|
|
369
575
|
|
|
370
576
|
|
|
371
|
-
def _span_bounds(
|
|
372
|
-
"""Return the 0-based [start, end) slice of ``lines`` owned by
|
|
373
|
-
|
|
374
|
-
idx =
|
|
577
|
+
def _span_bounds(line: int, lines: list[str], route_lines: list[int]) -> tuple[int, int]:
|
|
578
|
+
"""Return the 0-based [start, end) slice of ``lines`` owned by the handler
|
|
579
|
+
declared at 1-based ``line``."""
|
|
580
|
+
idx = line - 1 # 0-based route declaration line
|
|
375
581
|
# End at the next route declaration in the file (its decorators/body
|
|
376
582
|
# belong to it), capped so a huge handler can't swallow the file.
|
|
377
|
-
next_line = next((ln for ln in route_lines if ln >
|
|
378
|
-
end = min(len(lines),
|
|
583
|
+
next_line = next((ln for ln in route_lines if ln > line), None)
|
|
584
|
+
end = min(len(lines), line - 1 + _MAX_SPAN)
|
|
379
585
|
if next_line is not None:
|
|
380
586
|
end = min(end, next_line - 1)
|
|
381
587
|
# Extend upward over contiguous decorator lines (auth decorator placed
|
|
382
588
|
# above the @route decorator), but not past the previous route.
|
|
383
|
-
prev_line = next((ln for ln in reversed(route_lines) if ln <
|
|
589
|
+
prev_line = next((ln for ln in reversed(route_lines) if ln < line), 0)
|
|
384
590
|
start = idx
|
|
385
591
|
limit = max(prev_line, idx - _DECORATOR_LOOKUP)
|
|
386
592
|
while start - 1 >= limit and lines[start - 1].lstrip().startswith("@"):
|
|
@@ -119,7 +119,10 @@ def analyze_authz(scan: ScanResult, root: str | Path | None = None) -> list[Bola
|
|
|
119
119
|
taint_sql_routes = {
|
|
120
120
|
(c.route_method, c.route_path)
|
|
121
121
|
for c in scan.taint_chains
|
|
122
|
-
|
|
122
|
+
# Speculative recall chains (#148a) are unconfirmed leads — they must
|
|
123
|
+
# not manufacture an asserted HIGH BOLA finding that could trip the
|
|
124
|
+
# baseline gate.
|
|
125
|
+
if c.sink_kind == "sql_execute" and not c.speculative
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
# Cache per-file content (read at most once per route file).
|