attackmap 0.4.5__tar.gz → 0.4.10__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of attackmap might be problematic. Click here for more details.
- {attackmap-0.4.5/src/attackmap.egg-info → attackmap-0.4.10}/PKG-INFO +95 -1
- {attackmap-0.4.5 → attackmap-0.4.10}/README.md +93 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/pyproject.toml +2 -1
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/__init__.py +1 -1
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/cli.py +37 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/exploitability.py +4 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/llm_review.py +38 -3
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/merge.py +4 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/models.py +37 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/report.py +20 -1
- attackmap-0.4.10/src/attackmap/route_auth_fusion.py +299 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/sarif.py +55 -36
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/scanner.py +7 -0
- attackmap-0.4.10/src/attackmap/suppress.py +418 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/taint.py +79 -8
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/threat_model.py +105 -1
- attackmap-0.4.10/src/attackmap/workflow_scanner.py +328 -0
- {attackmap-0.4.5 → attackmap-0.4.10/src/attackmap.egg-info}/PKG-INFO +95 -1
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap.egg-info/SOURCES.txt +8 -1
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap.egg-info/requires.txt +1 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_llm_review.py +29 -0
- attackmap-0.4.10/tests/test_route_auth_fusion.py +249 -0
- attackmap-0.4.10/tests/test_suppress.py +340 -0
- attackmap-0.4.10/tests/test_taint_sanitizer.py +122 -0
- attackmap-0.4.10/tests/test_workflow_scanner.py +416 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/LICENSE +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/setup.cfg +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/analyzer.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/analyzer_contracts.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/analyzers.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/anomalies.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/asset_model.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/attack_taxonomy.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/authz.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/config_scanner.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/context_pack.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/control_model.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/crypto.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/cve.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/defensive_review.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/detection_opportunities.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/diagrams.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/diff.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/graph.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/insights.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/progress.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/recon_models.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/recon_to_analysis.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/review_eval.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/review_json.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/review_prompts.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/sbom.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/sdk/__init__.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/sdk/contracts.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/sdk/models.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/security_overlay.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/srcpaths.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/suggest.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/topology.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/weaknesses.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/webhardening.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap.egg-info/dependency_links.txt +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap.egg-info/entry_points.txt +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap.egg-info/top_level.txt +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_analyzer.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_analyzer_metadata_schema.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_analyzers.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_anomalies.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_artifact_schemas.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_authz.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_config_scanner.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_context_pack.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_crypto.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_cve.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_defensive_review.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_diagrams.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_diff.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_exploitability.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_graph.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_hunt.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_injection_sinks.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_merge.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_modules_cli.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_pr_comment.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_progress.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_provenance.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_recon_to_analysis.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_remediate.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_report.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_review_eval.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_review_json.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_review_prompts.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_sarif.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_sbom.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_scanner.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_security_overlay.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_shared_contract_imports.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_signal_v2.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_srcpaths.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_suggest.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_taint.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_threat_intel.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_threat_model.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_topology.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_weaknesses.py +0 -0
- {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_webhardening.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: attackmap
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.10
|
|
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>
|
|
@@ -34,6 +34,7 @@ License-File: LICENSE
|
|
|
34
34
|
Requires-Dist: typer>=0.12.3
|
|
35
35
|
Requires-Dist: pydantic>=2.7.0
|
|
36
36
|
Requires-Dist: networkx>=3.3
|
|
37
|
+
Requires-Dist: PyYAML>=6.0
|
|
37
38
|
Provides-Extra: llm
|
|
38
39
|
Requires-Dist: anthropic>=0.40.0; extra == "llm"
|
|
39
40
|
Requires-Dist: openai>=1.60.0; extra == "llm"
|
|
@@ -271,6 +272,48 @@ on unrelated commits, so a finding that persists across scans has the same id
|
|
|
271
272
|
in both. The diff has three sections — **New**, **Persisted**, **Resolved** —
|
|
272
273
|
which drop cleanly into a PR comment.
|
|
273
274
|
|
|
275
|
+
### Suppressing findings
|
|
276
|
+
|
|
277
|
+
Silence a residual false positive without going blind to new signal. Two
|
|
278
|
+
mechanisms, both honored across every pass:
|
|
279
|
+
|
|
280
|
+
**Repo-level baseline** — `.attackmap-suppress.yaml` at the repo root:
|
|
281
|
+
|
|
282
|
+
```yaml
|
|
283
|
+
version: 1
|
|
284
|
+
suppress:
|
|
285
|
+
# by stable finding id (the 16-hex id in attackmap-report.json)
|
|
286
|
+
- id: 1a2b3c4d5e6f7a8b
|
|
287
|
+
reason: accepted risk, tracked in JIRA-1234
|
|
288
|
+
|
|
289
|
+
# by rule (a slug of the finding title — same string as the SARIF ruleId),
|
|
290
|
+
# optionally scoped to paths
|
|
291
|
+
- rule: hard-coded-secret-literals-were-found-in-source-or-config
|
|
292
|
+
reason: example keys in docs, never shipped
|
|
293
|
+
paths: ["docs/**", "examples/**"]
|
|
294
|
+
|
|
295
|
+
# by path only — any finding whose evidence is *entirely* within these globs
|
|
296
|
+
- path: "vendor/**"
|
|
297
|
+
reason: third-party code, out of scope
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
A `path` selector matches a finding only when **every** file its evidence cites
|
|
301
|
+
falls under the glob, so a finding that also touches live code is never hidden.
|
|
302
|
+
`*` spans path separators (`vendor/*` covers the whole subtree).
|
|
303
|
+
|
|
304
|
+
**Inline directives** — a comment on the flagged line, in any language:
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
API_KEY = "AKIA…" # attackmap:ignore[hard-coded-secret-literals-were-found-in-source-or-config] staging only
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Suppressed findings are **not dropped**: they're excluded from
|
|
311
|
+
`--fail-on-new-high` and the console summary, but retained in
|
|
312
|
+
`attackmap-report.json` under `suppressed_findings` (with reasons) and marked
|
|
313
|
+
with a SARIF `suppressions` array so GitHub Code Scanning shows them as
|
|
314
|
+
suppressed. Suppression counts print in the run summary. Use `--no-suppress`
|
|
315
|
+
for a full unfiltered audit, or `--suppress-file PATH` to point elsewhere.
|
|
316
|
+
|
|
274
317
|
### Data-flow / injection detection
|
|
275
318
|
|
|
276
319
|
A lightweight taint pass (Python + JS/TS) walks the import graph up to two hops
|
|
@@ -306,6 +349,19 @@ template is not flagged. It's a heuristic (import-edge ≠ call-edge), so findin
|
|
|
306
349
|
are evidence, not proof; confidence tapers with hop distance. Chains appear in
|
|
307
350
|
`attackmap-report.json` under `scan.taint_chains`.
|
|
308
351
|
|
|
352
|
+
**Sanitizer awareness.** When a sink-appropriate neutralizer is present in the
|
|
353
|
+
sink file — `shlex.quote` / `escapeshellarg` (shell), `secure_filename` / path
|
|
354
|
+
allow-listers (`open`), `is_safe_url` (redirect), `markupsafe.escape` (SSTI),
|
|
355
|
+
driver escapers (SQL), mongo-sanitize (NoSQL) — the chain is marked
|
|
356
|
+
`sanitized`, its confidence is downgraded well below the finding threshold, and
|
|
357
|
+
the neutralizer is recorded in `sanitizer_evidence`. Sanitized chains stay in
|
|
358
|
+
`scan.taint_chains` for audit but don't raise a HIGH finding or earn an
|
|
359
|
+
exploitability score. Detection is file-granular (matching the import-walk's
|
|
360
|
+
granularity), so it trades a little recall for precision. To extend the table,
|
|
361
|
+
add a high-signal, sink-appropriate `(label, regex)` entry to
|
|
362
|
+
`_SANITIZER_PATTERNS` in [`taint.py`](src/attackmap/taint.py) — keep it specific
|
|
363
|
+
(a vague `validate(` would hide real bugs).
|
|
364
|
+
|
|
309
365
|
Test and spec files (`tests/`, `__tests__/`, `*.test.*`, `test_*.py`, …) are
|
|
310
366
|
excluded from all the heuristic passes above by default, since dangerous
|
|
311
367
|
patterns in test scaffolding are rarely real exposure. Set
|
|
@@ -347,6 +403,40 @@ identifier; cipher/ECB tokens are matched case-sensitively so algorithm names
|
|
|
347
403
|
aren't confused with prose (e.g. the French word "des"). Results appear under
|
|
348
404
|
`scan.crypto_weaknesses`.
|
|
349
405
|
|
|
406
|
+
### GitHub Actions / CI workflow security
|
|
407
|
+
|
|
408
|
+
CI config is a real attack surface. AttackMap parses `.github/workflows/*.yml`
|
|
409
|
+
and flags supply-chain and code-execution risks, each a finding with an ATT&CK
|
|
410
|
+
mapping:
|
|
411
|
+
|
|
412
|
+
| Kind | Catches | Severity |
|
|
413
|
+
|---|---|---|
|
|
414
|
+
| `script_injection` | attacker-controlled context (`github.event.*.{title,body,message,…}`, `github.head_ref`) interpolated into a `run:` step | HIGH |
|
|
415
|
+
| `pr_target_checkout` | `pull_request_target` + checkout of the PR head ref (untrusted code with secrets in scope) | HIGH |
|
|
416
|
+
| `unpinned_action` | `uses: org/action@tag\|branch` instead of a pinned commit SHA | branch = MEDIUM, semver tag = LOW |
|
|
417
|
+
| `secret_in_run` | `${{ secrets.* }}` expanded into a shell step (pass via `env:` instead) | MEDIUM |
|
|
418
|
+
| `broad_permissions` | `permissions: write-all` at workflow or job level | MEDIUM |
|
|
419
|
+
| `self_hosted_pr` | self-hosted runner on a `pull_request`/`pull_request_target` trigger | HIGH / MEDIUM |
|
|
420
|
+
|
|
421
|
+
A hardened workflow — SHA-pinned actions, scoped `permissions:`, secrets via
|
|
422
|
+
`env:`, no untrusted-context interpolation — produces nothing. Results appear
|
|
423
|
+
under `scan.workflow_issues`.
|
|
424
|
+
|
|
425
|
+
### Unauthenticated state-changing routes
|
|
426
|
+
|
|
427
|
+
Raw `auth_hints` are signals, not conclusions — a monorepo emits hundreds. A
|
|
428
|
+
fusion pass turns them into one precise finding: the public, state-changing
|
|
429
|
+
(`POST/PUT/PATCH/DELETE`) routes with **no auth control on their own chain**. It
|
|
430
|
+
resolves the control *per route* rather than by file proximity:
|
|
431
|
+
|
|
432
|
+
- **Express/Koa/Fastify** — auth middleware in the route's arguments, or a global `app.use(...)` / `router.use(...)`.
|
|
433
|
+
- **FastAPI/Flask** — an auth decorator, `dependencies=[Depends(...)]`, `Depends(<auth>)` / `Security(...)` in the signature, or a router-level dependency.
|
|
434
|
+
- **Spring** — `@PreAuthorize` / `@Secured` / `@RolesAllowed` on the method or controller, or a global "authenticated" policy.
|
|
435
|
+
|
|
436
|
+
Routes behind a control produce nothing, and the sensitive categories
|
|
437
|
+
(webhook/admin/upload/auth) keep their own dedicated findings — so this only
|
|
438
|
+
adds the general mutating-endpoint case, with the resolved chain as evidence.
|
|
439
|
+
|
|
350
440
|
### Broken object-level authorization (BOLA / IDOR)
|
|
351
441
|
|
|
352
442
|
OWASP API Security #1. AttackMap flags a route as a BOLA/IDOR candidate when it
|
|
@@ -562,6 +652,10 @@ attackmap analyze <path> --pr-comment pr.md # Markdown PR summary comment for
|
|
|
562
652
|
attackmap analyze <path> --baseline prev/attackmap-report.json \
|
|
563
653
|
--diff-output reports/attackmap-diff.md --fail-on-new-high
|
|
564
654
|
|
|
655
|
+
# Suppression (.attackmap-suppress.yaml + inline `attackmap:ignore` directives)
|
|
656
|
+
attackmap analyze <path> --no-suppress # full audit, ignore all suppressions
|
|
657
|
+
attackmap analyze <path> --suppress-file cfg.yaml # override baseline location
|
|
658
|
+
|
|
565
659
|
# Plugin discovery
|
|
566
660
|
attackmap suggest ./repo # recommend plugins for a repo shape
|
|
567
661
|
attackmap suggest ./repo --install # and pip-install the missing ones
|
|
@@ -211,6 +211,48 @@ on unrelated commits, so a finding that persists across scans has the same id
|
|
|
211
211
|
in both. The diff has three sections — **New**, **Persisted**, **Resolved** —
|
|
212
212
|
which drop cleanly into a PR comment.
|
|
213
213
|
|
|
214
|
+
### Suppressing findings
|
|
215
|
+
|
|
216
|
+
Silence a residual false positive without going blind to new signal. Two
|
|
217
|
+
mechanisms, both honored across every pass:
|
|
218
|
+
|
|
219
|
+
**Repo-level baseline** — `.attackmap-suppress.yaml` at the repo root:
|
|
220
|
+
|
|
221
|
+
```yaml
|
|
222
|
+
version: 1
|
|
223
|
+
suppress:
|
|
224
|
+
# by stable finding id (the 16-hex id in attackmap-report.json)
|
|
225
|
+
- id: 1a2b3c4d5e6f7a8b
|
|
226
|
+
reason: accepted risk, tracked in JIRA-1234
|
|
227
|
+
|
|
228
|
+
# by rule (a slug of the finding title — same string as the SARIF ruleId),
|
|
229
|
+
# optionally scoped to paths
|
|
230
|
+
- rule: hard-coded-secret-literals-were-found-in-source-or-config
|
|
231
|
+
reason: example keys in docs, never shipped
|
|
232
|
+
paths: ["docs/**", "examples/**"]
|
|
233
|
+
|
|
234
|
+
# by path only — any finding whose evidence is *entirely* within these globs
|
|
235
|
+
- path: "vendor/**"
|
|
236
|
+
reason: third-party code, out of scope
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
A `path` selector matches a finding only when **every** file its evidence cites
|
|
240
|
+
falls under the glob, so a finding that also touches live code is never hidden.
|
|
241
|
+
`*` spans path separators (`vendor/*` covers the whole subtree).
|
|
242
|
+
|
|
243
|
+
**Inline directives** — a comment on the flagged line, in any language:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
API_KEY = "AKIA…" # attackmap:ignore[hard-coded-secret-literals-were-found-in-source-or-config] staging only
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Suppressed findings are **not dropped**: they're excluded from
|
|
250
|
+
`--fail-on-new-high` and the console summary, but retained in
|
|
251
|
+
`attackmap-report.json` under `suppressed_findings` (with reasons) and marked
|
|
252
|
+
with a SARIF `suppressions` array so GitHub Code Scanning shows them as
|
|
253
|
+
suppressed. Suppression counts print in the run summary. Use `--no-suppress`
|
|
254
|
+
for a full unfiltered audit, or `--suppress-file PATH` to point elsewhere.
|
|
255
|
+
|
|
214
256
|
### Data-flow / injection detection
|
|
215
257
|
|
|
216
258
|
A lightweight taint pass (Python + JS/TS) walks the import graph up to two hops
|
|
@@ -246,6 +288,19 @@ template is not flagged. It's a heuristic (import-edge ≠ call-edge), so findin
|
|
|
246
288
|
are evidence, not proof; confidence tapers with hop distance. Chains appear in
|
|
247
289
|
`attackmap-report.json` under `scan.taint_chains`.
|
|
248
290
|
|
|
291
|
+
**Sanitizer awareness.** When a sink-appropriate neutralizer is present in the
|
|
292
|
+
sink file — `shlex.quote` / `escapeshellarg` (shell), `secure_filename` / path
|
|
293
|
+
allow-listers (`open`), `is_safe_url` (redirect), `markupsafe.escape` (SSTI),
|
|
294
|
+
driver escapers (SQL), mongo-sanitize (NoSQL) — the chain is marked
|
|
295
|
+
`sanitized`, its confidence is downgraded well below the finding threshold, and
|
|
296
|
+
the neutralizer is recorded in `sanitizer_evidence`. Sanitized chains stay in
|
|
297
|
+
`scan.taint_chains` for audit but don't raise a HIGH finding or earn an
|
|
298
|
+
exploitability score. Detection is file-granular (matching the import-walk's
|
|
299
|
+
granularity), so it trades a little recall for precision. To extend the table,
|
|
300
|
+
add a high-signal, sink-appropriate `(label, regex)` entry to
|
|
301
|
+
`_SANITIZER_PATTERNS` in [`taint.py`](src/attackmap/taint.py) — keep it specific
|
|
302
|
+
(a vague `validate(` would hide real bugs).
|
|
303
|
+
|
|
249
304
|
Test and spec files (`tests/`, `__tests__/`, `*.test.*`, `test_*.py`, …) are
|
|
250
305
|
excluded from all the heuristic passes above by default, since dangerous
|
|
251
306
|
patterns in test scaffolding are rarely real exposure. Set
|
|
@@ -287,6 +342,40 @@ identifier; cipher/ECB tokens are matched case-sensitively so algorithm names
|
|
|
287
342
|
aren't confused with prose (e.g. the French word "des"). Results appear under
|
|
288
343
|
`scan.crypto_weaknesses`.
|
|
289
344
|
|
|
345
|
+
### GitHub Actions / CI workflow security
|
|
346
|
+
|
|
347
|
+
CI config is a real attack surface. AttackMap parses `.github/workflows/*.yml`
|
|
348
|
+
and flags supply-chain and code-execution risks, each a finding with an ATT&CK
|
|
349
|
+
mapping:
|
|
350
|
+
|
|
351
|
+
| Kind | Catches | Severity |
|
|
352
|
+
|---|---|---|
|
|
353
|
+
| `script_injection` | attacker-controlled context (`github.event.*.{title,body,message,…}`, `github.head_ref`) interpolated into a `run:` step | HIGH |
|
|
354
|
+
| `pr_target_checkout` | `pull_request_target` + checkout of the PR head ref (untrusted code with secrets in scope) | HIGH |
|
|
355
|
+
| `unpinned_action` | `uses: org/action@tag\|branch` instead of a pinned commit SHA | branch = MEDIUM, semver tag = LOW |
|
|
356
|
+
| `secret_in_run` | `${{ secrets.* }}` expanded into a shell step (pass via `env:` instead) | MEDIUM |
|
|
357
|
+
| `broad_permissions` | `permissions: write-all` at workflow or job level | MEDIUM |
|
|
358
|
+
| `self_hosted_pr` | self-hosted runner on a `pull_request`/`pull_request_target` trigger | HIGH / MEDIUM |
|
|
359
|
+
|
|
360
|
+
A hardened workflow — SHA-pinned actions, scoped `permissions:`, secrets via
|
|
361
|
+
`env:`, no untrusted-context interpolation — produces nothing. Results appear
|
|
362
|
+
under `scan.workflow_issues`.
|
|
363
|
+
|
|
364
|
+
### Unauthenticated state-changing routes
|
|
365
|
+
|
|
366
|
+
Raw `auth_hints` are signals, not conclusions — a monorepo emits hundreds. A
|
|
367
|
+
fusion pass turns them into one precise finding: the public, state-changing
|
|
368
|
+
(`POST/PUT/PATCH/DELETE`) routes with **no auth control on their own chain**. It
|
|
369
|
+
resolves the control *per route* rather than by file proximity:
|
|
370
|
+
|
|
371
|
+
- **Express/Koa/Fastify** — auth middleware in the route's arguments, or a global `app.use(...)` / `router.use(...)`.
|
|
372
|
+
- **FastAPI/Flask** — an auth decorator, `dependencies=[Depends(...)]`, `Depends(<auth>)` / `Security(...)` in the signature, or a router-level dependency.
|
|
373
|
+
- **Spring** — `@PreAuthorize` / `@Secured` / `@RolesAllowed` on the method or controller, or a global "authenticated" policy.
|
|
374
|
+
|
|
375
|
+
Routes behind a control produce nothing, and the sensitive categories
|
|
376
|
+
(webhook/admin/upload/auth) keep their own dedicated findings — so this only
|
|
377
|
+
adds the general mutating-endpoint case, with the resolved chain as evidence.
|
|
378
|
+
|
|
290
379
|
### Broken object-level authorization (BOLA / IDOR)
|
|
291
380
|
|
|
292
381
|
OWASP API Security #1. AttackMap flags a route as a BOLA/IDOR candidate when it
|
|
@@ -502,6 +591,10 @@ attackmap analyze <path> --pr-comment pr.md # Markdown PR summary comment for
|
|
|
502
591
|
attackmap analyze <path> --baseline prev/attackmap-report.json \
|
|
503
592
|
--diff-output reports/attackmap-diff.md --fail-on-new-high
|
|
504
593
|
|
|
594
|
+
# Suppression (.attackmap-suppress.yaml + inline `attackmap:ignore` directives)
|
|
595
|
+
attackmap analyze <path> --no-suppress # full audit, ignore all suppressions
|
|
596
|
+
attackmap analyze <path> --suppress-file cfg.yaml # override baseline location
|
|
597
|
+
|
|
505
598
|
# Plugin discovery
|
|
506
599
|
attackmap suggest ./repo # recommend plugins for a repo shape
|
|
507
600
|
attackmap suggest ./repo --install # and pip-install the missing ones
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "attackmap"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.10"
|
|
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"
|
|
@@ -51,6 +51,7 @@ dependencies = [
|
|
|
51
51
|
"typer>=0.12.3",
|
|
52
52
|
"pydantic>=2.7.0",
|
|
53
53
|
"networkx>=3.3",
|
|
54
|
+
"PyYAML>=6.0",
|
|
54
55
|
]
|
|
55
56
|
|
|
56
57
|
[project.optional-dependencies]
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
__all__ = ["__version__"]
|
|
2
|
-
__version__ = "0.4.
|
|
2
|
+
__version__ = "0.4.10"
|
|
@@ -29,6 +29,7 @@ from .progress import create_progress
|
|
|
29
29
|
from .recon_to_analysis import translate_recon
|
|
30
30
|
from .report import render_console_summary, render_pr_comment, write_reports
|
|
31
31
|
from .suggest import detect_ecosystems
|
|
32
|
+
from .suppress import apply_suppressions, collect_suppressions
|
|
32
33
|
|
|
33
34
|
app = typer.Typer(help="AttackMap: understand your system and map your attack surface.")
|
|
34
35
|
|
|
@@ -144,6 +145,16 @@ def analyze(
|
|
|
144
145
|
"--pr-comment",
|
|
145
146
|
help="Write a Markdown PR summary comment to this path (for the GitHub Action / bot to post). Includes the baseline diff and top exploitability when available.",
|
|
146
147
|
),
|
|
148
|
+
no_suppress: bool = typer.Option(
|
|
149
|
+
False,
|
|
150
|
+
"--no-suppress",
|
|
151
|
+
help="Ignore all suppressions — the .attackmap-suppress.yaml baseline and inline `attackmap:ignore` directives (#144). Use for a full, unfiltered audit.",
|
|
152
|
+
),
|
|
153
|
+
suppress_file: str | None = typer.Option(
|
|
154
|
+
None,
|
|
155
|
+
"--suppress-file",
|
|
156
|
+
help="Path to a suppression baseline, overriding auto-discovery of .attackmap-suppress.yaml at the repo root (#144).",
|
|
157
|
+
),
|
|
147
158
|
) -> None:
|
|
148
159
|
repo_path = Path(path).resolve()
|
|
149
160
|
if not repo_path.exists():
|
|
@@ -201,6 +212,31 @@ def analyze(
|
|
|
201
212
|
attack_surface_md = summarize_attack_surface(scan, attack_surfaces)
|
|
202
213
|
findings = analysis.findings
|
|
203
214
|
attack_paths = analysis.attack_paths
|
|
215
|
+
|
|
216
|
+
# Finding suppression (#144): the .attackmap-suppress.yaml baseline plus
|
|
217
|
+
# inline `attackmap:ignore` directives. Suppressed findings are partitioned
|
|
218
|
+
# out of the active set used for the review, console, and the diff gate —
|
|
219
|
+
# but retained (with reasons) in report.json and marked suppressed in SARIF.
|
|
220
|
+
suppressed_findings: list = []
|
|
221
|
+
if no_suppress:
|
|
222
|
+
if suppress_file is not None:
|
|
223
|
+
typer.echo("Note: --suppress-file is ignored because --no-suppress is set.", err=True)
|
|
224
|
+
else:
|
|
225
|
+
explicit = Path(suppress_file) if suppress_file else None
|
|
226
|
+
if explicit is not None and not explicit.exists():
|
|
227
|
+
raise typer.BadParameter(f"Suppress file not found: {explicit}")
|
|
228
|
+
suppset, sup_warnings = collect_suppressions(repo_path, findings, explicit_file=explicit)
|
|
229
|
+
for warning in sup_warnings:
|
|
230
|
+
typer.echo(f"Suppression warning: {warning}", err=True)
|
|
231
|
+
outcome = apply_suppressions(findings, suppset)
|
|
232
|
+
findings = outcome.active
|
|
233
|
+
suppressed_findings = outcome.suppressed
|
|
234
|
+
if suppressed_findings:
|
|
235
|
+
typer.echo("")
|
|
236
|
+
typer.echo(f"Suppressed {len(suppressed_findings)} finding(s) (#144):")
|
|
237
|
+
for summary_line in outcome.summary_lines():
|
|
238
|
+
typer.echo(summary_line)
|
|
239
|
+
|
|
204
240
|
defensive_review_md = render_defensive_review(scan, attack_surfaces, findings, attack_paths)
|
|
205
241
|
|
|
206
242
|
write_reports(
|
|
@@ -221,6 +257,7 @@ def analyze(
|
|
|
221
257
|
}
|
|
222
258
|
for metadata in (get_analyzer_metadata(analyzer) for analyzer in active_analyzers)
|
|
223
259
|
],
|
|
260
|
+
suppressed=suppressed_findings,
|
|
224
261
|
)
|
|
225
262
|
typer.echo(render_console_summary(scan, findings, attack_paths))
|
|
226
263
|
typer.echo("")
|
|
@@ -128,6 +128,10 @@ def score_exploitability(
|
|
|
128
128
|
|
|
129
129
|
scores: list[ExploitabilityScore] = []
|
|
130
130
|
for chain in scan.taint_chains:
|
|
131
|
+
# Sanitized chains (#137) are neutralized before the sink — not
|
|
132
|
+
# "exploitable now", so they don't earn an exploitability score.
|
|
133
|
+
if chain.sanitized:
|
|
134
|
+
continue
|
|
131
135
|
# Skip conventional infra/static routes — a SQL/RCE sink "reachable"
|
|
132
136
|
# from robots.txt or /_health is over-linking, not an exploit.
|
|
133
137
|
if is_infra_route(chain.route_path):
|
|
@@ -197,6 +197,14 @@ def _claude_cli_available() -> bool:
|
|
|
197
197
|
return shutil.which("claude") is not None
|
|
198
198
|
|
|
199
199
|
|
|
200
|
+
def _anthropic_sdk_available() -> bool:
|
|
201
|
+
try:
|
|
202
|
+
import anthropic # noqa: F401
|
|
203
|
+
except ImportError:
|
|
204
|
+
return False
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
|
|
200
208
|
def _run_via_claude_cli(
|
|
201
209
|
rendered_system: str,
|
|
202
210
|
rendered_user: str,
|
|
@@ -412,6 +420,14 @@ def _codex_cli_available() -> bool:
|
|
|
412
420
|
return shutil.which("codex") is not None
|
|
413
421
|
|
|
414
422
|
|
|
423
|
+
def _openai_sdk_available() -> bool:
|
|
424
|
+
try:
|
|
425
|
+
import openai # noqa: F401
|
|
426
|
+
except ImportError:
|
|
427
|
+
return False
|
|
428
|
+
return True
|
|
429
|
+
|
|
430
|
+
|
|
415
431
|
def _run_via_codex_cli(
|
|
416
432
|
rendered_system: str,
|
|
417
433
|
rendered_user: str,
|
|
@@ -514,10 +530,18 @@ def _resolve_openai_backend(
|
|
|
514
530
|
)
|
|
515
531
|
return "cli"
|
|
516
532
|
# auto
|
|
517
|
-
if client is not None
|
|
533
|
+
if client is not None:
|
|
534
|
+
return "api"
|
|
535
|
+
has_key = bool(api_key or os.environ.get("OPENAI_API_KEY"))
|
|
536
|
+
# Prefer the API backend only when the openai SDK is importable; otherwise
|
|
537
|
+
# fall back to the `codex` CLI (needs no SDK) so a key set against an
|
|
538
|
+
# SDK-less install still works via subscription auth.
|
|
539
|
+
if has_key and _openai_sdk_available():
|
|
518
540
|
return "api"
|
|
519
541
|
if _codex_cli_available():
|
|
520
542
|
return "cli"
|
|
543
|
+
if has_key:
|
|
544
|
+
return "api" # no SDK and no CLI — surface the actionable SDK-missing error
|
|
521
545
|
raise LlmReviewError(
|
|
522
546
|
"No OpenAI backend available. Set OPENAI_API_KEY, or install the `codex` "
|
|
523
547
|
"CLI and run `codex login`."
|
|
@@ -539,12 +563,23 @@ def _resolve_backend(
|
|
|
539
563
|
)
|
|
540
564
|
return "cli"
|
|
541
565
|
# auto
|
|
542
|
-
if client is not None
|
|
566
|
+
if client is not None:
|
|
543
567
|
return "api"
|
|
544
|
-
|
|
568
|
+
has_key = bool(
|
|
569
|
+
api_key
|
|
570
|
+
or os.environ.get("ANTHROPIC_API_KEY")
|
|
571
|
+
or os.environ.get("ANTHROPIC_AUTH_TOKEN")
|
|
572
|
+
)
|
|
573
|
+
# Prefer the API backend only when the SDK is actually importable. Otherwise
|
|
574
|
+
# fall back to the `claude` CLI (needs no SDK), so a key set against an
|
|
575
|
+
# SDK-less install (e.g. Homebrew, which doesn't vendor `attackmap[llm]`)
|
|
576
|
+
# still works via subscription auth instead of erroring.
|
|
577
|
+
if has_key and _anthropic_sdk_available():
|
|
545
578
|
return "api"
|
|
546
579
|
if _claude_cli_available():
|
|
547
580
|
return "cli"
|
|
581
|
+
if has_key:
|
|
582
|
+
return "api" # no SDK and no CLI — surface the actionable SDK-missing error
|
|
548
583
|
raise LlmReviewError(
|
|
549
584
|
"No LLM backend available. Set ANTHROPIC_API_KEY, set ANTHROPIC_AUTH_TOKEN, "
|
|
550
585
|
"or install the `claude` CLI and run `claude login`."
|
|
@@ -96,6 +96,10 @@ MERGE_SCHEMA: tuple[MergeRule, ...] = (
|
|
|
96
96
|
"code_weaknesses",
|
|
97
97
|
lambda item: (item.kind, item.file, item.line),
|
|
98
98
|
),
|
|
99
|
+
MergeRule(
|
|
100
|
+
"workflow_issues",
|
|
101
|
+
lambda item: (item.kind, item.file, item.line, item.context),
|
|
102
|
+
),
|
|
99
103
|
MergeRule(
|
|
100
104
|
"anomalies",
|
|
101
105
|
lambda item: (item.kind, item.route_method, item.route_path, item.route_file),
|
|
@@ -203,6 +203,35 @@ class WebHardeningIssue(BaseModel):
|
|
|
203
203
|
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
204
204
|
|
|
205
205
|
|
|
206
|
+
class WorkflowIssue(BaseModel):
|
|
207
|
+
"""A CI-workflow security issue in a GitHub Actions file (#142).
|
|
208
|
+
|
|
209
|
+
Emitted by the built-in workflow scanner over ``.github/workflows/*.yml``.
|
|
210
|
+
Every issue is a positively-present misconfiguration (an unpinned action,
|
|
211
|
+
an untrusted-code checkout under ``pull_request_target``, a secret or an
|
|
212
|
+
attacker-controlled context interpolated into a shell step, ``write-all``
|
|
213
|
+
permissions, a self-hosted runner on a PR trigger) — a hardened workflow
|
|
214
|
+
produces nothing.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
kind: Literal[
|
|
218
|
+
"unpinned_action",
|
|
219
|
+
"pr_target_checkout",
|
|
220
|
+
"secret_in_run",
|
|
221
|
+
"script_injection",
|
|
222
|
+
"broad_permissions",
|
|
223
|
+
"self_hosted_pr",
|
|
224
|
+
]
|
|
225
|
+
file: str
|
|
226
|
+
line: int | None = None
|
|
227
|
+
# Human-readable location within the workflow (job / step) so evidence
|
|
228
|
+
# points somewhere actionable even when a precise line isn't available.
|
|
229
|
+
context: str | None = None
|
|
230
|
+
evidence_text: str | None = None
|
|
231
|
+
severity: Literal["low", "medium", "high"] = "medium"
|
|
232
|
+
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
233
|
+
|
|
234
|
+
|
|
206
235
|
class CryptoWeakness(BaseModel):
|
|
207
236
|
"""An insecure-cryptography or weak-randomness usage (#70).
|
|
208
237
|
|
|
@@ -279,6 +308,13 @@ class TaintChain(BaseModel):
|
|
|
279
308
|
files: list[str] = Field(default_factory=list)
|
|
280
309
|
evidence_text: str | None = None
|
|
281
310
|
confidence: float = 0.6
|
|
311
|
+
# Sanitizer awareness (#137). When a known neutralizer for this sink kind
|
|
312
|
+
# is present in the sink file (escaper/validator/parameter-binder/
|
|
313
|
+
# allow-lister), the chain is marked sanitized and its confidence is
|
|
314
|
+
# downgraded — the tainted value is likely neutralized before the sink.
|
|
315
|
+
# `sanitizer_evidence` names the neutralizer so the chain stays auditable.
|
|
316
|
+
sanitized: bool = False
|
|
317
|
+
sanitizer_evidence: str | None = None
|
|
282
318
|
source_analyzer: str | None = _PROVENANCE_FIELD
|
|
283
319
|
|
|
284
320
|
|
|
@@ -548,6 +584,7 @@ class ScanResult(BaseModel):
|
|
|
548
584
|
crypto_weaknesses: list[CryptoWeakness] = Field(default_factory=list)
|
|
549
585
|
web_hardening_issues: list[WebHardeningIssue] = Field(default_factory=list)
|
|
550
586
|
code_weaknesses: list[CodeWeakness] = Field(default_factory=list)
|
|
587
|
+
workflow_issues: list[WorkflowIssue] = Field(default_factory=list)
|
|
551
588
|
anomalies: list[Anomaly] = Field(default_factory=list)
|
|
552
589
|
files_scanned: int = 0
|
|
553
590
|
|
|
@@ -15,6 +15,7 @@ from .exploitability import score_exploitability
|
|
|
15
15
|
from .models import AttackPath, AttackSurface, ExploitabilityScore, Finding, ScanResult
|
|
16
16
|
from .review_json import build_defensive_review_json
|
|
17
17
|
from .sarif import build_sarif
|
|
18
|
+
from .suppress import SuppressedFinding
|
|
18
19
|
from .topology import build_service_graph
|
|
19
20
|
|
|
20
21
|
|
|
@@ -32,9 +33,11 @@ def write_reports(
|
|
|
32
33
|
findings: list[Finding],
|
|
33
34
|
attack_paths: list[AttackPath],
|
|
34
35
|
analyzer_metadata: list[dict[str, object]] | None = None,
|
|
36
|
+
suppressed: list[SuppressedFinding] | None = None,
|
|
35
37
|
) -> None:
|
|
36
38
|
out = Path(output_dir)
|
|
37
39
|
out.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
suppressed = suppressed or []
|
|
38
41
|
|
|
39
42
|
(out / "architecture.md").write_text(architecture_md + "\n", encoding="utf-8")
|
|
40
43
|
(out / "attack-surface.md").write_text(attack_surface_md + "\n", encoding="utf-8")
|
|
@@ -64,6 +67,18 @@ def write_reports(
|
|
|
64
67
|
"findings": [
|
|
65
68
|
{"id": finding_id(finding.title), **finding.model_dump()} for finding in findings
|
|
66
69
|
],
|
|
70
|
+
# Suppressed findings are retained (not dropped) with their reason so
|
|
71
|
+
# audits can see what was silenced and why (#144).
|
|
72
|
+
"suppressed_findings": [
|
|
73
|
+
{
|
|
74
|
+
"id": s.id,
|
|
75
|
+
"rule": s.rule,
|
|
76
|
+
"reason": s.reason,
|
|
77
|
+
"suppressed_by": [m.label() for m in s.matched],
|
|
78
|
+
**s.finding.model_dump(),
|
|
79
|
+
}
|
|
80
|
+
for s in suppressed
|
|
81
|
+
],
|
|
67
82
|
"attack_paths": [path.model_dump() for path in attack_paths],
|
|
68
83
|
"exploitability": [score.model_dump() for score in exploitability],
|
|
69
84
|
}
|
|
@@ -71,7 +86,11 @@ def write_reports(
|
|
|
71
86
|
|
|
72
87
|
# SARIF 2.1.0 for GitHub Code Scanning / VS Code / other SARIF
|
|
73
88
|
# consumers. Emitted alongside JSON, not in place of it.
|
|
74
|
-
sarif_report = build_sarif(
|
|
89
|
+
sarif_report = build_sarif(
|
|
90
|
+
findings,
|
|
91
|
+
attack_paths,
|
|
92
|
+
suppressed=[(s.finding, s.reason) for s in suppressed],
|
|
93
|
+
)
|
|
75
94
|
(out / "attackmap-report.sarif").write_text(
|
|
76
95
|
json.dumps(sarif_report, indent=2) + "\n", encoding="utf-8"
|
|
77
96
|
)
|