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.

Files changed (106) hide show
  1. {attackmap-0.4.5/src/attackmap.egg-info → attackmap-0.4.10}/PKG-INFO +95 -1
  2. {attackmap-0.4.5 → attackmap-0.4.10}/README.md +93 -0
  3. {attackmap-0.4.5 → attackmap-0.4.10}/pyproject.toml +2 -1
  4. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/__init__.py +1 -1
  5. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/cli.py +37 -0
  6. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/exploitability.py +4 -0
  7. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/llm_review.py +38 -3
  8. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/merge.py +4 -0
  9. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/models.py +37 -0
  10. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/report.py +20 -1
  11. attackmap-0.4.10/src/attackmap/route_auth_fusion.py +299 -0
  12. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/sarif.py +55 -36
  13. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/scanner.py +7 -0
  14. attackmap-0.4.10/src/attackmap/suppress.py +418 -0
  15. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/taint.py +79 -8
  16. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/threat_model.py +105 -1
  17. attackmap-0.4.10/src/attackmap/workflow_scanner.py +328 -0
  18. {attackmap-0.4.5 → attackmap-0.4.10/src/attackmap.egg-info}/PKG-INFO +95 -1
  19. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap.egg-info/SOURCES.txt +8 -1
  20. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap.egg-info/requires.txt +1 -0
  21. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_llm_review.py +29 -0
  22. attackmap-0.4.10/tests/test_route_auth_fusion.py +249 -0
  23. attackmap-0.4.10/tests/test_suppress.py +340 -0
  24. attackmap-0.4.10/tests/test_taint_sanitizer.py +122 -0
  25. attackmap-0.4.10/tests/test_workflow_scanner.py +416 -0
  26. {attackmap-0.4.5 → attackmap-0.4.10}/LICENSE +0 -0
  27. {attackmap-0.4.5 → attackmap-0.4.10}/setup.cfg +0 -0
  28. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/analyzer.py +0 -0
  29. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/analyzer_contracts.py +0 -0
  30. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/analyzers.py +0 -0
  31. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/anomalies.py +0 -0
  32. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/asset_model.py +0 -0
  33. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/attack_taxonomy.py +0 -0
  34. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/authz.py +0 -0
  35. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/config_scanner.py +0 -0
  36. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/context_pack.py +0 -0
  37. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/control_model.py +0 -0
  38. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/crypto.py +0 -0
  39. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/cve.py +0 -0
  40. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/defensive_review.py +0 -0
  41. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/detection_opportunities.py +0 -0
  42. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/diagrams.py +0 -0
  43. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/diff.py +0 -0
  44. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/graph.py +0 -0
  45. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/insights.py +0 -0
  46. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/progress.py +0 -0
  47. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/recon_models.py +0 -0
  48. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/recon_to_analysis.py +0 -0
  49. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/review_eval.py +0 -0
  50. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/review_json.py +0 -0
  51. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/review_prompts.py +0 -0
  52. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/sbom.py +0 -0
  53. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/sdk/__init__.py +0 -0
  54. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/sdk/contracts.py +0 -0
  55. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/sdk/models.py +0 -0
  56. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/security_overlay.py +0 -0
  57. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/srcpaths.py +0 -0
  58. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/suggest.py +0 -0
  59. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/topology.py +0 -0
  60. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/weaknesses.py +0 -0
  61. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap/webhardening.py +0 -0
  62. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap.egg-info/dependency_links.txt +0 -0
  63. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap.egg-info/entry_points.txt +0 -0
  64. {attackmap-0.4.5 → attackmap-0.4.10}/src/attackmap.egg-info/top_level.txt +0 -0
  65. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_analyzer.py +0 -0
  66. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_analyzer_metadata_schema.py +0 -0
  67. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_analyzers.py +0 -0
  68. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_anomalies.py +0 -0
  69. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_artifact_schemas.py +0 -0
  70. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_authz.py +0 -0
  71. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_config_scanner.py +0 -0
  72. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_context_pack.py +0 -0
  73. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_crypto.py +0 -0
  74. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_cve.py +0 -0
  75. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_defensive_review.py +0 -0
  76. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_diagrams.py +0 -0
  77. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_diff.py +0 -0
  78. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_exploitability.py +0 -0
  79. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_graph.py +0 -0
  80. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_hunt.py +0 -0
  81. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_injection_sinks.py +0 -0
  82. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_merge.py +0 -0
  83. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_modules_cli.py +0 -0
  84. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_pr_comment.py +0 -0
  85. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_progress.py +0 -0
  86. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_provenance.py +0 -0
  87. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_recon_to_analysis.py +0 -0
  88. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_remediate.py +0 -0
  89. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_report.py +0 -0
  90. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_review_eval.py +0 -0
  91. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_review_json.py +0 -0
  92. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_review_prompts.py +0 -0
  93. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_sarif.py +0 -0
  94. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_sbom.py +0 -0
  95. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_scanner.py +0 -0
  96. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_security_overlay.py +0 -0
  97. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_shared_contract_imports.py +0 -0
  98. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_signal_v2.py +0 -0
  99. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_srcpaths.py +0 -0
  100. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_suggest.py +0 -0
  101. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_taint.py +0 -0
  102. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_threat_intel.py +0 -0
  103. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_threat_model.py +0 -0
  104. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_topology.py +0 -0
  105. {attackmap-0.4.5 → attackmap-0.4.10}/tests/test_weaknesses.py +0 -0
  106. {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.5
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.5"
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.5"
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 or api_key or os.environ.get("OPENAI_API_KEY"):
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 or api_key:
566
+ if client is not None:
543
567
  return "api"
544
- if os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN"):
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(findings, attack_paths)
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
  )