attackmap 0.4.0__tar.gz → 0.4.2__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.
Files changed (99) hide show
  1. {attackmap-0.4.0/src/attackmap.egg-info → attackmap-0.4.2}/PKG-INFO +1 -1
  2. {attackmap-0.4.0 → attackmap-0.4.2}/pyproject.toml +1 -1
  3. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/__init__.py +1 -1
  4. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/anomalies.py +22 -2
  5. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/cli.py +57 -31
  6. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/progress.py +99 -1
  7. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/scanner.py +3 -4
  8. {attackmap-0.4.0 → attackmap-0.4.2/src/attackmap.egg-info}/PKG-INFO +1 -1
  9. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_anomalies.py +26 -0
  10. attackmap-0.4.2/tests/test_progress.py +183 -0
  11. attackmap-0.4.0/tests/test_progress.py +0 -97
  12. {attackmap-0.4.0 → attackmap-0.4.2}/LICENSE +0 -0
  13. {attackmap-0.4.0 → attackmap-0.4.2}/README.md +0 -0
  14. {attackmap-0.4.0 → attackmap-0.4.2}/setup.cfg +0 -0
  15. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/analyzer.py +0 -0
  16. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/analyzer_contracts.py +0 -0
  17. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/analyzers.py +0 -0
  18. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/asset_model.py +0 -0
  19. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/attack_taxonomy.py +0 -0
  20. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/authz.py +0 -0
  21. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/config_scanner.py +0 -0
  22. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/context_pack.py +0 -0
  23. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/control_model.py +0 -0
  24. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/crypto.py +0 -0
  25. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/cve.py +0 -0
  26. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/defensive_review.py +0 -0
  27. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/detection_opportunities.py +0 -0
  28. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/diagrams.py +0 -0
  29. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/diff.py +0 -0
  30. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/exploitability.py +0 -0
  31. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/graph.py +0 -0
  32. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/insights.py +0 -0
  33. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/llm_review.py +0 -0
  34. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/merge.py +0 -0
  35. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/models.py +0 -0
  36. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/recon_models.py +0 -0
  37. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/recon_to_analysis.py +0 -0
  38. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/report.py +0 -0
  39. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/review_eval.py +0 -0
  40. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/review_json.py +0 -0
  41. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/review_prompts.py +0 -0
  42. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/sarif.py +0 -0
  43. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/sbom.py +0 -0
  44. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/sdk/__init__.py +0 -0
  45. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/sdk/contracts.py +0 -0
  46. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/sdk/models.py +0 -0
  47. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/security_overlay.py +0 -0
  48. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/srcpaths.py +0 -0
  49. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/suggest.py +0 -0
  50. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/taint.py +0 -0
  51. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/threat_model.py +0 -0
  52. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/topology.py +0 -0
  53. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/weaknesses.py +0 -0
  54. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap/webhardening.py +0 -0
  55. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap.egg-info/SOURCES.txt +0 -0
  56. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap.egg-info/dependency_links.txt +0 -0
  57. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap.egg-info/entry_points.txt +0 -0
  58. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap.egg-info/requires.txt +0 -0
  59. {attackmap-0.4.0 → attackmap-0.4.2}/src/attackmap.egg-info/top_level.txt +0 -0
  60. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_analyzer.py +0 -0
  61. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_analyzer_metadata_schema.py +0 -0
  62. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_analyzers.py +0 -0
  63. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_artifact_schemas.py +0 -0
  64. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_authz.py +0 -0
  65. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_config_scanner.py +0 -0
  66. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_context_pack.py +0 -0
  67. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_crypto.py +0 -0
  68. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_cve.py +0 -0
  69. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_defensive_review.py +0 -0
  70. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_diagrams.py +0 -0
  71. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_diff.py +0 -0
  72. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_exploitability.py +0 -0
  73. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_graph.py +0 -0
  74. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_hunt.py +0 -0
  75. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_injection_sinks.py +0 -0
  76. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_llm_review.py +0 -0
  77. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_merge.py +0 -0
  78. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_pr_comment.py +0 -0
  79. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_provenance.py +0 -0
  80. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_recon_to_analysis.py +0 -0
  81. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_remediate.py +0 -0
  82. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_report.py +0 -0
  83. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_review_eval.py +0 -0
  84. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_review_json.py +0 -0
  85. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_review_prompts.py +0 -0
  86. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_sarif.py +0 -0
  87. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_sbom.py +0 -0
  88. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_scanner.py +0 -0
  89. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_security_overlay.py +0 -0
  90. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_shared_contract_imports.py +0 -0
  91. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_signal_v2.py +0 -0
  92. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_srcpaths.py +0 -0
  93. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_suggest.py +0 -0
  94. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_taint.py +0 -0
  95. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_threat_intel.py +0 -0
  96. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_threat_model.py +0 -0
  97. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_topology.py +0 -0
  98. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_weaknesses.py +0 -0
  99. {attackmap-0.4.0 → attackmap-0.4.2}/tests/test_webhardening.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: attackmap
3
- Version: 0.4.0
3
+ Version: 0.4.2
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.0"
7
+ version = "0.4.2"
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.0"
2
+ __version__ = "0.4.2"
@@ -36,10 +36,16 @@ from __future__ import annotations
36
36
  import re
37
37
  from dataclasses import dataclass
38
38
  from pathlib import Path
39
+ from typing import TYPE_CHECKING
39
40
 
40
41
  from .models import Anomaly, Route, ScanResult
41
42
  from .srcpaths import is_test_file
42
43
 
44
+ if TYPE_CHECKING:
45
+ from .progress import JsonScanProgress, ScanProgress
46
+
47
+ _Progress = ScanProgress | JsonScanProgress
48
+
43
49
 
44
50
  @dataclass
45
51
  class _SignalCtx:
@@ -106,8 +112,17 @@ _MIN_GROUP = 3
106
112
  _SCAFFOLD_RE = re.compile(r"^(?:api|rest|graphql|gql|v\d+(?:beta\d*|alpha\d*)?)$", re.IGNORECASE)
107
113
 
108
114
 
109
- def find_anomalies(scan: ScanResult, root: str | Path | None = None) -> list[Anomaly]:
110
- """Return within-repo consistency outliers for the routes in ``scan``."""
115
+ def find_anomalies(
116
+ scan: ScanResult,
117
+ root: str | Path | None = None,
118
+ progress: "_Progress | None" = None,
119
+ ) -> list[Anomaly]:
120
+ """Return within-repo consistency outliers for the routes in ``scan``.
121
+
122
+ On a large route surface the per-cohort source-window reads dominate this
123
+ pass, so when a ``progress`` reporter is supplied it drives a determinate
124
+ bar over the cohorts (rather than an open-ended spinner).
125
+ """
111
126
  root_path = Path(root or scan.root).resolve()
112
127
 
113
128
  routes = [r for r in scan.routes if not is_test_file(r.file)]
@@ -129,8 +144,13 @@ def find_anomalies(scan: ScanResult, root: str | Path | None = None) -> list[Ano
129
144
 
130
145
  ctx = _SignalCtx(root_path, line_cache, file_route_lines)
131
146
 
147
+ if progress is not None:
148
+ progress.begin(len(groups), "Anomaly / outlier detection")
149
+
132
150
  out: list[Anomaly] = []
133
151
  for key, members in groups.items():
152
+ if progress is not None:
153
+ progress.advance(key)
134
154
  if len(members) < _MIN_GROUP:
135
155
  continue
136
156
  if not _is_route_like_cohort(key, members):
@@ -25,7 +25,7 @@ from .diff import (
25
25
  )
26
26
  from .graph import build_graph
27
27
  from .llm_review import LlmReviewError, generate_llm_review
28
- from .progress import ScanProgress
28
+ from .progress import create_progress
29
29
  from .recon_to_analysis import translate_recon
30
30
  from .report import render_console_summary, render_pr_comment, write_reports
31
31
  from .suggest import detect_ecosystems
@@ -124,6 +124,11 @@ def analyze(
124
124
  "--no-progress",
125
125
  help="Disable the live progress bar / ETA during scanning. Progress auto-disables when stderr isn't a terminal (CI, piped output).",
126
126
  ),
127
+ progress_format: str = typer.Option(
128
+ "auto",
129
+ "--progress-format",
130
+ help="Progress reporting: 'auto' (TTY bar when stderr is a terminal), 'json' (newline-delimited JSON events on stderr, for GUI/tool front-ends), or 'none'. --no-progress is equivalent to 'none'.",
131
+ ),
127
132
  pr_comment: str | None = typer.Option(
128
133
  None,
129
134
  "--pr-comment",
@@ -148,12 +153,18 @@ def analyze(
148
153
  except ValueError as exc:
149
154
  raise typer.BadParameter(str(exc)) from exc
150
155
 
156
+ if progress_format not in {"auto", "tty", "json", "none"}:
157
+ raise typer.BadParameter("--progress-format must be one of: auto, json, none.")
151
158
  active_analyzers = resolve_run_analyzers(repo_path, analyzers=selected_analyzers)
152
- scan_progress = ScanProgress(enabled=not no_progress)
159
+ scan_progress = create_progress(progress_format, no_progress=no_progress)
153
160
  scan = analyze_repository(repo_path, analyzers=active_analyzers, progress=scan_progress)
154
161
  if cve and scan.dependencies:
155
162
  typer.echo(f"Checking {len(scan.dependencies)} dependencies against OSV.dev…")
156
- vulns, cve_summary = query_vulnerabilities(scan.dependencies)
163
+ scan_progress.stage(f"Checking {len(scan.dependencies)} dependencies against OSV.dev")
164
+ try:
165
+ vulns, cve_summary = query_vulnerabilities(scan.dependencies)
166
+ finally:
167
+ scan_progress.done()
157
168
  scan.vulnerabilities = vulns
158
169
  typer.echo(
159
170
  f"CVE lookup: {len(vulns)} vulnerabilit"
@@ -250,15 +261,19 @@ def analyze(
250
261
  typer.echo(
251
262
  f"Generating narrative review via Claude (backend={llm_backend}, may take a minute)..."
252
263
  )
253
- result = generate_llm_review(
254
- scan,
255
- attack_surfaces,
256
- findings,
257
- attack_paths,
258
- model=llm_model,
259
- effort=effort_value, # type: ignore[arg-type]
260
- backend=llm_backend, # type: ignore[arg-type]
261
- )
264
+ scan_progress.stage(f"Claude is writing the defensive review (backend={llm_backend})")
265
+ try:
266
+ result = generate_llm_review(
267
+ scan,
268
+ attack_surfaces,
269
+ findings,
270
+ attack_paths,
271
+ model=llm_model,
272
+ effort=effort_value, # type: ignore[arg-type]
273
+ backend=llm_backend, # type: ignore[arg-type]
274
+ )
275
+ finally:
276
+ scan_progress.done()
262
277
  except LlmReviewError as exc:
263
278
  typer.echo(f"LLM review skipped: {exc}", err=True)
264
279
  else:
@@ -300,16 +315,23 @@ def analyze(
300
315
  f"Hunting for vulnerability hypotheses via Claude "
301
316
  f"({'adjudicated against source, ' if verify else ''}backend={llm_backend}, may take a minute)..."
302
317
  )
303
- hunt_result = generate_llm_review(
304
- scan,
305
- attack_surfaces,
306
- findings,
307
- attack_paths,
308
- model=llm_model,
309
- effort=hunt_effort_value, # type: ignore[arg-type]
310
- backend=llm_backend, # type: ignore[arg-type]
311
- mode=hunt_mode, # type: ignore[arg-type]
318
+ scan_progress.stage(
319
+ f"Claude is hunting exploit-chain hypotheses"
320
+ f"{' + verifying against source' if verify else ''} (backend={llm_backend})"
312
321
  )
322
+ try:
323
+ hunt_result = generate_llm_review(
324
+ scan,
325
+ attack_surfaces,
326
+ findings,
327
+ attack_paths,
328
+ model=llm_model,
329
+ effort=hunt_effort_value, # type: ignore[arg-type]
330
+ backend=llm_backend, # type: ignore[arg-type]
331
+ mode=hunt_mode, # type: ignore[arg-type]
332
+ )
333
+ finally:
334
+ scan_progress.done()
313
335
  except LlmReviewError as exc:
314
336
  typer.echo(f"Vulnerability hunt skipped: {exc}", err=True)
315
337
  else:
@@ -352,16 +374,20 @@ def analyze(
352
374
  typer.echo(
353
375
  f"Generating remediation suggestions via Claude (backend={llm_backend}, may take a minute)..."
354
376
  )
355
- rem_result = generate_llm_review(
356
- scan,
357
- attack_surfaces,
358
- findings,
359
- attack_paths,
360
- model=llm_model,
361
- effort=rem_effort_value, # type: ignore[arg-type]
362
- backend=llm_backend, # type: ignore[arg-type]
363
- mode="remediate",
364
- )
377
+ scan_progress.stage(f"Claude is drafting remediation suggestions (backend={llm_backend})")
378
+ try:
379
+ rem_result = generate_llm_review(
380
+ scan,
381
+ attack_surfaces,
382
+ findings,
383
+ attack_paths,
384
+ model=llm_model,
385
+ effort=rem_effort_value, # type: ignore[arg-type]
386
+ backend=llm_backend, # type: ignore[arg-type]
387
+ mode="remediate",
388
+ )
389
+ finally:
390
+ scan_progress.done()
365
391
  except LlmReviewError as exc:
366
392
  typer.echo(f"Remediation skipped: {exc}", err=True)
367
393
  else:
@@ -15,11 +15,16 @@ scanner drives it through the small hook surface (`begin`/`advance`/`stage`/
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import json
18
19
  import sys
19
20
  import threading
20
21
  import time
21
22
  from typing import TextIO
22
23
 
24
+ # NDJSON progress protocol version (consumed by the macOS GUI and other
25
+ # non-TTY front-ends). Bump only on a breaking change to the event shape.
26
+ PROGRESS_PROTOCOL_VERSION = 1
27
+
23
28
  _SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
24
29
  _BAR_WIDTH = 24
25
30
  _MIN_REDRAW_INTERVAL = 0.08 # seconds — cap redraws so we don't thrash the TTY
@@ -152,4 +157,97 @@ def _truncate(text: str, width: int) -> str:
152
157
  return "…" + text[-(width - 1):]
153
158
 
154
159
 
155
- __all__ = ["ScanProgress"]
160
+ class JsonScanProgress:
161
+ """Emit scan progress as newline-delimited JSON (one object per line).
162
+
163
+ Same hook surface as ``ScanProgress`` (``begin``/``advance``/``stage``/
164
+ ``done``) so the scanner drives either interchangeably. Unlike the TTY
165
+ renderer this never gates on ``isatty`` — its whole purpose is to feed a
166
+ non-terminal consumer (the macOS GUI) reading the child process's stderr.
167
+
168
+ Event shapes (all carry ``"v": PROGRESS_PROTOCOL_VERSION``)::
169
+
170
+ {"v":1,"event":"begin","total":1240,"label":"Scanning files"}
171
+ {"v":1,"event":"advance","done":37,"total":1240,"current":"src/app.ts"}
172
+ {"v":1,"event":"stage","label":"Taint analysis"}
173
+ {"v":1,"event":"done","summary":"…","done":1240,"total":1240,"elapsed_s":92.4}
174
+
175
+ ``advance`` events are throttled to at most one per ``min_interval`` seconds
176
+ to keep the stream sane on large trees; ``done`` (per-file count) stays
177
+ accurate because every advance still increments the internal counter and the
178
+ latest count rides on each emitted event.
179
+ """
180
+
181
+ def __init__(self, *, stream: TextIO | None = None, min_interval: float = 0.1) -> None:
182
+ self._stream = stream if stream is not None else sys.stderr
183
+ self._min_interval = max(0.0, min_interval)
184
+ self._total = 0
185
+ self._done = 0
186
+ self._start = 0.0
187
+ self._last_emit = 0.0
188
+
189
+ def begin(self, total: int, label: str = "Scanning files") -> None:
190
+ self._total = max(0, total)
191
+ self._done = 0
192
+ self._start = time.monotonic()
193
+ self._last_emit = self._start # anchor throttle window to scan start
194
+ self._emit("begin", total=self._total, label=label)
195
+
196
+ def advance(self, current: str = "") -> None:
197
+ self._done += 1
198
+ now = time.monotonic()
199
+ # Always emit the final file; throttle the rest.
200
+ if self._done < self._total and (now - self._last_emit) < self._min_interval:
201
+ return
202
+ self._last_emit = now
203
+ self._emit("advance", done=self._done, total=self._total, current=current)
204
+
205
+ def stage(self, label: str) -> None:
206
+ self._emit("stage", label=label)
207
+
208
+ def done(self, summary: str = "") -> None:
209
+ elapsed = round(time.monotonic() - self._start, 3) if self._start else 0.0
210
+ self._emit(
211
+ "done",
212
+ summary=summary,
213
+ done=self._done,
214
+ total=self._total,
215
+ elapsed_s=elapsed,
216
+ )
217
+
218
+ def _emit(self, event: str, **fields: object) -> None:
219
+ payload = {"v": PROGRESS_PROTOCOL_VERSION, "event": event, **fields}
220
+ try:
221
+ self._stream.write(json.dumps(payload, ensure_ascii=False) + "\n")
222
+ self._stream.flush()
223
+ except (ValueError, OSError):
224
+ # Never let a broken/closed pipe take down a scan.
225
+ pass
226
+
227
+
228
+ def create_progress(
229
+ fmt: str = "auto",
230
+ *,
231
+ no_progress: bool = False,
232
+ stream: TextIO | None = None,
233
+ ):
234
+ """Build the right progress sink for ``--progress-format``.
235
+
236
+ - ``none`` (or ``no_progress=True``) → a disabled ``ScanProgress`` no-op.
237
+ - ``json`` → :class:`JsonScanProgress` (NDJSON, always emits).
238
+ - ``auto`` / ``tty`` → :class:`ScanProgress` (TTY bar, self-silences when
239
+ stderr isn't a terminal).
240
+ """
241
+ if no_progress or fmt == "none":
242
+ return ScanProgress(enabled=False, stream=stream)
243
+ if fmt == "json":
244
+ return JsonScanProgress(stream=stream)
245
+ return ScanProgress(enabled=True, stream=stream)
246
+
247
+
248
+ __all__ = [
249
+ "ScanProgress",
250
+ "JsonScanProgress",
251
+ "create_progress",
252
+ "PROGRESS_PROTOCOL_VERSION",
253
+ ]
@@ -736,10 +736,9 @@ def scan_repo(
736
736
  progress.stage("Authorization (BOLA/IDOR)")
737
737
  result.authz_candidates = analyze_authz(result, root_path)
738
738
  # Anomaly / outlier pass (#78): the odd-one-out among sibling routes.
739
- # Runs last so the full route list is assembled into cohorts.
740
- if progress is not None:
741
- progress.stage("Anomaly / outlier detection")
742
- result.anomalies = find_anomalies(result, root_path)
739
+ # Runs last so the full route list is assembled into cohorts. It drives its
740
+ # own determinate per-cohort progress (the slow tail on big route surfaces).
741
+ result.anomalies = find_anomalies(result, root_path, progress=progress)
743
742
  if progress is not None:
744
743
  progress.done()
745
744
  return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: attackmap
3
- Version: 0.4.0
3
+ Version: 0.4.2
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>
@@ -369,3 +369,29 @@ def test_auth_outlier_becomes_tagged_finding(tmp_path: Path) -> None:
369
369
  assert auth.severity == "high"
370
370
  assert auth.attack_techniques
371
371
  assert any("/api/users/export" in e for e in auth.evidence)
372
+
373
+
374
+ def test_progress_reported_per_cohort(tmp_path: Path) -> None:
375
+ """find_anomalies drives a determinate begin/advance over cohorts (#status)."""
376
+ import io
377
+ import json
378
+
379
+ from attackmap.progress import JsonScanProgress
380
+
381
+ scan = _scan_with_routes(
382
+ tmp_path,
383
+ "views.py",
384
+ _AUTH_COHORT,
385
+ [
386
+ ("/api/users", "GET", '"/api/users")'),
387
+ ("/api/users/<id>", "GET", '"/api/users/<id>")'),
388
+ ("/api/users/export", "GET", '"/api/users/export")'),
389
+ ],
390
+ )
391
+ stream = io.StringIO()
392
+ find_anomalies(scan, progress=JsonScanProgress(stream=stream, min_interval=0.0))
393
+ events = [json.loads(line) for line in stream.getvalue().splitlines() if line.strip()]
394
+ begin = next(e for e in events if e["event"] == "begin")
395
+ assert begin["label"] == "Anomaly / outlier detection"
396
+ assert begin["total"] >= 1
397
+ assert any(e["event"] == "advance" for e in events)
@@ -0,0 +1,183 @@
1
+ """Tests for the scan progress reporter (#36)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ from pathlib import Path
7
+
8
+ import json
9
+
10
+ from attackmap.progress import (
11
+ PROGRESS_PROTOCOL_VERSION,
12
+ JsonScanProgress,
13
+ ScanProgress,
14
+ _fmt_duration,
15
+ _truncate,
16
+ create_progress,
17
+ )
18
+ from attackmap.scanner import scan_repo
19
+
20
+
21
+ def _events(stream: io.StringIO) -> list[dict]:
22
+ return [json.loads(line) for line in stream.getvalue().splitlines() if line.strip()]
23
+
24
+
25
+ class _FakeTTY(io.StringIO):
26
+ """StringIO that claims to be a terminal so progress renders."""
27
+
28
+ def isatty(self) -> bool:
29
+ return True
30
+
31
+
32
+ def test_disabled_when_not_a_tty() -> None:
33
+ stream = io.StringIO() # plain StringIO → isatty() False
34
+ p = ScanProgress(stream=stream)
35
+ p.begin(10)
36
+ p.advance("a.py")
37
+ p.done("done")
38
+ assert stream.getvalue() == ""
39
+
40
+
41
+ def test_disabled_when_explicitly_off() -> None:
42
+ stream = _FakeTTY()
43
+ p = ScanProgress(enabled=False, stream=stream)
44
+ p.begin(10)
45
+ p.advance("a.py")
46
+ p.done()
47
+ assert stream.getvalue() == ""
48
+
49
+
50
+ def test_renders_bar_and_percentage_on_tty() -> None:
51
+ stream = _FakeTTY()
52
+ p = ScanProgress(stream=stream)
53
+ p.begin(4)
54
+ for i in range(4):
55
+ p.advance(f"file{i}.py")
56
+ p.done("Scanned 4 files")
57
+ out = stream.getvalue()
58
+ assert "%" in out
59
+ assert "ETA" in out
60
+ assert "Scanned 4 files" in out
61
+
62
+
63
+ def test_stage_renders_and_stops_cleanly() -> None:
64
+ stream = _FakeTTY()
65
+ p = ScanProgress(stream=stream)
66
+ p.begin(1)
67
+ p.advance("x.py")
68
+ p.stage("Taint / data-flow analysis")
69
+ p.done()
70
+ # spinner thread must be joined/stopped
71
+ assert p._spin_thread is None
72
+
73
+
74
+ def test_fmt_duration() -> None:
75
+ assert _fmt_duration(0) == "00:00"
76
+ assert _fmt_duration(65) == "01:05"
77
+ assert _fmt_duration(3725) == "1h02m"
78
+
79
+
80
+ def test_truncate_keeps_tail() -> None:
81
+ assert _truncate("short.py", 32) == "short.py"
82
+ long = "a/very/deeply/nested/path/to/module.py"
83
+ out = _truncate(long, 20)
84
+ assert out.startswith("…")
85
+ assert out.endswith("module.py")
86
+ assert len(out) == 20
87
+
88
+
89
+ def test_scan_repo_drives_progress_without_error(tmp_path: Path) -> None:
90
+ (tmp_path / "app.py").write_text(
91
+ "from flask import Flask, request\n"
92
+ "app = Flask(__name__)\n"
93
+ "@app.route('/x')\n"
94
+ "def x(): return eval(request.args['e'])\n",
95
+ encoding="utf-8",
96
+ )
97
+ stream = _FakeTTY()
98
+ p = ScanProgress(stream=stream)
99
+ scan = scan_repo(tmp_path, progress=p)
100
+ assert scan.files_scanned == 1
101
+ out = stream.getvalue()
102
+ # per-file bar + at least one tail stage label rendered
103
+ assert "Scanning files" in out
104
+ assert "Taint" in out
105
+
106
+
107
+ def test_scan_repo_unaffected_without_progress(tmp_path: Path) -> None:
108
+ (tmp_path / "app.py").write_text("print('hi')\n", encoding="utf-8")
109
+ scan = scan_repo(tmp_path) # no progress arg → old behavior
110
+ assert scan.files_scanned == 1
111
+
112
+
113
+ # --- NDJSON progress sink (M0 for the macOS GUI) -------------------------
114
+
115
+
116
+ def test_json_progress_emits_ndjson_lifecycle() -> None:
117
+ stream = io.StringIO() # plain StringIO (not a TTY) — JSON sink emits anyway
118
+ p = JsonScanProgress(stream=stream, min_interval=0.0)
119
+ p.begin(3, label="Scanning files")
120
+ p.advance("a.py")
121
+ p.advance("b.py")
122
+ p.advance("c.py")
123
+ p.stage("Taint analysis")
124
+ p.done("3 files, 1 finding")
125
+
126
+ events = _events(stream)
127
+ # Every line is valid JSON carrying the protocol version.
128
+ assert all(e["v"] == PROGRESS_PROTOCOL_VERSION for e in events)
129
+ kinds = [e["event"] for e in events]
130
+ assert kinds[0] == "begin"
131
+ assert kinds[-1] == "done"
132
+ assert "stage" in kinds
133
+
134
+ begin = events[0]
135
+ assert begin["total"] == 3 and begin["label"] == "Scanning files"
136
+
137
+ advances = [e for e in events if e["event"] == "advance"]
138
+ assert [a["done"] for a in advances] == [1, 2, 3]
139
+ assert advances[-1]["current"] == "c.py"
140
+
141
+ done = events[-1]
142
+ assert done["done"] == 3 and done["total"] == 3
143
+ assert done["summary"] == "3 files, 1 finding"
144
+ assert isinstance(done["elapsed_s"], (int, float)) and done["elapsed_s"] >= 0
145
+
146
+
147
+ def test_json_progress_throttles_but_keeps_count_accurate() -> None:
148
+ stream = io.StringIO()
149
+ # Large min_interval → intermediate advances are suppressed, but the final
150
+ # file always emits and its count reflects every advance() call.
151
+ p = JsonScanProgress(stream=stream, min_interval=1_000.0)
152
+ p.begin(5)
153
+ for name in ("a", "b", "c", "d", "e"):
154
+ p.advance(name)
155
+ p.done()
156
+
157
+ advances = [e for e in _events(stream) if e["event"] == "advance"]
158
+ assert len(advances) == 1 # only the final file survived the throttle
159
+ assert advances[0]["done"] == 5 and advances[0]["total"] == 5
160
+
161
+
162
+ def test_json_progress_survives_closed_stream() -> None:
163
+ stream = io.StringIO()
164
+ p = JsonScanProgress(stream=stream, min_interval=0.0)
165
+ p.begin(1)
166
+ stream.close() # simulate the GUI hanging up mid-scan
167
+ p.advance("a.py") # must not raise
168
+ p.done("done")
169
+
170
+
171
+ def test_create_progress_selects_sink() -> None:
172
+ tty = _FakeTTY()
173
+ assert isinstance(create_progress("json", stream=io.StringIO()), JsonScanProgress)
174
+ assert isinstance(create_progress("auto", stream=tty), ScanProgress)
175
+ # 'none' and no_progress both yield a disabled no-op ScanProgress.
176
+ off = create_progress("none", stream=tty)
177
+ assert isinstance(off, ScanProgress)
178
+ off.begin(3)
179
+ off.advance("a.py")
180
+ off.done()
181
+ forced_off = create_progress("json", no_progress=True, stream=tty)
182
+ assert isinstance(forced_off, ScanProgress)
183
+ assert tty.getvalue() == "" # nothing rendered by either disabled sink
@@ -1,97 +0,0 @@
1
- """Tests for the scan progress reporter (#36)."""
2
-
3
- from __future__ import annotations
4
-
5
- import io
6
- from pathlib import Path
7
-
8
- from attackmap.progress import ScanProgress, _fmt_duration, _truncate
9
- from attackmap.scanner import scan_repo
10
-
11
-
12
- class _FakeTTY(io.StringIO):
13
- """StringIO that claims to be a terminal so progress renders."""
14
-
15
- def isatty(self) -> bool:
16
- return True
17
-
18
-
19
- def test_disabled_when_not_a_tty() -> None:
20
- stream = io.StringIO() # plain StringIO → isatty() False
21
- p = ScanProgress(stream=stream)
22
- p.begin(10)
23
- p.advance("a.py")
24
- p.done("done")
25
- assert stream.getvalue() == ""
26
-
27
-
28
- def test_disabled_when_explicitly_off() -> None:
29
- stream = _FakeTTY()
30
- p = ScanProgress(enabled=False, stream=stream)
31
- p.begin(10)
32
- p.advance("a.py")
33
- p.done()
34
- assert stream.getvalue() == ""
35
-
36
-
37
- def test_renders_bar_and_percentage_on_tty() -> None:
38
- stream = _FakeTTY()
39
- p = ScanProgress(stream=stream)
40
- p.begin(4)
41
- for i in range(4):
42
- p.advance(f"file{i}.py")
43
- p.done("Scanned 4 files")
44
- out = stream.getvalue()
45
- assert "%" in out
46
- assert "ETA" in out
47
- assert "Scanned 4 files" in out
48
-
49
-
50
- def test_stage_renders_and_stops_cleanly() -> None:
51
- stream = _FakeTTY()
52
- p = ScanProgress(stream=stream)
53
- p.begin(1)
54
- p.advance("x.py")
55
- p.stage("Taint / data-flow analysis")
56
- p.done()
57
- # spinner thread must be joined/stopped
58
- assert p._spin_thread is None
59
-
60
-
61
- def test_fmt_duration() -> None:
62
- assert _fmt_duration(0) == "00:00"
63
- assert _fmt_duration(65) == "01:05"
64
- assert _fmt_duration(3725) == "1h02m"
65
-
66
-
67
- def test_truncate_keeps_tail() -> None:
68
- assert _truncate("short.py", 32) == "short.py"
69
- long = "a/very/deeply/nested/path/to/module.py"
70
- out = _truncate(long, 20)
71
- assert out.startswith("…")
72
- assert out.endswith("module.py")
73
- assert len(out) == 20
74
-
75
-
76
- def test_scan_repo_drives_progress_without_error(tmp_path: Path) -> None:
77
- (tmp_path / "app.py").write_text(
78
- "from flask import Flask, request\n"
79
- "app = Flask(__name__)\n"
80
- "@app.route('/x')\n"
81
- "def x(): return eval(request.args['e'])\n",
82
- encoding="utf-8",
83
- )
84
- stream = _FakeTTY()
85
- p = ScanProgress(stream=stream)
86
- scan = scan_repo(tmp_path, progress=p)
87
- assert scan.files_scanned == 1
88
- out = stream.getvalue()
89
- # per-file bar + at least one tail stage label rendered
90
- assert "Scanning files" in out
91
- assert "Taint" in out
92
-
93
-
94
- def test_scan_repo_unaffected_without_progress(tmp_path: Path) -> None:
95
- (tmp_path / "app.py").write_text("print('hi')\n", encoding="utf-8")
96
- scan = scan_repo(tmp_path) # no progress arg → old behavior
97
- assert scan.files_scanned == 1
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