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/scanner.py ADDED
@@ -0,0 +1,446 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from .sdk.models import AuthHint, DatabaseHint, ExternalCall, Route, ScanResult, SecretHint
7
+
8
+ # Scanner responsibilities are intentionally generic-only:
9
+ # - file walking and suffix filtering
10
+ # - route extraction
11
+ # - external-call extraction
12
+ # - datastore/auth/secret hint extraction
13
+ # Ecosystem overlays (for example node-service and atproto service/protocol hints)
14
+ # must be emitted by specialized analyzers, not by this module.
15
+
16
+ CODE_EXTENSIONS = {
17
+ ".py": "python",
18
+ ".js": "javascript",
19
+ ".ts": "typescript",
20
+ ".tsx": "typescript",
21
+ }
22
+
23
+ FASTAPI_ROUTER_PATTERN = re.compile(
24
+ r"(\w+)\s*=\s*APIRouter\(\s*(?:[^)]*?\bprefix\s*=\s*['\"]([^'\"]*)['\"])?",
25
+ re.IGNORECASE | re.DOTALL,
26
+ )
27
+ FASTAPI_INCLUDE_ROUTER_PATTERN = re.compile(
28
+ r"(\w+)\.include_router\(\s*(\w+)(?:\s*,\s*prefix\s*=\s*['\"]([^'\"]*)['\"])?",
29
+ re.IGNORECASE,
30
+ )
31
+ FASTAPI_DECORATOR_PATTERN = re.compile(
32
+ r"@(\w+)\.(get|post|put|delete|patch|options|head)\(\s*['\"]([^'\"]+)['\"]",
33
+ re.IGNORECASE,
34
+ )
35
+ FASTAPI_API_ROUTE_PATTERN = re.compile(
36
+ r"@(\w+)\.api_route\(\s*['\"]([^'\"]+)['\"](?P<args>.*?)\)",
37
+ re.IGNORECASE | re.DOTALL,
38
+ )
39
+ FLASK_BLUEPRINT_PATTERN = re.compile(
40
+ r"(\w+)\s*=\s*Blueprint\(\s*['\"][^'\"]+['\"]\s*,\s*[^,]+(?:,\s*url_prefix\s*=\s*['\"]([^'\"]*)['\"])?",
41
+ re.IGNORECASE | re.DOTALL,
42
+ )
43
+ FLASK_REGISTER_BLUEPRINT_PATTERN = re.compile(
44
+ r"(\w+)\.register_blueprint\(\s*(\w+)(?:\s*,\s*url_prefix\s*=\s*['\"]([^'\"]*)['\"])?",
45
+ re.IGNORECASE,
46
+ )
47
+ FLASK_ROUTE_PATTERN = re.compile(
48
+ r"@(\w+)\.route\(\s*['\"]([^'\"]+)['\"](?P<args>.*?)\)",
49
+ re.IGNORECASE | re.DOTALL,
50
+ )
51
+ EXPRESS_DIRECT_ROUTE_PATTERN = re.compile(
52
+ r"\b(\w+)\.(get|post|put|delete|patch|options|head|all)\(\s*['\"]([^'\"]+)['\"]",
53
+ re.IGNORECASE,
54
+ )
55
+ EXPRESS_CHAIN_ROUTE_PATTERN = re.compile(
56
+ r"\b(\w+)\.route\(\s*['\"]([^'\"]+)['\"]\s*\)(?P<chain>[\s\S]*?)(?=(?:\n\s*\w+\.)|\Z)",
57
+ re.IGNORECASE,
58
+ )
59
+ EXPRESS_USE_PATTERN = re.compile(
60
+ r"\b(\w+)\.use\(\s*['\"]([^'\"]+)['\"]\s*,\s*(\w+)\s*\)",
61
+ re.IGNORECASE,
62
+ )
63
+ METHOD_LIST_PATTERN = re.compile(r"['\"](GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD|ALL)['\"]", re.IGNORECASE)
64
+
65
+ EXTERNAL_CALL_PATTERNS = [
66
+ re.compile(r"requests\.(get|post|put|delete|patch)\(['\"]([^'\"]+)['\"]"),
67
+ re.compile(r"axios\.(get|post|put|delete|patch)\(['\"]([^'\"]+)['\"]"),
68
+ re.compile(r"fetch\(['\"]([^'\"]+)['\"]"),
69
+ ]
70
+
71
+ DB_KEYWORDS = {
72
+ "postgres": "postgresql",
73
+ "psycopg": "postgresql",
74
+ "sqlalchemy": "sql",
75
+ "mongodb": "mongodb",
76
+ "mongo": "mongodb",
77
+ "redis": "redis",
78
+ "sqlite": "sqlite",
79
+ "mysql": "mysql",
80
+ }
81
+
82
+ DB_PATTERNS = [
83
+ (re.compile(r"create_engine\(\s*['\"](?:postgresql|mysql|sqlite|mssql|oracle)\+?", re.IGNORECASE), "sql"),
84
+ (re.compile(r"sqlite3\.connect\(", re.IGNORECASE), "sqlite"),
85
+ (re.compile(r"psycopg(?:2)?\.connect\(", re.IGNORECASE), "postgresql"),
86
+ (re.compile(r"(?:AsyncIOMotorClient|MongoClient)\(", re.IGNORECASE), "mongodb"),
87
+ (re.compile(r"redis\.(?:Redis|StrictRedis)\(", re.IGNORECASE), "redis"),
88
+ (re.compile(r"new\s+PrismaClient\(", re.IGNORECASE), "sql"),
89
+ (re.compile(r"mongoose\.connect\(", re.IGNORECASE), "mongodb"),
90
+ (re.compile(r"new\s+Pool\(", re.IGNORECASE), "postgresql"),
91
+ ]
92
+
93
+ AUTH_KEYWORDS = [
94
+ "jwt",
95
+ "oauth",
96
+ "auth0",
97
+ "apikey",
98
+ "api_key",
99
+ "bearer",
100
+ "session",
101
+ "password",
102
+ "token",
103
+ "mfa",
104
+ ]
105
+
106
+ AUTH_PATTERNS = [
107
+ (re.compile(r"@login_required\b", re.IGNORECASE), "login_required"),
108
+ (re.compile(r"@jwt_required\b", re.IGNORECASE), "jwt"),
109
+ (re.compile(r"Depends\(\s*(?:oauth2_scheme|get_current_user|current_user|verify_token)\s*\)", re.IGNORECASE), "depends_auth"),
110
+ (re.compile(r"OAuth2PasswordBearer\(", re.IGNORECASE), "oauth"),
111
+ (re.compile(r"request\.authorization\b", re.IGNORECASE), "authorization"),
112
+ (re.compile(r"Authorization['\"]?\s*\]", re.IGNORECASE), "authorization"),
113
+ (re.compile(r"passport\.authenticate\(", re.IGNORECASE), "passport"),
114
+ (re.compile(r"\b(?:verify|require)Token\b", re.IGNORECASE), "token"),
115
+ (re.compile(r"\bauthMiddleware\b", re.IGNORECASE), "auth_middleware"),
116
+ ]
117
+
118
+ SECRET_PATTERNS = [
119
+ re.compile(r"os\.getenv\(['\"]([^'\"]*(SECRET|TOKEN|KEY|PASSWORD)[^'\"]*)['\"]", re.IGNORECASE),
120
+ re.compile(r"process\.env\.([A-Z0-9_]*(SECRET|TOKEN|KEY|PASSWORD)[A-Z0-9_]*)", re.IGNORECASE),
121
+ ]
122
+
123
+
124
+ _SNIPPET_MAX_CHARS = 160
125
+
126
+
127
+ def _line_of(content: str, offset: int) -> int:
128
+ """1-indexed line number for a character offset within content."""
129
+ if offset <= 0:
130
+ return 1
131
+ return content.count("\n", 0, offset) + 1
132
+
133
+
134
+ def _line_snippet(content: str, offset: int, *, max_chars: int = _SNIPPET_MAX_CHARS) -> str:
135
+ """Return the line containing `offset`, stripped and length-capped."""
136
+ line_start = content.rfind("\n", 0, offset) + 1
137
+ line_end = content.find("\n", offset)
138
+ if line_end == -1:
139
+ line_end = len(content)
140
+ line = content[line_start:line_end].strip()
141
+ if len(line) > max_chars:
142
+ line = line[: max_chars - 1] + "…"
143
+ return line
144
+
145
+
146
+ def should_scan(path: Path) -> bool:
147
+ if path.name.startswith("."):
148
+ return False
149
+ if any(part in {"node_modules", ".git", ".venv", "dist", "build"} for part in path.parts):
150
+ return False
151
+ return path.suffix in CODE_EXTENSIONS
152
+
153
+
154
+ def should_scan_with_suffixes(path: Path, suffixes: set[str] | None = None) -> bool:
155
+ if not should_scan(path):
156
+ return False
157
+ if suffixes is None:
158
+ return True
159
+ return path.suffix in suffixes
160
+
161
+
162
+ def _normalize_path(path: str) -> str:
163
+ """Ensure path is non-empty and starts with a single forward slash.
164
+
165
+ This guarantees consistent path output across frameworks: FastAPI, Flask,
166
+ and Express can all be invoked with or without a leading ``/``, but
167
+ downstream consumers (path joining, prefix concatenation, attack-surface
168
+ display) rely on a canonical ``/foo/bar`` shape.
169
+ """
170
+ cleaned = (path or "").strip()
171
+ if not cleaned:
172
+ return "/"
173
+ if not cleaned.startswith("/"):
174
+ cleaned = f"/{cleaned}"
175
+ # Collapse runs of slashes so ``//a//b`` -> ``/a/b``.
176
+ while "//" in cleaned:
177
+ cleaned = cleaned.replace("//", "/")
178
+ return cleaned
179
+
180
+
181
+ def _join_route_parts(prefix: str, path: str) -> str:
182
+ base = _normalize_path(prefix)
183
+ suffix = _normalize_path(path)
184
+
185
+ if base == "/":
186
+ return suffix
187
+ if suffix == "/":
188
+ return base
189
+ return f"{base.rstrip('/')}/{suffix.lstrip('/')}"
190
+
191
+
192
+ def _extract_methods(args: str, default_method: str = "ANY") -> list[str]:
193
+ methods = [match.upper() for match in METHOD_LIST_PATTERN.findall(args)]
194
+ # "ALL" is a non-standard alias sometimes used in API code; normalize it
195
+ # to the canonical Route(method="ANY") sentinel.
196
+ methods = ["ANY" if m == "ALL" else m for m in methods]
197
+ return methods or [default_method]
198
+
199
+
200
+ def _python_route_prefixes(content: str) -> dict[str, str]:
201
+ local_prefixes: dict[str, str] = {}
202
+
203
+ for match in FASTAPI_ROUTER_PATTERN.finditer(content):
204
+ local_prefixes[match.group(1)] = match.group(2) or ""
205
+
206
+ for match in FLASK_BLUEPRINT_PATTERN.finditer(content):
207
+ local_prefixes[match.group(1)] = match.group(2) or ""
208
+
209
+ prefixes = dict(local_prefixes)
210
+ updated = True
211
+ while updated:
212
+ updated = False
213
+
214
+ for match in FASTAPI_INCLUDE_ROUTER_PATTERN.finditer(content):
215
+ parent_name, child_name, extra_prefix = match.groups()
216
+ parent_prefix = prefixes.get(parent_name, "")
217
+ child_prefix = local_prefixes.get(child_name, "")
218
+ combined = _join_route_parts(parent_prefix, _join_route_parts(extra_prefix or "", child_prefix))
219
+ if prefixes.get(child_name) != combined:
220
+ prefixes[child_name] = combined
221
+ updated = True
222
+
223
+ for match in FLASK_REGISTER_BLUEPRINT_PATTERN.finditer(content):
224
+ parent_name, child_name, extra_prefix = match.groups()
225
+ parent_prefix = prefixes.get(parent_name, "")
226
+ child_prefix = local_prefixes.get(child_name, "")
227
+ combined = _join_route_parts(parent_prefix, _join_route_parts(extra_prefix or "", child_prefix))
228
+ if prefixes.get(child_name) != combined:
229
+ prefixes[child_name] = combined
230
+ updated = True
231
+
232
+ return prefixes
233
+
234
+
235
+ def _extract_python_routes(content: str, file: str) -> list[Route]:
236
+ routes: list[Route] = []
237
+ prefixes = _python_route_prefixes(content)
238
+
239
+ for match in FASTAPI_DECORATOR_PATTERN.finditer(content):
240
+ router_name, method, route_path = match.groups()
241
+ routes.append(
242
+ Route(
243
+ path=_join_route_parts(prefixes.get(router_name, ""), route_path),
244
+ method=method.upper(),
245
+ file=file,
246
+ line=_line_of(content, match.start()),
247
+ )
248
+ )
249
+
250
+ for match in FASTAPI_API_ROUTE_PATTERN.finditer(content):
251
+ router_name, route_path, args = match.group(1), match.group(2), match.group("args")
252
+ full_path = _join_route_parts(prefixes.get(router_name, ""), route_path)
253
+ line = _line_of(content, match.start())
254
+ for method in _extract_methods(args, default_method="ANY"):
255
+ routes.append(Route(path=full_path, method=method, file=file, line=line))
256
+
257
+ for match in FLASK_ROUTE_PATTERN.finditer(content):
258
+ router_name, route_path, args = match.group(1), match.group(2), match.group("args")
259
+ full_path = _join_route_parts(prefixes.get(router_name, ""), route_path)
260
+ line = _line_of(content, match.start())
261
+ for method in _extract_methods(args, default_method="GET"):
262
+ routes.append(Route(path=full_path, method=method, file=file, line=line))
263
+
264
+ return routes
265
+
266
+
267
+ def _express_prefixes(content: str) -> dict[str, str]:
268
+ local_prefixes: dict[str, str] = {}
269
+ prefixes: dict[str, str] = {}
270
+
271
+ updated = True
272
+ while updated:
273
+ updated = False
274
+ for match in EXPRESS_USE_PATTERN.finditer(content):
275
+ parent_name, mount_path, child_name = match.groups()
276
+ parent_prefix = prefixes.get(parent_name, "")
277
+ child_prefix = local_prefixes.get(child_name, "")
278
+ local_prefixes[child_name] = child_prefix
279
+ combined = _join_route_parts(parent_prefix, _join_route_parts(mount_path, child_prefix))
280
+ if prefixes.get(child_name) != combined:
281
+ prefixes[child_name] = combined
282
+ updated = True
283
+
284
+ return prefixes
285
+
286
+
287
+ def _extract_javascript_routes(content: str, file: str) -> list[Route]:
288
+ routes: list[Route] = []
289
+ prefixes = _express_prefixes(content)
290
+
291
+ for match in EXPRESS_DIRECT_ROUTE_PATTERN.finditer(content):
292
+ router_name, method, route_path = match.groups()
293
+ method_normalized = method.upper()
294
+ if method_normalized == "ALL":
295
+ method_normalized = "ANY"
296
+ full_path = _join_route_parts(prefixes.get(router_name, ""), route_path)
297
+ routes.append(
298
+ Route(
299
+ path=full_path,
300
+ method=method_normalized,
301
+ file=file,
302
+ line=_line_of(content, match.start()),
303
+ )
304
+ )
305
+
306
+ for match in EXPRESS_CHAIN_ROUTE_PATTERN.finditer(content):
307
+ router_name, route_path, chain = match.group(1), match.group(2), match.group("chain")
308
+ full_path = _join_route_parts(prefixes.get(router_name, ""), route_path)
309
+ chain_offset = match.start("chain")
310
+ for method_match in re.finditer(r"\.(get|post|put|delete|patch|options|head|all)\s*\(", chain, re.IGNORECASE):
311
+ method = method_match.group(1).upper()
312
+ if method == "ALL":
313
+ method = "ANY"
314
+ routes.append(
315
+ Route(
316
+ path=full_path,
317
+ method=method,
318
+ file=file,
319
+ line=_line_of(content, chain_offset + method_match.start()),
320
+ )
321
+ )
322
+
323
+ return routes
324
+
325
+
326
+ def extract_routes(content: str, file: str, suffix: str) -> list[Route]:
327
+ if suffix == ".py":
328
+ return _extract_python_routes(content, file)
329
+ if suffix in {".js", ".ts", ".tsx"}:
330
+ return _extract_javascript_routes(content, file)
331
+ return []
332
+
333
+
334
+ def _append_unique_database_hints(result: ScanResult, relative: str, content: str, lowered: str) -> None:
335
+ seen = {(hint.kind, hint.file) for hint in result.databases}
336
+
337
+ for pattern, kind in DB_PATTERNS:
338
+ match = pattern.search(content)
339
+ if match and (kind, relative) not in seen:
340
+ result.databases.append(
341
+ DatabaseHint(
342
+ kind=kind,
343
+ file=relative,
344
+ line=_line_of(content, match.start()),
345
+ evidence_text=_line_snippet(content, match.start()),
346
+ )
347
+ )
348
+ seen.add((kind, relative))
349
+
350
+ for keyword, kind in DB_KEYWORDS.items():
351
+ idx = lowered.find(keyword)
352
+ if idx != -1 and (kind, relative) not in seen:
353
+ result.databases.append(
354
+ DatabaseHint(
355
+ kind=kind,
356
+ file=relative,
357
+ line=_line_of(content, idx),
358
+ evidence_text=_line_snippet(content, idx),
359
+ )
360
+ )
361
+ seen.add((kind, relative))
362
+
363
+
364
+ def _append_unique_auth_hints(result: ScanResult, relative: str, content: str, lowered: str) -> None:
365
+ seen = {(hint.hint, hint.file) for hint in result.auth_hints}
366
+
367
+ for pattern, hint in AUTH_PATTERNS:
368
+ match = pattern.search(content)
369
+ if match and (hint, relative) not in seen:
370
+ result.auth_hints.append(
371
+ AuthHint(
372
+ hint=hint,
373
+ file=relative,
374
+ line=_line_of(content, match.start()),
375
+ evidence_text=_line_snippet(content, match.start()),
376
+ confidence=0.85,
377
+ )
378
+ )
379
+ seen.add((hint, relative))
380
+
381
+ for keyword in AUTH_KEYWORDS:
382
+ idx = lowered.find(keyword)
383
+ if idx != -1 and (keyword, relative) not in seen:
384
+ result.auth_hints.append(
385
+ AuthHint(
386
+ hint=keyword,
387
+ file=relative,
388
+ line=_line_of(content, idx),
389
+ evidence_text=_line_snippet(content, idx),
390
+ confidence=0.5,
391
+ )
392
+ )
393
+ seen.add((keyword, relative))
394
+
395
+
396
+ def scan_repo(root: str | Path, suffixes: set[str] | None = None) -> ScanResult:
397
+ root_path = Path(root).resolve()
398
+ result = ScanResult(root=str(root_path))
399
+
400
+ for file_path in root_path.rglob("*"):
401
+ if not file_path.is_file() or not should_scan_with_suffixes(file_path, suffixes):
402
+ continue
403
+
404
+ result.files_scanned += 1
405
+ language = CODE_EXTENSIONS[file_path.suffix]
406
+ if language not in result.languages:
407
+ result.languages.append(language)
408
+
409
+ try:
410
+ content = file_path.read_text(encoding="utf-8")
411
+ except UnicodeDecodeError:
412
+ continue
413
+
414
+ relative = str(file_path.relative_to(root_path))
415
+ file_routes = extract_routes(content, relative, file_path.suffix)
416
+ result.routes.extend(file_routes)
417
+
418
+ for pattern in EXTERNAL_CALL_PATTERNS:
419
+ for match in pattern.finditer(content):
420
+ target = match.groups()[-1]
421
+ result.external_calls.append(
422
+ ExternalCall(
423
+ target=target,
424
+ file=relative,
425
+ line=_line_of(content, match.start()),
426
+ evidence_text=_line_snippet(content, match.start()),
427
+ )
428
+ )
429
+
430
+ lowered = content.lower()
431
+ _append_unique_database_hints(result, relative, content, lowered)
432
+ _append_unique_auth_hints(result, relative, content, lowered)
433
+
434
+ for pattern in SECRET_PATTERNS:
435
+ for match in pattern.finditer(content):
436
+ result.secret_hints.append(
437
+ SecretHint(
438
+ name=match.groups()[0],
439
+ file=relative,
440
+ line=_line_of(content, match.start()),
441
+ evidence_text=_line_snippet(content, match.start()),
442
+ )
443
+ )
444
+
445
+ result.languages.sort()
446
+ return result
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from .contracts import (
4
+ AnalyzerMetadata,
5
+ AnalyzerProtocol,
6
+ AnalyzerRepositoryModule,
7
+ AnalyzerResult,
8
+ normalize_analyzer_metadata,
9
+ )
10
+ from .models import (
11
+ AuthHint,
12
+ DatabaseHint,
13
+ EdgeHint,
14
+ EntrypointHint,
15
+ ExternalCall,
16
+ FrameworkHint,
17
+ ProtocolHint,
18
+ Route,
19
+ ScanResult,
20
+ SecretHint,
21
+ ServiceHint,
22
+ )
23
+
24
+ __all__ = [
25
+ "AnalyzerResult",
26
+ "AnalyzerMetadata",
27
+ "AnalyzerRepositoryModule",
28
+ "AnalyzerProtocol",
29
+ "normalize_analyzer_metadata",
30
+ "Route",
31
+ "ExternalCall",
32
+ "DatabaseHint",
33
+ "AuthHint",
34
+ "ServiceHint",
35
+ "EdgeHint",
36
+ "EntrypointHint",
37
+ "ProtocolHint",
38
+ "FrameworkHint",
39
+ "SecretHint",
40
+ "ScanResult",
41
+ ]
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from ..analyzer_contracts import (
4
+ AnalyzerMetadata,
5
+ AnalyzerProtocol,
6
+ AnalyzerRepositoryModule,
7
+ AnalyzerResult,
8
+ normalize_analyzer_metadata,
9
+ )
10
+
11
+ __all__ = [
12
+ "AnalyzerResult",
13
+ "AnalyzerMetadata",
14
+ "AnalyzerRepositoryModule",
15
+ "AnalyzerProtocol",
16
+ "normalize_analyzer_metadata",
17
+ ]
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from ..recon_models import (
4
+ AuthHint,
5
+ DatabaseHint,
6
+ EdgeHint,
7
+ EntrypointHint,
8
+ ExternalCall,
9
+ FrameworkHint,
10
+ ProtocolHint,
11
+ Route,
12
+ ScanResult,
13
+ SecretHint,
14
+ ServiceHint,
15
+ )
16
+
17
+ __all__ = [
18
+ "Route",
19
+ "ExternalCall",
20
+ "DatabaseHint",
21
+ "AuthHint",
22
+ "ServiceHint",
23
+ "EdgeHint",
24
+ "EntrypointHint",
25
+ "ProtocolHint",
26
+ "FrameworkHint",
27
+ "SecretHint",
28
+ "ScanResult",
29
+ ]
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .asset_model import detect_assets
6
+ from .attack_taxonomy import annotate_findings, annotate_insights
7
+ from .control_model import detect_controls
8
+ from .detection_opportunities import generate_detection_opportunities
9
+ from .insights import generate_insights
10
+ from .models import (
11
+ Asset,
12
+ AttackPath,
13
+ AttackSurface,
14
+ Control,
15
+ DetectionOpportunity,
16
+ Finding,
17
+ Insight,
18
+ ScanResult,
19
+ )
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class SecurityOverlay:
24
+ """Cross-cutting security view layered on top of raw scan signals.
25
+
26
+ Bundles:
27
+ * the asset inventory (`assets`),
28
+ * the defensive control map (`controls`, present + absent),
29
+ * the set of cross-cutting insights connecting them (`insights`),
30
+ * defender-facing detection-engineering hints (`detection_opportunities`),
31
+ * findings with ATT&CK technique mappings layered in (`findings`).
32
+
33
+ Computed once per analyze run and consumed by the JSON report, the
34
+ markdown review, and the LLM prompt pack.
35
+ """
36
+
37
+ assets: list[Asset]
38
+ controls: list[Control]
39
+ insights: list[Insight]
40
+ detection_opportunities: list[DetectionOpportunity]
41
+ findings: list[Finding]
42
+
43
+
44
+ def build_security_overlay(
45
+ scan: ScanResult,
46
+ attack_surfaces: list[AttackSurface],
47
+ findings: list[Finding],
48
+ attack_paths: list[AttackPath],
49
+ ) -> SecurityOverlay:
50
+ assets = detect_assets(scan)
51
+ controls = detect_controls(scan, attack_surfaces, assets)
52
+ raw_insights = generate_insights(
53
+ scan,
54
+ attack_surfaces,
55
+ [finding.title for finding in findings],
56
+ attack_paths,
57
+ assets,
58
+ controls,
59
+ )
60
+ insights = annotate_insights(raw_insights)
61
+ annotated_findings = annotate_findings(findings)
62
+ detection_opportunities = generate_detection_opportunities(insights, annotated_findings)
63
+ return SecurityOverlay(
64
+ assets=assets,
65
+ controls=controls,
66
+ insights=insights,
67
+ detection_opportunities=detection_opportunities,
68
+ findings=annotated_findings,
69
+ )