attackmap 0.1.0__py3-none-any.whl
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/__init__.py +2 -0
- attackmap/analyzer.py +278 -0
- attackmap/analyzer_contracts.py +104 -0
- attackmap/analyzers.py +561 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +140 -0
- attackmap/cli.py +185 -0
- attackmap/context_pack.py +82 -0
- attackmap/control_model.py +272 -0
- attackmap/defensive_review.py +597 -0
- attackmap/detection_opportunities.py +343 -0
- attackmap/graph.py +26 -0
- attackmap/insights.py +702 -0
- attackmap/llm_review.py +346 -0
- attackmap/models.py +352 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +121 -0
- attackmap/report.py +74 -0
- attackmap/review_eval.py +281 -0
- attackmap/review_json.py +300 -0
- attackmap/review_prompts.py +230 -0
- attackmap/scanner.py +446 -0
- attackmap/sdk/__init__.py +41 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/models.py +29 -0
- attackmap/security_overlay.py +69 -0
- attackmap/threat_model.py +919 -0
- attackmap-0.1.0.dist-info/METADATA +257 -0
- attackmap-0.1.0.dist-info/RECORD +33 -0
- attackmap-0.1.0.dist-info/WHEEL +5 -0
- attackmap-0.1.0.dist-info/entry_points.txt +2 -0
- attackmap-0.1.0.dist-info/licenses/LICENSE +21 -0
- attackmap-0.1.0.dist-info/top_level.txt +1 -0
attackmap/asset_model.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from .models import Asset, AssetKind, ScanResult
|
|
7
|
+
|
|
8
|
+
LOW_QUALITY_SEGMENTS = ("/tests/", "/__tests__/", "/fixtures/", "/mocks/", "/examples/", "/test_", "/_test.")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _is_low_quality(path: str) -> bool:
|
|
12
|
+
normalized = ("/" + path.replace("\\", "/").lower() + "/")
|
|
13
|
+
return any(segment in normalized for segment in LOW_QUALITY_SEGMENTS)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class _AssetRule:
|
|
18
|
+
kind: AssetKind
|
|
19
|
+
name: str
|
|
20
|
+
criticality: str
|
|
21
|
+
secret_patterns: tuple[str, ...] = ()
|
|
22
|
+
route_patterns: tuple[str, ...] = ()
|
|
23
|
+
file_patterns: tuple[str, ...] = ()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_ASSET_RULES: tuple[_AssetRule, ...] = (
|
|
27
|
+
_AssetRule(
|
|
28
|
+
kind="session",
|
|
29
|
+
name="Authentication tokens / session material",
|
|
30
|
+
criticality="critical",
|
|
31
|
+
secret_patterns=("jwt_secret", "session_secret", "cookie_secret", "auth_secret", "refresh_secret", "signing_key"),
|
|
32
|
+
route_patterns=("/login", "/signin", "/token", "/oauth", "/session", "/refresh", "/logout"),
|
|
33
|
+
file_patterns=("auth/", "session/", "/jwt"),
|
|
34
|
+
),
|
|
35
|
+
_AssetRule(
|
|
36
|
+
kind="credentials",
|
|
37
|
+
name="User credentials at rest",
|
|
38
|
+
criticality="critical",
|
|
39
|
+
secret_patterns=("password_pepper", "hash_secret"),
|
|
40
|
+
route_patterns=("/password", "/signup", "/register", "/reset-password", "/forgot"),
|
|
41
|
+
file_patterns=("models/user", "models/account", "entities/user", "entities/account", "user_repository", "auth/password"),
|
|
42
|
+
),
|
|
43
|
+
_AssetRule(
|
|
44
|
+
kind="payment",
|
|
45
|
+
name="Payment / billing records",
|
|
46
|
+
criticality="critical",
|
|
47
|
+
secret_patterns=("stripe", "paypal", "braintree", "adyen", "billing_key"),
|
|
48
|
+
route_patterns=("/payment", "/checkout", "/billing", "/invoice", "/subscription", "/charge"),
|
|
49
|
+
file_patterns=("models/payment", "models/invoice", "models/order", "entities/payment", "entities/invoice", "entities/order", "billing/"),
|
|
50
|
+
),
|
|
51
|
+
_AssetRule(
|
|
52
|
+
kind="user_pii",
|
|
53
|
+
name="User PII / profile data",
|
|
54
|
+
criticality="high",
|
|
55
|
+
route_patterns=("/users", "/user/", "/profile", "/account", "/me", "/contacts", "/address"),
|
|
56
|
+
file_patterns=("models/user", "models/profile", "models/contact", "entities/user", "entities/profile", "user_repository", "user_service"),
|
|
57
|
+
),
|
|
58
|
+
_AssetRule(
|
|
59
|
+
kind="internal_secret",
|
|
60
|
+
name="Internal service / API secrets",
|
|
61
|
+
criticality="high",
|
|
62
|
+
secret_patterns=(
|
|
63
|
+
"api_key",
|
|
64
|
+
"private_key",
|
|
65
|
+
"webhook_secret",
|
|
66
|
+
"signing_secret",
|
|
67
|
+
"service_token",
|
|
68
|
+
"admin_token",
|
|
69
|
+
"internal_key",
|
|
70
|
+
"encryption_key",
|
|
71
|
+
"master_key",
|
|
72
|
+
"deploy_key",
|
|
73
|
+
),
|
|
74
|
+
file_patterns=("vault/", "kms/", "/keys/"),
|
|
75
|
+
),
|
|
76
|
+
_AssetRule(
|
|
77
|
+
kind="audit_log",
|
|
78
|
+
name="Audit / security logs",
|
|
79
|
+
criticality="medium",
|
|
80
|
+
route_patterns=("/audit", "/security/log", "/events"),
|
|
81
|
+
file_patterns=("audit/", "/audit_log", "security/log", "logs/security"),
|
|
82
|
+
),
|
|
83
|
+
_AssetRule(
|
|
84
|
+
kind="configuration",
|
|
85
|
+
name="Security-relevant configuration",
|
|
86
|
+
criticality="medium",
|
|
87
|
+
secret_patterns=("config_signing", "feature_flag_secret"),
|
|
88
|
+
file_patterns=(".env", "config/secrets", "secrets.yml", "secrets.yaml", "config/security"),
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _normalize(text: str) -> str:
|
|
94
|
+
return text.replace("\\", "/").lower()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _matches_any(haystack: str, needles: tuple[str, ...]) -> bool:
|
|
98
|
+
return any(needle in haystack for needle in needles)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _loc(file_path: str, line: int | None) -> str:
|
|
102
|
+
"""`file:line` if line is known, else `file`."""
|
|
103
|
+
return f"{file_path}:{line}" if line is not None else file_path
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _route_path_set(scan: ScanResult) -> set[str]:
|
|
107
|
+
return {_normalize(route.path) for route in scan.routes}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _route_files_for(scan: ScanResult, route_lower: str) -> list[str]:
|
|
111
|
+
return [route.file for route in scan.routes if _normalize(route.path) == route_lower]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _gather_asset(rule: _AssetRule, scan: ScanResult) -> Asset | None:
|
|
115
|
+
locations: set[str] = set()
|
|
116
|
+
evidence: set[str] = set()
|
|
117
|
+
|
|
118
|
+
if rule.secret_patterns:
|
|
119
|
+
for hint in scan.secret_hints:
|
|
120
|
+
if _is_low_quality(hint.file):
|
|
121
|
+
continue
|
|
122
|
+
name_lower = hint.name.lower()
|
|
123
|
+
if _matches_any(name_lower, rule.secret_patterns):
|
|
124
|
+
locations.add(hint.file)
|
|
125
|
+
evidence.add(f"secret:{hint.name} ({_loc(hint.file, hint.line)})")
|
|
126
|
+
|
|
127
|
+
if rule.route_patterns:
|
|
128
|
+
for route in scan.routes:
|
|
129
|
+
if _is_low_quality(route.file):
|
|
130
|
+
continue
|
|
131
|
+
path_lower = _normalize(route.path)
|
|
132
|
+
if _matches_any(path_lower, rule.route_patterns):
|
|
133
|
+
locations.add(route.file)
|
|
134
|
+
evidence.add(f"route:{route.method} {route.path} ({_loc(route.file, route.line)})")
|
|
135
|
+
|
|
136
|
+
if rule.file_patterns:
|
|
137
|
+
candidate_files: set[str] = set()
|
|
138
|
+
candidate_files.update(route.file for route in scan.routes)
|
|
139
|
+
candidate_files.update(hint.file for hint in scan.secret_hints)
|
|
140
|
+
candidate_files.update(hint.file for hint in scan.framework_hints)
|
|
141
|
+
candidate_files.update(hint.file for hint in scan.service_hints)
|
|
142
|
+
candidate_files.update(hint.file for hint in scan.auth_hints)
|
|
143
|
+
for file_path in candidate_files:
|
|
144
|
+
if _is_low_quality(file_path):
|
|
145
|
+
continue
|
|
146
|
+
file_lower = _normalize(file_path)
|
|
147
|
+
if _matches_any(file_lower, rule.file_patterns):
|
|
148
|
+
locations.add(file_path)
|
|
149
|
+
evidence.add(f"file:{file_path}")
|
|
150
|
+
|
|
151
|
+
if not evidence:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
asset_id = f"asset:{rule.kind}"
|
|
155
|
+
return Asset(
|
|
156
|
+
id=asset_id,
|
|
157
|
+
kind=rule.kind,
|
|
158
|
+
name=rule.name,
|
|
159
|
+
criticality=rule.criticality, # type: ignore[arg-type]
|
|
160
|
+
locations=sorted(locations),
|
|
161
|
+
evidence=sorted(evidence)[:12],
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
_SECRET_KIND_HEURISTICS: tuple[tuple[re.Pattern[str], AssetKind, str, str], ...] = (
|
|
166
|
+
(re.compile(r"jwt|session|cookie|refresh|auth_secret"), "session", "Auth/session secret", "critical"),
|
|
167
|
+
(re.compile(r"stripe|paypal|braintree|adyen"), "payment", "Payment processor secret", "critical"),
|
|
168
|
+
(re.compile(r"webhook|signing|hmac"), "internal_secret", "Webhook / signing secret", "high"),
|
|
169
|
+
(re.compile(r"api[_-]?key|service[_-]?token|admin[_-]?token|deploy[_-]?key"), "internal_secret", "API / service token", "high"),
|
|
170
|
+
(re.compile(r"private[_-]?key|encryption[_-]?key|master[_-]?key"), "internal_secret", "Cryptographic key material", "critical"),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _secrets_as_assets(scan: ScanResult) -> list[Asset]:
|
|
175
|
+
assets: list[Asset] = []
|
|
176
|
+
seen_ids: set[str] = set()
|
|
177
|
+
for hint in scan.secret_hints:
|
|
178
|
+
if _is_low_quality(hint.file):
|
|
179
|
+
continue
|
|
180
|
+
name_lower = hint.name.lower()
|
|
181
|
+
for pattern, kind, name, criticality in _SECRET_KIND_HEURISTICS:
|
|
182
|
+
if pattern.search(name_lower):
|
|
183
|
+
asset_id = f"asset:secret:{name_lower}"
|
|
184
|
+
if asset_id in seen_ids:
|
|
185
|
+
break
|
|
186
|
+
seen_ids.add(asset_id)
|
|
187
|
+
assets.append(
|
|
188
|
+
Asset(
|
|
189
|
+
id=asset_id,
|
|
190
|
+
kind=kind,
|
|
191
|
+
name=f"{name}: {hint.name}",
|
|
192
|
+
criticality=criticality, # type: ignore[arg-type]
|
|
193
|
+
locations=[hint.file],
|
|
194
|
+
evidence=[f"secret:{hint.name} ({_loc(hint.file, hint.line)})"],
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
break
|
|
198
|
+
return assets
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _business_data_fallback(scan: ScanResult, assets: list[Asset]) -> Asset | None:
|
|
202
|
+
runtime_dbs = [db for db in scan.databases if not _is_low_quality(db.file)]
|
|
203
|
+
if not runtime_dbs:
|
|
204
|
+
return None
|
|
205
|
+
sensitive_kinds = {"user_pii", "payment", "credentials", "session"}
|
|
206
|
+
if any(asset.kind in sensitive_kinds for asset in assets):
|
|
207
|
+
return None
|
|
208
|
+
files = sorted({db.file for db in runtime_dbs})[:8]
|
|
209
|
+
kinds = sorted({db.kind for db in runtime_dbs})
|
|
210
|
+
return Asset(
|
|
211
|
+
id="asset:business_data",
|
|
212
|
+
kind="business_data",
|
|
213
|
+
name="Business / operational data store",
|
|
214
|
+
criticality="medium",
|
|
215
|
+
locations=files,
|
|
216
|
+
evidence=[f"datastore:{kind}" for kind in kinds],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def detect_assets(scan: ScanResult) -> list[Asset]:
|
|
221
|
+
"""Return the inferred asset inventory for a scan, deduplicated by id."""
|
|
222
|
+
assets: list[Asset] = []
|
|
223
|
+
seen_ids: set[str] = set()
|
|
224
|
+
|
|
225
|
+
for rule in _ASSET_RULES:
|
|
226
|
+
asset = _gather_asset(rule, scan)
|
|
227
|
+
if asset is None or asset.id in seen_ids:
|
|
228
|
+
continue
|
|
229
|
+
seen_ids.add(asset.id)
|
|
230
|
+
assets.append(asset)
|
|
231
|
+
|
|
232
|
+
for asset in _secrets_as_assets(scan):
|
|
233
|
+
if asset.id in seen_ids:
|
|
234
|
+
continue
|
|
235
|
+
seen_ids.add(asset.id)
|
|
236
|
+
assets.append(asset)
|
|
237
|
+
|
|
238
|
+
fallback = _business_data_fallback(scan, assets)
|
|
239
|
+
if fallback is not None and fallback.id not in seen_ids:
|
|
240
|
+
seen_ids.add(fallback.id)
|
|
241
|
+
assets.append(fallback)
|
|
242
|
+
|
|
243
|
+
_criticality_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
244
|
+
assets.sort(key=lambda asset: (_criticality_rank.get(asset.criticality, 9), asset.kind, asset.id))
|
|
245
|
+
return assets
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""MITRE ATT&CK technique mapping for AttackMap insights and findings.
|
|
2
|
+
|
|
3
|
+
Maps internal `InsightKind` values and finding-title keywords to relevant
|
|
4
|
+
ATT&CK techniques (Enterprise matrix). Conservative by design — we only
|
|
5
|
+
emit techniques where the static-analysis evidence directly motivates
|
|
6
|
+
the mapping. Defenders use these to slot AttackMap output into existing
|
|
7
|
+
ATT&CK-aligned detection programs.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from .models import AttackTechnique, Finding, Insight, InsightKind
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _technique(technique_id: str, name: str, tactic: str) -> AttackTechnique:
|
|
18
|
+
return AttackTechnique(
|
|
19
|
+
technique_id=technique_id,
|
|
20
|
+
name=name,
|
|
21
|
+
tactic=tactic,
|
|
22
|
+
url=f"https://attack.mitre.org/techniques/{technique_id.replace('.', '/')}/",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Curated technique catalog — only techniques AttackMap actually maps to.
|
|
27
|
+
T = {
|
|
28
|
+
"T1190": _technique("T1190", "Exploit Public-Facing Application", "Initial Access"),
|
|
29
|
+
"T1078": _technique("T1078", "Valid Accounts", "Defense Evasion / Persistence / Initial Access"),
|
|
30
|
+
"T1199": _technique("T1199", "Trusted Relationship", "Initial Access"),
|
|
31
|
+
"T1212": _technique("T1212", "Exploitation for Credential Access", "Credential Access"),
|
|
32
|
+
"T1552": _technique("T1552", "Unsecured Credentials", "Credential Access"),
|
|
33
|
+
"T1110": _technique("T1110", "Brute Force", "Credential Access"),
|
|
34
|
+
"T1528": _technique("T1528", "Steal Application Access Token", "Credential Access"),
|
|
35
|
+
"T1068": _technique("T1068", "Exploitation for Privilege Escalation", "Privilege Escalation"),
|
|
36
|
+
"T1098": _technique("T1098", "Account Manipulation", "Persistence / Privilege Escalation"),
|
|
37
|
+
"T1562": _technique("T1562", "Impair Defenses", "Defense Evasion"),
|
|
38
|
+
"T1565": _technique("T1565", "Data Manipulation", "Impact"),
|
|
39
|
+
"T1485": _technique("T1485", "Data Destruction", "Impact"),
|
|
40
|
+
"T1041": _technique("T1041", "Exfiltration Over C2 Channel", "Exfiltration"),
|
|
41
|
+
"T1071": _technique("T1071", "Application Layer Protocol", "Command and Control"),
|
|
42
|
+
"T1059": _technique("T1059", "Command and Scripting Interpreter", "Execution"),
|
|
43
|
+
"T1556": _technique("T1556", "Modify Authentication Process", "Defense Evasion / Persistence"),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class _Mapping:
|
|
49
|
+
technique_ids: tuple[str, ...]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_INSIGHT_KIND_MAP: dict[InsightKind, _Mapping] = {
|
|
53
|
+
"shared_secret_blast_radius": _Mapping(("T1552", "T1528", "T1078")),
|
|
54
|
+
"sensitive_asset_reachability": _Mapping(("T1190", "T1041")),
|
|
55
|
+
"control_bypass": _Mapping(("T1562", "T1190")),
|
|
56
|
+
"defense_gap_in_chain": _Mapping(("T1190", "T1212")),
|
|
57
|
+
"asymmetric_protection": _Mapping(("T1190", "T1078")),
|
|
58
|
+
"trust_boundary_violation": _Mapping(("T1199", "T1190")),
|
|
59
|
+
"audit_gap": _Mapping(("T1562",)),
|
|
60
|
+
"control_strength_mismatch": _Mapping(("T1110", "T1552")),
|
|
61
|
+
"single_point_of_failure": _Mapping(("T1552", "T1528", "T1556")),
|
|
62
|
+
"stale_or_contradictory_signal": _Mapping(()),
|
|
63
|
+
"admin_action_without_auth": _Mapping(("T1078", "T1068", "T1098")),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Title/evidence keyword fallback for findings (which don't carry a kind enum).
|
|
68
|
+
_FINDING_KEYWORD_MAP: tuple[tuple[tuple[str, ...], tuple[str, ...]], ...] = (
|
|
69
|
+
(("webhook", "callback"), ("T1190", "T1199")),
|
|
70
|
+
(("admin", "manage", "privileged"), ("T1078", "T1068", "T1098")),
|
|
71
|
+
(("upload",), ("T1190", "T1059")),
|
|
72
|
+
(("auth", "login", "session"), ("T1078", "T1110", "T1556")),
|
|
73
|
+
(("secret", "token", "key"), ("T1552", "T1528")),
|
|
74
|
+
(("public route", "external"), ("T1190",)),
|
|
75
|
+
(("exfil",), ("T1041",)),
|
|
76
|
+
(("command", "rce", "exec"), ("T1059",)),
|
|
77
|
+
(("data store", "datastore", "database"), ("T1565",)),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def techniques_for_insight(insight: Insight) -> list[AttackTechnique]:
|
|
82
|
+
mapping = _INSIGHT_KIND_MAP.get(insight.kind)
|
|
83
|
+
if mapping is None:
|
|
84
|
+
return []
|
|
85
|
+
return [T[tid] for tid in mapping.technique_ids if tid in T]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def techniques_for_finding(finding: Finding) -> list[AttackTechnique]:
|
|
89
|
+
haystack = " ".join([finding.title, *finding.evidence]).lower()
|
|
90
|
+
seen: set[str] = set()
|
|
91
|
+
matched: list[AttackTechnique] = []
|
|
92
|
+
for keywords, technique_ids in _FINDING_KEYWORD_MAP:
|
|
93
|
+
if not any(keyword in haystack for keyword in keywords):
|
|
94
|
+
continue
|
|
95
|
+
for tid in technique_ids:
|
|
96
|
+
if tid in seen or tid not in T:
|
|
97
|
+
continue
|
|
98
|
+
seen.add(tid)
|
|
99
|
+
matched.append(T[tid])
|
|
100
|
+
return matched
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def annotate_insights(insights: list[Insight]) -> list[Insight]:
|
|
104
|
+
"""Return new Insight objects with `attack_techniques` populated.
|
|
105
|
+
|
|
106
|
+
Insights are immutable pydantic models, so we model_copy with an update.
|
|
107
|
+
"""
|
|
108
|
+
annotated: list[Insight] = []
|
|
109
|
+
for insight in insights:
|
|
110
|
+
if insight.attack_techniques:
|
|
111
|
+
annotated.append(insight)
|
|
112
|
+
continue
|
|
113
|
+
techniques = techniques_for_insight(insight)
|
|
114
|
+
if not techniques:
|
|
115
|
+
annotated.append(insight)
|
|
116
|
+
continue
|
|
117
|
+
annotated.append(insight.model_copy(update={"attack_techniques": techniques}))
|
|
118
|
+
return annotated
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def annotate_findings(findings: list[Finding]) -> list[Finding]:
|
|
122
|
+
annotated: list[Finding] = []
|
|
123
|
+
for finding in findings:
|
|
124
|
+
if finding.attack_techniques:
|
|
125
|
+
annotated.append(finding)
|
|
126
|
+
continue
|
|
127
|
+
techniques = techniques_for_finding(finding)
|
|
128
|
+
if not techniques:
|
|
129
|
+
annotated.append(finding)
|
|
130
|
+
continue
|
|
131
|
+
annotated.append(finding.model_copy(update={"attack_techniques": techniques}))
|
|
132
|
+
return annotated
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
__all__ = [
|
|
136
|
+
"techniques_for_insight",
|
|
137
|
+
"techniques_for_finding",
|
|
138
|
+
"annotate_insights",
|
|
139
|
+
"annotate_findings",
|
|
140
|
+
]
|
attackmap/cli.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from .analyzer import summarize_architecture, summarize_attack_surface
|
|
9
|
+
from .defensive_review import render_defensive_review
|
|
10
|
+
from .analyzers import (
|
|
11
|
+
analyze_repository,
|
|
12
|
+
get_available_modules,
|
|
13
|
+
get_available_repository_modules,
|
|
14
|
+
get_analyzer_metadata,
|
|
15
|
+
resolve_run_analyzers,
|
|
16
|
+
select_requested_analyzers,
|
|
17
|
+
)
|
|
18
|
+
from .graph import build_graph
|
|
19
|
+
from .llm_review import LlmReviewError, generate_llm_review
|
|
20
|
+
from .recon_to_analysis import translate_recon
|
|
21
|
+
from .report import render_console_summary, write_reports
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(help="AttackMap: understand your system and map your attack surface.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command()
|
|
27
|
+
def analyze(
|
|
28
|
+
path: str = typer.Argument(".", help="Path to the repository to analyze."),
|
|
29
|
+
output: str = typer.Option("reports", "--output", "-o", help="Directory for generated reports."),
|
|
30
|
+
format: str = typer.Option("all", "--format", help="Output format: all, markdown, or json."),
|
|
31
|
+
module: list[str] | None = typer.Option(
|
|
32
|
+
None,
|
|
33
|
+
"--module",
|
|
34
|
+
"-m",
|
|
35
|
+
help="Analyzer module(s) to run. Repeat to select multiple. Missing external analyzers are auto-installed from the mlaify GitHub organization.",
|
|
36
|
+
),
|
|
37
|
+
llm: bool = typer.Option(
|
|
38
|
+
False,
|
|
39
|
+
"--llm",
|
|
40
|
+
help="Generate a narrative defensive review by calling Claude with the evidence pack. Auth resolves automatically: ANTHROPIC_API_KEY → ANTHROPIC_AUTH_TOKEN → `claude` CLI (subscription auth). Force a backend with --llm-backend.",
|
|
41
|
+
),
|
|
42
|
+
llm_model: str | None = typer.Option(
|
|
43
|
+
None,
|
|
44
|
+
"--llm-model",
|
|
45
|
+
help="Claude model ID for --llm (defaults to claude-opus-4-7).",
|
|
46
|
+
),
|
|
47
|
+
llm_effort: str | None = typer.Option(
|
|
48
|
+
None,
|
|
49
|
+
"--llm-effort",
|
|
50
|
+
help="Effort for --llm: low|medium|high|xhigh|max. Defaults to high.",
|
|
51
|
+
),
|
|
52
|
+
llm_backend: str = typer.Option(
|
|
53
|
+
"auto",
|
|
54
|
+
"--llm-backend",
|
|
55
|
+
help="Which backend --llm uses: 'auto' (default) tries ANTHROPIC_API_KEY → ANTHROPIC_AUTH_TOKEN → `claude` CLI; 'api' forces the SDK; 'cli' forces the `claude` CLI (uses your `claude login` auth, e.g. Pro/Max subscription).",
|
|
56
|
+
),
|
|
57
|
+
) -> None:
|
|
58
|
+
repo_path = Path(path).resolve()
|
|
59
|
+
if not repo_path.exists():
|
|
60
|
+
raise typer.BadParameter(f"Path does not exist: {repo_path}")
|
|
61
|
+
|
|
62
|
+
selected_analyzers = None
|
|
63
|
+
if module:
|
|
64
|
+
try:
|
|
65
|
+
selected_analyzers = select_requested_analyzers(module, auto_install=True)
|
|
66
|
+
except ValueError as exc:
|
|
67
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
68
|
+
|
|
69
|
+
active_analyzers = resolve_run_analyzers(repo_path, analyzers=selected_analyzers)
|
|
70
|
+
scan = analyze_repository(repo_path, analyzers=active_analyzers)
|
|
71
|
+
graph = build_graph(scan)
|
|
72
|
+
analysis = translate_recon(scan)
|
|
73
|
+
attack_surfaces = analysis.attack_surfaces
|
|
74
|
+
architecture_md = summarize_architecture(scan, graph)
|
|
75
|
+
attack_surface_md = summarize_attack_surface(scan, attack_surfaces)
|
|
76
|
+
findings = analysis.findings
|
|
77
|
+
attack_paths = analysis.attack_paths
|
|
78
|
+
defensive_review_md = render_defensive_review(scan, attack_surfaces, findings, attack_paths)
|
|
79
|
+
|
|
80
|
+
write_reports(
|
|
81
|
+
output,
|
|
82
|
+
scan,
|
|
83
|
+
architecture_md,
|
|
84
|
+
attack_surface_md,
|
|
85
|
+
defensive_review_md,
|
|
86
|
+
attack_surfaces,
|
|
87
|
+
findings,
|
|
88
|
+
attack_paths,
|
|
89
|
+
analyzer_metadata=[
|
|
90
|
+
{
|
|
91
|
+
"name": metadata.name,
|
|
92
|
+
"description": metadata.description,
|
|
93
|
+
"scope": metadata.scope,
|
|
94
|
+
"ecosystems": list(metadata.ecosystems),
|
|
95
|
+
}
|
|
96
|
+
for metadata in (get_analyzer_metadata(analyzer) for analyzer in active_analyzers)
|
|
97
|
+
],
|
|
98
|
+
)
|
|
99
|
+
typer.echo(render_console_summary(scan, findings, attack_paths))
|
|
100
|
+
typer.echo("")
|
|
101
|
+
typer.echo(f"Reports written to: {Path(output).resolve()}")
|
|
102
|
+
|
|
103
|
+
if llm:
|
|
104
|
+
try:
|
|
105
|
+
effort_value = None
|
|
106
|
+
if llm_effort is not None:
|
|
107
|
+
if llm_effort not in {"low", "medium", "high", "xhigh", "max"}:
|
|
108
|
+
raise typer.BadParameter(
|
|
109
|
+
f"Invalid --llm-effort '{llm_effort}'. Use one of: low, medium, high, xhigh, max."
|
|
110
|
+
)
|
|
111
|
+
effort_value = llm_effort # type: ignore[assignment]
|
|
112
|
+
|
|
113
|
+
if llm_backend not in {"auto", "api", "cli"}:
|
|
114
|
+
raise typer.BadParameter(
|
|
115
|
+
f"Invalid --llm-backend '{llm_backend}'. Use one of: auto, api, cli."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
typer.echo("")
|
|
119
|
+
typer.echo(
|
|
120
|
+
f"Generating narrative review via Claude (backend={llm_backend}, may take a minute)..."
|
|
121
|
+
)
|
|
122
|
+
result = generate_llm_review(
|
|
123
|
+
scan,
|
|
124
|
+
attack_surfaces,
|
|
125
|
+
findings,
|
|
126
|
+
attack_paths,
|
|
127
|
+
model=llm_model,
|
|
128
|
+
effort=effort_value, # type: ignore[arg-type]
|
|
129
|
+
backend=llm_backend, # type: ignore[arg-type]
|
|
130
|
+
)
|
|
131
|
+
except LlmReviewError as exc:
|
|
132
|
+
typer.echo(f"LLM review skipped: {exc}", err=True)
|
|
133
|
+
else:
|
|
134
|
+
output_path = Path(output)
|
|
135
|
+
llm_md_path = output_path / "defensive-review-llm.md"
|
|
136
|
+
llm_md_path.write_text(result.markdown + "\n", encoding="utf-8")
|
|
137
|
+
llm_meta_path = output_path / "defensive-review-llm.meta.json"
|
|
138
|
+
llm_meta_path.write_text(
|
|
139
|
+
json.dumps(
|
|
140
|
+
{
|
|
141
|
+
"backend": result.backend,
|
|
142
|
+
"model": result.model,
|
|
143
|
+
"stop_reason": result.stop_reason,
|
|
144
|
+
"usage": result.usage,
|
|
145
|
+
},
|
|
146
|
+
indent=2,
|
|
147
|
+
)
|
|
148
|
+
+ "\n",
|
|
149
|
+
encoding="utf-8",
|
|
150
|
+
)
|
|
151
|
+
typer.echo(f"LLM review written to: {llm_md_path.resolve()} (backend={result.backend})")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.command("modules")
|
|
155
|
+
def modules() -> None:
|
|
156
|
+
available_modules = get_available_modules()
|
|
157
|
+
if not available_modules:
|
|
158
|
+
typer.echo("No analyzer modules are currently available.")
|
|
159
|
+
else:
|
|
160
|
+
typer.echo("Available analyzer modules (installed):")
|
|
161
|
+
for module_metadata in available_modules:
|
|
162
|
+
ecosystems = ", ".join(module_metadata.ecosystems) if module_metadata.ecosystems else "none"
|
|
163
|
+
typer.echo(f"- {module_metadata.name}: {module_metadata.description}")
|
|
164
|
+
typer.echo(f" scope: {module_metadata.scope}")
|
|
165
|
+
typer.echo(f" ecosystems: {ecosystems}")
|
|
166
|
+
|
|
167
|
+
typer.echo("")
|
|
168
|
+
typer.echo("Available module repositories (mlaify GitHub org):")
|
|
169
|
+
try:
|
|
170
|
+
repository_modules = get_available_repository_modules()
|
|
171
|
+
except ValueError as exc:
|
|
172
|
+
typer.echo(f"- Unable to fetch remote module repositories: {exc}")
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
if not repository_modules:
|
|
176
|
+
typer.echo("- No module repositories were discovered.")
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
for module in repository_modules:
|
|
180
|
+
typer.echo(f"- {module.analyzer_name} ({module.repo_name})")
|
|
181
|
+
typer.echo(f" repo: {module.web_url}")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
app()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .models import ScanResult
|
|
4
|
+
|
|
5
|
+
SCHEMA_VERSION = "1.0.0"
|
|
6
|
+
LOW_QUALITY_SEGMENTS = ["/tests/", "/__tests__/", "/fixtures/", "/mocks/", "/examples/"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _infer_domain_hints(scan: ScanResult) -> dict:
|
|
10
|
+
route_values = [route.path.lower() for route in scan.routes]
|
|
11
|
+
hint_values = [hint.hint.lower() for hint in scan.auth_hints]
|
|
12
|
+
file_values = [route.file.lower() for route in scan.routes]
|
|
13
|
+
combined = " ".join([*route_values, *hint_values, *file_values])
|
|
14
|
+
|
|
15
|
+
tags: list[str] = []
|
|
16
|
+
signals: list[str] = []
|
|
17
|
+
|
|
18
|
+
if "/xrpc/" in combined or "atproto_" in combined or "com.atproto." in combined:
|
|
19
|
+
tags.append("atproto")
|
|
20
|
+
signals.append("xrpc/atproto namespace patterns observed")
|
|
21
|
+
if "app.bsky." in combined:
|
|
22
|
+
if "atproto" not in tags:
|
|
23
|
+
tags.append("atproto")
|
|
24
|
+
tags.append("bluesky")
|
|
25
|
+
signals.append("app.bsky namespace patterns observed")
|
|
26
|
+
if "laminas" in combined or "module.config.php" in combined:
|
|
27
|
+
tags.append("laminas")
|
|
28
|
+
signals.append("laminas/module config patterns observed")
|
|
29
|
+
|
|
30
|
+
if not tags:
|
|
31
|
+
tags.append("general")
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
"tags": sorted(set(tags)),
|
|
35
|
+
"signals": sorted(set(signals)),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_review_context_pack(
|
|
40
|
+
review_json: dict,
|
|
41
|
+
scan: ScanResult,
|
|
42
|
+
analyzer_metadata: list[dict[str, object]],
|
|
43
|
+
) -> dict:
|
|
44
|
+
return {
|
|
45
|
+
"schema_version": SCHEMA_VERSION,
|
|
46
|
+
"artifact_type": "attackmap_review_context_pack",
|
|
47
|
+
"attackmap_review_json": review_json,
|
|
48
|
+
"analyzer_metadata_used": analyzer_metadata,
|
|
49
|
+
"source_quality_rules": {
|
|
50
|
+
"low_quality_path_segments": LOW_QUALITY_SEGMENTS,
|
|
51
|
+
"handling": {
|
|
52
|
+
"default": "down-rank low-quality signals in triage and prioritization",
|
|
53
|
+
"reporting": "retain low-quality signals in raw_structured_signals with explicit evidence-class labeling",
|
|
54
|
+
"operator_note": "do not treat low-quality-only evidence as strong production exposure without corroboration",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
"output_hints": {
|
|
58
|
+
"audience": ["security engineers", "software engineers", "tech leads"],
|
|
59
|
+
"style": {
|
|
60
|
+
"tone": "defensive, concrete, non-hype",
|
|
61
|
+
"reasoning": "evidence-first and uncertainty-aware",
|
|
62
|
+
"disallowed": [
|
|
63
|
+
"inventing findings or architecture elements",
|
|
64
|
+
"offensive exploitation guidance",
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
"prioritization": "focus on highest-impact, highest-confidence chains and trust-boundary issues first",
|
|
68
|
+
},
|
|
69
|
+
"domain_hints": _infer_domain_hints(scan),
|
|
70
|
+
"rag_expansion_hooks": {
|
|
71
|
+
"enabled": False,
|
|
72
|
+
"notes": "local-first context pack; retrieval slots intentionally minimal for future expansion",
|
|
73
|
+
"slots": [],
|
|
74
|
+
},
|
|
75
|
+
"limitations_meta": {
|
|
76
|
+
"analysis_mode": "heuristic",
|
|
77
|
+
"notes": [
|
|
78
|
+
"Domain tags are inferred heuristically from routes, hints, and file patterns.",
|
|
79
|
+
"Analyzer metadata reflects analyzers selected to run after detect-filtering.",
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
}
|