attackmap 0.4.4__tar.gz → 0.4.6__tar.gz

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.

Potentially problematic release.


This version of attackmap might be problematic. Click here for more details.

Files changed (99) hide show
  1. {attackmap-0.4.4/src/attackmap.egg-info → attackmap-0.4.6}/PKG-INFO +1 -1
  2. {attackmap-0.4.4 → attackmap-0.4.6}/pyproject.toml +1 -1
  3. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/__init__.py +1 -1
  4. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/config_scanner.py +46 -5
  5. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/llm_review.py +38 -3
  6. {attackmap-0.4.4 → attackmap-0.4.6/src/attackmap.egg-info}/PKG-INFO +1 -1
  7. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_config_scanner.py +40 -0
  8. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_llm_review.py +29 -0
  9. {attackmap-0.4.4 → attackmap-0.4.6}/LICENSE +0 -0
  10. {attackmap-0.4.4 → attackmap-0.4.6}/README.md +0 -0
  11. {attackmap-0.4.4 → attackmap-0.4.6}/setup.cfg +0 -0
  12. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/analyzer.py +0 -0
  13. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/analyzer_contracts.py +0 -0
  14. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/analyzers.py +0 -0
  15. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/anomalies.py +0 -0
  16. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/asset_model.py +0 -0
  17. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/attack_taxonomy.py +0 -0
  18. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/authz.py +0 -0
  19. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/cli.py +0 -0
  20. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/context_pack.py +0 -0
  21. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/control_model.py +0 -0
  22. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/crypto.py +0 -0
  23. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/cve.py +0 -0
  24. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/defensive_review.py +0 -0
  25. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/detection_opportunities.py +0 -0
  26. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/diagrams.py +0 -0
  27. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/diff.py +0 -0
  28. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/exploitability.py +0 -0
  29. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/graph.py +0 -0
  30. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/insights.py +0 -0
  31. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/merge.py +0 -0
  32. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/models.py +0 -0
  33. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/progress.py +0 -0
  34. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/recon_models.py +0 -0
  35. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/recon_to_analysis.py +0 -0
  36. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/report.py +0 -0
  37. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/review_eval.py +0 -0
  38. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/review_json.py +0 -0
  39. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/review_prompts.py +0 -0
  40. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/sarif.py +0 -0
  41. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/sbom.py +0 -0
  42. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/scanner.py +0 -0
  43. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/sdk/__init__.py +0 -0
  44. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/sdk/contracts.py +0 -0
  45. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/sdk/models.py +0 -0
  46. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/security_overlay.py +0 -0
  47. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/srcpaths.py +0 -0
  48. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/suggest.py +0 -0
  49. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/taint.py +0 -0
  50. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/threat_model.py +0 -0
  51. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/topology.py +0 -0
  52. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/weaknesses.py +0 -0
  53. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap/webhardening.py +0 -0
  54. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap.egg-info/SOURCES.txt +0 -0
  55. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap.egg-info/dependency_links.txt +0 -0
  56. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap.egg-info/entry_points.txt +0 -0
  57. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap.egg-info/requires.txt +0 -0
  58. {attackmap-0.4.4 → attackmap-0.4.6}/src/attackmap.egg-info/top_level.txt +0 -0
  59. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_analyzer.py +0 -0
  60. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_analyzer_metadata_schema.py +0 -0
  61. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_analyzers.py +0 -0
  62. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_anomalies.py +0 -0
  63. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_artifact_schemas.py +0 -0
  64. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_authz.py +0 -0
  65. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_context_pack.py +0 -0
  66. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_crypto.py +0 -0
  67. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_cve.py +0 -0
  68. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_defensive_review.py +0 -0
  69. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_diagrams.py +0 -0
  70. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_diff.py +0 -0
  71. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_exploitability.py +0 -0
  72. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_graph.py +0 -0
  73. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_hunt.py +0 -0
  74. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_injection_sinks.py +0 -0
  75. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_merge.py +0 -0
  76. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_modules_cli.py +0 -0
  77. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_pr_comment.py +0 -0
  78. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_progress.py +0 -0
  79. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_provenance.py +0 -0
  80. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_recon_to_analysis.py +0 -0
  81. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_remediate.py +0 -0
  82. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_report.py +0 -0
  83. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_review_eval.py +0 -0
  84. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_review_json.py +0 -0
  85. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_review_prompts.py +0 -0
  86. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_sarif.py +0 -0
  87. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_sbom.py +0 -0
  88. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_scanner.py +0 -0
  89. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_security_overlay.py +0 -0
  90. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_shared_contract_imports.py +0 -0
  91. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_signal_v2.py +0 -0
  92. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_srcpaths.py +0 -0
  93. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_suggest.py +0 -0
  94. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_taint.py +0 -0
  95. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_threat_intel.py +0 -0
  96. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_threat_model.py +0 -0
  97. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_topology.py +0 -0
  98. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_weaknesses.py +0 -0
  99. {attackmap-0.4.4 → attackmap-0.4.6}/tests/test_webhardening.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: attackmap
3
- Version: 0.4.4
3
+ Version: 0.4.6
4
4
  Summary: AI-assisted defensive security analyzer for codebases — scans a repository, models assets and controls, finds cross-cutting weaknesses, and generates an evidence-grounded review with MITRE ATT&CK mappings and detection-engineering hints.
5
5
  Author: AttackMap Contributors
6
6
  Author-email: Matthew Davis <matthewd@matthewd.xyz>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "attackmap"
7
- version = "0.4.4"
7
+ version = "0.4.6"
8
8
  description = "AI-assisted defensive security analyzer for codebases — scans a repository, models assets and controls, finds cross-cutting weaknesses, and generates an evidence-grounded review with MITRE ATT&CK mappings and detection-engineering hints."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,2 +1,2 @@
1
1
  __all__ = ["__version__"]
2
- __version__ = "0.4.4"
2
+ __version__ = "0.4.6"
@@ -15,11 +15,45 @@ want structural analysis can extend on top; this layer stays regex-only.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import bisect
18
19
  import re
19
20
  from pathlib import Path
20
21
 
21
22
  from .models import DatabaseHint, ExternalCall, ScanResult, SecretHint
22
- from .scanner import _line_of, _line_snippet
23
+ from .scanner import _line_snippet
24
+
25
+ # Config files larger than this are skipped — at that size they're invariably
26
+ # generated (lock files, minified bundles, or AttackMap's own JSON reports)
27
+ # rather than hand-written config, and scanning multi-megabyte text for URL /
28
+ # secret regexes is pointlessly slow.
29
+ _MAX_CONFIG_BYTES = 5_000_000
30
+
31
+
32
+ class _LineIndex:
33
+ """O(log n) line-number lookups for a file's text.
34
+
35
+ `scanner._line_of` counts newlines from the start on every call — O(n) each,
36
+ which degrades to O(n^2) when a large file yields many matches (the cause of
37
+ a scan hang on multi-megabyte JSON). Precomputing the line-start offsets once
38
+ and binary-searching keeps extraction linear.
39
+ """
40
+
41
+ __slots__ = ("_starts",)
42
+
43
+ def __init__(self, content: str) -> None:
44
+ starts = [0]
45
+ find = content.find
46
+ idx = find("\n")
47
+ while idx != -1:
48
+ starts.append(idx + 1)
49
+ idx = find("\n", idx + 1)
50
+ self._starts = starts
51
+
52
+ def line_of(self, offset: int) -> int:
53
+ """1-indexed line number for a character offset."""
54
+ if offset <= 0:
55
+ return 1
56
+ return bisect.bisect_right(self._starts, offset)
23
57
 
24
58
 
25
59
  CONFIG_SUFFIXES = {".yaml", ".yml", ".toml", ".json", ".ini", ".cfg", ".env"}
@@ -48,6 +82,8 @@ CONFIG_EXCLUDE_FILENAMES = frozenset({
48
82
  _SKIP_DIRS = frozenset({
49
83
  "node_modules",
50
84
  ".git",
85
+ ".attackmap-gui", # the macOS GUI's report output dir — never scan our own output
86
+ ".attackmap", # CLI cache / output dir
51
87
  ".venv",
52
88
  "venv",
53
89
  "dist",
@@ -183,6 +219,8 @@ def scan_config_repo(root: str | Path) -> ScanResult:
183
219
  if not should_scan_config_file(path):
184
220
  continue
185
221
  try:
222
+ if path.stat().st_size > _MAX_CONFIG_BYTES:
223
+ continue
186
224
  content = path.read_text(encoding="utf-8")
187
225
  except (UnicodeDecodeError, OSError):
188
226
  continue
@@ -196,6 +234,7 @@ def scan_config_repo(root: str | Path) -> ScanResult:
196
234
 
197
235
  def _extract_db_urls(content: str, relative: str, result: ScanResult) -> None:
198
236
  seen: set[str] = {(h.kind, h.file) for h in result.databases if h.file == relative}
237
+ index = _LineIndex(content)
199
238
  for match in _DB_URL_PATTERN.finditer(content):
200
239
  scheme = match.group("scheme").lower()
201
240
  kind = _DB_SCHEME_TO_KIND.get(scheme, scheme)
@@ -205,7 +244,7 @@ def _extract_db_urls(content: str, relative: str, result: ScanResult) -> None:
205
244
  DatabaseHint(
206
245
  kind=kind,
207
246
  file=relative,
208
- line=_line_of(content, match.start()),
247
+ line=index.line_of(match.start()),
209
248
  evidence_text=_line_snippet(content, match.start()),
210
249
  )
211
250
  )
@@ -221,7 +260,7 @@ def _extract_db_urls(content: str, relative: str, result: ScanResult) -> None:
221
260
  SecretHint(
222
261
  name=_redact(password),
223
262
  file=relative,
224
- line=_line_of(content, match.start()),
263
+ line=index.line_of(match.start()),
225
264
  evidence_text=_line_snippet(content, match.start()),
226
265
  confidence=1.0,
227
266
  kind=f"{kind}_url_password",
@@ -231,6 +270,7 @@ def _extract_db_urls(content: str, relative: str, result: ScanResult) -> None:
231
270
 
232
271
  def _extract_http_targets(content: str, relative: str, result: ScanResult) -> None:
233
272
  seen_targets = {(c.target, c.file) for c in result.external_calls if c.file == relative}
273
+ index = _LineIndex(content)
234
274
  for match in _HTTP_URL_PATTERN.finditer(content):
235
275
  target = match.group(0)
236
276
  # Trim trailing punctuation that looks like URL end
@@ -244,7 +284,7 @@ def _extract_http_targets(content: str, relative: str, result: ScanResult) -> No
244
284
  ExternalCall(
245
285
  target=target,
246
286
  file=relative,
247
- line=_line_of(content, match.start()),
287
+ line=index.line_of(match.start()),
248
288
  evidence_text=_line_snippet(content, match.start()),
249
289
  )
250
290
  )
@@ -258,12 +298,13 @@ def _extract_host(url: str) -> str | None:
258
298
 
259
299
  def _extract_secret_kvs(content: str, relative: str, result: ScanResult) -> None:
260
300
  seen: set[tuple[str, int]] = {(h.name, h.line or 0) for h in result.secret_hints if h.file == relative}
301
+ index = _LineIndex(content)
261
302
  for match in _SECRET_KEY_RE.finditer(content):
262
303
  key = match.group("key")
263
304
  value = match.group("value") or match.group("bare") or ""
264
305
  if not value or _is_placeholder(value):
265
306
  continue
266
- line = _line_of(content, match.start())
307
+ line = index.line_of(match.start())
267
308
  # Store `key` as the SecretHint.name so consumers see WHICH secret
268
309
  # was leaked (auth vs stripe vs db). Value itself is redacted into
269
310
  # evidence_text via the same helper hardcoded-secret detection uses.
@@ -197,6 +197,14 @@ def _claude_cli_available() -> bool:
197
197
  return shutil.which("claude") is not None
198
198
 
199
199
 
200
+ def _anthropic_sdk_available() -> bool:
201
+ try:
202
+ import anthropic # noqa: F401
203
+ except ImportError:
204
+ return False
205
+ return True
206
+
207
+
200
208
  def _run_via_claude_cli(
201
209
  rendered_system: str,
202
210
  rendered_user: str,
@@ -412,6 +420,14 @@ def _codex_cli_available() -> bool:
412
420
  return shutil.which("codex") is not None
413
421
 
414
422
 
423
+ def _openai_sdk_available() -> bool:
424
+ try:
425
+ import openai # noqa: F401
426
+ except ImportError:
427
+ return False
428
+ return True
429
+
430
+
415
431
  def _run_via_codex_cli(
416
432
  rendered_system: str,
417
433
  rendered_user: str,
@@ -514,10 +530,18 @@ def _resolve_openai_backend(
514
530
  )
515
531
  return "cli"
516
532
  # auto
517
- if client is not None or api_key or os.environ.get("OPENAI_API_KEY"):
533
+ if client is not None:
534
+ return "api"
535
+ has_key = bool(api_key or os.environ.get("OPENAI_API_KEY"))
536
+ # Prefer the API backend only when the openai SDK is importable; otherwise
537
+ # fall back to the `codex` CLI (needs no SDK) so a key set against an
538
+ # SDK-less install still works via subscription auth.
539
+ if has_key and _openai_sdk_available():
518
540
  return "api"
519
541
  if _codex_cli_available():
520
542
  return "cli"
543
+ if has_key:
544
+ return "api" # no SDK and no CLI — surface the actionable SDK-missing error
521
545
  raise LlmReviewError(
522
546
  "No OpenAI backend available. Set OPENAI_API_KEY, or install the `codex` "
523
547
  "CLI and run `codex login`."
@@ -539,12 +563,23 @@ def _resolve_backend(
539
563
  )
540
564
  return "cli"
541
565
  # auto
542
- if client is not None or api_key:
566
+ if client is not None:
543
567
  return "api"
544
- if os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN"):
568
+ has_key = bool(
569
+ api_key
570
+ or os.environ.get("ANTHROPIC_API_KEY")
571
+ or os.environ.get("ANTHROPIC_AUTH_TOKEN")
572
+ )
573
+ # Prefer the API backend only when the SDK is actually importable. Otherwise
574
+ # fall back to the `claude` CLI (needs no SDK), so a key set against an
575
+ # SDK-less install (e.g. Homebrew, which doesn't vendor `attackmap[llm]`)
576
+ # still works via subscription auth instead of erroring.
577
+ if has_key and _anthropic_sdk_available():
545
578
  return "api"
546
579
  if _claude_cli_available():
547
580
  return "cli"
581
+ if has_key:
582
+ return "api" # no SDK and no CLI — surface the actionable SDK-missing error
548
583
  raise LlmReviewError(
549
584
  "No LLM backend available. Set ANTHROPIC_API_KEY, set ANTHROPIC_AUTH_TOKEN, "
550
585
  "or install the `claude` CLI and run `claude login`."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: attackmap
3
- Version: 0.4.4
3
+ Version: 0.4.6
4
4
  Summary: AI-assisted defensive security analyzer for codebases — scans a repository, models assets and controls, finds cross-cutting weaknesses, and generates an evidence-grounded review with MITRE ATT&CK mappings and detection-engineering hints.
5
5
  Author: AttackMap Contributors
6
6
  Author-email: Matthew Davis <matthewd@matthewd.xyz>
@@ -263,3 +263,43 @@ def test_analyze_repository_preserves_provenance_on_config_signals(tmp_path: Pat
263
263
  config_dbs = [d for d in result.databases if d.file == "app.yaml"]
264
264
  assert config_dbs
265
265
  assert config_dbs[0].source_analyzer == "config"
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # Large-file / feedback-loop hang regression (config_scanner _line_of O(n^2))
270
+ # ---------------------------------------------------------------------------
271
+
272
+ import attackmap.config_scanner as _cfg
273
+ from attackmap.config_scanner import _LineIndex
274
+
275
+
276
+ def test_line_index_matches_naive_line_numbers() -> None:
277
+ content = "a\nbb\n\nccc\nd"
278
+ expected = {0: 1, 1: 1, 2: 2, 4: 2, 5: 3, 6: 4, 9: 4, 10: 5}
279
+ index = _LineIndex(content)
280
+ for offset, line in expected.items():
281
+ assert index.line_of(offset) == line, (offset, index.line_of(offset))
282
+ assert index.line_of(-5) == 1
283
+
284
+
285
+ def test_skips_attackmap_output_dir(tmp_path: Path) -> None:
286
+ # AttackMap writes reports into .attackmap-gui/ inside scanned repos; scanning
287
+ # them back is a feedback loop that hung on the multi-MB report (#gui-hang).
288
+ (tmp_path / "app.json").write_text('{"api": "https://api.real-service.com/v1"}')
289
+ reports = tmp_path / ".attackmap-gui" / "reports"
290
+ reports.mkdir(parents=True)
291
+ (reports / "attackmap-report.json").write_text('{"u": "https://leaked.internal-host.net/x"}')
292
+
293
+ files = {c.file for c in scan_config_repo(tmp_path).external_calls}
294
+ assert not any(".attackmap-gui" in f for f in files)
295
+ assert any("app.json" in f for f in files)
296
+
297
+
298
+ def test_skips_oversized_config_file(tmp_path: Path, monkeypatch) -> None:
299
+ monkeypatch.setattr(_cfg, "_MAX_CONFIG_BYTES", 200)
300
+ (tmp_path / "big.json").write_text('{"u":"https://api.real-service.com/"}' + " " * 500)
301
+ (tmp_path / "small.json").write_text('{"u":"https://api.real-service.com/"}')
302
+
303
+ files = {c.file for c in scan_config_repo(tmp_path).external_calls}
304
+ assert "big.json" not in files
305
+ assert "small.json" in files
@@ -424,6 +424,35 @@ def test_auto_backend_falls_back_to_cli_when_no_creds(monkeypatch) -> None:
424
424
  assert result.backend == "cli"
425
425
 
426
426
 
427
+ def test_auto_backend_falls_back_to_cli_when_key_set_but_sdk_missing(monkeypatch) -> None:
428
+ # Homebrew installs attackmap without the anthropic SDK. A key set in that
429
+ # environment must fall back to the `claude` CLI, not error.
430
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
431
+ monkeypatch.setattr("attackmap.llm_review._anthropic_sdk_available", lambda: False)
432
+ monkeypatch.setattr("attackmap.llm_review._claude_cli_available", lambda: True)
433
+
434
+ payload = {"type": "result", "is_error": False, "result": "hi"}
435
+ runner, _ = _make_cli_runner(json.dumps(payload))
436
+
437
+ result = generate_llm_review(
438
+ _trivial_scan(), _surfaces(), _findings(), [], backend="auto", cli_runner=runner
439
+ )
440
+ assert result.backend == "cli"
441
+
442
+
443
+ def test_openai_auto_falls_back_to_codex_when_key_set_but_sdk_missing(monkeypatch) -> None:
444
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-test")
445
+ monkeypatch.setattr("attackmap.llm_review._openai_sdk_available", lambda: False)
446
+ monkeypatch.setattr("attackmap.llm_review._codex_cli_available", lambda: True)
447
+ runner, _ = _make_cli_runner("hi")
448
+
449
+ result = generate_llm_review(
450
+ _trivial_scan(), _surfaces(), _findings(), [],
451
+ provider="openai", backend="auto", codex_runner=runner,
452
+ )
453
+ assert result.backend == "cli"
454
+
455
+
427
456
  def test_auto_backend_raises_when_no_creds_and_no_cli(monkeypatch) -> None:
428
457
  monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
429
458
  monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes