attackmap 0.1.0__tar.gz → 0.1.1__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.1.1}/PKG-INFO +23 -16
- {attackmap-0.1.0 → attackmap-0.1.1}/README.md +9 -2
- {attackmap-0.1.0 → attackmap-0.1.1}/pyproject.toml +14 -14
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/analyzers.py +24 -56
- attackmap-0.1.1/src/attackmap/merge.py +91 -0
- attackmap-0.1.1/src/attackmap/sdk/__init__.py +105 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/threat_model.py +101 -34
- {attackmap-0.1.0 → attackmap-0.1.1/src/attackmap.egg-info}/PKG-INFO +23 -16
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap.egg-info/SOURCES.txt +2 -0
- attackmap-0.1.1/src/attackmap.egg-info/requires.txt +26 -0
- attackmap-0.1.1/tests/test_merge.py +188 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_threat_model.py +180 -1
- 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 → attackmap-0.1.1}/LICENSE +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/setup.cfg +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/__init__.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/analyzer.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/analyzer_contracts.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/asset_model.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/attack_taxonomy.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/cli.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/context_pack.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/control_model.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/defensive_review.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/detection_opportunities.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/graph.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/insights.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/llm_review.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/models.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/recon_models.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/recon_to_analysis.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/report.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/review_eval.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/review_json.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/review_prompts.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/scanner.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/sdk/contracts.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/sdk/models.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap/security_overlay.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap.egg-info/dependency_links.txt +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap.egg-info/entry_points.txt +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/src/attackmap.egg-info/top_level.txt +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_analyzer.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_analyzers.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_artifact_schemas.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_context_pack.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_defensive_review.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_graph.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_llm_review.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_recon_to_analysis.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_report.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_review_eval.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_review_json.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_review_prompts.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_scanner.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_security_overlay.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_shared_contract_imports.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_signal_v2.py +0 -0
- {attackmap-0.1.0 → attackmap-0.1.1}/tests/test_threat_intel.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: attackmap
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
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,19 @@ 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
54
|
Provides-Extra: dev
|
|
55
55
|
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
56
56
|
Requires-Dist: build>=1.2.0; extra == "dev"
|
|
@@ -107,7 +107,7 @@ Read `reports/defensive-review.md` (heuristic) and `reports/defensive-review-llm
|
|
|
107
107
|
```bash
|
|
108
108
|
pip install attackmap # core only
|
|
109
109
|
pip install "attackmap[llm]" # add LLM narrative support
|
|
110
|
-
pip install "attackmap[all]" # core + LLM + all
|
|
110
|
+
pip install "attackmap[all]" # core + LLM + all 13 analyzer plugins
|
|
111
111
|
```
|
|
112
112
|
|
|
113
113
|
You can also install individual analyzer plugins on demand:
|
|
@@ -187,7 +187,7 @@ Layered on top: **MITRE ATT&CK technique mappings** on every insight and
|
|
|
187
187
|
|
|
188
188
|
## Supported ecosystems
|
|
189
189
|
|
|
190
|
-
|
|
190
|
+
Thirteen official analyzer plugins, each distributable as a separate package:
|
|
191
191
|
|
|
192
192
|
| Plugin | Coverage |
|
|
193
193
|
|---|---|
|
|
@@ -205,6 +205,13 @@ Eleven official analyzer plugins, each distributable as a separate package:
|
|
|
205
205
|
|
|
206
206
|
`pip install "attackmap[all]"` installs every official plugin.
|
|
207
207
|
|
|
208
|
+
### Building your own analyzer
|
|
209
|
+
|
|
210
|
+
The plugin contract is documented in code at
|
|
211
|
+
[`attackmap.sdk`](src/attackmap/sdk/__init__.py); the developer cookbook with
|
|
212
|
+
scaffolding, testing, and publishing instructions is in
|
|
213
|
+
[`docs/external-analyzers.md`](docs/external-analyzers.md).
|
|
214
|
+
|
|
208
215
|
---
|
|
209
216
|
|
|
210
217
|
## CLI reference
|
|
@@ -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:
|
|
@@ -129,7 +129,7 @@ Layered on top: **MITRE ATT&CK technique mappings** on every insight and
|
|
|
129
129
|
|
|
130
130
|
## Supported ecosystems
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
Thirteen official analyzer plugins, each distributable as a separate package:
|
|
133
133
|
|
|
134
134
|
| Plugin | Coverage |
|
|
135
135
|
|---|---|
|
|
@@ -147,6 +147,13 @@ Eleven official analyzer plugins, each distributable as a separate package:
|
|
|
147
147
|
|
|
148
148
|
`pip install "attackmap[all]"` installs every official plugin.
|
|
149
149
|
|
|
150
|
+
### Building your own analyzer
|
|
151
|
+
|
|
152
|
+
The plugin contract is documented in code at
|
|
153
|
+
[`attackmap.sdk`](src/attackmap/sdk/__init__.py); the developer cookbook with
|
|
154
|
+
scaffolding, testing, and publishing instructions is in
|
|
155
|
+
[`docs/external-analyzers.md`](docs/external-analyzers.md).
|
|
156
|
+
|
|
150
157
|
---
|
|
151
158
|
|
|
152
159
|
## CLI reference
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "attackmap"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
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,19 @@ 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
75
|
]
|
|
76
76
|
dev = [
|
|
77
77
|
"pytest>=8.0.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from collections.abc import
|
|
3
|
+
from collections.abc import Iterable
|
|
4
4
|
from dataclasses import dataclass
|
|
5
5
|
from importlib.metadata import entry_points
|
|
6
6
|
import json
|
|
@@ -8,15 +8,14 @@ import logging
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
import subprocess
|
|
10
10
|
import sys
|
|
11
|
-
from typing import Protocol
|
|
12
|
-
|
|
13
|
-
_T = TypeVar("_T")
|
|
14
|
-
_K = TypeVar("_K")
|
|
11
|
+
from typing import Protocol
|
|
15
12
|
from urllib.error import URLError
|
|
16
13
|
from urllib.request import urlopen
|
|
17
14
|
|
|
18
15
|
from pydantic import BaseModel, Field
|
|
19
16
|
|
|
17
|
+
from .merge import MERGE_SCHEMA, initial_seen, merge_into
|
|
18
|
+
|
|
20
19
|
from .sdk.contracts import (
|
|
21
20
|
AnalyzerMetadata,
|
|
22
21
|
AnalyzerProtocol,
|
|
@@ -166,23 +165,22 @@ def get_builtin_analyzers() -> tuple[FileAnalyzer, ...]:
|
|
|
166
165
|
|
|
167
166
|
|
|
168
167
|
def merge_analyzer_signals(scan: ScanResult, signals: AnalyzerSignals) -> None:
|
|
168
|
+
"""Apply file-by-file analyzer signals onto a running scan result.
|
|
169
|
+
|
|
170
|
+
`AnalyzerSignals` is the narrow per-file shape used by the built-in
|
|
171
|
+
micro-analyzers in this module. Dedup follows :data:`merge.MERGE_SCHEMA`
|
|
172
|
+
for the field families it covers; routes, external calls, and secrets
|
|
173
|
+
have always been straight extend (intentional: per-file analyzers emit
|
|
174
|
+
one batch per file, dedup happens at the result-level merge step).
|
|
175
|
+
"""
|
|
169
176
|
scan.routes.extend(signals.routes)
|
|
170
177
|
scan.external_calls.extend(signals.external_calls)
|
|
171
178
|
scan.secret_hints.extend(signals.secret_hints)
|
|
172
179
|
|
|
173
|
-
|
|
174
|
-
for
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
scan.databases.append(hint)
|
|
178
|
-
seen_databases.add(key)
|
|
179
|
-
|
|
180
|
-
seen_auth_hints = {(hint.hint, hint.file) for hint in scan.auth_hints}
|
|
181
|
-
for hint in signals.auth_hints:
|
|
182
|
-
key = (hint.hint, hint.file)
|
|
183
|
-
if key not in seen_auth_hints:
|
|
184
|
-
scan.auth_hints.append(hint)
|
|
185
|
-
seen_auth_hints.add(key)
|
|
180
|
+
rules = {r.attr: r for r in MERGE_SCHEMA}
|
|
181
|
+
for attr in ("databases", "auth_hints"):
|
|
182
|
+
rule = rules[attr]
|
|
183
|
+
merge_into(scan, getattr(signals, attr), rule, initial_seen(scan, rule))
|
|
186
184
|
|
|
187
185
|
|
|
188
186
|
# Backward-compatible alias for existing imports from attackmap.analyzers.
|
|
@@ -505,6 +503,11 @@ def merge_analyzer_results(
|
|
|
505
503
|
results: Iterable[AnalyzerResult],
|
|
506
504
|
root: str | Path | None = None,
|
|
507
505
|
) -> AnalyzerResult:
|
|
506
|
+
"""Combine multiple whole-repo analyzer results into one.
|
|
507
|
+
|
|
508
|
+
Field-by-field merge behavior is declared in :data:`merge.MERGE_SCHEMA`
|
|
509
|
+
and applied uniformly here. See `merge.py` for the rules.
|
|
510
|
+
"""
|
|
508
511
|
result_list = list(results)
|
|
509
512
|
if not result_list:
|
|
510
513
|
resolved_root = Path(root).resolve() if root is not None else Path(".").resolve()
|
|
@@ -512,50 +515,15 @@ def merge_analyzer_results(
|
|
|
512
515
|
|
|
513
516
|
resolved_root = Path(root).resolve() if root is not None else Path(result_list[0].root).resolve()
|
|
514
517
|
merged = AnalyzerResult(root=str(resolved_root))
|
|
515
|
-
|
|
516
|
-
external_keys: set[tuple[str, str]] = set()
|
|
517
|
-
database_keys: set[tuple[str, str]] = set()
|
|
518
|
-
auth_keys: set[tuple[str, str]] = set()
|
|
519
|
-
service_keys: set[tuple[str, str]] = set()
|
|
520
|
-
edge_keys: set[tuple[str, str]] = set()
|
|
521
|
-
entrypoint_keys: set[tuple[str, str]] = set()
|
|
522
|
-
protocol_keys: set[tuple[str, str]] = set()
|
|
523
|
-
framework_keys: set[tuple[str, str]] = set()
|
|
524
|
-
secret_keys: set[tuple[str, str]] = set()
|
|
518
|
+
seen_by_attr: dict[str, set] = {rule.attr: set() for rule in MERGE_SCHEMA}
|
|
525
519
|
|
|
526
520
|
for result in result_list:
|
|
527
521
|
merged.files_scanned += result.files_scanned
|
|
528
522
|
for language in result.languages:
|
|
529
523
|
if language not in merged.languages:
|
|
530
524
|
merged.languages.append(language)
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
_merge_unique_items(merged.databases, database_keys, result.databases, lambda item: (item.kind, item.file))
|
|
534
|
-
_merge_unique_items(merged.auth_hints, auth_keys, result.auth_hints, lambda item: (item.hint, item.file))
|
|
535
|
-
_merge_unique_items(merged.service_hints, service_keys, result.service_hints, lambda item: (item.hint, item.file))
|
|
536
|
-
_merge_unique_items(merged.edge_hints, edge_keys, result.edge_hints, lambda item: (item.hint, item.file))
|
|
537
|
-
_merge_unique_items(
|
|
538
|
-
merged.entrypoint_hints, entrypoint_keys, result.entrypoint_hints, lambda item: (item.hint, item.file)
|
|
539
|
-
)
|
|
540
|
-
_merge_unique_items(merged.protocol_hints, protocol_keys, result.protocol_hints, lambda item: (item.hint, item.file))
|
|
541
|
-
_merge_unique_items(
|
|
542
|
-
merged.framework_hints, framework_keys, result.framework_hints, lambda item: (item.hint, item.file)
|
|
543
|
-
)
|
|
544
|
-
_merge_unique_items(merged.secret_hints, secret_keys, result.secret_hints, lambda item: (item.name, item.file))
|
|
525
|
+
for rule in MERGE_SCHEMA:
|
|
526
|
+
merge_into(merged, getattr(result, rule.attr), rule, seen_by_attr[rule.attr])
|
|
545
527
|
|
|
546
528
|
merged.languages.sort()
|
|
547
529
|
return merged
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
def _merge_unique_items(
|
|
551
|
-
destination: list[_T],
|
|
552
|
-
seen: set[_K],
|
|
553
|
-
items: Iterable[_T],
|
|
554
|
-
key_fn: Callable[[_T], _K],
|
|
555
|
-
) -> None:
|
|
556
|
-
for item in items:
|
|
557
|
-
key = key_fn(item)
|
|
558
|
-
if key in seen:
|
|
559
|
-
continue
|
|
560
|
-
seen.add(key)
|
|
561
|
-
destination.append(item)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Centralized merge behavior for analyzer outputs.
|
|
2
|
+
|
|
3
|
+
This module is the single source of truth for **how** analyzer-emitted
|
|
4
|
+
signals are combined into a single ScanResult. It is loaded by both the
|
|
5
|
+
public `merge_analyzer_results` (whole-repo analyzers) and the internal
|
|
6
|
+
`merge_analyzer_signals` (file-by-file built-in micro-analyzers).
|
|
7
|
+
|
|
8
|
+
A new structured signal type is added in one place: append a row to
|
|
9
|
+
:data:`MERGE_SCHEMA` describing the field name and its dedup key. All
|
|
10
|
+
merge / dedup behavior follows automatically.
|
|
11
|
+
|
|
12
|
+
## Merge rules
|
|
13
|
+
|
|
14
|
+
- **Deterministic ordering**: first-seen wins. The order signals appear
|
|
15
|
+
in the merged result is the order they first appeared across the input
|
|
16
|
+
results, walked in input order. We do not reorder.
|
|
17
|
+
- **Deduplication**: each list field has a stable key extracted from the
|
|
18
|
+
signal itself (e.g. `(path, method, file)` for routes). Two signals
|
|
19
|
+
that hash to the same key are treated as duplicates; only the first is
|
|
20
|
+
kept.
|
|
21
|
+
- **`languages`**: union, then sorted alphabetically for stable display.
|
|
22
|
+
- **`files_scanned`**: summed across results.
|
|
23
|
+
- **`root`**: taken from the explicit `root` argument when supplied,
|
|
24
|
+
otherwise from the first result.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from collections.abc import Callable, Iterable
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class MergeRule:
|
|
36
|
+
"""How one list field on `ScanResult` is merged across analyzer outputs.
|
|
37
|
+
|
|
38
|
+
- `attr`: the attribute name on `ScanResult` (e.g. `"routes"`).
|
|
39
|
+
- `key`: a callable that returns a hashable dedup key for one signal.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
attr: str
|
|
43
|
+
key: Callable[[Any], tuple]
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
if not self.attr.isidentifier():
|
|
47
|
+
raise ValueError(f"MergeRule.attr must be a valid identifier, got {self.attr!r}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# The complete schema of list fields on `ScanResult` that get merged
|
|
51
|
+
# across analyzer outputs. Order is preserved in the merged result;
|
|
52
|
+
# duplicates (by key) are suppressed.
|
|
53
|
+
MERGE_SCHEMA: tuple[MergeRule, ...] = (
|
|
54
|
+
MergeRule("routes", lambda item: (item.path, item.method, item.file)),
|
|
55
|
+
MergeRule("external_calls", lambda item: (item.target, item.file)),
|
|
56
|
+
MergeRule("databases", lambda item: (item.kind, item.file)),
|
|
57
|
+
MergeRule("auth_hints", lambda item: (item.hint, item.file)),
|
|
58
|
+
MergeRule("service_hints", lambda item: (item.hint, item.file)),
|
|
59
|
+
MergeRule("edge_hints", lambda item: (item.hint, item.file)),
|
|
60
|
+
MergeRule("entrypoint_hints", lambda item: (item.hint, item.file)),
|
|
61
|
+
MergeRule("protocol_hints", lambda item: (item.hint, item.file)),
|
|
62
|
+
MergeRule("framework_hints", lambda item: (item.hint, item.file)),
|
|
63
|
+
MergeRule("secret_hints", lambda item: (item.name, item.file)),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def merge_into(destination: Any, items: Iterable[Any], rule: MergeRule, seen: set) -> None:
|
|
68
|
+
"""Append `items` onto `destination.<rule.attr>` deduping against `seen`.
|
|
69
|
+
|
|
70
|
+
`seen` is mutated to include the keys of items that were appended.
|
|
71
|
+
Pre-populate `seen` with keys from the existing destination if you
|
|
72
|
+
want to merge across multiple sources without rescanning the list.
|
|
73
|
+
"""
|
|
74
|
+
target = getattr(destination, rule.attr)
|
|
75
|
+
for item in items:
|
|
76
|
+
k = rule.key(item)
|
|
77
|
+
if k in seen:
|
|
78
|
+
continue
|
|
79
|
+
seen.add(k)
|
|
80
|
+
target.append(item)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def initial_seen(destination: Any, rule: MergeRule) -> set:
|
|
84
|
+
"""Compute the `seen` set for items already on `destination.<rule.attr>`.
|
|
85
|
+
|
|
86
|
+
Used by callers that merge into a non-empty destination.
|
|
87
|
+
"""
|
|
88
|
+
return {rule.key(item) for item in getattr(destination, rule.attr)}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
__all__ = ["MergeRule", "MERGE_SCHEMA", "merge_into", "initial_seen"]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Public, stable contract surface for AttackMap analyzers.
|
|
2
|
+
|
|
3
|
+
Anything you can import from ``attackmap.sdk`` is part of the stable analyzer
|
|
4
|
+
contract. Anything you cannot is an internal implementation detail of the
|
|
5
|
+
``attackmap`` package and may change without a major-version bump.
|
|
6
|
+
|
|
7
|
+
## What an analyzer is
|
|
8
|
+
|
|
9
|
+
An analyzer is a Python object that satisfies :class:`AnalyzerProtocol`:
|
|
10
|
+
|
|
11
|
+
- exposes ``metadata: AnalyzerMetadata``
|
|
12
|
+
- exposes a ``name`` property
|
|
13
|
+
- implements ``detect(root) -> bool`` — does this repo look like something
|
|
14
|
+
the analyzer should run on?
|
|
15
|
+
- implements ``analyze(root) -> AnalyzerResult`` — extract structured
|
|
16
|
+
signals (routes, external calls, hints, …) from the repo
|
|
17
|
+
|
|
18
|
+
External analyzers register themselves via the ``attackmap.analyzers``
|
|
19
|
+
Python entry-point group. See the README of any official analyzer (e.g.
|
|
20
|
+
``attackmap-analyzer-python``) for a working example.
|
|
21
|
+
|
|
22
|
+
## Responsibilities
|
|
23
|
+
|
|
24
|
+
Analyzers emit **structured signals** — small, evidence-bearing data points
|
|
25
|
+
with a file/line citation. They do *not* generate findings, attack paths,
|
|
26
|
+
or reports. Specifically:
|
|
27
|
+
|
|
28
|
+
| Owned by analyzers | Owned by core |
|
|
29
|
+
|---|---|
|
|
30
|
+
| Detecting whether they should run | Discovering and loading analyzers |
|
|
31
|
+
| Extracting routes, external calls, databases | Merging results across analyzers |
|
|
32
|
+
| Emitting auth/secret/service/edge/protocol/framework/entrypoint hints | Building the system graph |
|
|
33
|
+
| Surface-level normalization (path canonicalization, etc.) | Generating findings + severity |
|
|
34
|
+
| | Generating attack paths and threat-model output |
|
|
35
|
+
| | Rendering CLI / JSON / markdown reports |
|
|
36
|
+
|
|
37
|
+
If you find yourself wanting to add finding generation or report rendering
|
|
38
|
+
to an analyzer, the answer is almost always "emit a richer signal and let
|
|
39
|
+
core do the reasoning."
|
|
40
|
+
|
|
41
|
+
## Merge semantics
|
|
42
|
+
|
|
43
|
+
When multiple analyzers run against the same repo, their results are
|
|
44
|
+
merged by `attackmap.analyzers.merge_analyzer_results`. The schema is
|
|
45
|
+
declared in `attackmap.merge.MERGE_SCHEMA`; the rules are:
|
|
46
|
+
|
|
47
|
+
- **Order**: first-seen wins. Order across analyzers is the order
|
|
48
|
+
`entry_points()` returns them in (Python's discovery order, by entry
|
|
49
|
+
point name).
|
|
50
|
+
- **Dedup**: each list field has a stable tuple key, e.g. routes are
|
|
51
|
+
deduped by ``(path, method, file)`` and auth hints by ``(hint, file)``.
|
|
52
|
+
- **Languages**: union, sorted for stable display.
|
|
53
|
+
- **Files scanned**: summed.
|
|
54
|
+
|
|
55
|
+
Two analyzers that both emit a route for the same ``(path, method, file)``
|
|
56
|
+
produce one route in the merged result — whichever was emitted first.
|
|
57
|
+
|
|
58
|
+
## Versioning
|
|
59
|
+
|
|
60
|
+
The names exported below are stable across minor releases of the
|
|
61
|
+
``attackmap`` package. Breaking changes to this surface only happen on
|
|
62
|
+
a major-version bump; deprecations get one release of overlap.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
from __future__ import annotations
|
|
66
|
+
|
|
67
|
+
from .contracts import (
|
|
68
|
+
AnalyzerMetadata,
|
|
69
|
+
AnalyzerProtocol,
|
|
70
|
+
AnalyzerRepositoryModule,
|
|
71
|
+
AnalyzerResult,
|
|
72
|
+
normalize_analyzer_metadata,
|
|
73
|
+
)
|
|
74
|
+
from .models import (
|
|
75
|
+
AuthHint,
|
|
76
|
+
DatabaseHint,
|
|
77
|
+
EdgeHint,
|
|
78
|
+
EntrypointHint,
|
|
79
|
+
ExternalCall,
|
|
80
|
+
FrameworkHint,
|
|
81
|
+
ProtocolHint,
|
|
82
|
+
Route,
|
|
83
|
+
ScanResult,
|
|
84
|
+
SecretHint,
|
|
85
|
+
ServiceHint,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
__all__ = [
|
|
89
|
+
"AnalyzerResult",
|
|
90
|
+
"AnalyzerMetadata",
|
|
91
|
+
"AnalyzerRepositoryModule",
|
|
92
|
+
"AnalyzerProtocol",
|
|
93
|
+
"normalize_analyzer_metadata",
|
|
94
|
+
"Route",
|
|
95
|
+
"ExternalCall",
|
|
96
|
+
"DatabaseHint",
|
|
97
|
+
"AuthHint",
|
|
98
|
+
"ServiceHint",
|
|
99
|
+
"EdgeHint",
|
|
100
|
+
"EntrypointHint",
|
|
101
|
+
"ProtocolHint",
|
|
102
|
+
"FrameworkHint",
|
|
103
|
+
"SecretHint",
|
|
104
|
+
"ScanResult",
|
|
105
|
+
]
|