attackmap 0.1.1__tar.gz → 0.2.0__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.
Files changed (80) hide show
  1. {attackmap-0.1.1/src/attackmap.egg-info → attackmap-0.2.0}/PKG-INFO +108 -1
  2. {attackmap-0.1.1 → attackmap-0.2.0}/README.md +106 -0
  3. {attackmap-0.1.1 → attackmap-0.2.0}/pyproject.toml +2 -1
  4. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/__init__.py +1 -1
  5. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/analyzer.py +51 -6
  6. attackmap-0.2.0/src/attackmap/analyzer_contracts.py +190 -0
  7. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/analyzers.py +155 -13
  8. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/cli.py +153 -0
  9. attackmap-0.2.0/src/attackmap/config_scanner.py +291 -0
  10. attackmap-0.2.0/src/attackmap/cve.py +436 -0
  11. attackmap-0.2.0/src/attackmap/diagrams.py +224 -0
  12. attackmap-0.2.0/src/attackmap/diff.py +179 -0
  13. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/llm_review.py +25 -2
  14. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/merge.py +19 -0
  15. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/models.py +146 -0
  16. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/report.py +36 -1
  17. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/review_eval.py +70 -16
  18. attackmap-0.2.0/src/attackmap/sarif.py +231 -0
  19. attackmap-0.2.0/src/attackmap/sbom.py +384 -0
  20. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/scanner.py +278 -11
  21. attackmap-0.2.0/src/attackmap/suggest.py +386 -0
  22. attackmap-0.2.0/src/attackmap/taint.py +369 -0
  23. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/threat_model.py +397 -19
  24. attackmap-0.2.0/src/attackmap/topology.py +316 -0
  25. {attackmap-0.1.1 → attackmap-0.2.0/src/attackmap.egg-info}/PKG-INFO +108 -1
  26. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap.egg-info/SOURCES.txt +21 -1
  27. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap.egg-info/requires.txt +1 -0
  28. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_analyzer.py +101 -0
  29. attackmap-0.2.0/tests/test_analyzer_metadata_schema.py +194 -0
  30. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_analyzers.py +127 -10
  31. attackmap-0.2.0/tests/test_config_scanner.py +265 -0
  32. attackmap-0.2.0/tests/test_cve.py +403 -0
  33. attackmap-0.2.0/tests/test_diagrams.py +256 -0
  34. attackmap-0.2.0/tests/test_diff.py +368 -0
  35. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_llm_review.py +38 -0
  36. attackmap-0.2.0/tests/test_provenance.py +208 -0
  37. attackmap-0.2.0/tests/test_review_eval.py +165 -0
  38. attackmap-0.2.0/tests/test_sarif.py +219 -0
  39. attackmap-0.2.0/tests/test_sbom.py +250 -0
  40. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_scanner.py +378 -0
  41. attackmap-0.2.0/tests/test_suggest.py +259 -0
  42. attackmap-0.2.0/tests/test_taint.py +205 -0
  43. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_threat_model.py +374 -2
  44. attackmap-0.2.0/tests/test_topology.py +220 -0
  45. attackmap-0.1.1/src/attackmap/analyzer_contracts.py +0 -104
  46. attackmap-0.1.1/tests/test_review_eval.py +0 -68
  47. {attackmap-0.1.1 → attackmap-0.2.0}/LICENSE +0 -0
  48. {attackmap-0.1.1 → attackmap-0.2.0}/setup.cfg +0 -0
  49. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/asset_model.py +0 -0
  50. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/attack_taxonomy.py +0 -0
  51. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/context_pack.py +0 -0
  52. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/control_model.py +0 -0
  53. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/defensive_review.py +0 -0
  54. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/detection_opportunities.py +0 -0
  55. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/graph.py +0 -0
  56. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/insights.py +0 -0
  57. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/recon_models.py +0 -0
  58. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/recon_to_analysis.py +0 -0
  59. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/review_json.py +0 -0
  60. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/review_prompts.py +0 -0
  61. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/sdk/__init__.py +0 -0
  62. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/sdk/contracts.py +0 -0
  63. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/sdk/models.py +0 -0
  64. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap/security_overlay.py +0 -0
  65. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap.egg-info/dependency_links.txt +0 -0
  66. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap.egg-info/entry_points.txt +0 -0
  67. {attackmap-0.1.1 → attackmap-0.2.0}/src/attackmap.egg-info/top_level.txt +0 -0
  68. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_artifact_schemas.py +0 -0
  69. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_context_pack.py +0 -0
  70. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_defensive_review.py +0 -0
  71. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_graph.py +0 -0
  72. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_merge.py +0 -0
  73. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_recon_to_analysis.py +0 -0
  74. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_report.py +0 -0
  75. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_review_json.py +0 -0
  76. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_review_prompts.py +0 -0
  77. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_security_overlay.py +0 -0
  78. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_shared_contract_imports.py +0 -0
  79. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_signal_v2.py +0 -0
  80. {attackmap-0.1.1 → attackmap-0.2.0}/tests/test_threat_intel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: attackmap
3
- Version: 0.1.1
3
+ Version: 0.2.0
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>
@@ -51,6 +51,7 @@ Requires-Dist: attackmap-analyzer-atproto>=0.1.0; extra == "all"
51
51
  Requires-Dist: attackmap-analyzer-php-web>=0.1.0; extra == "all"
52
52
  Requires-Dist: attackmap-analyzer-php-laminas>=0.1.0; extra == "all"
53
53
  Requires-Dist: attackmap-analyzer-omeka-s>=0.1.0; extra == "all"
54
+ Requires-Dist: attackmap-analyzer-iac>=0.1.0; extra == "all"
54
55
  Provides-Extra: dev
55
56
  Requires-Dist: pytest>=8.0.0; extra == "dev"
56
57
  Requires-Dist: build>=1.2.0; extra == "dev"
@@ -116,6 +117,17 @@ You can also install individual analyzer plugins on demand:
116
117
  pip install attackmap-analyzer-python attackmap-analyzer-go
117
118
  ```
118
119
 
120
+ Not sure which plugins your repo needs? Let AttackMap tell you:
121
+
122
+ ```bash
123
+ attackmap suggest ./path/to/repo # print ranked pip lines
124
+ attackmap suggest ./path/to/repo --install # and install them (prompts once)
125
+ ```
126
+
127
+ `suggest` inspects the repo's manifest files, extensions, and directory layout
128
+ and recommends only the plugins that would give it deeper signal — useful
129
+ when you want a smaller install footprint than `[all]`.
130
+
119
131
  ### With Docker
120
132
 
121
133
  ```bash
@@ -150,9 +162,102 @@ Every `attackmap analyze` run writes:
150
162
  | `defensive-review.json` | Structured equivalent (schema v1.2.0) |
151
163
  | `review-context-pack.json` | Structured evidence pack consumed by the LLM stage |
152
164
  | `attackmap-report.json` | Everything bundled |
165
+ | `attackmap-report.sarif` | SARIF 2.1.0 log — ingestable by GitHub Code Scanning, VS Code, and other SARIF consumers |
166
+ | `attackmap-paths.md` | Mermaid flowcharts of each attack path — renders inline on GitHub |
167
+ | `attackmap-topology.md` | Mermaid graph of the service topology, with edge kinds styled per relationship type |
168
+ | `attackmap-paths.dot` / `attackmap-topology.dot` | Graphviz DOT versions of the two diagrams — feed into `dot -Tsvg` for slide-quality graphics |
153
169
  | `defensive-review-llm.md` *(with `--llm`)* | Claude-narrated review |
154
170
  | `defensive-review-llm.meta.json` *(with `--llm`)* | Backend, model, token usage |
155
171
 
172
+ ### GitHub Code Scanning integration
173
+
174
+ Drop this into `.github/workflows/attackmap.yml` to get AttackMap findings inline on every PR:
175
+
176
+ ```yaml
177
+ name: AttackMap
178
+ on: [pull_request, push]
179
+ jobs:
180
+ scan:
181
+ runs-on: ubuntu-latest
182
+ permissions:
183
+ security-events: write
184
+ contents: read
185
+ steps:
186
+ - uses: actions/checkout@v4
187
+ - uses: actions/setup-python@v5
188
+ with: { python-version: "3.12" }
189
+ - run: pip install "attackmap[all]"
190
+ - run: attackmap analyze . --output reports
191
+ - uses: github/codeql-action/upload-sarif@v3
192
+ with:
193
+ sarif_file: reports/attackmap-report.sarif
194
+ category: attackmap
195
+ ```
196
+
197
+ ### Diff mode (PR gating)
198
+
199
+ For a lighter CI integration than Code Scanning — a bot comment, a JSON delta,
200
+ or a hard fail on newly-introduced HIGH findings — point `--baseline` at a
201
+ prior report and AttackMap will emit a Markdown diff alongside the fresh
202
+ report:
203
+
204
+ ```bash
205
+ attackmap analyze . --output reports \
206
+ --baseline path/to/previous/attackmap-report.json \
207
+ --diff-output reports/attackmap-diff.md \
208
+ --fail-on-new-high # exit non-zero if the PR introduces any HIGH finding
209
+ ```
210
+
211
+ Findings get a stable id (hash of the finding title) that survives line drift
212
+ on unrelated commits, so a finding that persists across scans has the same id
213
+ in both. The diff has three sections — **New**, **Persisted**, **Resolved** —
214
+ which drop cleanly into a PR comment.
215
+
216
+ ### SBOM inventory
217
+
218
+ Every scan also produces a lightweight SBOM by parsing direct dependencies out
219
+ of the common manifest files:
220
+
221
+ | Ecosystem | Files parsed |
222
+ |---|---|
223
+ | PyPI (Python) | `pyproject.toml` (PEP 621 + Poetry), `requirements.txt` |
224
+ | npm (Node.js) | `package.json` (dependencies + devDependencies + peer/optional) |
225
+ | Go | `go.mod` (single-line + block-form `require`, `// indirect` flagged) |
226
+ | Cargo (Rust) | `Cargo.toml` (dependencies + dev-dependencies + build-dependencies) |
227
+ | Composer (PHP) | `composer.json` (require + require-dev; platform reqs skipped) |
228
+
229
+ Each entry appears in `attackmap-report.json` under `scan.dependencies` with
230
+ `{name, version, ecosystem, file, dev}`. Version ranges are kept verbatim
231
+ (`^4.16.0`, `>=2,<3`, `latest`) — this slice does not resolve lockfiles.
232
+
233
+ ### CVE cross-reference (opt-in)
234
+
235
+ `attackmap analyze --cve` cross-references every SBOM entry against
236
+ [OSV.dev](https://osv.dev) and emits one finding per vulnerable dependency
237
+ (all known advisories aggregated in the evidence list). CVSS scores map into
238
+ low/medium/high — anything ≥ 7.0 is HIGH.
239
+
240
+ ```bash
241
+ attackmap analyze . --cve
242
+ ```
243
+
244
+ - **Off by default.** The flag exists precisely because CVE lookup does
245
+ network I/O; regular scans stay offline.
246
+ - **Cached** under `~/.attackmap/cache/osv/` keyed by
247
+ `sha256(ecosystem+name+version)`. TTL is 24h by default, overridable via
248
+ `ATTACKMAP_OSV_CACHE_TTL_HOURS`. Repeat scans of the same repo don't hit
249
+ the network.
250
+ - **Offline-tolerant.** If the network's unavailable but the cache is warm,
251
+ cached results still surface; only fresh queries are skipped.
252
+ - **Version resolution is best-effort.** Manifest ranges (`^4.16.0`,
253
+ `>=2.28,<3`) resolve to a queryable lower-bound; OSV does the range math.
254
+ Unpinned specs (`*`, `latest`) are skipped.
255
+
256
+ The structured vulnerability list is available under
257
+ `scan.vulnerabilities` in `attackmap-report.json` with
258
+ `{id, aliases, summary, severity, cvss_score, references, affected_range,
259
+ package_name, package_version, ecosystem}`.
260
+
156
261
  ---
157
262
 
158
263
  ## How it works
@@ -222,6 +327,8 @@ attackmap analyze <path> --output dir # write outputs to `dir/`
222
327
  attackmap analyze <path> --module python --module rust # only these analyzers
223
328
  attackmap analyze <path> --llm # add LLM narrative (auto-resolve auth)
224
329
  attackmap analyze <path> --llm --llm-backend cli # force Claude CLI
330
+ attackmap suggest ./repo # recommend plugins for a repo shape
331
+ attackmap suggest ./repo --install # and pip-install the missing ones
225
332
  attackmap modules # list installed analyzers
226
333
  ```
227
334
 
@@ -58,6 +58,17 @@ You can also install individual analyzer plugins on demand:
58
58
  pip install attackmap-analyzer-python attackmap-analyzer-go
59
59
  ```
60
60
 
61
+ Not sure which plugins your repo needs? Let AttackMap tell you:
62
+
63
+ ```bash
64
+ attackmap suggest ./path/to/repo # print ranked pip lines
65
+ attackmap suggest ./path/to/repo --install # and install them (prompts once)
66
+ ```
67
+
68
+ `suggest` inspects the repo's manifest files, extensions, and directory layout
69
+ and recommends only the plugins that would give it deeper signal — useful
70
+ when you want a smaller install footprint than `[all]`.
71
+
61
72
  ### With Docker
62
73
 
63
74
  ```bash
@@ -92,9 +103,102 @@ Every `attackmap analyze` run writes:
92
103
  | `defensive-review.json` | Structured equivalent (schema v1.2.0) |
93
104
  | `review-context-pack.json` | Structured evidence pack consumed by the LLM stage |
94
105
  | `attackmap-report.json` | Everything bundled |
106
+ | `attackmap-report.sarif` | SARIF 2.1.0 log — ingestable by GitHub Code Scanning, VS Code, and other SARIF consumers |
107
+ | `attackmap-paths.md` | Mermaid flowcharts of each attack path — renders inline on GitHub |
108
+ | `attackmap-topology.md` | Mermaid graph of the service topology, with edge kinds styled per relationship type |
109
+ | `attackmap-paths.dot` / `attackmap-topology.dot` | Graphviz DOT versions of the two diagrams — feed into `dot -Tsvg` for slide-quality graphics |
95
110
  | `defensive-review-llm.md` *(with `--llm`)* | Claude-narrated review |
96
111
  | `defensive-review-llm.meta.json` *(with `--llm`)* | Backend, model, token usage |
97
112
 
113
+ ### GitHub Code Scanning integration
114
+
115
+ Drop this into `.github/workflows/attackmap.yml` to get AttackMap findings inline on every PR:
116
+
117
+ ```yaml
118
+ name: AttackMap
119
+ on: [pull_request, push]
120
+ jobs:
121
+ scan:
122
+ runs-on: ubuntu-latest
123
+ permissions:
124
+ security-events: write
125
+ contents: read
126
+ steps:
127
+ - uses: actions/checkout@v4
128
+ - uses: actions/setup-python@v5
129
+ with: { python-version: "3.12" }
130
+ - run: pip install "attackmap[all]"
131
+ - run: attackmap analyze . --output reports
132
+ - uses: github/codeql-action/upload-sarif@v3
133
+ with:
134
+ sarif_file: reports/attackmap-report.sarif
135
+ category: attackmap
136
+ ```
137
+
138
+ ### Diff mode (PR gating)
139
+
140
+ For a lighter CI integration than Code Scanning — a bot comment, a JSON delta,
141
+ or a hard fail on newly-introduced HIGH findings — point `--baseline` at a
142
+ prior report and AttackMap will emit a Markdown diff alongside the fresh
143
+ report:
144
+
145
+ ```bash
146
+ attackmap analyze . --output reports \
147
+ --baseline path/to/previous/attackmap-report.json \
148
+ --diff-output reports/attackmap-diff.md \
149
+ --fail-on-new-high # exit non-zero if the PR introduces any HIGH finding
150
+ ```
151
+
152
+ Findings get a stable id (hash of the finding title) that survives line drift
153
+ on unrelated commits, so a finding that persists across scans has the same id
154
+ in both. The diff has three sections — **New**, **Persisted**, **Resolved** —
155
+ which drop cleanly into a PR comment.
156
+
157
+ ### SBOM inventory
158
+
159
+ Every scan also produces a lightweight SBOM by parsing direct dependencies out
160
+ of the common manifest files:
161
+
162
+ | Ecosystem | Files parsed |
163
+ |---|---|
164
+ | PyPI (Python) | `pyproject.toml` (PEP 621 + Poetry), `requirements.txt` |
165
+ | npm (Node.js) | `package.json` (dependencies + devDependencies + peer/optional) |
166
+ | Go | `go.mod` (single-line + block-form `require`, `// indirect` flagged) |
167
+ | Cargo (Rust) | `Cargo.toml` (dependencies + dev-dependencies + build-dependencies) |
168
+ | Composer (PHP) | `composer.json` (require + require-dev; platform reqs skipped) |
169
+
170
+ Each entry appears in `attackmap-report.json` under `scan.dependencies` with
171
+ `{name, version, ecosystem, file, dev}`. Version ranges are kept verbatim
172
+ (`^4.16.0`, `>=2,<3`, `latest`) — this slice does not resolve lockfiles.
173
+
174
+ ### CVE cross-reference (opt-in)
175
+
176
+ `attackmap analyze --cve` cross-references every SBOM entry against
177
+ [OSV.dev](https://osv.dev) and emits one finding per vulnerable dependency
178
+ (all known advisories aggregated in the evidence list). CVSS scores map into
179
+ low/medium/high — anything ≥ 7.0 is HIGH.
180
+
181
+ ```bash
182
+ attackmap analyze . --cve
183
+ ```
184
+
185
+ - **Off by default.** The flag exists precisely because CVE lookup does
186
+ network I/O; regular scans stay offline.
187
+ - **Cached** under `~/.attackmap/cache/osv/` keyed by
188
+ `sha256(ecosystem+name+version)`. TTL is 24h by default, overridable via
189
+ `ATTACKMAP_OSV_CACHE_TTL_HOURS`. Repeat scans of the same repo don't hit
190
+ the network.
191
+ - **Offline-tolerant.** If the network's unavailable but the cache is warm,
192
+ cached results still surface; only fresh queries are skipped.
193
+ - **Version resolution is best-effort.** Manifest ranges (`^4.16.0`,
194
+ `>=2.28,<3`) resolve to a queryable lower-bound; OSV does the range math.
195
+ Unpinned specs (`*`, `latest`) are skipped.
196
+
197
+ The structured vulnerability list is available under
198
+ `scan.vulnerabilities` in `attackmap-report.json` with
199
+ `{id, aliases, summary, severity, cvss_score, references, affected_range,
200
+ package_name, package_version, ecosystem}`.
201
+
98
202
  ---
99
203
 
100
204
  ## How it works
@@ -164,6 +268,8 @@ attackmap analyze <path> --output dir # write outputs to `dir/`
164
268
  attackmap analyze <path> --module python --module rust # only these analyzers
165
269
  attackmap analyze <path> --llm # add LLM narrative (auto-resolve auth)
166
270
  attackmap analyze <path> --llm --llm-backend cli # force Claude CLI
271
+ attackmap suggest ./repo # recommend plugins for a repo shape
272
+ attackmap suggest ./repo --install # and pip-install the missing ones
167
273
  attackmap modules # list installed analyzers
168
274
  ```
169
275
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "attackmap"
7
- version = "0.1.1"
7
+ version = "0.2.0"
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"
@@ -72,6 +72,7 @@ all = [
72
72
  "attackmap-analyzer-php-web>=0.1.0",
73
73
  "attackmap-analyzer-php-laminas>=0.1.0",
74
74
  "attackmap-analyzer-omeka-s>=0.1.0",
75
+ "attackmap-analyzer-iac>=0.1.0",
75
76
  ]
76
77
  dev = [
77
78
  "pytest>=8.0.0",
@@ -1,2 +1,2 @@
1
1
  __all__ = ["__version__"]
2
- __version__ = "0.1.0"
2
+ __version__ = "0.2.0"
@@ -4,15 +4,61 @@ from collections import Counter
4
4
 
5
5
  import networkx as nx
6
6
 
7
- from .models import AttackSurface, ScanResult
7
+ from .models import AttackSurface, AuthHint, Route, ScanResult
8
+
9
+
10
+ # Bounded line window used to attribute auth hints to a specific route
11
+ # handler. Chosen so a hint at the top of the handler body still lands
12
+ # on the route even if the handler is 40 lines long. Bigger than a typical
13
+ # handler; smaller than the distance between two adjacent routes. See #41
14
+ # / Bluesky FINDINGS §55 for the noise problem this fixes.
15
+ _ROUTE_AUTH_LINE_WINDOW = 40
16
+
17
+
18
+ def _auth_hints_for_route(
19
+ route: Route,
20
+ scan_auth_hints: list[AuthHint],
21
+ hints_by_file: dict[str, list[AuthHint]],
22
+ ) -> list[str]:
23
+ """Return the auth-hint strings that plausibly attach to `route`.
24
+
25
+ If the route has a known `line`, only hints in the same file whose
26
+ line falls within `_ROUTE_AUTH_LINE_WINDOW` above OR below the
27
+ route are counted. If the route lacks line info (framework
28
+ config-driven routes commonly do), fall back to file-scoped
29
+ attribution — same as the old behavior.
30
+
31
+ Deliberately no global fallback: the "if no hints in this file,
32
+ inherit every auth hint in the repo" behavior was the biggest
33
+ noise source per FINDINGS §55. Downstream code handles empty
34
+ auth_signals correctly.
35
+ """
36
+ file_hints = hints_by_file.get(route.file, [])
37
+ if not file_hints:
38
+ return []
39
+ if route.line is None:
40
+ return sorted({hint.hint for hint in file_hints})
41
+ lo = route.line - _ROUTE_AUTH_LINE_WINDOW
42
+ hi = route.line + _ROUTE_AUTH_LINE_WINDOW
43
+ windowed = {
44
+ hint.hint
45
+ for hint in file_hints
46
+ if hint.line is None or lo <= hint.line <= hi
47
+ }
48
+ if windowed:
49
+ return sorted(windowed)
50
+ # Hints exist in the file but none are within the window. That's the
51
+ # signal-to-noise fix: the previous route in this file had auth code,
52
+ # this one doesn't. Return empty — the surface's rationale below will
53
+ # say "no auth indicators near this route".
54
+ return []
8
55
 
9
56
 
10
57
  def identify_attack_surfaces(scan: ScanResult) -> list[AttackSurface]:
11
58
  surfaces: list[AttackSurface] = []
12
- auth_hints_by_file: dict[str, set[str]] = {}
59
+ auth_hints_by_file: dict[str, list[AuthHint]] = {}
13
60
  for hint in scan.auth_hints:
14
- auth_hints_by_file.setdefault(hint.file, set()).add(hint.hint)
15
- global_auth_hints = sorted({hint.hint for hint in scan.auth_hints})
61
+ auth_hints_by_file.setdefault(hint.file, []).append(hint)
16
62
 
17
63
  supporting_hints_by_file: dict[str, set[str]] = {}
18
64
  all_supporting_hints = [
@@ -29,8 +75,7 @@ def identify_attack_surfaces(scan: ScanResult) -> list[AttackSurface]:
29
75
 
30
76
  for route in scan.routes:
31
77
  path_lower = route.path.lower()
32
- file_auth_hints = sorted(auth_hints_by_file.get(route.file, set()))
33
- auth_signals = file_auth_hints or global_auth_hints
78
+ auth_signals = _auth_hints_for_route(route, scan.auth_hints, auth_hints_by_file)
34
79
  supporting_signals = sorted(supporting_hints_by_file.get(route.file, set())) or sorted(
35
80
  {hint.hint for hint in all_supporting_hints}
36
81
  )
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Protocol
6
+
7
+ from pydantic import BaseModel, Field, field_validator, model_validator
8
+
9
+ from .recon_models import ScanResult
10
+
11
+ # Phase-1 shared repository analyzer contract.
12
+ # Keep AnalyzerResult mapped to ScanResult for backward compatibility.
13
+ AnalyzerResult = ScanResult
14
+
15
+ # `name` must be a slug: lowercase letters, digits, hyphens. This is the
16
+ # entry-point key AttackMap uses to identify the analyzer, so it needs
17
+ # to be safe in URLs, filesystem paths, and CLI arguments.
18
+ _ANALYZER_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*$")
19
+
20
+
21
+ class AnalyzerMetadata(BaseModel):
22
+ """Static, self-describing metadata every analyzer exposes.
23
+
24
+ Every analyzer plugin ships a `metadata: AnalyzerMetadata` attribute so
25
+ core (and future tooling) can reason about the plugin without loading
26
+ or running it. The field set is stable: adding fields is safe on a
27
+ minor release, removing or renaming is a major-version break.
28
+ """
29
+
30
+ name: str = Field(
31
+ description="Slug identifier. Must match /[a-z0-9][a-z0-9-]*/. "
32
+ "This is the entry-point key AttackMap uses to select the analyzer "
33
+ "(e.g. from --module) and to attribute signals via provenance.",
34
+ )
35
+ display_name: str = Field(
36
+ default="",
37
+ description="Human-friendly name shown in `attackmap modules` output "
38
+ "and reports. Defaults to `name` when omitted.",
39
+ )
40
+ version: str = Field(
41
+ default="0.1.0",
42
+ description="Semver of the analyzer plugin itself (not attackmap). "
43
+ "Should match the analyzer package's pyproject.toml version.",
44
+ )
45
+ description: str = Field(
46
+ default="",
47
+ description="One-sentence summary of what the analyzer detects. "
48
+ "Shown in module listings and matter briefings.",
49
+ )
50
+ scope: str = Field(
51
+ default="",
52
+ description="One-sentence description of the kinds of repositories "
53
+ "this analyzer targets (e.g. 'Node/TypeScript backend service repos').",
54
+ )
55
+ targets: list[str] = Field(
56
+ default_factory=list,
57
+ description="Frameworks, protocols, or platform tokens this analyzer "
58
+ "specializes in (e.g. ['react-native', 'expo']). Used with `languages` "
59
+ "to compute `ecosystems`.",
60
+ )
61
+ languages: list[str] = Field(
62
+ default_factory=list,
63
+ description="Programming languages this analyzer parses (e.g. "
64
+ "['javascript', 'typescript']). Used with `targets` to compute "
65
+ "`ecosystems`.",
66
+ )
67
+ priority: int = Field(
68
+ default=100,
69
+ ge=0,
70
+ description="Discovery ordering hint. Lower runs first when multiple "
71
+ "analyzers match. Ties are broken by entry-point name. Convention: "
72
+ "0-49 is broad language analyzers (python, node-service); 50-149 is "
73
+ "framework analyzers (spring, laminas); 150+ is app-specific "
74
+ "analyzers (omeka-s).",
75
+ )
76
+ experimental: bool = Field(
77
+ default=True,
78
+ description="Whether this analyzer is still stabilizing. Doesn't "
79
+ "affect execution, just how core presents it in listings.",
80
+ )
81
+ enabled_by_default: bool = Field(
82
+ default=False,
83
+ description="Whether to run this analyzer without an explicit "
84
+ "--module flag. Broad language analyzers set this True; specialty "
85
+ "framework/app analyzers usually leave it False.",
86
+ )
87
+
88
+ @field_validator("name")
89
+ @classmethod
90
+ def _validate_name(cls, value: str) -> str:
91
+ if not value:
92
+ raise ValueError("AnalyzerMetadata.name must not be empty")
93
+ if not _ANALYZER_NAME_PATTERN.match(value):
94
+ raise ValueError(
95
+ f"AnalyzerMetadata.name {value!r} must match "
96
+ f"/{_ANALYZER_NAME_PATTERN.pattern}/ — lowercase letters, "
97
+ f"digits, and hyphens only, starting with a letter or digit."
98
+ )
99
+ return value
100
+
101
+ @field_validator("version")
102
+ @classmethod
103
+ def _validate_version(cls, value: str) -> str:
104
+ if not value:
105
+ raise ValueError("AnalyzerMetadata.version must not be empty")
106
+ return value
107
+
108
+ @model_validator(mode="before")
109
+ @classmethod
110
+ def _coerce_legacy_ecosystems(cls, value: object) -> object:
111
+ if not isinstance(value, dict):
112
+ return value
113
+ payload = dict(value)
114
+ # Backward-compat: older call sites may pass ecosystems directly.
115
+ # Use it as a fallback source for languages when newer fields are omitted.
116
+ ecosystems = payload.pop("ecosystems", None)
117
+ if ecosystems and not payload.get("languages") and not payload.get("targets"):
118
+ payload["languages"] = list(ecosystems)
119
+ if not payload.get("display_name") and payload.get("name"):
120
+ payload["display_name"] = str(payload["name"])
121
+ return payload
122
+
123
+ @property
124
+ def ecosystems(self) -> tuple[str, ...]:
125
+ """Deduplicated tuple of languages + targets, lowercased.
126
+
127
+ Convenience view for callers that don't care about the language vs
128
+ target distinction (e.g. picking analyzers by ecosystem token).
129
+ """
130
+ values = [*self.languages, *self.targets]
131
+ seen: set[str] = set()
132
+ ordered: list[str] = []
133
+ for value in values:
134
+ lowered = value.lower()
135
+ if lowered in seen:
136
+ continue
137
+ seen.add(lowered)
138
+ ordered.append(lowered)
139
+ return tuple(ordered)
140
+
141
+
142
+ class AnalyzerRepositoryModule(BaseModel):
143
+ analyzer_name: str
144
+ repo_name: str
145
+ web_url: str
146
+
147
+
148
+ class AnalyzerProtocol(Protocol):
149
+ metadata: AnalyzerMetadata
150
+
151
+ @property
152
+ def name(self) -> str: ...
153
+
154
+ def detect(self, root: str | Path) -> bool: ...
155
+
156
+ def analyze(self, root: str | Path) -> AnalyzerResult: ...
157
+
158
+
159
+ __all__ = [
160
+ "AnalyzerResult",
161
+ "AnalyzerMetadata",
162
+ "AnalyzerRepositoryModule",
163
+ "AnalyzerProtocol",
164
+ "normalize_analyzer_metadata",
165
+ ]
166
+
167
+
168
+ def normalize_analyzer_metadata(value: object) -> AnalyzerMetadata:
169
+ if isinstance(value, AnalyzerMetadata):
170
+ return value
171
+ if isinstance(value, dict):
172
+ return AnalyzerMetadata.model_validate(value)
173
+
174
+ payload: dict[str, object] = {}
175
+ for key in (
176
+ "name",
177
+ "display_name",
178
+ "version",
179
+ "description",
180
+ "scope",
181
+ "targets",
182
+ "languages",
183
+ "priority",
184
+ "experimental",
185
+ "enabled_by_default",
186
+ "ecosystems",
187
+ ):
188
+ if hasattr(value, key):
189
+ payload[key] = getattr(value, key)
190
+ return AnalyzerMetadata.model_validate(payload)