attackmap 0.6.4__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.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. attackmap-0.6.4.dist-info/top_level.txt +1 -0
@@ -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,133 @@
1
+ """MITRE ATT&CK technique mapping for AttackMap insights and findings.
2
+
3
+ Maps internal `InsightKind` values to ATT&CK techniques (Enterprise
4
+ matrix). Findings map through their rule id (`taxonomy.py`, #250); a
5
+ whole-word title fallback covers only findings with no registered rule.
6
+ Conservative by design — we only emit techniques where the static-analysis
7
+ evidence directly motivates the mapping. Defenders use these to slot AttackMap output into existing
8
+ ATT&CK-aligned detection programs.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+
16
+ from .models import AttackTechnique, Finding, Insight, InsightKind
17
+ from .taxonomy import ATTACK_TECHNIQUES, attack_url, taxonomy_for
18
+
19
+
20
+ def _technique(technique_id: str) -> AttackTechnique:
21
+ name, tactic = ATTACK_TECHNIQUES[technique_id]
22
+ return AttackTechnique(technique_id=technique_id, name=name, tactic=tactic, url=attack_url(technique_id))
23
+
24
+
25
+ # Technique catalog, shared with the per-rule registry (`taxonomy.py`).
26
+ T = {tid: _technique(tid) for tid in ATTACK_TECHNIQUES}
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class _Mapping:
31
+ technique_ids: tuple[str, ...]
32
+
33
+
34
+ _INSIGHT_KIND_MAP: dict[InsightKind, _Mapping] = {
35
+ "shared_secret_blast_radius": _Mapping(("T1552", "T1528", "T1078")),
36
+ "sensitive_asset_reachability": _Mapping(("T1190", "T1041")),
37
+ "control_bypass": _Mapping(("T1685", "T1190")),
38
+ "defense_gap_in_chain": _Mapping(("T1190", "T1212")),
39
+ "asymmetric_protection": _Mapping(("T1190", "T1078")),
40
+ "trust_boundary_violation": _Mapping(("T1199", "T1190")),
41
+ "audit_gap": _Mapping(("T1685",)),
42
+ "control_strength_mismatch": _Mapping(("T1110", "T1552")),
43
+ "single_point_of_failure": _Mapping(("T1552", "T1528", "T1556")),
44
+ "stale_or_contradictory_signal": _Mapping(()),
45
+ "admin_action_without_auth": _Mapping(("T1078", "T1068", "T1098")),
46
+ }
47
+
48
+
49
+ # Fallback for findings whose rule id isn't in the taxonomy registry (plugin
50
+ # findings): whole-word matches against the title only — never evidence, which
51
+ # carries repo text ("monkey", "cursor.execute", "author") (#250).
52
+ _FINDING_KEYWORD_MAP: tuple[tuple[tuple[str, ...], tuple[str, ...]], ...] = (
53
+ (("webhook", "webhooks", "callback", "callbacks"), ("T1190", "T1199")),
54
+ (("admin", "administrative", "privileged"), ("T1078", "T1068", "T1098")),
55
+ (("upload", "uploads"), ("T1190", "T1505.003")),
56
+ (("auth", "authentication", "login", "session", "sessions"), ("T1078", "T1110", "T1556")),
57
+ (("secret", "secrets", "token", "tokens", "key", "keys", "credential", "credentials"), ("T1552", "T1528")),
58
+ (("rce", "command execution", "command injection"), ("T1059",)),
59
+ )
60
+ _KEYWORD_PATTERNS = tuple(
61
+ (re.compile(r"\b(?:" + "|".join(re.escape(k) for k in keywords) + r")\b"), tids)
62
+ for keywords, tids in _FINDING_KEYWORD_MAP
63
+ )
64
+
65
+
66
+ def techniques_for_insight(insight: Insight) -> list[AttackTechnique]:
67
+ mapping = _INSIGHT_KIND_MAP.get(insight.kind)
68
+ if mapping is None:
69
+ return []
70
+ return [T[tid] for tid in mapping.technique_ids if tid in T]
71
+
72
+
73
+ def techniques_for_finding(finding: Finding) -> list[AttackTechnique]:
74
+ """ATT&CK techniques for a finding: the registry entry for its rule id
75
+ when there is one, else the whole-word title fallback."""
76
+ entry = taxonomy_for(finding.rule_id)
77
+ if entry is not None:
78
+ if entry.attack:
79
+ return [T[tid] for tid in entry.attack]
80
+ return list(finding.attack_techniques)
81
+ if finding.attack_techniques:
82
+ return list(finding.attack_techniques)
83
+ title = finding.title.lower()
84
+ seen: set[str] = set()
85
+ matched: list[AttackTechnique] = []
86
+ for pattern, technique_ids in _KEYWORD_PATTERNS:
87
+ if not pattern.search(title):
88
+ continue
89
+ for tid in technique_ids:
90
+ if tid not in seen:
91
+ seen.add(tid)
92
+ matched.append(T[tid])
93
+ return matched
94
+
95
+
96
+ def annotate_insights(insights: list[Insight]) -> list[Insight]:
97
+ """Return new Insight objects with `attack_techniques` populated.
98
+
99
+ Insights are immutable pydantic models, so we model_copy with an update.
100
+ """
101
+ annotated: list[Insight] = []
102
+ for insight in insights:
103
+ if insight.attack_techniques:
104
+ annotated.append(insight)
105
+ continue
106
+ techniques = techniques_for_insight(insight)
107
+ if not techniques:
108
+ annotated.append(insight)
109
+ continue
110
+ annotated.append(insight.model_copy(update={"attack_techniques": techniques}))
111
+ return annotated
112
+
113
+
114
+ def annotate_findings(findings: list[Finding]) -> list[Finding]:
115
+ annotated: list[Finding] = []
116
+ for finding in findings:
117
+ if finding.attack_techniques:
118
+ annotated.append(finding)
119
+ continue
120
+ techniques = techniques_for_finding(finding)
121
+ if not techniques:
122
+ annotated.append(finding)
123
+ continue
124
+ annotated.append(finding.model_copy(update={"attack_techniques": techniques}))
125
+ return annotated
126
+
127
+
128
+ __all__ = [
129
+ "techniques_for_insight",
130
+ "techniques_for_finding",
131
+ "annotate_insights",
132
+ "annotate_findings",
133
+ ]