attackmap 0.4.25__tar.gz → 0.4.27__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 (119) hide show
  1. {attackmap-0.4.25/src/attackmap.egg-info → attackmap-0.4.27}/PKG-INFO +27 -9
  2. {attackmap-0.4.25 → attackmap-0.4.27}/README.md +26 -8
  3. {attackmap-0.4.25 → attackmap-0.4.27}/pyproject.toml +1 -1
  4. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/cli.py +9 -3
  5. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/cve.py +226 -33
  6. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/lockfiles.py +21 -4
  7. {attackmap-0.4.25 → attackmap-0.4.27/src/attackmap.egg-info}/PKG-INFO +27 -9
  8. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_cve.py +188 -17
  9. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_lockfiles.py +54 -0
  10. {attackmap-0.4.25 → attackmap-0.4.27}/LICENSE +0 -0
  11. {attackmap-0.4.25 → attackmap-0.4.27}/setup.cfg +0 -0
  12. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/__init__.py +0 -0
  13. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/analyzer.py +0 -0
  14. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/analyzer_contracts.py +0 -0
  15. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/analyzers.py +0 -0
  16. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/anomalies.py +0 -0
  17. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/asset_model.py +0 -0
  18. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/attack_taxonomy.py +0 -0
  19. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/authz.py +0 -0
  20. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/config_scanner.py +0 -0
  21. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/context_pack.py +0 -0
  22. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/contracts.py +0 -0
  23. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/control_model.py +0 -0
  24. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/crossrepo.py +0 -0
  25. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/crypto.py +0 -0
  26. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/defensive_review.py +0 -0
  27. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/detection_opportunities.py +0 -0
  28. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/diagrams.py +0 -0
  29. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/diff.py +0 -0
  30. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/exploitability.py +0 -0
  31. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/fleet.py +0 -0
  32. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/graph.py +0 -0
  33. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/hunt_harness.py +0 -0
  34. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/insights.py +0 -0
  35. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/llm_review.py +0 -0
  36. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/merge.py +0 -0
  37. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/models.py +0 -0
  38. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/progress.py +0 -0
  39. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/recon_models.py +0 -0
  40. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/recon_to_analysis.py +0 -0
  41. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/report.py +0 -0
  42. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/review_eval.py +0 -0
  43. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/review_json.py +0 -0
  44. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/review_prompts.py +0 -0
  45. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/route_auth_fusion.py +0 -0
  46. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/sarif.py +0 -0
  47. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/sbom.py +0 -0
  48. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/scanner.py +0 -0
  49. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/sdk/__init__.py +0 -0
  50. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/sdk/contracts.py +0 -0
  51. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/sdk/models.py +0 -0
  52. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/security_overlay.py +0 -0
  53. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/srcpaths.py +0 -0
  54. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/suggest.py +0 -0
  55. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/suppress.py +0 -0
  56. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/taint.py +0 -0
  57. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/threat_model.py +0 -0
  58. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/topology.py +0 -0
  59. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/triage.py +0 -0
  60. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/weaknesses.py +0 -0
  61. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/webhardening.py +0 -0
  62. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap/workflow_scanner.py +0 -0
  63. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap.egg-info/SOURCES.txt +0 -0
  64. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap.egg-info/dependency_links.txt +0 -0
  65. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap.egg-info/entry_points.txt +0 -0
  66. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap.egg-info/requires.txt +0 -0
  67. {attackmap-0.4.25 → attackmap-0.4.27}/src/attackmap.egg-info/top_level.txt +0 -0
  68. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_analyzer.py +0 -0
  69. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_analyzer_metadata_schema.py +0 -0
  70. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_analyzers.py +0 -0
  71. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_anomalies.py +0 -0
  72. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_artifact_schemas.py +0 -0
  73. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_authz.py +0 -0
  74. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_config_scanner.py +0 -0
  75. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_context_pack.py +0 -0
  76. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_contracts.py +0 -0
  77. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_crossrepo.py +0 -0
  78. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_crypto.py +0 -0
  79. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_defensive_review.py +0 -0
  80. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_diagrams.py +0 -0
  81. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_diff.py +0 -0
  82. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_exploitability.py +0 -0
  83. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_fleet.py +0 -0
  84. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_graph.py +0 -0
  85. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_hunt.py +0 -0
  86. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_hunt_harness.py +0 -0
  87. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_injection_sinks.py +0 -0
  88. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_llm_review.py +0 -0
  89. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_merge.py +0 -0
  90. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_modules_cli.py +0 -0
  91. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_pr_comment.py +0 -0
  92. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_progress.py +0 -0
  93. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_provenance.py +0 -0
  94. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_recall.py +0 -0
  95. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_recon_to_analysis.py +0 -0
  96. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_remediate.py +0 -0
  97. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_report.py +0 -0
  98. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_review_eval.py +0 -0
  99. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_review_json.py +0 -0
  100. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_review_prompts.py +0 -0
  101. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_route_auth_fusion.py +0 -0
  102. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_sarif.py +0 -0
  103. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_sbom.py +0 -0
  104. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_scanner.py +0 -0
  105. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_security_overlay.py +0 -0
  106. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_shared_contract_imports.py +0 -0
  107. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_signal_v2.py +0 -0
  108. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_srcpaths.py +0 -0
  109. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_suggest.py +0 -0
  110. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_suppress.py +0 -0
  111. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_taint.py +0 -0
  112. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_taint_sanitizer.py +0 -0
  113. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_threat_intel.py +0 -0
  114. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_threat_model.py +0 -0
  115. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_topology.py +0 -0
  116. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_triage.py +0 -0
  117. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_weaknesses.py +0 -0
  118. {attackmap-0.4.25 → attackmap-0.4.27}/tests/test_webhardening.py +0 -0
  119. {attackmap-0.4.25 → attackmap-0.4.27}/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.25
3
+ Version: 0.4.27
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>
@@ -790,10 +790,12 @@ scaffolding, testing, and publishing instructions is in
790
790
 
791
791
  ```bash
792
792
  attackmap analyze <path> # run a review on a repository
793
+ attackmap analyze repoA repoB gateway # multi-repo fleet scan (per-repo reports + fleet summary)
793
794
  attackmap analyze <path> --output dir # write outputs to `dir/`
794
795
  attackmap analyze <path> --format json # json | markdown | all (default)
795
796
  attackmap analyze <path> --module python --module rust # only these analyzers
796
797
  attackmap analyze <path> --cve # cross-reference SBOM against OSV.dev
798
+ attackmap analyze <path> --recall # widen taint discovery (deeper hops, capability-reach); extra reach marked SPECULATIVE
797
799
  attackmap analyze <path> --llm # add LLM narrative (auto-resolve auth)
798
800
  attackmap analyze <path> --llm --llm-backend cli # force Claude CLI
799
801
  attackmap analyze <path> --llm --llm-provider openai # use OpenAI/Codex (OPENAI_API_KEY or `codex` CLI)
@@ -803,6 +805,8 @@ attackmap analyze <path> --hunt --verify # adjudicate each lead vs. source (conf
803
805
  attackmap analyze <path> --hunt --verify --verify-votes 3 # majority vote of N independent skeptics (default 3; 1 = single pass)
804
806
  attackmap analyze <path> --hunt --verify --hunt-lenses 4 # N lens-specialised generation passes, deduped, then verified
805
807
  attackmap analyze <path> --hunt --verify --hunt-rounds 4 # loop-until-dry generation (completeness critic seeds each round)
808
+ attackmap analyze <path> --hunt --verify --hunt-budget 500000 # cap total hunt output tokens across rounds
809
+ attackmap analyze <path> --recall --hunt --verify # widen the net, then adjudicate the speculative reach
806
810
  attackmap analyze <path> --remediate # LLM review-first fix suggestions (remediation.md)
807
811
  attackmap analyze <path> --triage # cluster/dedupe/rank existing findings (triage.md; deterministic fallback)
808
812
  attackmap analyze <path> --pr-comment pr.md # Markdown PR summary comment for CI
@@ -849,7 +853,7 @@ introduces a new HIGH finding.
849
853
 
850
854
  ## Project status
851
855
 
852
- AttackMap is **beta** (v0.4.18) — published and validated on real codebases, but
856
+ AttackMap is **beta** (v0.4.25) — published and validated on real codebases, but
853
857
  pre-1.0 and heuristic.
854
858
 
855
859
  **Solid today:**
@@ -867,13 +871,27 @@ pre-1.0 and heuristic.
867
871
  - BOLA/IDOR authorization detection (with custom-middleware / guard-arg awareness).
868
872
  - Insecure-crypto / weak-randomness (Python/JS/Go/PHP) and web-hardening
869
873
  (CORS/CSRF/cookies/CSP/debug) detection.
870
- - Anomaly / outlier detection and **exploitability fusion** — a deterministic,
871
- explainable 0–100 "exploitable now" score that ranks route→sink combinations
872
- and folds in known-vulnerable dependencies on the path.
873
- - **`--hunt`** (LLM exploit-chain hypotheses) with **`--verify`** (adjudicate
874
- each lead CONFIRMED/REFUTED/NEEDS-REVIEW against the actual source),
875
- **`--remediate`** (review-first fix suggestions), and **`--triage`**
876
- (cluster/dedupe/rank existing findings, with a deterministic fallback).
874
+ - Anomaly / outlier detection — including **signature-free invariant mining**
875
+ (flag the handler that reaches a dangerous sink without the guard its peers
876
+ apply) — and **exploitability fusion**, a deterministic, explainable 0–100
877
+ "exploitable now" score that ranks route→sink combinations and folds in
878
+ known-vulnerable dependencies on the path.
879
+ - **`--hunt`** (LLM exploit-chain hypotheses) with a **verify jury** —
880
+ **`--verify`** adjudicates each lead CONFIRMED/REFUTED/NEEDS-REVIEW against the
881
+ actual source; **`--verify-votes`** runs N independent skeptics, **`--hunt-lenses`**
882
+ fans generation across failure-mode specialists, **`--hunt-rounds`** loops
883
+ until dry with a completeness critic, **`--hunt-budget`** caps token spend —
884
+ plus **`--remediate`** (review-first fixes) and **`--triage`**
885
+ (cluster/dedupe/rank existing findings, deterministic fallback).
886
+ - **`--recall`** — verifier-gated aggressive discovery: widens the taint walk
887
+ (deeper hops, capability-reach enumeration of every reach to exec/fs/net/
888
+ deserialize/template/SQL) and marks the extra reach SPECULATIVE, kept out of
889
+ `--fail-on-new-high` and left for `--hunt --verify` to adjudicate.
890
+ - **Cross-repo / fleet analysis** — `attackmap analyze repoA repoB …` scans a
891
+ fleet, links one repo's outbound HTTP calls to another's routes, and surfaces
892
+ bugs that live in the seams: cross-boundary (confused-deputy) flows,
893
+ trust-assumption gaps, and the sibling service that omits a control its peers
894
+ enforce. All cross-repo findings are speculative until verified.
877
895
  - SBOM inventory (5 ecosystems) + OSV.dev CVE cross-reference (`--cve`).
878
896
  - Output: Markdown + JSON + **SARIF 2.1.0** + **Mermaid / Graphviz** diagrams;
879
897
  **diff/baseline** PR gating; a reusable **GitHub Action + PR bot**; optional
@@ -729,10 +729,12 @@ scaffolding, testing, and publishing instructions is in
729
729
 
730
730
  ```bash
731
731
  attackmap analyze <path> # run a review on a repository
732
+ attackmap analyze repoA repoB gateway # multi-repo fleet scan (per-repo reports + fleet summary)
732
733
  attackmap analyze <path> --output dir # write outputs to `dir/`
733
734
  attackmap analyze <path> --format json # json | markdown | all (default)
734
735
  attackmap analyze <path> --module python --module rust # only these analyzers
735
736
  attackmap analyze <path> --cve # cross-reference SBOM against OSV.dev
737
+ attackmap analyze <path> --recall # widen taint discovery (deeper hops, capability-reach); extra reach marked SPECULATIVE
736
738
  attackmap analyze <path> --llm # add LLM narrative (auto-resolve auth)
737
739
  attackmap analyze <path> --llm --llm-backend cli # force Claude CLI
738
740
  attackmap analyze <path> --llm --llm-provider openai # use OpenAI/Codex (OPENAI_API_KEY or `codex` CLI)
@@ -742,6 +744,8 @@ attackmap analyze <path> --hunt --verify # adjudicate each lead vs. source (conf
742
744
  attackmap analyze <path> --hunt --verify --verify-votes 3 # majority vote of N independent skeptics (default 3; 1 = single pass)
743
745
  attackmap analyze <path> --hunt --verify --hunt-lenses 4 # N lens-specialised generation passes, deduped, then verified
744
746
  attackmap analyze <path> --hunt --verify --hunt-rounds 4 # loop-until-dry generation (completeness critic seeds each round)
747
+ attackmap analyze <path> --hunt --verify --hunt-budget 500000 # cap total hunt output tokens across rounds
748
+ attackmap analyze <path> --recall --hunt --verify # widen the net, then adjudicate the speculative reach
745
749
  attackmap analyze <path> --remediate # LLM review-first fix suggestions (remediation.md)
746
750
  attackmap analyze <path> --triage # cluster/dedupe/rank existing findings (triage.md; deterministic fallback)
747
751
  attackmap analyze <path> --pr-comment pr.md # Markdown PR summary comment for CI
@@ -788,7 +792,7 @@ introduces a new HIGH finding.
788
792
 
789
793
  ## Project status
790
794
 
791
- AttackMap is **beta** (v0.4.18) — published and validated on real codebases, but
795
+ AttackMap is **beta** (v0.4.25) — published and validated on real codebases, but
792
796
  pre-1.0 and heuristic.
793
797
 
794
798
  **Solid today:**
@@ -806,13 +810,27 @@ pre-1.0 and heuristic.
806
810
  - BOLA/IDOR authorization detection (with custom-middleware / guard-arg awareness).
807
811
  - Insecure-crypto / weak-randomness (Python/JS/Go/PHP) and web-hardening
808
812
  (CORS/CSRF/cookies/CSP/debug) detection.
809
- - Anomaly / outlier detection and **exploitability fusion** — a deterministic,
810
- explainable 0–100 "exploitable now" score that ranks route→sink combinations
811
- and folds in known-vulnerable dependencies on the path.
812
- - **`--hunt`** (LLM exploit-chain hypotheses) with **`--verify`** (adjudicate
813
- each lead CONFIRMED/REFUTED/NEEDS-REVIEW against the actual source),
814
- **`--remediate`** (review-first fix suggestions), and **`--triage`**
815
- (cluster/dedupe/rank existing findings, with a deterministic fallback).
813
+ - Anomaly / outlier detection — including **signature-free invariant mining**
814
+ (flag the handler that reaches a dangerous sink without the guard its peers
815
+ apply) — and **exploitability fusion**, a deterministic, explainable 0–100
816
+ "exploitable now" score that ranks route→sink combinations and folds in
817
+ known-vulnerable dependencies on the path.
818
+ - **`--hunt`** (LLM exploit-chain hypotheses) with a **verify jury** —
819
+ **`--verify`** adjudicates each lead CONFIRMED/REFUTED/NEEDS-REVIEW against the
820
+ actual source; **`--verify-votes`** runs N independent skeptics, **`--hunt-lenses`**
821
+ fans generation across failure-mode specialists, **`--hunt-rounds`** loops
822
+ until dry with a completeness critic, **`--hunt-budget`** caps token spend —
823
+ plus **`--remediate`** (review-first fixes) and **`--triage`**
824
+ (cluster/dedupe/rank existing findings, deterministic fallback).
825
+ - **`--recall`** — verifier-gated aggressive discovery: widens the taint walk
826
+ (deeper hops, capability-reach enumeration of every reach to exec/fs/net/
827
+ deserialize/template/SQL) and marks the extra reach SPECULATIVE, kept out of
828
+ `--fail-on-new-high` and left for `--hunt --verify` to adjudicate.
829
+ - **Cross-repo / fleet analysis** — `attackmap analyze repoA repoB …` scans a
830
+ fleet, links one repo's outbound HTTP calls to another's routes, and surfaces
831
+ bugs that live in the seams: cross-boundary (confused-deputy) flows,
832
+ trust-assumption gaps, and the sibling service that omits a control its peers
833
+ enforce. All cross-repo findings are speculative until verified.
816
834
  - SBOM inventory (5 ecosystems) + OSV.dev CVE cross-reference (`--cve`).
817
835
  - Output: Markdown + JSON + **SARIF 2.1.0** + **Mermaid / Graphviz** diagrams;
818
836
  **diff/baseline** PR gating; a reusable **GitHub Action + PR bot**; optional
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "attackmap"
7
- version = "0.4.25"
7
+ version = "0.4.27"
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"
@@ -126,7 +126,9 @@ def _run_fleet(
126
126
  )
127
127
  if cve and scan.dependencies:
128
128
  try:
129
- vulns, _cve_summary = query_vulnerabilities(scan.dependencies)
129
+ vulns, _cve_summary = query_vulnerabilities(
130
+ scan.dependencies, progress=scan_progress
131
+ )
130
132
  finally:
131
133
  scan_progress.done()
132
134
  scan.vulnerabilities = vulns
@@ -434,9 +436,13 @@ def analyze(
434
436
  )
435
437
  if cve and scan.dependencies:
436
438
  typer.echo(f"Checking {len(scan.dependencies)} dependencies against OSV.dev…")
437
- scan_progress.stage(f"Checking {len(scan.dependencies)} dependencies against OSV.dev")
439
+ # query_vulnerabilities drives a determinate begin/advance bar itself
440
+ # (per unique package), so big lockfile trees show real progress + ETA
441
+ # instead of an indeterminate spinner.
438
442
  try:
439
- vulns, cve_summary = query_vulnerabilities(scan.dependencies)
443
+ vulns, cve_summary = query_vulnerabilities(
444
+ scan.dependencies, progress=scan_progress
445
+ )
440
446
  finally:
441
447
  scan_progress.done()
442
448
  scan.vulnerabilities = vulns
@@ -7,9 +7,16 @@ and doesn't belong in an offline-only scan.
7
7
 
8
8
  ## Design summary
9
9
 
10
- - **Endpoint**: ``POST https://api.osv.dev/v1/query`` per (ecosystem,
11
- name, version). Full details in a single response; simpler cache
12
- semantics than the batch + fetch-by-id combo.
10
+ - **Endpoints**: ``POST https://api.osv.dev/v1/querybatch`` in chunks of
11
+ up to ``_BATCH_SIZE`` (ecosystem, name, version) tuples — one request
12
+ covers hundreds of packages instead of one request per package (a
13
+ lockfile-resolved monorepo can carry thousands of pinned deps; per-
14
+ package queries took ~15 minutes on such repos). The batch response
15
+ carries vulnerability IDs only, so full records are then fetched via
16
+ ``GET https://api.osv.dev/v1/vulns/{id}`` — but only for the handful
17
+ of packages that actually have advisories, memoized per run. A batch
18
+ result that paginates (``next_page_token``, rare) falls back to the
19
+ classic ``POST /v1/query`` for that one package.
13
20
  - **Concrete version resolution**: OSV needs a concrete version to
14
21
  match against affected ranges. Manifests carry range specs
15
22
  (``^4.16.0``, ``>=2.28,<3``, ``latest``); we extract a best-effort
@@ -48,6 +55,10 @@ logger = logging.getLogger(__name__)
48
55
 
49
56
 
50
57
  _OSV_QUERY_URL = "https://api.osv.dev/v1/query"
58
+ _OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch"
59
+ _OSV_VULNS_URL = "https://api.osv.dev/v1/vulns/"
60
+ # OSV accepts up to 1000 queries per batch call; stay comfortably under.
61
+ _BATCH_SIZE = 500
51
62
  _OSV_ECOSYSTEM: dict[str, str] = {
52
63
  "pypi": "PyPI",
53
64
  "npm": "npm",
@@ -85,6 +96,7 @@ def query_vulnerabilities(
85
96
  cache_dir: Path | None = None,
86
97
  transport: Callable[[str, bytes], bytes] | None = None,
87
98
  clock: Callable[[], float] | None = None,
99
+ progress=None,
88
100
  ) -> tuple[list[Vulnerability], LookupSummary]:
89
101
  """Look up CVEs for each ``DependencyHint`` and return records.
90
102
 
@@ -98,9 +110,15 @@ def query_vulnerabilities(
98
110
  supply a per-test tmpdir here.
99
111
  transport
100
112
  Injectable network transport ``(url, body) -> response_bytes``.
101
- Tests pass a stub; default is urllib.
113
+ Tests pass a stub; default is urllib. Detail lookups pass an
114
+ empty body (a GET in the default transport).
102
115
  clock
103
116
  Injectable ``time.time`` for deterministic cache-TTL tests.
117
+ progress
118
+ Optional duck-typed progress sink with ``begin(total, label)``
119
+ and ``advance(current)`` — the CLI passes its ``ScanProgress``
120
+ so a long lookup renders as a determinate bar, not a stalled
121
+ spinner.
104
122
  """
105
123
  cache = _resolve_cache_dir(cache_dir)
106
124
  cache.mkdir(parents=True, exist_ok=True)
@@ -111,6 +129,12 @@ def query_vulnerabilities(
111
129
  summary = LookupSummary()
112
130
  seen_keys: set[tuple[str, str, str, str]] = set()
113
131
 
132
+ # Pass 1 — resolve each hint to a concrete queryable version and dedupe
133
+ # to unique (ecosystem, name, version) packages. A lockfile monorepo can
134
+ # carry thousands of hints; querying per unique package (batched below)
135
+ # instead of per hint is what keeps --cve tractable.
136
+ entries: list[tuple[DependencyHint, tuple[str, str, str]]] = []
137
+ unique: dict[tuple[str, str, str], tuple[str, str, str]] = {} # key -> (osv_eco, name, concrete)
114
138
  for dep in dependencies:
115
139
  osv_eco = _OSV_ECOSYSTEM.get(dep.ecosystem)
116
140
  if osv_eco is None:
@@ -125,21 +149,76 @@ def query_vulnerabilities(
125
149
  if concrete is None:
126
150
  summary.skipped_no_version += 1
127
151
  continue
128
-
129
152
  key = (dep.ecosystem, dep.name, concrete)
130
- cache_path = cache / (_cache_key(*key) + ".json")
153
+ entries.append((dep, key))
154
+ unique.setdefault(key, (osv_eco, dep.name, concrete))
131
155
 
132
- payload: dict | None = _read_cache(cache_path, now, ttl_seconds)
156
+ if progress is not None:
157
+ progress.begin(len(unique), label="Checking dependencies against OSV.dev")
158
+
159
+ # Pass 2 — serve unique packages from the cache where fresh.
160
+ payload_by_key: dict[tuple[str, str, str], dict] = {}
161
+ for key in unique:
162
+ cache_path = cache / (_cache_key(*key) + ".json")
163
+ payload = _read_cache(cache_path, now, ttl_seconds)
133
164
  if payload is not None:
165
+ payload_by_key[key] = payload
134
166
  summary.cached += 1
135
- else:
136
- payload = _fetch_osv(osv_eco, dep.name, concrete, transport, summary)
167
+ if progress is not None:
168
+ progress.advance(key[1])
169
+
170
+ # Pass 3 — batch-query the misses (one POST per _BATCH_SIZE packages),
171
+ # then fetch full advisory records only for the IDs that actually hit,
172
+ # memoized so a CVE shared by many packages is fetched once.
173
+ misses = [key for key in unique if key not in payload_by_key]
174
+ details_memo: dict[str, dict] = {}
175
+ for start in range(0, len(misses), _BATCH_SIZE):
176
+ chunk = misses[start : start + _BATCH_SIZE]
177
+ results = _fetch_osv_batch([unique[key] for key in chunk], transport, summary)
178
+ if results is None:
179
+ # Whole batch unreachable — same offline semantics as before,
180
+ # tallied per package so the summary line stays meaningful.
181
+ summary.skipped_offline += len(chunk)
182
+ if progress is not None:
183
+ for key in chunk:
184
+ progress.advance(key[1])
185
+ continue
186
+ for key, result in zip(chunk, results):
187
+ osv_eco, name, concrete = unique[key]
188
+ payload: dict | None
189
+ if not isinstance(result, dict):
190
+ result = {}
191
+ if result.get("next_page_token"):
192
+ # A package with >1 page of advisories (rare): fall back to
193
+ # the classic single query, following ITS pagination too —
194
+ # exactly these high-advisory packages paginate there as well.
195
+ payload = _fetch_osv_paged(osv_eco, name, concrete, transport, summary)
196
+ else:
197
+ # Assemble the full advisory records for this package. If any
198
+ # detail fetch fails, the package must NOT be cached — a
199
+ # partial payload would suppress a known vulnerability for the
200
+ # whole cache TTL. Treat it as offline for this run instead.
201
+ payload = _assemble_details(result, details_memo, transport, summary)
137
202
  if payload is None:
138
- continue # skipped_offline / network_errors already tallied
139
- _write_cache(cache_path, payload, now)
203
+ summary.skipped_offline += 1
204
+ if progress is not None:
205
+ progress.advance(name)
206
+ continue
207
+ payload_by_key[key] = payload
208
+ _write_cache(cache / (_cache_key(*key) + ".json"), payload, now)
140
209
  summary.queried += 1
141
- if transport is None:
142
- time.sleep(_INTER_REQUEST_SLEEP_S)
210
+ if progress is not None:
211
+ progress.advance(name)
212
+ if transport is None and start + _BATCH_SIZE < len(misses):
213
+ time.sleep(_INTER_REQUEST_SLEEP_S)
214
+
215
+ # Pass 4 — fan the per-package payloads back out to every hint (dedup by
216
+ # (eco, name, version, id) keeps multi-lockfile repeats from double-emitting).
217
+ for dep, key in entries:
218
+ payload = payload_by_key.get(key)
219
+ if payload is None:
220
+ continue
221
+ concrete = key[2]
143
222
 
144
223
  for entry in payload.get("vulns") or []:
145
224
  vuln = _entry_to_vulnerability(entry, dep, concrete)
@@ -404,37 +483,151 @@ def _write_cache(path: Path, payload: dict, now: float) -> None:
404
483
  pass
405
484
 
406
485
 
407
- def _fetch_osv(
486
+ def _fetch_osv_batch(
487
+ packages: list[tuple[str, str, str]],
488
+ transport: Callable[[str, bytes], bytes] | None,
489
+ summary: LookupSummary,
490
+ ) -> list[dict] | None:
491
+ """One ``POST /v1/querybatch`` for up to ``_BATCH_SIZE`` packages.
492
+
493
+ Returns the ``results`` list (aligned with ``packages`` by index — OSV
494
+ guarantees request order) or ``None`` on a network/shape failure, in
495
+ which case the whole chunk is treated as offline.
496
+ """
497
+ body = json.dumps(
498
+ {
499
+ "queries": [
500
+ {"package": {"name": name, "ecosystem": osv_eco}, "version": version}
501
+ for osv_eco, name, version in packages
502
+ ]
503
+ }
504
+ ).encode("utf-8")
505
+ raw = _http(_OSV_BATCH_URL, body, transport, summary)
506
+ if raw is None:
507
+ return None
508
+ try:
509
+ payload = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
510
+ except json.JSONDecodeError:
511
+ summary.network_errors += 1
512
+ return None
513
+ results = payload.get("results") if isinstance(payload, dict) else None
514
+ if not isinstance(results, list) or len(results) != len(packages):
515
+ summary.network_errors += 1
516
+ return None
517
+ return results
518
+
519
+
520
+ def _assemble_details(
521
+ result: dict,
522
+ details_memo: dict[str, dict],
523
+ transport: Callable[[str, bytes], bytes] | None,
524
+ summary: LookupSummary,
525
+ ) -> dict | None:
526
+ """Turn one batch result's ID references into full advisory records.
527
+
528
+ Returns ``None`` if any advertised detail could not be fetched — the
529
+ caller must then skip caching the package, because caching a partial
530
+ payload would suppress a known vulnerability for the whole TTL (P1).
531
+ """
532
+ full_entries = []
533
+ for ref in result.get("vulns") or []:
534
+ vuln_id = str(ref.get("id", "")) if isinstance(ref, dict) else ""
535
+ if not vuln_id:
536
+ continue # shape anomaly, not a fetch failure — nothing to fetch
537
+ entry = details_memo.get(vuln_id)
538
+ if entry is None:
539
+ entry = _fetch_osv_vuln(vuln_id, transport, summary)
540
+ if entry is None:
541
+ return None
542
+ details_memo[vuln_id] = entry
543
+ full_entries.append(entry)
544
+ return {"vulns": full_entries} if full_entries else {}
545
+
546
+
547
+ def _fetch_osv_paged(
408
548
  osv_ecosystem: str,
409
549
  name: str,
410
550
  version: str,
411
551
  transport: Callable[[str, bytes], bytes] | None,
412
552
  summary: LookupSummary,
413
553
  ) -> dict | None:
414
- body = json.dumps(
415
- {"package": {"name": name, "ecosystem": osv_ecosystem}, "version": version}
416
- ).encode("utf-8")
554
+ """Classic ``POST /v1/query``, following ``next_page_token`` to the end.
555
+
556
+ Returns the union of all pages' ``vulns``, or ``None`` if any page fails
557
+ — never a partial result, for the same don't-cache-partials reason as
558
+ :func:`_assemble_details` (P2: single-query responses paginate too).
559
+ """
560
+ all_vulns: list[dict] = []
561
+ page_token: str | None = None
562
+ while True:
563
+ query: dict = {
564
+ "package": {"name": name, "ecosystem": osv_ecosystem},
565
+ "version": version,
566
+ }
567
+ if page_token:
568
+ query["page_token"] = page_token
569
+ raw = _http(_OSV_QUERY_URL, json.dumps(query).encode("utf-8"), transport, summary)
570
+ if raw is None:
571
+ return None
572
+ try:
573
+ payload = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
574
+ except json.JSONDecodeError:
575
+ summary.network_errors += 1
576
+ return None
577
+ if not isinstance(payload, dict):
578
+ summary.network_errors += 1
579
+ return None
580
+ all_vulns.extend(payload.get("vulns") or [])
581
+ page_token = payload.get("next_page_token")
582
+ if not page_token:
583
+ break
584
+ if transport is None:
585
+ time.sleep(_INTER_REQUEST_SLEEP_S)
586
+ return {"vulns": all_vulns} if all_vulns else {}
587
+
588
+
589
+ def _fetch_osv_vuln(
590
+ vuln_id: str,
591
+ transport: Callable[[str, bytes], bytes] | None,
592
+ summary: LookupSummary,
593
+ ) -> dict | None:
594
+ """Fetch one full advisory record via ``GET /v1/vulns/{id}``."""
595
+ raw = _http(_OSV_VULNS_URL + vuln_id, b"", transport, summary)
596
+ if raw is None:
597
+ return None
417
598
  try:
418
- if transport is not None:
419
- raw = transport(_OSV_QUERY_URL, body)
420
- else:
421
- request = urllib.request.Request(
422
- _OSV_QUERY_URL,
423
- data=body,
424
- headers={"Content-Type": "application/json"},
425
- method="POST",
426
- )
427
- with urllib.request.urlopen(request, timeout=_REQUEST_TIMEOUT_S) as response:
428
- raw = response.read()
429
- except (urllib.error.URLError, TimeoutError, OSError) as exc:
599
+ entry = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
600
+ except json.JSONDecodeError:
430
601
  summary.network_errors += 1
431
- summary.skipped_offline += 1
432
- logger.debug("OSV network error for %s/%s@%s: %s", osv_ecosystem, name, version, exc)
433
602
  return None
603
+ return entry if isinstance(entry, dict) else None
604
+
605
+
606
+ def _http(
607
+ url: str,
608
+ body: bytes,
609
+ transport: Callable[[str, bytes], bytes] | None,
610
+ summary: LookupSummary,
611
+ ) -> bytes | None:
612
+ """One HTTP exchange: POST when ``body`` is non-empty, GET otherwise.
613
+
614
+ The injectable ``transport`` sees ``(url, body)`` either way, so test
615
+ stubs dispatch on the URL.
616
+ """
434
617
  try:
435
- return json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
436
- except json.JSONDecodeError:
618
+ if transport is not None:
619
+ return transport(url, body)
620
+ request = urllib.request.Request(
621
+ url,
622
+ data=body or None,
623
+ headers={"Content-Type": "application/json"} if body else {},
624
+ method="POST" if body else "GET",
625
+ )
626
+ with urllib.request.urlopen(request, timeout=_REQUEST_TIMEOUT_S) as response:
627
+ return response.read()
628
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
437
629
  summary.network_errors += 1
630
+ logger.debug("OSV network error for %s: %s", url, exc)
438
631
  return None
439
632
 
440
633
 
@@ -263,11 +263,29 @@ def _parse_pnpm_lock(path: Path) -> _Graph | None:
263
263
  import yaml
264
264
  except ImportError: # pragma: no cover - PyYAML is a declared dependency
265
265
  return None
266
- data = yaml.safe_load(path.read_text(encoding="utf-8"))
267
- if not isinstance(data, dict):
266
+ # pnpm-lock is normally a single YAML document, but some files concatenate
267
+ # several with `---` separators (e.g. a merge artifact, or a tool that
268
+ # appends per-workspace lockfiles). Parse every document and merge them, and
269
+ # never let a malformed-YAML error abort the whole scan — a lockfile we can't
270
+ # read just yields no dependency hints.
271
+ try:
272
+ documents = [
273
+ doc
274
+ for doc in yaml.safe_load_all(path.read_text(encoding="utf-8"))
275
+ if isinstance(doc, dict)
276
+ ]
277
+ except yaml.YAMLError:
278
+ return None
279
+ if not documents:
268
280
  return None
269
281
  graph = _Graph(ecosystem="npm")
282
+ for data in documents:
283
+ _absorb_pnpm_document(data, graph)
284
+ return graph
270
285
 
286
+
287
+ def _absorb_pnpm_document(data: dict, graph: _Graph) -> None:
288
+ """Fold one parsed pnpm-lock YAML document into ``graph`` (roots + packages)."""
271
289
  # Roots: importers.'.'.{dependencies,devDependencies} (v6+) or top-level.
272
290
  importers = data.get("importers")
273
291
  root_sections = []
@@ -285,7 +303,7 @@ def _parse_pnpm_lock(path: Path) -> _Graph | None:
285
303
 
286
304
  packages = data.get("packages")
287
305
  if not isinstance(packages, dict):
288
- return graph
306
+ return
289
307
  for raw_key, meta in packages.items():
290
308
  m = _PNPM_PKG_KEY_RE.match(str(raw_key))
291
309
  if not m:
@@ -302,7 +320,6 @@ def _parse_pnpm_lock(path: Path) -> _Graph | None:
302
320
  children.update(deps.keys())
303
321
  if children:
304
322
  graph.edges.setdefault(name, set()).update(children)
305
- return graph
306
323
 
307
324
 
308
325
  # --- pypi: poetry.lock / uv.lock; cargo: Cargo.lock (all TOML [[package]]) --
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: attackmap
3
- Version: 0.4.25
3
+ Version: 0.4.27
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>
@@ -790,10 +790,12 @@ scaffolding, testing, and publishing instructions is in
790
790
 
791
791
  ```bash
792
792
  attackmap analyze <path> # run a review on a repository
793
+ attackmap analyze repoA repoB gateway # multi-repo fleet scan (per-repo reports + fleet summary)
793
794
  attackmap analyze <path> --output dir # write outputs to `dir/`
794
795
  attackmap analyze <path> --format json # json | markdown | all (default)
795
796
  attackmap analyze <path> --module python --module rust # only these analyzers
796
797
  attackmap analyze <path> --cve # cross-reference SBOM against OSV.dev
798
+ attackmap analyze <path> --recall # widen taint discovery (deeper hops, capability-reach); extra reach marked SPECULATIVE
797
799
  attackmap analyze <path> --llm # add LLM narrative (auto-resolve auth)
798
800
  attackmap analyze <path> --llm --llm-backend cli # force Claude CLI
799
801
  attackmap analyze <path> --llm --llm-provider openai # use OpenAI/Codex (OPENAI_API_KEY or `codex` CLI)
@@ -803,6 +805,8 @@ attackmap analyze <path> --hunt --verify # adjudicate each lead vs. source (conf
803
805
  attackmap analyze <path> --hunt --verify --verify-votes 3 # majority vote of N independent skeptics (default 3; 1 = single pass)
804
806
  attackmap analyze <path> --hunt --verify --hunt-lenses 4 # N lens-specialised generation passes, deduped, then verified
805
807
  attackmap analyze <path> --hunt --verify --hunt-rounds 4 # loop-until-dry generation (completeness critic seeds each round)
808
+ attackmap analyze <path> --hunt --verify --hunt-budget 500000 # cap total hunt output tokens across rounds
809
+ attackmap analyze <path> --recall --hunt --verify # widen the net, then adjudicate the speculative reach
806
810
  attackmap analyze <path> --remediate # LLM review-first fix suggestions (remediation.md)
807
811
  attackmap analyze <path> --triage # cluster/dedupe/rank existing findings (triage.md; deterministic fallback)
808
812
  attackmap analyze <path> --pr-comment pr.md # Markdown PR summary comment for CI
@@ -849,7 +853,7 @@ introduces a new HIGH finding.
849
853
 
850
854
  ## Project status
851
855
 
852
- AttackMap is **beta** (v0.4.18) — published and validated on real codebases, but
856
+ AttackMap is **beta** (v0.4.25) — published and validated on real codebases, but
853
857
  pre-1.0 and heuristic.
854
858
 
855
859
  **Solid today:**
@@ -867,13 +871,27 @@ pre-1.0 and heuristic.
867
871
  - BOLA/IDOR authorization detection (with custom-middleware / guard-arg awareness).
868
872
  - Insecure-crypto / weak-randomness (Python/JS/Go/PHP) and web-hardening
869
873
  (CORS/CSRF/cookies/CSP/debug) detection.
870
- - Anomaly / outlier detection and **exploitability fusion** — a deterministic,
871
- explainable 0–100 "exploitable now" score that ranks route→sink combinations
872
- and folds in known-vulnerable dependencies on the path.
873
- - **`--hunt`** (LLM exploit-chain hypotheses) with **`--verify`** (adjudicate
874
- each lead CONFIRMED/REFUTED/NEEDS-REVIEW against the actual source),
875
- **`--remediate`** (review-first fix suggestions), and **`--triage`**
876
- (cluster/dedupe/rank existing findings, with a deterministic fallback).
874
+ - Anomaly / outlier detection — including **signature-free invariant mining**
875
+ (flag the handler that reaches a dangerous sink without the guard its peers
876
+ apply) — and **exploitability fusion**, a deterministic, explainable 0–100
877
+ "exploitable now" score that ranks route→sink combinations and folds in
878
+ known-vulnerable dependencies on the path.
879
+ - **`--hunt`** (LLM exploit-chain hypotheses) with a **verify jury** —
880
+ **`--verify`** adjudicates each lead CONFIRMED/REFUTED/NEEDS-REVIEW against the
881
+ actual source; **`--verify-votes`** runs N independent skeptics, **`--hunt-lenses`**
882
+ fans generation across failure-mode specialists, **`--hunt-rounds`** loops
883
+ until dry with a completeness critic, **`--hunt-budget`** caps token spend —
884
+ plus **`--remediate`** (review-first fixes) and **`--triage`**
885
+ (cluster/dedupe/rank existing findings, deterministic fallback).
886
+ - **`--recall`** — verifier-gated aggressive discovery: widens the taint walk
887
+ (deeper hops, capability-reach enumeration of every reach to exec/fs/net/
888
+ deserialize/template/SQL) and marks the extra reach SPECULATIVE, kept out of
889
+ `--fail-on-new-high` and left for `--hunt --verify` to adjudicate.
890
+ - **Cross-repo / fleet analysis** — `attackmap analyze repoA repoB …` scans a
891
+ fleet, links one repo's outbound HTTP calls to another's routes, and surfaces
892
+ bugs that live in the seams: cross-boundary (confused-deputy) flows,
893
+ trust-assumption gaps, and the sibling service that omits a control its peers
894
+ enforce. All cross-repo findings are speculative until verified.
877
895
  - SBOM inventory (5 ecosystems) + OSV.dev CVE cross-reference (`--cve`).
878
896
  - Output: Markdown + JSON + **SARIF 2.1.0** + **Mermaid / Graphviz** diagrams;
879
897
  **diff/baseline** PR gating; a reusable **GitHub Action + PR bot**; optional