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
attackmap/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ __all__ = ["__version__"]
2
+ __version__ = "0.6.4"
attackmap/analyzer.py ADDED
@@ -0,0 +1,449 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import Counter
5
+
6
+ import networkx as nx
7
+
8
+ from .md import md_text
9
+ from .models import AttackSurface, AuthHint, Route, ScanResult
10
+ from .webhook_verification import assess_webhook_surfaces, reclaim_receivers, webhook_management_routes
11
+
12
+
13
+ # Bounded line window used to attribute auth hints to a specific route
14
+ # handler. Chosen so a hint at the top of the handler body still lands
15
+ # on the route even if the handler is 40 lines long. Bigger than a typical
16
+ # handler; smaller than the distance between two adjacent routes. See #41
17
+ # / Bluesky FINDINGS §55 for the noise problem this fixes.
18
+ _ROUTE_AUTH_LINE_WINDOW = 40
19
+
20
+ # An authentication path segment (#340): the term starts the segment and is
21
+ # followed by the end of the segment, a non-letter (`login-form`, `oauth2`,
22
+ # `auth_callback`, `authCallback`) or one of its own word forms
23
+ # (`authenticate`, `authorize`, `sessions`, `tokens`). A substring match made
24
+ # `/api/authorities` and `/authors` auth routes.
25
+ _AUTH_SEGMENT = re.compile(
26
+ r"\A(?i:login|signin|signup|auth|session|token|oauth)"
27
+ r"(?i:s|enticate|entication|orize|orization)?"
28
+ r"(?![a-z])"
29
+ )
30
+
31
+
32
+ def is_auth_path(path: str) -> bool:
33
+ """Whether a route path has an authentication segment (see `_AUTH_SEGMENT`)."""
34
+ return any(_AUTH_SEGMENT.match(segment) for segment in path.split("/")[1:] if segment)
35
+
36
+
37
+ # Authentication entry points (#328). The route that authenticates the caller
38
+ # has to be reachable without authentication, so `POST /admin/login` is an
39
+ # `auth` surface (brute force, lockout), not an unguarded admin action. Matched
40
+ # on whole path segments, and only when every segment before the action is
41
+ # admin/auth vocabulary: `/admin/users/:id/logout` (forcing another user out)
42
+ # and `/admin/login-as/:id` (impersonation) stay admin actions.
43
+ _LOGIN_SEGMENTS = frozenset({
44
+ "login", "log-in", "log_in", "logon", "signin", "sign-in", "sign_in", "authenticate",
45
+ "logout", "log-out", "log_out", "logoff", "signout", "sign-out", "sign_out",
46
+ "forgot-password", "forgot_password", "forgotpassword", "reset-password", "reset_password",
47
+ "resetpassword", "password-reset", "password_reset", "passwordreset",
48
+ })
49
+ # `/admin/password/reset`, `/admin/password/email` (Laravel).
50
+ _PASSWORD_ACTIONS = frozenset({"reset", "forgot", "email"})
51
+ # Token and callback actions count only under an auth segment
52
+ # (`/admin/auth/token`, `/admin/oauth/callback`): `/admin/api/token` may mint
53
+ # API tokens for others.
54
+ _TOKEN_SEGMENTS = frozenset({"token", "refresh", "refresh-token", "refresh_token", "callback"})
55
+ _AUTH_SEGMENTS = frozenset({
56
+ "auth", "authentication", "oauth", "oauth2", "oidc", "openid", "sso", "saml", "jwt", "token",
57
+ })
58
+ # Exact words only: `administrators`, `admin-users`, `accounts`, `staff` or
59
+ # `sessions` name a resource an admin acts on (`/admin/accounts/reset-password`
60
+ # resets someone else's password).
61
+ _ADMIN_PREFIX_SEGMENTS = frozenset({
62
+ "admin", "manage", "root", "api", "dashboard", "backend", "backoffice", "panel", "console", "password",
63
+ })
64
+ _VERSION_SEGMENT_RE = re.compile(r"v\d{1,3}")
65
+ _PLAIN_SEGMENT_RE = re.compile(r"[a-z0-9_-]{1,40}")
66
+ _ENTRY_SUFFIXES = (".php", ".aspx", ".asp", ".jsp", ".html", ".htm", ".do")
67
+
68
+
69
+ def _prefix_segment(segment: str) -> bool:
70
+ return (
71
+ segment in _ADMIN_PREFIX_SEGMENTS
72
+ or segment in _AUTH_SEGMENTS
73
+ or _VERSION_SEGMENT_RE.fullmatch(segment) is not None
74
+ )
75
+
76
+
77
+ def is_auth_entry_point(path: str) -> bool:
78
+ """Whether `path` is a login/logout/password-reset/token/OAuth-callback
79
+ endpoint rather than an action behind authentication (#328)."""
80
+ segments = [seg for seg in path.lower().split("?", 1)[0].split("/") if seg]
81
+ if not segments or len(segments) > 8:
82
+ return False
83
+ action = segments[-1]
84
+ for suffix in _ENTRY_SUFFIXES:
85
+ if action.endswith(suffix):
86
+ action = action[: -len(suffix)]
87
+ break
88
+ before = segments[:-1]
89
+ if action in _LOGIN_SEGMENTS:
90
+ pass
91
+ elif action in _PASSWORD_ACTIONS:
92
+ if not before or before[-1] != "password":
93
+ return False
94
+ elif action in _TOKEN_SEGMENTS:
95
+ # One provider segment may sit before a callback: /admin/auth/google/callback.
96
+ if action == "callback" and len(before) >= 2 and before[-2] in _AUTH_SEGMENTS:
97
+ if _PLAIN_SEGMENT_RE.fullmatch(before[-1]) is None:
98
+ return False
99
+ before = before[:-1]
100
+ if not any(seg in _AUTH_SEGMENTS for seg in before):
101
+ return False
102
+ else:
103
+ return False
104
+ return all(_prefix_segment(seg) for seg in before)
105
+
106
+
107
+ def _auth_hints_for_route(
108
+ route: Route,
109
+ scan_auth_hints: list[AuthHint],
110
+ hints_by_file: dict[str, list[AuthHint]],
111
+ ) -> list[str]:
112
+ """Return the auth-hint strings that plausibly attach to `route`.
113
+
114
+ If the route has a known `line`, only hints in the same file whose
115
+ line falls within `_ROUTE_AUTH_LINE_WINDOW` above OR below the
116
+ route are counted. If the route lacks line info (framework
117
+ config-driven routes commonly do), fall back to file-scoped
118
+ attribution — same as the old behavior.
119
+
120
+ Deliberately no global fallback: the "if no hints in this file,
121
+ inherit every auth hint in the repo" behavior was the biggest
122
+ noise source per FINDINGS §55. Downstream code handles empty
123
+ auth_signals correctly.
124
+ """
125
+ file_hints = hints_by_file.get(route.file, [])
126
+ if not file_hints:
127
+ return []
128
+ if route.line is None:
129
+ return sorted({hint.hint for hint in file_hints})
130
+ lo = route.line - _ROUTE_AUTH_LINE_WINDOW
131
+ hi = route.line + _ROUTE_AUTH_LINE_WINDOW
132
+ windowed = {
133
+ hint.hint
134
+ for hint in file_hints
135
+ if hint.line is None or lo <= hint.line <= hi
136
+ }
137
+ if windowed:
138
+ return sorted(windowed)
139
+ # Hints exist in the file but none are within the window. That's the
140
+ # signal-to-noise fix: the previous route in this file had auth code,
141
+ # this one doesn't. Return empty — the surface's rationale below will
142
+ # say "no auth indicators near this route".
143
+ return []
144
+
145
+
146
+ def identify_attack_surfaces(scan: ScanResult) -> list[AttackSurface]:
147
+ surfaces: list[AttackSurface] = []
148
+ auth_hints_by_file: dict[str, list[AuthHint]] = {}
149
+ for hint in scan.auth_hints:
150
+ auth_hints_by_file.setdefault(hint.file, []).append(hint)
151
+
152
+ supporting_hints_by_file: dict[str, set[str]] = {}
153
+ all_supporting_hints = [
154
+ *scan.auth_hints,
155
+ *scan.service_hints,
156
+ *scan.edge_hints,
157
+ *scan.entrypoint_hints,
158
+ *scan.protocol_hints,
159
+ *scan.framework_hints,
160
+ ]
161
+
162
+ for hint in all_supporting_hints:
163
+ supporting_hints_by_file.setdefault(hint.file, set()).add(hint.hint)
164
+
165
+ management = webhook_management_routes(scan)
166
+ management_surfaces: list[tuple[AttackSurface, str]] = []
167
+
168
+ for route in scan.routes:
169
+ path_lower = route.path.lower()
170
+ management_reason = management.get((route.method, route.path, route.file))
171
+ # A plugin-declared route auth state (#256) beats the ±40-line file
172
+ # window, which lets a route inherit its neighbour's guard.
173
+ if route.auth == "required":
174
+ auth_signals = sorted(set(route.guards)) or ["plugin_declared_auth"]
175
+ elif route.auth == "anonymous":
176
+ auth_signals = []
177
+ else:
178
+ auth_signals = _auth_hints_for_route(route, scan.auth_hints, auth_hints_by_file)
179
+ supporting_signals = sorted(supporting_hints_by_file.get(route.file, set())) or sorted(
180
+ {hint.hint for hint in all_supporting_hints}
181
+ )
182
+ rationale: list[str] = []
183
+ exposure: AttackSurface["exposure"] = "public"
184
+ internal_handler_markers = {
185
+ "handler_visibility:internal",
186
+ "handler_type:internal_handler",
187
+ "service_role:worker",
188
+ "service_role:event_consumer",
189
+ "service_role:internal_handler",
190
+ }
191
+ has_explicit_public_visibility = "handler_visibility:public" in supporting_signals
192
+ has_internal_handler_hint = any(marker in supporting_signals for marker in internal_handler_markers) and not has_explicit_public_visibility
193
+
194
+ admin_path = any(segment in path_lower for segment in ("/admin", "/manage", "/root"))
195
+ if management_reason is not None:
196
+ # CRUD for the app's own outgoing webhooks is an ordinary route (#365).
197
+ rationale.append(
198
+ f"Manages the application's own webhook configuration ({management_reason}); "
199
+ "not an inbound event receiver."
200
+ )
201
+ if "webhook" in path_lower and management_reason is None:
202
+ category = "webhook"
203
+ risk = "high"
204
+ rationale.append("Webhook endpoints commonly accept attacker-controlled input from the internet.")
205
+ elif admin_path and not is_auth_entry_point(path_lower):
206
+ category = "admin"
207
+ risk = "high"
208
+ rationale.append("Administrative routes are high-value targets because they expose privileged actions.")
209
+ elif admin_path or is_auth_path(route.path): # an admin path here is its login/logout/token endpoint (#328)
210
+ category = "auth"
211
+ risk = "high" if not auth_signals else "medium"
212
+ rationale.append("Authentication endpoints are common targets for brute force, token abuse, and session attacks.")
213
+ elif any(segment in path_lower for segment in ("/upload", "/import", "/file")):
214
+ category = "upload"
215
+ risk = "high"
216
+ rationale.append("Upload and import flows often lead to parser abuse or untrusted file handling.")
217
+ elif any(segment in path_lower for segment in ("/internal", "/debug")):
218
+ category = "internal"
219
+ exposure = "internal"
220
+ risk = "medium"
221
+ rationale.append("Internal or debug routes are risky if they become reachable from untrusted networks.")
222
+ elif any(segment in path_lower for segment in ("/health", "/ready", "/live", "/metrics")):
223
+ category = "health"
224
+ risk = "low"
225
+ rationale.append("Operational endpoints reveal limited attack surface but may disclose useful reconnaissance details.")
226
+ else:
227
+ category = "public_api"
228
+ risk = "medium" if scan.databases or scan.external_calls else "low"
229
+ rationale.append("Public application routes are initial footholds for probing input handling and authorization gaps.")
230
+
231
+ if has_internal_handler_hint and category not in {"admin", "auth", "webhook"}:
232
+ exposure = "internal"
233
+ if category == "public_api":
234
+ category = "internal"
235
+ risk = "medium" if category != "health" else "low"
236
+ rationale.append("Service-level handler metadata suggests this route is internal-facing (worker/event/internal handler path).")
237
+
238
+ if auth_signals:
239
+ rationale.append(f"Auth indicators observed: {', '.join(auth_signals)}.")
240
+ else:
241
+ rationale.append("No auth indicators were observed near this route.")
242
+
243
+ if scan.databases:
244
+ rationale.append("Datastore usage was detected elsewhere in the repository, so route-to-data flows are plausible.")
245
+ if scan.external_calls:
246
+ rationale.append("Outbound HTTP calls were detected, creating trust boundaries to third-party systems.")
247
+
248
+ surface = AttackSurface(
249
+ route=route.path,
250
+ method=route.method,
251
+ file=route.file,
252
+ line=route.line,
253
+ category=category,
254
+ exposure=exposure,
255
+ risk=risk,
256
+ auth_signals=auth_signals,
257
+ data_store_interaction=bool(scan.databases),
258
+ outbound_integration=bool(scan.external_calls),
259
+ rationale=rationale,
260
+ )
261
+ surfaces.append(surface)
262
+ if management_reason is not None:
263
+ management_surfaces.append((surface, category))
264
+
265
+ # Precision first: a management candidate whose handler reaches signature
266
+ # verification is a receiver after all.
267
+ for surface in reclaim_receivers(scan, [s for s, _ in management_surfaces]):
268
+ surface.category = "webhook"
269
+ surface.risk = "high"
270
+ surface.exposure = "public"
271
+ surface.rationale = [
272
+ r for r in surface.rationale if not r.startswith("Manages the application's own webhook")
273
+ ]
274
+ surface.rationale.insert(0, "Webhook endpoints commonly accept attacker-controlled input from the internet.")
275
+
276
+ assess_webhook_surfaces(scan, surfaces)
277
+ return surfaces
278
+
279
+
280
+ def summarize_architecture(scan: ScanResult, graph: nx.DiGraph) -> str:
281
+ route_count = len(scan.routes)
282
+ dbs = sorted({db.kind for db in scan.databases})
283
+ external_targets = sorted({call.target for call in scan.external_calls})
284
+ auth_summary = sorted({hint.hint for hint in scan.auth_hints})
285
+ secret_count = len(scan.secret_hints)
286
+ graph_edges = sorted(
287
+ f"{source} -> {target} ({data['relation']})"
288
+ for source, target, data in graph.edges(data=True)
289
+ if "relation" in data
290
+ )
291
+
292
+ lines = [
293
+ "# Architecture Summary",
294
+ "",
295
+ "## Overview",
296
+ f"- AttackMap inferred a {'web-facing' if route_count else 'non-web'} repository with {route_count} entry point{'s' if route_count != 1 else ''}.",
297
+ f"- Files scanned: {scan.files_scanned}",
298
+ f"- Languages detected: {', '.join(scan.languages) if scan.languages else 'none'}",
299
+ f"- Inferred entry points: {route_count}",
300
+ f"- Datastores detected: {', '.join(dbs) if dbs else 'none'}",
301
+ f"- External targets detected: {', '.join(external_targets) if external_targets else 'none'}",
302
+ f"- Auth hints observed: {', '.join(auth_summary) if auth_summary else 'none'}",
303
+ f"- Secret-like env references: {secret_count}",
304
+ ]
305
+
306
+ if scan.routes:
307
+ common = Counter(route.file for route in scan.routes).most_common(3)
308
+ lines.extend(
309
+ [
310
+ "",
311
+ "## Entry Point Concentration",
312
+ *[f"- {file}: {count} routes" for file, count in common],
313
+ ]
314
+ )
315
+ busiest_file, busiest_count = common[0]
316
+ lines.extend(
317
+ [
318
+ "",
319
+ "## Likely Review Starting Point",
320
+ f"- Start with `{busiest_file}`. It contains {busiest_count} of the detected routes and is the fastest place to validate exposure and trust boundaries.",
321
+ ]
322
+ )
323
+
324
+ if graph_edges:
325
+ lines.extend(
326
+ [
327
+ "",
328
+ "## Inferred Trust Boundaries",
329
+ *[f"- {edge}" for edge in graph_edges[:10]],
330
+ ]
331
+ )
332
+
333
+ lines.extend(
334
+ [
335
+ "",
336
+ "## Analyst Notes",
337
+ "- AttackMap inferred this system shape heuristically from framework patterns, dependencies, and code hints.",
338
+ "- Treat this as an initial map for security review, then validate the highest-risk routes and boundaries manually.",
339
+ ]
340
+ )
341
+
342
+ return "\n".join(lines)
343
+
344
+
345
+ def _risk_rank(value: str) -> int:
346
+ return {"high": 0, "medium": 1, "low": 2}.get(value, 3)
347
+
348
+
349
+ def _surface_summary(surface: AttackSurface) -> str:
350
+ traits: list[str] = []
351
+ if surface.auth_signals:
352
+ traits.append(f"auth signals: {', '.join(md_text(signal) for signal in surface.auth_signals)}")
353
+ else:
354
+ traits.append("no auth signals observed")
355
+
356
+ if surface.data_store_interaction:
357
+ traits.append("data store reachable")
358
+ if surface.outbound_integration:
359
+ traits.append("external trust boundary")
360
+ if surface.webhook_verification is not None:
361
+ traits.append(f"webhook signature: {surface.webhook_verification.state.replace('_', ' ')}")
362
+
363
+ return "; ".join(traits)
364
+
365
+
366
+ def _surface_label(surface: AttackSurface) -> str:
367
+ """``METHOD route (file)`` with the repo-derived parts escaped: a route
368
+ string is attacker-controlled text in a scanned repository."""
369
+ return f"{md_text(surface.method)} {md_text(surface.route)} ({md_text(surface.file)})"
370
+
371
+
372
+ def summarize_attack_surface(scan: ScanResult, attack_surfaces: list[AttackSurface] | None = None) -> str:
373
+ surfaces = attack_surfaces if attack_surfaces is not None else identify_attack_surfaces(scan)
374
+ ordered_surfaces = sorted(surfaces, key=lambda surface: (_risk_rank(surface.risk), surface.route, surface.method))
375
+ external_targets = sorted({call.target for call in scan.external_calls})
376
+ auth_summary = sorted({a.hint for a in scan.auth_hints})
377
+ high_risk = [surface for surface in ordered_surfaces if surface.risk == "high"]
378
+ auth_and_admin = [surface for surface in ordered_surfaces if surface.category in {"auth", "admin"}]
379
+ public_entry_points = [surface for surface in ordered_surfaces if surface.exposure == "public" and surface.category not in {"auth", "admin", "health"}]
380
+ operational_routes = [surface for surface in ordered_surfaces if surface.category in {"health", "internal"}]
381
+
382
+ lines = [
383
+ "# Attack Surface",
384
+ "",
385
+ "## Priority View",
386
+ f"- High-risk entry points: {len(high_risk)}",
387
+ f"- Public routes detected: {sum(1 for surface in ordered_surfaces if surface.exposure == 'public')}",
388
+ f"- Internal-only routes detected: {sum(1 for surface in ordered_surfaces if surface.exposure == 'internal')}",
389
+ f"- Routes with likely data access: {sum(1 for surface in ordered_surfaces if surface.data_store_interaction)}",
390
+ f"- Routes with outbound trust boundaries: {sum(1 for surface in ordered_surfaces if surface.outbound_integration)}",
391
+ ]
392
+
393
+ lines.extend(["", "## Highest-Risk Entry Points"])
394
+ if high_risk:
395
+ for surface in high_risk[:10]:
396
+ lines.append(f"- {_surface_label(surface)} -> {surface.category}; {_surface_summary(surface)}")
397
+ else:
398
+ lines.append("- No high-risk entry points were classified")
399
+
400
+ lines.extend(["", "## Auth And Privileged Routes"])
401
+ if auth_and_admin:
402
+ for surface in auth_and_admin[:10]:
403
+ lines.append(
404
+ f"- [{surface.risk.upper()}] {_surface_label(surface)} "
405
+ f"-> {surface.category}, {surface.exposure}; {_surface_summary(surface)}"
406
+ )
407
+ else:
408
+ lines.append("- No dedicated auth or admin routes were classified")
409
+
410
+ lines.extend(["", "## Public Application Routes"])
411
+ if public_entry_points:
412
+ for surface in public_entry_points[:10]:
413
+ lines.append(
414
+ f"- [{surface.risk.upper()}] {_surface_label(surface)} "
415
+ f"-> {surface.category}, {surface.exposure}; {_surface_summary(surface)}"
416
+ )
417
+ else:
418
+ lines.append("- No additional public application routes were classified")
419
+
420
+ lines.extend(["", "## Operational And Internal Routes"])
421
+ if operational_routes:
422
+ for surface in operational_routes[:10]:
423
+ lines.append(
424
+ f"- [{surface.risk.upper()}] {_surface_label(surface)} "
425
+ f"-> {surface.category}, {surface.exposure}; {_surface_summary(surface)}"
426
+ )
427
+ else:
428
+ lines.append("- No health, metrics, debug, or internal routes were classified")
429
+
430
+ lines.extend(["", "## External Dependencies"])
431
+ if external_targets:
432
+ for target in external_targets[:25]:
433
+ lines.append(f"- {md_text(target)}")
434
+ else:
435
+ lines.append("- No explicit outbound HTTP calls detected")
436
+
437
+ lines.extend(["", "## Secrets And Auth Hints"])
438
+ if scan.secret_hints:
439
+ for hint in scan.secret_hints[:25]:
440
+ lines.append(f"- Secret-related env usage: {md_text(hint.name)} ({md_text(hint.file)})")
441
+ else:
442
+ lines.append("- No secret-like environment variable usage detected")
443
+
444
+ if auth_summary:
445
+ lines.append(f"- Auth-related keywords observed: {', '.join(md_text(hint) for hint in auth_summary)}")
446
+ else:
447
+ lines.append("- No auth-related keywords observed")
448
+
449
+ return "\n".join(lines)
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Protocol
6
+
7
+ from pydantic import BaseModel, Field, field_validator, model_validator
8
+
9
+ from .recon_models import ScanResult
10
+
11
+ # Phase-1 shared repository analyzer contract.
12
+ # Keep AnalyzerResult mapped to ScanResult for backward compatibility.
13
+ AnalyzerResult = ScanResult
14
+
15
+ # `name` must be a slug: lowercase letters, digits, hyphens. This is the
16
+ # entry-point key AttackMap uses to identify the analyzer, so it needs
17
+ # to be safe in URLs, filesystem paths, and CLI arguments.
18
+ _ANALYZER_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*$")
19
+
20
+
21
+ class AnalyzerMetadata(BaseModel):
22
+ """Static, self-describing metadata every analyzer exposes.
23
+
24
+ Every analyzer plugin ships a `metadata: AnalyzerMetadata` attribute so
25
+ core (and future tooling) can reason about the plugin without loading
26
+ or running it. The field set is stable: adding fields is safe on a
27
+ minor release, removing or renaming is a major-version break.
28
+ """
29
+
30
+ name: str = Field(
31
+ description="Slug identifier. Must match /[a-z0-9][a-z0-9-]*/. "
32
+ "This is the entry-point key AttackMap uses to select the analyzer "
33
+ "(e.g. from --module) and to attribute signals via provenance.",
34
+ )
35
+ display_name: str = Field(
36
+ default="",
37
+ description="Human-friendly name shown in `attackmap modules` output "
38
+ "and reports. Defaults to `name` when omitted.",
39
+ )
40
+ version: str = Field(
41
+ default="0.1.0",
42
+ description="Semver of the analyzer plugin itself (not attackmap). "
43
+ "Should match the analyzer package's pyproject.toml version.",
44
+ )
45
+ description: str = Field(
46
+ default="",
47
+ description="One-sentence summary of what the analyzer detects. "
48
+ "Shown in module listings and matter briefings.",
49
+ )
50
+ scope: str = Field(
51
+ default="",
52
+ description="One-sentence description of the kinds of repositories "
53
+ "this analyzer targets (e.g. 'Node/TypeScript backend service repos').",
54
+ )
55
+ targets: list[str] = Field(
56
+ default_factory=list,
57
+ description="Frameworks, protocols, or platform tokens this analyzer "
58
+ "specializes in (e.g. ['react-native', 'expo']). Used with `languages` "
59
+ "to compute `ecosystems`.",
60
+ )
61
+ languages: list[str] = Field(
62
+ default_factory=list,
63
+ description="Programming languages this analyzer parses (e.g. "
64
+ "['javascript', 'typescript']). Used with `targets` to compute "
65
+ "`ecosystems`.",
66
+ )
67
+ priority: int = Field(
68
+ default=100,
69
+ ge=0,
70
+ description="Run order across built-ins and plugins: lower runs first, "
71
+ "ties broken by analyzer name. Merge is first-seen-wins in that order, "
72
+ "so a lower value also wins a duplicate signal. Convention: "
73
+ "0-49 is broad language analyzers (python, node-service); 50-149 is "
74
+ "framework analyzers (spring, laminas); 150+ is app-specific "
75
+ "analyzers (omeka-s).",
76
+ )
77
+ experimental: bool = Field(
78
+ default=True,
79
+ description="Whether this analyzer is still stabilizing. Doesn't "
80
+ "affect execution, just how core presents it in listings. "
81
+ "Experimental analyzers should normally leave enabled_by_default False.",
82
+ )
83
+ enabled_by_default: bool = Field(
84
+ default=False,
85
+ description="Whether to run this analyzer without an explicit "
86
+ "--module flag. False makes it opt-in: it runs only when selected "
87
+ "with -m (detect() still gates it), and the CLI notes when an opt-in "
88
+ "analyzer matched but didn't run. Broad language analyzers set this "
89
+ "True; specialty framework/app analyzers usually leave it False.",
90
+ )
91
+
92
+ @field_validator("name")
93
+ @classmethod
94
+ def _validate_name(cls, value: str) -> str:
95
+ if not value:
96
+ raise ValueError("AnalyzerMetadata.name must not be empty")
97
+ if not _ANALYZER_NAME_PATTERN.match(value):
98
+ raise ValueError(
99
+ f"AnalyzerMetadata.name {value!r} must match "
100
+ f"/{_ANALYZER_NAME_PATTERN.pattern}/ — lowercase letters, "
101
+ f"digits, and hyphens only, starting with a letter or digit."
102
+ )
103
+ return value
104
+
105
+ @field_validator("version")
106
+ @classmethod
107
+ def _validate_version(cls, value: str) -> str:
108
+ if not value:
109
+ raise ValueError("AnalyzerMetadata.version must not be empty")
110
+ return value
111
+
112
+ @model_validator(mode="before")
113
+ @classmethod
114
+ def _coerce_legacy_ecosystems(cls, value: object) -> object:
115
+ if not isinstance(value, dict):
116
+ return value
117
+ payload = dict(value)
118
+ # Backward-compat: older call sites may pass ecosystems directly.
119
+ # Use it as a fallback source for languages when newer fields are omitted.
120
+ ecosystems = payload.pop("ecosystems", None)
121
+ if ecosystems and not payload.get("languages") and not payload.get("targets"):
122
+ payload["languages"] = list(ecosystems)
123
+ if not payload.get("display_name") and payload.get("name"):
124
+ payload["display_name"] = str(payload["name"])
125
+ return payload
126
+
127
+ @property
128
+ def ecosystems(self) -> tuple[str, ...]:
129
+ """Deduplicated tuple of languages + targets, lowercased.
130
+
131
+ Convenience view for callers that don't care about the language vs
132
+ target distinction (e.g. picking analyzers by ecosystem token).
133
+ """
134
+ values = [*self.languages, *self.targets]
135
+ seen: set[str] = set()
136
+ ordered: list[str] = []
137
+ for value in values:
138
+ lowered = value.lower()
139
+ if lowered in seen:
140
+ continue
141
+ seen.add(lowered)
142
+ ordered.append(lowered)
143
+ return tuple(ordered)
144
+
145
+
146
+ class AnalyzerRepositoryModule(BaseModel):
147
+ analyzer_name: str
148
+ repo_name: str
149
+ web_url: str
150
+
151
+
152
+ class AnalyzerProtocol(Protocol):
153
+ metadata: AnalyzerMetadata
154
+
155
+ @property
156
+ def name(self) -> str: ...
157
+
158
+ def detect(self, root: str | Path) -> bool: ...
159
+
160
+ def analyze(self, root: str | Path) -> AnalyzerResult: ...
161
+
162
+
163
+ __all__ = [
164
+ "AnalyzerResult",
165
+ "AnalyzerMetadata",
166
+ "AnalyzerRepositoryModule",
167
+ "AnalyzerProtocol",
168
+ "normalize_analyzer_metadata",
169
+ ]
170
+
171
+
172
+ def normalize_analyzer_metadata(value: object) -> AnalyzerMetadata:
173
+ if isinstance(value, AnalyzerMetadata):
174
+ return value
175
+ if isinstance(value, dict):
176
+ return AnalyzerMetadata.model_validate(value)
177
+
178
+ payload: dict[str, object] = {}
179
+ for key in (
180
+ "name",
181
+ "display_name",
182
+ "version",
183
+ "description",
184
+ "scope",
185
+ "targets",
186
+ "languages",
187
+ "priority",
188
+ "experimental",
189
+ "enabled_by_default",
190
+ "ecosystems",
191
+ ):
192
+ if hasattr(value, key):
193
+ payload[key] = getattr(value, key)
194
+ return AnalyzerMetadata.model_validate(payload)