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.
@@ -0,0 +1,272 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+ from .models import Asset, AttackSurface, Control, ControlKind, ControlStrength, ScanResult
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class _ControlPattern:
11
+ kind: ControlKind
12
+ name: str
13
+ strength: ControlStrength
14
+ pattern: re.Pattern[str]
15
+ notes: str | None = None
16
+
17
+
18
+ _CONTROL_PATTERNS: tuple[_ControlPattern, ...] = (
19
+ _ControlPattern(
20
+ kind="authentication",
21
+ name="Password hashing (bcrypt/argon2/scrypt)",
22
+ strength="strong",
23
+ pattern=re.compile(r"\b(bcrypt|argon2|scrypt|pbkdf2)\b", re.IGNORECASE),
24
+ notes="Strong adaptive hash function observed.",
25
+ ),
26
+ _ControlPattern(
27
+ kind="authentication",
28
+ name="JWT verification",
29
+ strength="moderate",
30
+ pattern=re.compile(r"jwt[._-]?(verify|decode|sign)|jsonwebtoken", re.IGNORECASE),
31
+ notes="JWT signature verification implies token-based auth.",
32
+ ),
33
+ _ControlPattern(
34
+ kind="authentication",
35
+ name="Auth middleware / guards",
36
+ strength="moderate",
37
+ pattern=re.compile(
38
+ r"\b(passport|require[_-]?auth|requireauth|@login_required|@auth\.login_required|authenticate_user|ensureauthenticated|@auth_required|isauthenticated)\b",
39
+ re.IGNORECASE,
40
+ ),
41
+ ),
42
+ _ControlPattern(
43
+ kind="mfa",
44
+ name="Multi-factor authentication",
45
+ strength="strong",
46
+ pattern=re.compile(r"\b(mfa|totp|2fa|webauthn|fido2|otp)\b", re.IGNORECASE),
47
+ ),
48
+ _ControlPattern(
49
+ kind="authorization",
50
+ name="Role / permission checks",
51
+ strength="moderate",
52
+ pattern=re.compile(
53
+ r"\b(haspermission|require_role|requireroles?|@permission_required|@requires_role|hasrole|abilities|policy_check|authorize\()\b",
54
+ re.IGNORECASE,
55
+ ),
56
+ ),
57
+ _ControlPattern(
58
+ kind="rbac",
59
+ name="Role-based access control",
60
+ strength="moderate",
61
+ pattern=re.compile(r"\b(rbac|role_based|access_control_list|acl_check)\b", re.IGNORECASE),
62
+ ),
63
+ _ControlPattern(
64
+ kind="input_validation",
65
+ name="Schema validation library",
66
+ strength="moderate",
67
+ pattern=re.compile(
68
+ r"\b(zod|joi|yup|ajv|pydantic|marshmallow|cerberus|validator\.is|class-validator)\b",
69
+ re.IGNORECASE,
70
+ ),
71
+ ),
72
+ _ControlPattern(
73
+ kind="rate_limiting",
74
+ name="Rate limiting middleware",
75
+ strength="moderate",
76
+ pattern=re.compile(
77
+ r"\b(rate[_-]?limit|express-rate-limit|slowapi|throttle|bottleneck|rack-attack)\b",
78
+ re.IGNORECASE,
79
+ ),
80
+ ),
81
+ _ControlPattern(
82
+ kind="csrf_protection",
83
+ name="CSRF protection",
84
+ strength="moderate",
85
+ pattern=re.compile(
86
+ r"\b(csurf|csrf[_-]?token|csrf_protect|csrfprotect|samesite=(strict|lax))\b",
87
+ re.IGNORECASE,
88
+ ),
89
+ ),
90
+ _ControlPattern(
91
+ kind="encryption_in_transit",
92
+ name="TLS / HTTPS / mTLS",
93
+ strength="moderate",
94
+ pattern=re.compile(r"\b(https://|tls|mtls|sslcontext|ssl_ctx|forcessl|hsts)\b", re.IGNORECASE),
95
+ ),
96
+ _ControlPattern(
97
+ kind="encryption_at_rest",
98
+ name="At-rest encryption / KMS",
99
+ strength="moderate",
100
+ pattern=re.compile(r"\b(kms|envelope_encrypt|aes-256|fernet|libsodium|sealedbox)\b", re.IGNORECASE),
101
+ ),
102
+ _ControlPattern(
103
+ kind="audit_logging",
104
+ name="Audit / security logging",
105
+ strength="moderate",
106
+ pattern=re.compile(
107
+ r"\b(audit_log|security_event|log_security|audit\.log|securityaudit|logaudit)\b",
108
+ re.IGNORECASE,
109
+ ),
110
+ ),
111
+ _ControlPattern(
112
+ kind="security_headers",
113
+ name="Security headers (helmet/secure)",
114
+ strength="moderate",
115
+ pattern=re.compile(r"\b(helmet|secure-headers|csp|content_security_policy|x-frame-options)\b", re.IGNORECASE),
116
+ ),
117
+ _ControlPattern(
118
+ kind="output_encoding",
119
+ name="Output encoding / sanitization",
120
+ strength="moderate",
121
+ pattern=re.compile(r"\b(escape_html|sanitize-html|dompurify|bleach\.clean|markupsafe)\b", re.IGNORECASE),
122
+ ),
123
+ _ControlPattern(
124
+ kind="secret_management",
125
+ name="Vault / secret manager",
126
+ strength="strong",
127
+ pattern=re.compile(r"\b(hashicorp[_-]?vault|aws_secretsmanager|gcp_secretmanager|azure_keyvault|doppler)\b", re.IGNORECASE),
128
+ ),
129
+ )
130
+
131
+
132
+ _SCOPE_HINTS: dict[ControlKind, str] = {
133
+ "authentication": "Routes that read or modify user-scoped data should sit behind this.",
134
+ "authorization": "Privileged or tenant-bounded routes should enforce this.",
135
+ "input_validation": "All untrusted input boundaries should validate request shape.",
136
+ "rate_limiting": "Auth endpoints and write-heavy endpoints should be throttled.",
137
+ "csrf_protection": "Browser-driven state-changing endpoints should require CSRF tokens.",
138
+ "audit_logging": "Critical-asset access and admin actions should be audit-logged.",
139
+ "encryption_in_transit": "All public surfaces should require TLS.",
140
+ "encryption_at_rest": "Sensitive-asset stores should be encrypted.",
141
+ "secret_management": "Long-lived credentials should be sourced from a vault.",
142
+ "mfa": "Privileged flows should require a second factor.",
143
+ }
144
+
145
+
146
+ def _scope_for(asset_kind: str) -> str:
147
+ if asset_kind in {"credentials", "session"}:
148
+ return "asset"
149
+ return "global"
150
+
151
+
152
+ def _all_signal_text(scan: ScanResult) -> list[tuple[str, str, int | None]]:
153
+ """Return (text_blob, file, line) tuples from all hint sources for pattern matching."""
154
+ blobs: list[tuple[str, str, int | None]] = []
155
+ for hint in scan.auth_hints:
156
+ blobs.append((hint.hint, hint.file, hint.line))
157
+ for hint in scan.framework_hints:
158
+ blobs.append((hint.hint, hint.file, hint.line))
159
+ for hint in scan.service_hints:
160
+ blobs.append((hint.hint, hint.file, hint.line))
161
+ for hint in scan.protocol_hints:
162
+ blobs.append((hint.hint, hint.file, hint.line))
163
+ for hint in scan.entrypoint_hints:
164
+ blobs.append((hint.hint, hint.file, hint.line))
165
+ for hint in scan.edge_hints:
166
+ blobs.append((hint.hint, hint.file, hint.line))
167
+ for route in scan.routes:
168
+ blobs.append((route.path, route.file, route.line))
169
+ return blobs
170
+
171
+
172
+ def _format_loc(file_path: str, line: int | None) -> str:
173
+ return f"{file_path}:{line}" if line is not None else file_path
174
+
175
+
176
+ def _detect_present_controls(scan: ScanResult) -> list[Control]:
177
+ blobs = _all_signal_text(scan)
178
+ controls_by_id: dict[str, Control] = {}
179
+ placements_by_id: dict[str, set[str]] = {}
180
+ evidence_by_id: dict[str, set[str]] = {}
181
+
182
+ for cp in _CONTROL_PATTERNS:
183
+ for text, file_path, line in blobs:
184
+ if cp.pattern.search(text):
185
+ control_id = f"control:{cp.kind}:{cp.name.lower().replace(' ', '_').replace('/', '_')}"
186
+ placements_by_id.setdefault(control_id, set()).add(file_path)
187
+ evidence_by_id.setdefault(control_id, set()).add(f"{text} ({_format_loc(file_path, line)})")
188
+ if control_id not in controls_by_id:
189
+ controls_by_id[control_id] = Control(
190
+ id=control_id,
191
+ kind=cp.kind,
192
+ name=cp.name,
193
+ strength=cp.strength,
194
+ scope="global",
195
+ placements=[],
196
+ evidence=[],
197
+ notes=cp.notes,
198
+ )
199
+
200
+ finalized: list[Control] = []
201
+ for control_id, control in controls_by_id.items():
202
+ placements = sorted(placements_by_id.get(control_id, set()))
203
+ evidence = sorted(evidence_by_id.get(control_id, set()))[:8]
204
+ scope = "global" if len(placements) >= 3 else ("module" if len(placements) > 1 else "route")
205
+ finalized.append(control.model_copy(update={"placements": placements[:12], "evidence": evidence, "scope": scope}))
206
+ finalized.sort(key=lambda c: (c.kind, c.name))
207
+ return finalized
208
+
209
+
210
+ def _detect_absent_controls(
211
+ scan: ScanResult,
212
+ attack_surfaces: list[AttackSurface],
213
+ assets: list[Asset],
214
+ present_kinds: set[ControlKind],
215
+ ) -> list[Control]:
216
+ """Surface controls that are *expected* but not detected anywhere."""
217
+ absences: list[Control] = []
218
+
219
+ has_public_routes = any(s.exposure == "public" for s in attack_surfaces)
220
+ has_admin_or_auth = any(s.category in {"admin", "auth"} for s in attack_surfaces)
221
+ has_state_changing_public = any(
222
+ s.exposure == "public" and s.method.upper() in {"POST", "PUT", "PATCH", "DELETE"}
223
+ for s in attack_surfaces
224
+ )
225
+ sensitive_kinds = {"credentials", "session", "user_pii", "payment"}
226
+ has_sensitive_assets = any(asset.criticality in {"critical", "high"} for asset in assets) or any(
227
+ asset.kind in sensitive_kinds for asset in assets
228
+ )
229
+
230
+ expected: list[tuple[ControlKind, str, str]] = []
231
+ if has_public_routes and "rate_limiting" not in present_kinds:
232
+ expected.append(("rate_limiting", "Rate limiting", "Public routes observed without any rate-limiting markers."))
233
+ if has_admin_or_auth and "authentication" not in present_kinds:
234
+ expected.append(("authentication", "Authentication enforcement", "Admin or auth routes observed without any auth-middleware markers."))
235
+ if has_state_changing_public and "csrf_protection" not in present_kinds:
236
+ expected.append(("csrf_protection", "CSRF protection", "Public state-changing routes observed without CSRF markers."))
237
+ if has_sensitive_assets and "audit_logging" not in present_kinds:
238
+ expected.append(("audit_logging", "Audit logging", "Sensitive assets identified but no audit-logging markers found."))
239
+ if has_sensitive_assets and "encryption_at_rest" not in present_kinds:
240
+ expected.append(("encryption_at_rest", "Encryption at rest", "Sensitive assets identified but no at-rest encryption markers found."))
241
+ if has_admin_or_auth and "mfa" not in present_kinds:
242
+ expected.append(("mfa", "Multi-factor authentication", "Auth/admin surfaces observed without MFA markers."))
243
+
244
+ for kind, name, notes in expected:
245
+ absences.append(
246
+ Control(
247
+ id=f"control:absent:{kind}",
248
+ kind=kind,
249
+ name=name,
250
+ strength="absent",
251
+ scope="global",
252
+ placements=[],
253
+ evidence=[],
254
+ notes=notes,
255
+ )
256
+ )
257
+ return absences
258
+
259
+
260
+ def detect_controls(
261
+ scan: ScanResult,
262
+ attack_surfaces: list[AttackSurface],
263
+ assets: list[Asset],
264
+ ) -> list[Control]:
265
+ present = _detect_present_controls(scan)
266
+ present_kinds: set[ControlKind] = {c.kind for c in present}
267
+ absent = _detect_absent_controls(scan, attack_surfaces, assets, present_kinds)
268
+
269
+ _strength_rank = {"strong": 0, "moderate": 1, "weak": 2, "absent": 3}
270
+ combined = present + absent
271
+ combined.sort(key=lambda c: (_strength_rank.get(c.strength, 9), c.kind, c.name))
272
+ return combined