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,846 @@
1
+ """Anthropic-powered narrative review generator.
2
+
3
+ Wraps the existing prompt scaffolding (`review_prompts.render_review_prompts`)
4
+ in a real Claude call so users can produce a story-bearing defensive review
5
+ without copy-pasting JSON into a chat client.
6
+
7
+ Auth resolution (in order):
8
+ 1. Explicit `client=` (tests / programmatic override)
9
+ 2. `ANTHROPIC_API_KEY` env → SDK with api_key (per-token API billing)
10
+ 3. `ANTHROPIC_AUTH_TOKEN` env → SDK with auth_token (OAuth bearer)
11
+ 4. `claude` CLI on PATH → shell out to `claude -p --output-format=json`
12
+ (uses whatever auth `claude login` configured —
13
+ typically the user's Pro/Max subscription)
14
+
15
+ The user can force a backend via the `backend` argument or the CLI's
16
+ `--llm-backend` flag; "auto" walks the order above and picks the first one
17
+ that resolves.
18
+
19
+ Optional dependency: `pip install "attackmap[llm] @ git+https://github.com/mlaify/AttackMap.git"` installs the anthropic SDK.
20
+ The CLI backend only requires the `claude` binary on PATH (no SDK install).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import re
28
+ import shutil
29
+ import subprocess
30
+ import tempfile
31
+ from collections.abc import Iterator
32
+ from contextlib import contextmanager
33
+ from dataclasses import dataclass
34
+ from functools import lru_cache
35
+ from typing import Any, Literal
36
+
37
+ from .models import AttackPath, AttackSurface, Finding, ScanResult
38
+ from .review_prompts import (
39
+ render_critic_prompts,
40
+ render_hunt_generate_prompts,
41
+ render_hunt_prompts,
42
+ render_hunt_verify_prompts,
43
+ render_remediation_prompts,
44
+ render_review_prompts,
45
+ render_skeptic_prompts,
46
+ render_triage_prompts,
47
+ )
48
+
49
+ _LLM_INSTALL = 'pip install "attackmap[llm] @ git+https://github.com/mlaify/AttackMap.git"'
50
+
51
+ DEFAULT_MODEL = "claude-opus-4-8"
52
+ DEFAULT_EFFORT: Literal["low", "medium", "high", "xhigh", "max"] = "high"
53
+ DEFAULT_MAX_TOKENS = 32000
54
+
55
+ # Fast mode (2.5x output speed, premium price) is a research-preview beta and
56
+ # only runs on the Opus 4.8 / 4.7 tiers via the API backend — the `claude` CLI
57
+ # backend and other models silently fall back to standard speed.
58
+ FAST_MODE_BETA = "fast-mode-2026-02-01"
59
+ FAST_CAPABLE_MODELS = frozenset({"claude-opus-4-8", "claude-opus-4-7"})
60
+ CLAUDE_CLI_TIMEOUT_SECONDS = 600
61
+
62
+ # OpenAI / Codex provider. `gpt-5-codex` is the code-optimized GPT-5 (Responses
63
+ # API only) and the safe default; any other model ID is passed through verbatim
64
+ # so we never have to chase OpenAI's point-release churn. Reasoning effort maps
65
+ # onto the Responses API's `reasoning.effort` — OpenAI exposes low/medium/high,
66
+ # so our extended tiers (xhigh/max) clamp down to "high".
67
+ OPENAI_DEFAULT_MODEL = "gpt-5-codex"
68
+ OPENAI_API_TIMEOUT_SECONDS = 900
69
+ CODEX_CLI_TIMEOUT_SECONDS = 900
70
+ OPENAI_EFFORT_MAP = {
71
+ "low": "low",
72
+ "medium": "medium",
73
+ "high": "high",
74
+ "xhigh": "high",
75
+ "max": "high",
76
+ }
77
+
78
+ LlmProvider = Literal["claude", "openai"]
79
+ LlmBackend = Literal["auto", "api", "cli"]
80
+
81
+
82
+ class LlmReviewError(RuntimeError):
83
+ """Raised when the LLM-backed review cannot be produced."""
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class LlmReviewResult:
88
+ markdown: str
89
+ model: str
90
+ stop_reason: str | None
91
+ usage: dict[str, int]
92
+ backend: Literal["api", "cli"]
93
+
94
+
95
+ # ---------- Auth + SDK backend ----------
96
+
97
+
98
+ def _resolve_sdk_client(api_key: str | None, client: Any | None) -> tuple[Any, str]:
99
+ """Return (anthropic client, auth_kind). auth_kind ∈ {'api_key', 'auth_token'}."""
100
+ if client is not None:
101
+ return client, "explicit"
102
+ try:
103
+ import anthropic
104
+ except ImportError as exc:
105
+ raise LlmReviewError(
106
+ "The anthropic SDK is not installed. Install with "
107
+ f"`{_LLM_INSTALL}` "
108
+ "to use the API backend, or ensure the `claude` CLI is on PATH for the CLI backend."
109
+ ) from exc
110
+
111
+ resolved_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
112
+ if resolved_key:
113
+ return anthropic.Anthropic(api_key=resolved_key), "api_key"
114
+
115
+ auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN")
116
+ if auth_token:
117
+ return anthropic.Anthropic(auth_token=auth_token), "auth_token"
118
+
119
+ raise LlmReviewError(
120
+ "No API credentials found. Set ANTHROPIC_API_KEY (per-token API billing) or "
121
+ "ANTHROPIC_AUTH_TOKEN (OAuth bearer), or install the `claude` CLI and run "
122
+ "`claude login` to use your Claude subscription."
123
+ )
124
+
125
+
126
+ def _extract_text(blocks: list[Any]) -> str:
127
+ parts: list[str] = []
128
+ for block in blocks:
129
+ if getattr(block, "type", None) == "text":
130
+ text = getattr(block, "text", "")
131
+ if text:
132
+ parts.append(text)
133
+ return "\n".join(parts).strip()
134
+
135
+
136
+ def _usage_dict(usage: Any) -> dict[str, int]:
137
+ out: dict[str, int] = {}
138
+ for field in (
139
+ "input_tokens",
140
+ "output_tokens",
141
+ "cache_creation_input_tokens",
142
+ "cache_read_input_tokens",
143
+ ):
144
+ value = getattr(usage, field, None)
145
+ if isinstance(value, int):
146
+ out[field] = value
147
+ return out
148
+
149
+
150
+ def _run_via_sdk(
151
+ rendered_system: str,
152
+ rendered_user: str,
153
+ *,
154
+ model: str,
155
+ effort: str,
156
+ max_tokens: int,
157
+ api_key: str | None,
158
+ client: Any | None,
159
+ speed: str = "standard",
160
+ ) -> LlmReviewResult:
161
+ sdk_client, _ = _resolve_sdk_client(api_key, client)
162
+
163
+ request_kwargs: dict[str, Any] = {
164
+ "model": model,
165
+ "max_tokens": max_tokens,
166
+ "system": [
167
+ {"type": "text", "text": rendered_system, "cache_control": {"type": "ephemeral"}}
168
+ ],
169
+ "messages": [{"role": "user", "content": rendered_user}],
170
+ "thinking": {"type": "adaptive"},
171
+ "output_config": {"effort": effort},
172
+ }
173
+
174
+ # Fast mode needs the beta endpoint + flag + top-level speed, and only on
175
+ # the Opus 4.8/4.7 tiers. Anything else runs at standard speed.
176
+ fast = speed == "fast" and model in FAST_CAPABLE_MODELS
177
+ messages_api = sdk_client.messages
178
+ if fast:
179
+ request_kwargs["speed"] = "fast"
180
+ request_kwargs["betas"] = [FAST_MODE_BETA]
181
+ messages_api = sdk_client.beta.messages
182
+
183
+ try:
184
+ with messages_api.stream(**request_kwargs) as stream:
185
+ final_message = stream.get_final_message()
186
+ except LlmReviewError:
187
+ raise
188
+ except Exception as exc: # pragma: no cover - surface SDK errors verbatim
189
+ raise LlmReviewError(f"Claude API call failed: {exc}") from exc
190
+
191
+ markdown = _extract_text(getattr(final_message, "content", []) or [])
192
+ if not markdown:
193
+ raise LlmReviewError("Claude returned no text content for the review.")
194
+
195
+ stop_reason = getattr(final_message, "stop_reason", None)
196
+ return LlmReviewResult(
197
+ markdown=markdown,
198
+ model=model,
199
+ stop_reason=stop_reason if isinstance(stop_reason, str) else None,
200
+ usage=_usage_dict(getattr(final_message, "usage", None)),
201
+ backend="api",
202
+ )
203
+
204
+
205
+ # ---------- CLI backend isolation (#232) ----------
206
+ #
207
+ # The CLI backends hand an *agent* the evidence pack, which is derived from an
208
+ # untrusted repository and may carry prompt injection. They are spawned so that
209
+ # nothing from that repository can configure the agent and the agent has as
210
+ # little reach as its CLI allows:
211
+ # * cwd is a fresh empty temp dir, never the scan root or the caller's cwd,
212
+ # so project-scoped `.claude/settings.json` (hooks, permissions),
213
+ # `.mcp.json`, `CLAUDE.md` and `AGENTS.md` are never discovered;
214
+ # * claude: no built-in tools, user-level settings only, no MCP servers, no
215
+ # session persistence;
216
+ # * codex: read-only sandbox rooted at the temp dir, no execpolicy rules,
217
+ # ephemeral session. Codex has no tool-less mode, so its model can still
218
+ # run read-only commands as the user; `--llm-backend api` is the only fully
219
+ # tool-less path for OpenAI (documented in the README);
220
+ # * the environment is scrubbed of credentials unrelated to the provider.
221
+ # If the installed CLI lacks the hardening flags we refuse to run it rather
222
+ # than fall back to an unhardened invocation.
223
+
224
+ CLAUDE_CLI_HARDENING_ARGS = (
225
+ "--tools",
226
+ "",
227
+ "--setting-sources",
228
+ "user",
229
+ "--strict-mcp-config",
230
+ "--mcp-config",
231
+ '{"mcpServers":{}}',
232
+ "--no-session-persistence",
233
+ )
234
+ _CLAUDE_REQUIRED_FLAGS = ("--tools", "--setting-sources", "--strict-mcp-config", "--no-session-persistence")
235
+ _CODEX_REQUIRED_FLAGS = ("--cd", "--ephemeral", "--ignore-rules", "--sandbox")
236
+
237
+ # Environment variables whose names look like credentials are not forwarded to
238
+ # the agent, except the ones the selected provider needs to authenticate.
239
+ _SENSITIVE_ENV_RE = re.compile(
240
+ r"(TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY|API_KEY|ACCESS_KEY|SESSION_KEY|AUTH)",
241
+ re.IGNORECASE,
242
+ )
243
+ _CLOUD_ENV_PREFIXES = ("AWS_", "AZURE_", "GOOGLE_", "GCP_", "GCLOUD_", "CLOUDSDK_", "ANTHROPIC_VERTEX_")
244
+ _PROVIDER_ENV_ALLOW = {
245
+ "claude": frozenset(
246
+ {"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL"}
247
+ ),
248
+ "openai": frozenset({"OPENAI_API_KEY", "OPENAI_BASE_URL", "CODEX_API_KEY"}),
249
+ }
250
+ # Claude Code on Bedrock / Vertex / Foundry authenticates with cloud
251
+ # credentials, so those are kept when the user has opted into a 3P provider.
252
+ _CLAUDE_3P_SWITCHES = ("CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY")
253
+
254
+
255
+ def scrub_cli_env(env: dict[str, str], provider: LlmProvider) -> dict[str, str]:
256
+ """Return a copy of ``env`` without credentials the agent doesn't need."""
257
+ allow = _PROVIDER_ENV_ALLOW[provider]
258
+ keep_cloud = provider == "claude" and any(env.get(k) for k in _CLAUDE_3P_SWITCHES)
259
+ scrubbed: dict[str, str] = {}
260
+ for name, value in env.items():
261
+ upper = name.upper()
262
+ if upper in allow:
263
+ scrubbed[name] = value
264
+ continue
265
+ if upper.startswith(_CLOUD_ENV_PREFIXES):
266
+ if keep_cloud:
267
+ scrubbed[name] = value
268
+ continue
269
+ if _SENSITIVE_ENV_RE.search(upper):
270
+ continue
271
+ scrubbed[name] = value
272
+ return scrubbed
273
+
274
+
275
+ @contextmanager
276
+ def isolated_cli_workdir() -> Iterator[str]:
277
+ """A throwaway empty working directory for an agent CLI subprocess."""
278
+ with tempfile.TemporaryDirectory(prefix="attackmap-llm-") as tmp:
279
+ yield tmp
280
+
281
+
282
+ @lru_cache(maxsize=None)
283
+ def _cli_help_text(argv: tuple[str, ...]) -> str:
284
+ try:
285
+ with isolated_cli_workdir() as cwd:
286
+ completed = subprocess.run(
287
+ list(argv), capture_output=True, text=True, check=False, timeout=30, cwd=cwd
288
+ )
289
+ except (OSError, subprocess.TimeoutExpired):
290
+ return ""
291
+ return (completed.stdout or "") + (completed.stderr or "")
292
+
293
+
294
+ def _require_hardening_flags(name: str, help_argv: tuple[str, ...], flags: tuple[str, ...]) -> None:
295
+ help_text = _cli_help_text(help_argv)
296
+ missing = [flag for flag in flags if flag not in help_text]
297
+ if missing:
298
+ raise LlmReviewError(
299
+ f"The installed `{name}` CLI does not support {', '.join(missing)}, which AttackMap "
300
+ f"needs to run it isolated from the scanned repository. Upgrade `{name}`, or use "
301
+ "`--llm-backend api`."
302
+ )
303
+
304
+
305
+ # ---------- Claude CLI backend ----------
306
+
307
+
308
+ def _claude_cli_available() -> bool:
309
+ return shutil.which("claude") is not None
310
+
311
+
312
+ def _anthropic_sdk_available() -> bool:
313
+ try:
314
+ import anthropic # noqa: F401
315
+ except ImportError:
316
+ return False
317
+ return True
318
+
319
+
320
+ def _run_via_claude_cli(
321
+ rendered_system: str,
322
+ rendered_user: str,
323
+ *,
324
+ model: str,
325
+ runner: Any | None = None,
326
+ ) -> LlmReviewResult:
327
+ """Invoke `claude -p --output-format=json` and return the result.
328
+
329
+ The `runner` argument is only used by tests to inject a fake subprocess
330
+ runner; in production we go straight to subprocess.run.
331
+ """
332
+ if runner is None and not _claude_cli_available():
333
+ raise LlmReviewError(
334
+ "`claude` CLI was not found on PATH. Install Claude Code (https://claude.com/claude-code) "
335
+ "and run `claude login`, or set ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN to use the SDK backend."
336
+ )
337
+
338
+ cmd = [
339
+ "claude",
340
+ "-p",
341
+ "--output-format=json",
342
+ *CLAUDE_CLI_HARDENING_ARGS,
343
+ "--model",
344
+ model,
345
+ "--system-prompt",
346
+ rendered_system,
347
+ ]
348
+
349
+ try:
350
+ if runner is None:
351
+ _require_hardening_flags("claude", ("claude", "--help"), _CLAUDE_REQUIRED_FLAGS)
352
+ with isolated_cli_workdir() as cwd:
353
+ completed = subprocess.run(
354
+ cmd,
355
+ input=rendered_user,
356
+ text=True,
357
+ capture_output=True,
358
+ check=False,
359
+ timeout=CLAUDE_CLI_TIMEOUT_SECONDS,
360
+ cwd=cwd,
361
+ env=scrub_cli_env(dict(os.environ), "claude"),
362
+ )
363
+ else:
364
+ completed = runner(cmd, rendered_user)
365
+ except FileNotFoundError as exc:
366
+ raise LlmReviewError("`claude` CLI was not found on PATH.") from exc
367
+ except subprocess.TimeoutExpired as exc:
368
+ raise LlmReviewError(
369
+ f"`claude` CLI did not respond within {CLAUDE_CLI_TIMEOUT_SECONDS}s."
370
+ ) from exc
371
+
372
+ stdout = (completed.stdout or "").strip()
373
+ stderr = (completed.stderr or "").strip()
374
+ if completed.returncode != 0 and not stdout:
375
+ raise LlmReviewError(
376
+ f"`claude` CLI exited with status {completed.returncode}: {stderr or '<no stderr>'}"
377
+ )
378
+ if not stdout:
379
+ raise LlmReviewError("`claude` CLI produced no output.")
380
+
381
+ try:
382
+ payload = json.loads(stdout)
383
+ except json.JSONDecodeError as exc:
384
+ raise LlmReviewError(
385
+ f"`claude` CLI returned non-JSON output: {stdout[:200]}..."
386
+ ) from exc
387
+
388
+ if payload.get("is_error"):
389
+ # The CLI returns an unhelpful mix here:
390
+ # - `subtype` is often the literal "success" even when is_error=true
391
+ # - `result` carries the human-readable message ("Not logged in · Please run /login")
392
+ # - `errors` is sometimes absent
393
+ # Prefer `result` when it looks like a message; fall back to `errors`,
394
+ # then to `subtype`. Never show "success" — it just confuses users.
395
+ detail: str | None = None
396
+ result_text = payload.get("result")
397
+ if isinstance(result_text, str) and result_text.strip():
398
+ detail = result_text.strip()
399
+ elif payload.get("errors"):
400
+ detail = "; ".join(str(e) for e in payload["errors"])
401
+ else:
402
+ subtype = payload.get("subtype")
403
+ if isinstance(subtype, str) and subtype and subtype != "success":
404
+ detail = subtype
405
+ detail = detail or "unknown error"
406
+ hint = ""
407
+ low = detail.lower()
408
+ if "not logged in" in low or "/login" in low:
409
+ hint = (
410
+ " Run `claude login` (or `claude /login` inside the CLI), "
411
+ "or use --llm-backend api with ANTHROPIC_API_KEY set."
412
+ )
413
+ raise LlmReviewError(f"`claude` CLI reported an error: {detail}.{hint}")
414
+
415
+ markdown = payload.get("result")
416
+ if not isinstance(markdown, str) or not markdown.strip():
417
+ raise LlmReviewError("`claude` CLI returned no `result` text.")
418
+
419
+ usage_payload = payload.get("usage") or {}
420
+ usage: dict[str, int] = {}
421
+ for field in (
422
+ "input_tokens",
423
+ "output_tokens",
424
+ "cache_creation_input_tokens",
425
+ "cache_read_input_tokens",
426
+ ):
427
+ value = usage_payload.get(field)
428
+ if isinstance(value, int):
429
+ usage[field] = value
430
+
431
+ cli_model = model
432
+ model_usage = payload.get("modelUsage")
433
+ if isinstance(model_usage, dict) and model_usage:
434
+ cli_model = next(iter(model_usage.keys()), model)
435
+
436
+ stop_reason = payload.get("stop_reason")
437
+ return LlmReviewResult(
438
+ markdown=markdown.strip(),
439
+ model=cli_model,
440
+ stop_reason=stop_reason if isinstance(stop_reason, str) else None,
441
+ usage=usage,
442
+ backend="cli",
443
+ )
444
+
445
+
446
+ # ---------- OpenAI SDK backend (Responses API) ----------
447
+
448
+
449
+ def _resolve_openai_client(api_key: str | None, client: Any | None) -> Any:
450
+ if client is not None:
451
+ return client
452
+ try:
453
+ import openai
454
+ except ImportError as exc:
455
+ raise LlmReviewError(
456
+ "The openai SDK is not installed. Install with "
457
+ f"`{_LLM_INSTALL}` "
458
+ "to use the OpenAI API backend, or install the `codex` CLI for the CLI backend."
459
+ ) from exc
460
+
461
+ resolved_key = api_key or os.environ.get("OPENAI_API_KEY")
462
+ if not resolved_key:
463
+ raise LlmReviewError(
464
+ "No OpenAI API credentials found. Set OPENAI_API_KEY, or install the `codex` "
465
+ "CLI and run `codex login` to use --llm-backend cli."
466
+ )
467
+ return openai.OpenAI(api_key=resolved_key, timeout=OPENAI_API_TIMEOUT_SECONDS)
468
+
469
+
470
+ def _openai_output_text(response: Any) -> str:
471
+ text = getattr(response, "output_text", None)
472
+ if isinstance(text, str) and text.strip():
473
+ return text.strip()
474
+ # Fallback: walk output items → content blocks → text, for SDK/response
475
+ # shapes where the `output_text` convenience isn't populated.
476
+ parts: list[str] = []
477
+ for item in getattr(response, "output", None) or []:
478
+ for block in getattr(item, "content", None) or []:
479
+ value = getattr(block, "text", None)
480
+ if isinstance(value, str) and value:
481
+ parts.append(value)
482
+ return "\n".join(parts).strip()
483
+
484
+
485
+ def _openai_usage(usage: Any) -> dict[str, int]:
486
+ out: dict[str, int] = {}
487
+ for field in ("input_tokens", "output_tokens"):
488
+ value = getattr(usage, field, None)
489
+ if isinstance(value, int):
490
+ out[field] = value
491
+ return out
492
+
493
+
494
+ def _run_via_openai_sdk(
495
+ rendered_system: str,
496
+ rendered_user: str,
497
+ *,
498
+ model: str,
499
+ effort: str,
500
+ max_tokens: int,
501
+ api_key: str | None,
502
+ client: Any | None,
503
+ ) -> LlmReviewResult:
504
+ oi = _resolve_openai_client(api_key, client)
505
+ reasoning_effort = OPENAI_EFFORT_MAP.get(effort, "high")
506
+
507
+ try:
508
+ response = oi.responses.create(
509
+ model=model,
510
+ instructions=rendered_system,
511
+ input=rendered_user,
512
+ reasoning={"effort": reasoning_effort},
513
+ max_output_tokens=max_tokens,
514
+ )
515
+ except LlmReviewError:
516
+ raise
517
+ except Exception as exc: # pragma: no cover - surface SDK errors verbatim
518
+ raise LlmReviewError(f"OpenAI API call failed: {exc}") from exc
519
+
520
+ markdown = _openai_output_text(response)
521
+ if not markdown:
522
+ raise LlmReviewError("OpenAI returned no text content for the review.")
523
+
524
+ status = getattr(response, "status", None)
525
+ return LlmReviewResult(
526
+ markdown=markdown,
527
+ model=str(getattr(response, "model", None) or model),
528
+ stop_reason=status if isinstance(status, str) else None,
529
+ usage=_openai_usage(getattr(response, "usage", None)),
530
+ backend="api",
531
+ )
532
+
533
+
534
+ # ---------- Codex CLI backend ----------
535
+
536
+
537
+ def _codex_cli_available() -> bool:
538
+ return shutil.which("codex") is not None
539
+
540
+
541
+ def _openai_sdk_available() -> bool:
542
+ try:
543
+ import openai # noqa: F401
544
+ except ImportError:
545
+ return False
546
+ return True
547
+
548
+
549
+ def _run_via_codex_cli(
550
+ rendered_system: str,
551
+ rendered_user: str,
552
+ *,
553
+ model: str,
554
+ effort: str,
555
+ runner: Any | None = None,
556
+ ) -> LlmReviewResult:
557
+ """Invoke `codex exec` non-interactively and return the final message.
558
+
559
+ `codex exec` treats the positional prompt as the instruction and piped
560
+ stdin as additional context, so we pass the rendered system prompt as the
561
+ argument and stream the (large) evidence pack in on stdin. It prints only
562
+ the final agent message to stdout. Runs read-only so it can never mutate the
563
+ working tree, and `--skip-git-repo-check` lets it run outside a git repo.
564
+
565
+ The `runner` argument is only used by tests to inject a fake subprocess
566
+ runner; in production we go straight to subprocess.run.
567
+ """
568
+ if runner is None and not _codex_cli_available():
569
+ raise LlmReviewError(
570
+ "`codex` CLI was not found on PATH. Install it (npm install -g @openai/codex) "
571
+ "and run `codex login`, or set OPENAI_API_KEY to use --llm-backend api."
572
+ )
573
+
574
+ reasoning_effort = OPENAI_EFFORT_MAP.get(effort, "high")
575
+
576
+ def build_cmd(cwd: str) -> list[str]:
577
+ return [
578
+ "codex",
579
+ "exec",
580
+ "--model",
581
+ model,
582
+ "-c",
583
+ f'model_reasoning_effort="{reasoning_effort}"',
584
+ "--sandbox",
585
+ "read-only",
586
+ "--cd",
587
+ cwd,
588
+ "--ephemeral",
589
+ "--ignore-rules",
590
+ "--skip-git-repo-check",
591
+ rendered_system,
592
+ ]
593
+
594
+ try:
595
+ with isolated_cli_workdir() as cwd:
596
+ cmd = build_cmd(cwd)
597
+ if runner is None:
598
+ _require_hardening_flags("codex", ("codex", "exec", "--help"), _CODEX_REQUIRED_FLAGS)
599
+ completed = subprocess.run(
600
+ cmd,
601
+ input=rendered_user,
602
+ text=True,
603
+ capture_output=True,
604
+ check=False,
605
+ timeout=CODEX_CLI_TIMEOUT_SECONDS,
606
+ cwd=cwd,
607
+ env=scrub_cli_env(dict(os.environ), "openai"),
608
+ )
609
+ else:
610
+ completed = runner(cmd, rendered_user)
611
+ except FileNotFoundError as exc:
612
+ raise LlmReviewError("`codex` CLI was not found on PATH.") from exc
613
+ except subprocess.TimeoutExpired as exc:
614
+ raise LlmReviewError(
615
+ f"`codex` CLI did not respond within {CODEX_CLI_TIMEOUT_SECONDS}s."
616
+ ) from exc
617
+
618
+ stdout = (completed.stdout or "").strip()
619
+ stderr = (completed.stderr or "").strip()
620
+ if completed.returncode != 0:
621
+ hint = ""
622
+ low = stderr.lower()
623
+ if "not logged in" in low or "login" in low or "auth" in low:
624
+ hint = (
625
+ " Run `codex login`, or use --llm-backend api with OPENAI_API_KEY set."
626
+ )
627
+ raise LlmReviewError(
628
+ f"`codex` CLI exited with status {completed.returncode}: "
629
+ f"{stderr or '<no stderr>'}.{hint}"
630
+ )
631
+ if not stdout:
632
+ raise LlmReviewError("`codex` CLI produced no output.")
633
+
634
+ return LlmReviewResult(
635
+ markdown=stdout,
636
+ model=model,
637
+ stop_reason=None,
638
+ usage={},
639
+ backend="cli",
640
+ )
641
+
642
+
643
+ # ---------- Public entry point ----------
644
+
645
+
646
+ def _resolve_openai_backend(
647
+ backend: LlmBackend,
648
+ *,
649
+ api_key: str | None,
650
+ client: Any | None,
651
+ codex_runner: Any | None,
652
+ ) -> Literal["api", "cli"]:
653
+ if backend == "api":
654
+ return "api"
655
+ if backend == "cli":
656
+ if codex_runner is None and not _codex_cli_available():
657
+ raise LlmReviewError(
658
+ "`--llm-backend cli` was requested but the `codex` CLI is not on PATH."
659
+ )
660
+ return "cli"
661
+ # auto
662
+ if client is not None:
663
+ return "api"
664
+ has_key = bool(api_key or os.environ.get("OPENAI_API_KEY"))
665
+ # Prefer the API backend only when the openai SDK is importable; otherwise
666
+ # fall back to the `codex` CLI (needs no SDK) so a key set against an
667
+ # SDK-less install still works via subscription auth.
668
+ if has_key and _openai_sdk_available():
669
+ return "api"
670
+ if _codex_cli_available():
671
+ return "cli"
672
+ if has_key:
673
+ return "api" # no SDK and no CLI — surface the actionable SDK-missing error
674
+ raise LlmReviewError(
675
+ "No OpenAI backend available. Set OPENAI_API_KEY, or install the `codex` "
676
+ "CLI and run `codex login`."
677
+ )
678
+
679
+
680
+ def _resolve_backend(
681
+ backend: LlmBackend,
682
+ *,
683
+ api_key: str | None,
684
+ client: Any | None,
685
+ ) -> Literal["api", "cli"]:
686
+ if backend == "api":
687
+ return "api"
688
+ if backend == "cli":
689
+ if not _claude_cli_available():
690
+ raise LlmReviewError(
691
+ "`--llm-backend cli` was requested but the `claude` CLI is not on PATH."
692
+ )
693
+ return "cli"
694
+ # auto
695
+ if client is not None:
696
+ return "api"
697
+ has_key = bool(
698
+ api_key
699
+ or os.environ.get("ANTHROPIC_API_KEY")
700
+ or os.environ.get("ANTHROPIC_AUTH_TOKEN")
701
+ )
702
+ # Prefer the API backend only when the SDK is actually importable. Otherwise
703
+ # fall back to the `claude` CLI (needs no SDK), so a key set against an
704
+ # SDK-less install (one without the `llm` extra)
705
+ # still works via subscription auth instead of erroring.
706
+ if has_key and _anthropic_sdk_available():
707
+ return "api"
708
+ if _claude_cli_available():
709
+ return "cli"
710
+ if has_key:
711
+ return "api" # no SDK and no CLI — surface the actionable SDK-missing error
712
+ raise LlmReviewError(
713
+ "No LLM backend available. Set ANTHROPIC_API_KEY, set ANTHROPIC_AUTH_TOKEN, "
714
+ "or install the `claude` CLI and run `claude login`."
715
+ )
716
+
717
+
718
+ def generate_llm_review(
719
+ scan: ScanResult,
720
+ attack_surfaces: list[AttackSurface],
721
+ findings: list[Finding],
722
+ attack_paths: list[AttackPath],
723
+ *,
724
+ model: str | None = None,
725
+ effort: Literal["low", "medium", "high", "xhigh", "max"] | None = None,
726
+ max_tokens: int = DEFAULT_MAX_TOKENS,
727
+ api_key: str | None = None,
728
+ client: Any | None = None,
729
+ backend: LlmBackend = "auto",
730
+ cli_runner: Any | None = None,
731
+ mode: Literal[
732
+ "review", "hunt", "hunt_verify", "hunt_generate", "hunt_skeptic",
733
+ "hunt_critic", "remediate", "triage",
734
+ ] = "review",
735
+ speed: Literal["standard", "fast"] = "standard",
736
+ provider: LlmProvider = "claude",
737
+ openai_client: Any | None = None,
738
+ codex_runner: Any | None = None,
739
+ hypotheses: list[dict] | None = None,
740
+ lens: str | None = None,
741
+ avoid_titles: list[str] | None = None,
742
+ critic_hint: str | None = None,
743
+ ) -> LlmReviewResult:
744
+ """Produce a narrative defensive review — or, with ``mode="hunt"``, ranked
745
+ vulnerability hypotheses (#80) — by calling an LLM.
746
+
747
+ ``provider`` selects Claude (default) or OpenAI/Codex. For each provider,
748
+ the backend resolves to an API SDK or a subscription CLI based on available
749
+ auth, then runs the appropriate prompt pack through it. The Claude SDK path
750
+ streams so we never hit HTTP timeouts on long reviews; the `claude` CLI path
751
+ runs `claude -p --output-format=json`. The OpenAI path uses the Responses
752
+ API; the `codex` CLI path runs `codex exec`. All modes share the same
753
+ evidence grounding contract (every claim cites evidence IDs). ``speed`` (Fast
754
+ mode) is Claude/API-only and ignored for OpenAI.
755
+ """
756
+ resolved_effort = effort or DEFAULT_EFFORT
757
+
758
+ if mode == "hunt_skeptic":
759
+ # The skeptic pass needs the fixed hypothesis list (#147a).
760
+ rendered = render_skeptic_prompts(
761
+ scan, attack_surfaces, findings, attack_paths, hypotheses or []
762
+ )
763
+ elif mode == "hunt_generate":
764
+ # Generation can be primed with a lens (#147b) and prior-round context (#147c).
765
+ rendered = render_hunt_generate_prompts(
766
+ scan, attack_surfaces, findings, attack_paths,
767
+ lens=lens, avoid_titles=avoid_titles, critic_hint=critic_hint,
768
+ )
769
+ elif mode == "hunt_critic":
770
+ # The completeness critic names untried angles from the leads so far (#147c).
771
+ rendered = render_critic_prompts(
772
+ scan, attack_surfaces, findings, attack_paths, hypotheses or []
773
+ )
774
+ else:
775
+ render = {
776
+ "hunt": render_hunt_prompts,
777
+ "hunt_verify": render_hunt_verify_prompts,
778
+ "remediate": render_remediation_prompts,
779
+ "triage": render_triage_prompts,
780
+ }.get(mode, render_review_prompts)
781
+ rendered = render(scan, attack_surfaces, findings, attack_paths)
782
+
783
+ if provider == "openai":
784
+ resolved_model = (
785
+ model or os.environ.get("ATTACKMAP_OPENAI_MODEL") or OPENAI_DEFAULT_MODEL
786
+ )
787
+ if backend == "cli" and codex_runner is not None:
788
+ chosen: Literal["api", "cli"] = "cli"
789
+ else:
790
+ chosen = _resolve_openai_backend(
791
+ backend, api_key=api_key, client=openai_client, codex_runner=codex_runner
792
+ )
793
+ if chosen == "api":
794
+ return _run_via_openai_sdk(
795
+ rendered.system,
796
+ rendered.user,
797
+ model=resolved_model,
798
+ effort=resolved_effort,
799
+ max_tokens=max_tokens,
800
+ api_key=api_key,
801
+ client=openai_client,
802
+ )
803
+ return _run_via_codex_cli(
804
+ rendered.system,
805
+ rendered.user,
806
+ model=resolved_model,
807
+ effort=resolved_effort,
808
+ runner=codex_runner,
809
+ )
810
+
811
+ resolved_model = model or os.environ.get("ATTACKMAP_LLM_MODEL") or DEFAULT_MODEL
812
+ if backend == "cli" and cli_runner is not None:
813
+ chosen_backend: Literal["api", "cli"] = "cli"
814
+ else:
815
+ chosen_backend = _resolve_backend(backend, api_key=api_key, client=client)
816
+
817
+ if chosen_backend == "api":
818
+ return _run_via_sdk(
819
+ rendered.system,
820
+ rendered.user,
821
+ model=resolved_model,
822
+ effort=resolved_effort,
823
+ max_tokens=max_tokens,
824
+ api_key=api_key,
825
+ client=client,
826
+ speed=speed,
827
+ )
828
+ return _run_via_claude_cli(
829
+ rendered.system,
830
+ rendered.user,
831
+ model=resolved_model,
832
+ runner=cli_runner,
833
+ )
834
+
835
+
836
+ __all__ = [
837
+ "generate_llm_review",
838
+ "LlmReviewError",
839
+ "LlmReviewResult",
840
+ "LlmBackend",
841
+ "LlmProvider",
842
+ "DEFAULT_MODEL",
843
+ "OPENAI_DEFAULT_MODEL",
844
+ "DEFAULT_EFFORT",
845
+ "DEFAULT_MAX_TOKENS",
846
+ ]