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.

Files changed (114) hide show
  1. {attackmap-0.4.15/src/attackmap.egg-info → attackmap-0.4.21}/PKG-INFO +59 -3
  2. {attackmap-0.4.15 → attackmap-0.4.21}/README.md +58 -2
  3. {attackmap-0.4.15 → attackmap-0.4.21}/pyproject.toml +1 -1
  4. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/__init__.py +1 -1
  5. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/analyzers.py +37 -18
  6. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/anomalies.py +214 -8
  7. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/authz.py +4 -1
  8. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/cli.py +129 -39
  9. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/exploitability.py +3 -1
  10. attackmap-0.4.21/src/attackmap/hunt_harness.py +451 -0
  11. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/llm_review.py +35 -8
  12. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/models.py +23 -1
  13. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/review_prompts.py +172 -0
  14. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/scanner.py +5 -2
  15. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/taint.py +191 -20
  16. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/threat_model.py +63 -2
  17. {attackmap-0.4.15 → attackmap-0.4.21/src/attackmap.egg-info}/PKG-INFO +59 -3
  18. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap.egg-info/SOURCES.txt +3 -0
  19. attackmap-0.4.21/tests/test_anomalies.py +663 -0
  20. attackmap-0.4.21/tests/test_hunt_harness.py +558 -0
  21. attackmap-0.4.21/tests/test_recall.py +310 -0
  22. attackmap-0.4.15/tests/test_anomalies.py +0 -397
  23. {attackmap-0.4.15 → attackmap-0.4.21}/LICENSE +0 -0
  24. {attackmap-0.4.15 → attackmap-0.4.21}/setup.cfg +0 -0
  25. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/analyzer.py +0 -0
  26. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/analyzer_contracts.py +0 -0
  27. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/asset_model.py +0 -0
  28. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/attack_taxonomy.py +0 -0
  29. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/config_scanner.py +0 -0
  30. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/context_pack.py +0 -0
  31. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/control_model.py +0 -0
  32. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/crypto.py +0 -0
  33. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/cve.py +0 -0
  34. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/defensive_review.py +0 -0
  35. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/detection_opportunities.py +0 -0
  36. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/diagrams.py +0 -0
  37. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/diff.py +0 -0
  38. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/graph.py +0 -0
  39. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/insights.py +0 -0
  40. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/lockfiles.py +0 -0
  41. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/merge.py +0 -0
  42. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/progress.py +0 -0
  43. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/recon_models.py +0 -0
  44. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/recon_to_analysis.py +0 -0
  45. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/report.py +0 -0
  46. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/review_eval.py +0 -0
  47. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/review_json.py +0 -0
  48. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/route_auth_fusion.py +0 -0
  49. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/sarif.py +0 -0
  50. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/sbom.py +0 -0
  51. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/sdk/__init__.py +0 -0
  52. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/sdk/contracts.py +0 -0
  53. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/sdk/models.py +0 -0
  54. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/security_overlay.py +0 -0
  55. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/srcpaths.py +0 -0
  56. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/suggest.py +0 -0
  57. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/suppress.py +0 -0
  58. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/topology.py +0 -0
  59. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/triage.py +0 -0
  60. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/weaknesses.py +0 -0
  61. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/webhardening.py +0 -0
  62. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap/workflow_scanner.py +0 -0
  63. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap.egg-info/dependency_links.txt +0 -0
  64. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap.egg-info/entry_points.txt +0 -0
  65. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap.egg-info/requires.txt +0 -0
  66. {attackmap-0.4.15 → attackmap-0.4.21}/src/attackmap.egg-info/top_level.txt +0 -0
  67. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_analyzer.py +0 -0
  68. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_analyzer_metadata_schema.py +0 -0
  69. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_analyzers.py +0 -0
  70. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_artifact_schemas.py +0 -0
  71. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_authz.py +0 -0
  72. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_config_scanner.py +0 -0
  73. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_context_pack.py +0 -0
  74. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_crypto.py +0 -0
  75. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_cve.py +0 -0
  76. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_defensive_review.py +0 -0
  77. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_diagrams.py +0 -0
  78. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_diff.py +0 -0
  79. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_exploitability.py +0 -0
  80. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_graph.py +0 -0
  81. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_hunt.py +0 -0
  82. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_injection_sinks.py +0 -0
  83. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_llm_review.py +0 -0
  84. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_lockfiles.py +0 -0
  85. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_merge.py +0 -0
  86. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_modules_cli.py +0 -0
  87. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_pr_comment.py +0 -0
  88. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_progress.py +0 -0
  89. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_provenance.py +0 -0
  90. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_recon_to_analysis.py +0 -0
  91. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_remediate.py +0 -0
  92. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_report.py +0 -0
  93. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_review_eval.py +0 -0
  94. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_review_json.py +0 -0
  95. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_review_prompts.py +0 -0
  96. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_route_auth_fusion.py +0 -0
  97. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_sarif.py +0 -0
  98. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_sbom.py +0 -0
  99. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_scanner.py +0 -0
  100. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_security_overlay.py +0 -0
  101. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_shared_contract_imports.py +0 -0
  102. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_signal_v2.py +0 -0
  103. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_srcpaths.py +0 -0
  104. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_suggest.py +0 -0
  105. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_suppress.py +0 -0
  106. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_taint.py +0 -0
  107. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_taint_sanitizer.py +0 -0
  108. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_threat_intel.py +0 -0
  109. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_threat_model.py +0 -0
  110. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_topology.py +0 -0
  111. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_triage.py +0 -0
  112. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_weaknesses.py +0 -0
  113. {attackmap-0.4.15 → attackmap-0.4.21}/tests/test_webhardening.py +0 -0
  114. {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.15
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
  [![Python: 3.11+](https://img.shields.io/badge/Python-3.11%2B-blue.svg)](https://www.python.org/)
78
78
  [![PyPI](https://img.shields.io/pypi/v/attackmap.svg)](https://pypi.org/project/attackmap/)
79
79
 
80
- > **Status: beta (v0.4.15).** Core engine and 14 analyzer plugins are published
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.15) — published and validated on real codebases, but
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
  [![Python: 3.11+](https://img.shields.io/badge/Python-3.11%2B-blue.svg)](https://www.python.org/)
17
17
  [![PyPI](https://img.shields.io/pypi/v/attackmap.svg)](https://pypi.org/project/attackmap/)
18
18
 
19
- > **Status: beta (v0.4.15).** Core engine and 14 analyzer plugins are published
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.15) — published and validated on real codebases, but
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.15"
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.15"
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(self, root: str | Path, progress: "ScanProgress | None" = None) -> AnalyzerResult:
228
+ def analyze(
229
+ self, root: str | Path, progress: "ScanProgress | None" = None, recall: bool = False
230
+ ) -> AnalyzerResult:
229
231
  return scan_repo(
230
- root, suffixes=set(CODE_EXTENSIONS) - self._CLAIMED_SUFFIXES, progress=progress
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(self, root: str | Path, progress: "ScanProgress | None" = None) -> AnalyzerResult:
253
- return scan_repo(root, suffixes={".py"}, progress=progress)
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(self, root: str | Path, progress: "ScanProgress | None" = None) -> AnalyzerResult:
327
- return scan_repo(root, suffixes=self._JS_SUFFIXES, progress=progress)
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(analyzer: Analyzer, root: Path, progress: "ScanProgress | None") -> AnalyzerResult:
621
- """Invoke ``analyzer.analyze``, forwarding ``progress`` only to analyzers
622
- that accept it. Keeps the plugin ``analyze(root)`` contract intact — old
623
- plugins that don't know about progress are called unchanged."""
624
- if progress is not None:
625
- try:
626
- if "progress" in inspect.signature(analyzer.analyze).parameters:
627
- return analyzer.analyze(root, progress=progress) # type: ignore[call-arg]
628
- except (TypeError, ValueError):
629
- pass
630
- return analyzer.analyze(root)
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(route: Route, lines: list[str], route_lines: list[int]) -> tuple[int, int]:
372
- """Return the 0-based [start, end) slice of ``lines`` owned by ``route``."""
373
- assert route.line is not None
374
- idx = route.line - 1 # 0-based route declaration line
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 > route.line), None)
378
- end = min(len(lines), route.line - 1 + _MAX_SPAN)
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 < route.line), 0)
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
- if c.sink_kind == "sql_execute"
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).