attackmap 0.1.0__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.
- {attackmap-0.1.0/src/attackmap.egg-info → attackmap-0.2.0}/PKG-INFO +130 -16
- {attackmap-0.1.0 → attackmap-0.2.0}/README.md +115 -2
- {attackmap-0.1.0 → attackmap-0.2.0}/pyproject.toml +15 -14
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/__init__.py +1 -1
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/analyzer.py +51 -6
- attackmap-0.2.0/src/attackmap/analyzer_contracts.py +190 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/analyzers.py +179 -69
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/cli.py +153 -0
- attackmap-0.2.0/src/attackmap/config_scanner.py +291 -0
- attackmap-0.2.0/src/attackmap/cve.py +436 -0
- attackmap-0.2.0/src/attackmap/diagrams.py +224 -0
- attackmap-0.2.0/src/attackmap/diff.py +179 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/llm_review.py +25 -2
- attackmap-0.2.0/src/attackmap/merge.py +110 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/models.py +146 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/report.py +36 -1
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/review_eval.py +70 -16
- attackmap-0.2.0/src/attackmap/sarif.py +231 -0
- attackmap-0.2.0/src/attackmap/sbom.py +384 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/scanner.py +278 -11
- attackmap-0.2.0/src/attackmap/sdk/__init__.py +105 -0
- attackmap-0.2.0/src/attackmap/suggest.py +386 -0
- attackmap-0.2.0/src/attackmap/taint.py +369 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/threat_model.py +494 -49
- attackmap-0.2.0/src/attackmap/topology.py +316 -0
- {attackmap-0.1.0 → attackmap-0.2.0/src/attackmap.egg-info}/PKG-INFO +130 -16
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap.egg-info/SOURCES.txt +23 -1
- attackmap-0.2.0/src/attackmap.egg-info/requires.txt +27 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/tests/test_analyzer.py +101 -0
- attackmap-0.2.0/tests/test_analyzer_metadata_schema.py +194 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/tests/test_analyzers.py +127 -10
- attackmap-0.2.0/tests/test_config_scanner.py +265 -0
- attackmap-0.2.0/tests/test_cve.py +403 -0
- attackmap-0.2.0/tests/test_diagrams.py +256 -0
- attackmap-0.2.0/tests/test_diff.py +368 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/tests/test_llm_review.py +38 -0
- attackmap-0.2.0/tests/test_merge.py +188 -0
- attackmap-0.2.0/tests/test_provenance.py +208 -0
- attackmap-0.2.0/tests/test_review_eval.py +165 -0
- attackmap-0.2.0/tests/test_sarif.py +219 -0
- attackmap-0.2.0/tests/test_sbom.py +250 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/tests/test_scanner.py +378 -0
- attackmap-0.2.0/tests/test_suggest.py +259 -0
- attackmap-0.2.0/tests/test_taint.py +205 -0
- attackmap-0.2.0/tests/test_threat_model.py +740 -0
- attackmap-0.2.0/tests/test_topology.py +220 -0
- attackmap-0.1.0/src/attackmap/analyzer_contracts.py +0 -104
- attackmap-0.1.0/src/attackmap/sdk/__init__.py +0 -41
- attackmap-0.1.0/src/attackmap.egg-info/requires.txt +0 -26
- attackmap-0.1.0/tests/test_review_eval.py +0 -68
- attackmap-0.1.0/tests/test_threat_model.py +0 -189
- {attackmap-0.1.0 → attackmap-0.2.0}/LICENSE +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/setup.cfg +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/asset_model.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/attack_taxonomy.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/context_pack.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/control_model.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/defensive_review.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/detection_opportunities.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/graph.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/insights.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/recon_models.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/recon_to_analysis.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/review_json.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/review_prompts.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/sdk/contracts.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/sdk/models.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap/security_overlay.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap.egg-info/dependency_links.txt +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap.egg-info/entry_points.txt +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/src/attackmap.egg-info/top_level.txt +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/tests/test_artifact_schemas.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/tests/test_context_pack.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/tests/test_defensive_review.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/tests/test_graph.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/tests/test_recon_to_analysis.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/tests/test_report.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/tests/test_review_json.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/tests/test_review_prompts.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/tests/test_security_overlay.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/tests/test_shared_contract_imports.py +0 -0
- {attackmap-0.1.0 → attackmap-0.2.0}/tests/test_signal_v2.py +0 -0
- {attackmap-0.1.0 → 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.
|
|
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>
|
|
@@ -38,19 +38,20 @@ Provides-Extra: llm
|
|
|
38
38
|
Requires-Dist: anthropic>=0.40.0; extra == "llm"
|
|
39
39
|
Provides-Extra: all
|
|
40
40
|
Requires-Dist: attackmap[llm]; extra == "all"
|
|
41
|
-
Requires-Dist: attackmap-analyzer-python; extra == "all"
|
|
42
|
-
Requires-Dist: attackmap-analyzer-rust; extra == "all"
|
|
43
|
-
Requires-Dist: attackmap-analyzer-go; extra == "all"
|
|
44
|
-
Requires-Dist: attackmap-analyzer-java-spring; extra == "all"
|
|
45
|
-
Requires-Dist: attackmap-analyzer-dotnet; extra == "all"
|
|
46
|
-
Requires-Dist: attackmap-analyzer-terraform; extra == "all"
|
|
47
|
-
Requires-Dist: attackmap-analyzer-c; extra == "all"
|
|
48
|
-
Requires-Dist: attackmap-analyzer-cpp; extra == "all"
|
|
49
|
-
Requires-Dist: attackmap-analyzer-node-service; extra == "all"
|
|
50
|
-
Requires-Dist: attackmap-analyzer-atproto; extra == "all"
|
|
51
|
-
Requires-Dist: attackmap-analyzer-php-web; extra == "all"
|
|
52
|
-
Requires-Dist: attackmap-analyzer-php-laminas; extra == "all"
|
|
53
|
-
Requires-Dist: attackmap-analyzer-omeka-s; extra == "all"
|
|
41
|
+
Requires-Dist: attackmap-analyzer-python>=0.1.0; extra == "all"
|
|
42
|
+
Requires-Dist: attackmap-analyzer-rust>=0.1.0; extra == "all"
|
|
43
|
+
Requires-Dist: attackmap-analyzer-go>=0.1.0; extra == "all"
|
|
44
|
+
Requires-Dist: attackmap-analyzer-java-spring>=0.1.0; extra == "all"
|
|
45
|
+
Requires-Dist: attackmap-analyzer-dotnet>=0.1.0; extra == "all"
|
|
46
|
+
Requires-Dist: attackmap-analyzer-terraform>=0.1.0; extra == "all"
|
|
47
|
+
Requires-Dist: attackmap-analyzer-c>=0.1.0; extra == "all"
|
|
48
|
+
Requires-Dist: attackmap-analyzer-cpp>=0.1.0; extra == "all"
|
|
49
|
+
Requires-Dist: attackmap-analyzer-node-service>=0.2.0; extra == "all"
|
|
50
|
+
Requires-Dist: attackmap-analyzer-atproto>=0.1.0; extra == "all"
|
|
51
|
+
Requires-Dist: attackmap-analyzer-php-web>=0.1.0; extra == "all"
|
|
52
|
+
Requires-Dist: attackmap-analyzer-php-laminas>=0.1.0; extra == "all"
|
|
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"
|
|
@@ -107,7 +108,7 @@ Read `reports/defensive-review.md` (heuristic) and `reports/defensive-review-llm
|
|
|
107
108
|
```bash
|
|
108
109
|
pip install attackmap # core only
|
|
109
110
|
pip install "attackmap[llm]" # add LLM narrative support
|
|
110
|
-
pip install "attackmap[all]" # core + LLM + all
|
|
111
|
+
pip install "attackmap[all]" # core + LLM + all 13 analyzer plugins
|
|
111
112
|
```
|
|
112
113
|
|
|
113
114
|
You can also install individual analyzer plugins on demand:
|
|
@@ -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
|
|
@@ -187,7 +292,7 @@ Layered on top: **MITRE ATT&CK technique mappings** on every insight and
|
|
|
187
292
|
|
|
188
293
|
## Supported ecosystems
|
|
189
294
|
|
|
190
|
-
|
|
295
|
+
Thirteen official analyzer plugins, each distributable as a separate package:
|
|
191
296
|
|
|
192
297
|
| Plugin | Coverage |
|
|
193
298
|
|---|---|
|
|
@@ -205,6 +310,13 @@ Eleven official analyzer plugins, each distributable as a separate package:
|
|
|
205
310
|
|
|
206
311
|
`pip install "attackmap[all]"` installs every official plugin.
|
|
207
312
|
|
|
313
|
+
### Building your own analyzer
|
|
314
|
+
|
|
315
|
+
The plugin contract is documented in code at
|
|
316
|
+
[`attackmap.sdk`](src/attackmap/sdk/__init__.py); the developer cookbook with
|
|
317
|
+
scaffolding, testing, and publishing instructions is in
|
|
318
|
+
[`docs/external-analyzers.md`](docs/external-analyzers.md).
|
|
319
|
+
|
|
208
320
|
---
|
|
209
321
|
|
|
210
322
|
## CLI reference
|
|
@@ -215,6 +327,8 @@ attackmap analyze <path> --output dir # write outputs to `dir/`
|
|
|
215
327
|
attackmap analyze <path> --module python --module rust # only these analyzers
|
|
216
328
|
attackmap analyze <path> --llm # add LLM narrative (auto-resolve auth)
|
|
217
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
|
|
218
332
|
attackmap modules # list installed analyzers
|
|
219
333
|
```
|
|
220
334
|
|
|
@@ -49,7 +49,7 @@ Read `reports/defensive-review.md` (heuristic) and `reports/defensive-review-llm
|
|
|
49
49
|
```bash
|
|
50
50
|
pip install attackmap # core only
|
|
51
51
|
pip install "attackmap[llm]" # add LLM narrative support
|
|
52
|
-
pip install "attackmap[all]" # core + LLM + all
|
|
52
|
+
pip install "attackmap[all]" # core + LLM + all 13 analyzer plugins
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
You can also install individual analyzer plugins on demand:
|
|
@@ -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
|
|
@@ -129,7 +233,7 @@ Layered on top: **MITRE ATT&CK technique mappings** on every insight and
|
|
|
129
233
|
|
|
130
234
|
## Supported ecosystems
|
|
131
235
|
|
|
132
|
-
|
|
236
|
+
Thirteen official analyzer plugins, each distributable as a separate package:
|
|
133
237
|
|
|
134
238
|
| Plugin | Coverage |
|
|
135
239
|
|---|---|
|
|
@@ -147,6 +251,13 @@ Eleven official analyzer plugins, each distributable as a separate package:
|
|
|
147
251
|
|
|
148
252
|
`pip install "attackmap[all]"` installs every official plugin.
|
|
149
253
|
|
|
254
|
+
### Building your own analyzer
|
|
255
|
+
|
|
256
|
+
The plugin contract is documented in code at
|
|
257
|
+
[`attackmap.sdk`](src/attackmap/sdk/__init__.py); the developer cookbook with
|
|
258
|
+
scaffolding, testing, and publishing instructions is in
|
|
259
|
+
[`docs/external-analyzers.md`](docs/external-analyzers.md).
|
|
260
|
+
|
|
150
261
|
---
|
|
151
262
|
|
|
152
263
|
## CLI reference
|
|
@@ -157,6 +268,8 @@ attackmap analyze <path> --output dir # write outputs to `dir/`
|
|
|
157
268
|
attackmap analyze <path> --module python --module rust # only these analyzers
|
|
158
269
|
attackmap analyze <path> --llm # add LLM narrative (auto-resolve auth)
|
|
159
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
|
|
160
273
|
attackmap modules # list installed analyzers
|
|
161
274
|
```
|
|
162
275
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "attackmap"
|
|
7
|
-
version = "0.
|
|
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"
|
|
@@ -59,19 +59,20 @@ llm = [
|
|
|
59
59
|
]
|
|
60
60
|
all = [
|
|
61
61
|
"attackmap[llm]",
|
|
62
|
-
"attackmap-analyzer-python",
|
|
63
|
-
"attackmap-analyzer-rust",
|
|
64
|
-
"attackmap-analyzer-go",
|
|
65
|
-
"attackmap-analyzer-java-spring",
|
|
66
|
-
"attackmap-analyzer-dotnet",
|
|
67
|
-
"attackmap-analyzer-terraform",
|
|
68
|
-
"attackmap-analyzer-c",
|
|
69
|
-
"attackmap-analyzer-cpp",
|
|
70
|
-
"attackmap-analyzer-node-service",
|
|
71
|
-
"attackmap-analyzer-atproto",
|
|
72
|
-
"attackmap-analyzer-php-web",
|
|
73
|
-
"attackmap-analyzer-php-laminas",
|
|
74
|
-
"attackmap-analyzer-omeka-s",
|
|
62
|
+
"attackmap-analyzer-python>=0.1.0",
|
|
63
|
+
"attackmap-analyzer-rust>=0.1.0",
|
|
64
|
+
"attackmap-analyzer-go>=0.1.0",
|
|
65
|
+
"attackmap-analyzer-java-spring>=0.1.0",
|
|
66
|
+
"attackmap-analyzer-dotnet>=0.1.0",
|
|
67
|
+
"attackmap-analyzer-terraform>=0.1.0",
|
|
68
|
+
"attackmap-analyzer-c>=0.1.0",
|
|
69
|
+
"attackmap-analyzer-cpp>=0.1.0",
|
|
70
|
+
"attackmap-analyzer-node-service>=0.2.0",
|
|
71
|
+
"attackmap-analyzer-atproto>=0.1.0",
|
|
72
|
+
"attackmap-analyzer-php-web>=0.1.0",
|
|
73
|
+
"attackmap-analyzer-php-laminas>=0.1.0",
|
|
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.
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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)
|