attackmap 0.4.0__tar.gz → 0.4.1__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.1}/PKG-INFO +1 -1
  2. {attackmap-0.4.0 → attackmap-0.4.1}/pyproject.toml +1 -1
  3. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/__init__.py +1 -1
  4. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/cli.py +9 -2
  5. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/progress.py +99 -1
  6. {attackmap-0.4.0 → attackmap-0.4.1/src/attackmap.egg-info}/PKG-INFO +1 -1
  7. attackmap-0.4.1/tests/test_progress.py +183 -0
  8. attackmap-0.4.0/tests/test_progress.py +0 -97
  9. {attackmap-0.4.0 → attackmap-0.4.1}/LICENSE +0 -0
  10. {attackmap-0.4.0 → attackmap-0.4.1}/README.md +0 -0
  11. {attackmap-0.4.0 → attackmap-0.4.1}/setup.cfg +0 -0
  12. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/analyzer.py +0 -0
  13. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/analyzer_contracts.py +0 -0
  14. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/analyzers.py +0 -0
  15. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/anomalies.py +0 -0
  16. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/asset_model.py +0 -0
  17. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/attack_taxonomy.py +0 -0
  18. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/authz.py +0 -0
  19. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/config_scanner.py +0 -0
  20. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/context_pack.py +0 -0
  21. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/control_model.py +0 -0
  22. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/crypto.py +0 -0
  23. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/cve.py +0 -0
  24. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/defensive_review.py +0 -0
  25. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/detection_opportunities.py +0 -0
  26. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/diagrams.py +0 -0
  27. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/diff.py +0 -0
  28. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/exploitability.py +0 -0
  29. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/graph.py +0 -0
  30. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/insights.py +0 -0
  31. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/llm_review.py +0 -0
  32. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/merge.py +0 -0
  33. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/models.py +0 -0
  34. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/recon_models.py +0 -0
  35. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/recon_to_analysis.py +0 -0
  36. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/report.py +0 -0
  37. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/review_eval.py +0 -0
  38. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/review_json.py +0 -0
  39. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/review_prompts.py +0 -0
  40. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/sarif.py +0 -0
  41. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/sbom.py +0 -0
  42. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/scanner.py +0 -0
  43. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/sdk/__init__.py +0 -0
  44. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/sdk/contracts.py +0 -0
  45. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/sdk/models.py +0 -0
  46. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/security_overlay.py +0 -0
  47. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/srcpaths.py +0 -0
  48. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/suggest.py +0 -0
  49. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/taint.py +0 -0
  50. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/threat_model.py +0 -0
  51. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/topology.py +0 -0
  52. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/weaknesses.py +0 -0
  53. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/webhardening.py +0 -0
  54. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap.egg-info/SOURCES.txt +0 -0
  55. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap.egg-info/dependency_links.txt +0 -0
  56. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap.egg-info/entry_points.txt +0 -0
  57. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap.egg-info/requires.txt +0 -0
  58. {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap.egg-info/top_level.txt +0 -0
  59. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_analyzer.py +0 -0
  60. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_analyzer_metadata_schema.py +0 -0
  61. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_analyzers.py +0 -0
  62. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_anomalies.py +0 -0
  63. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_artifact_schemas.py +0 -0
  64. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_authz.py +0 -0
  65. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_config_scanner.py +0 -0
  66. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_context_pack.py +0 -0
  67. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_crypto.py +0 -0
  68. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_cve.py +0 -0
  69. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_defensive_review.py +0 -0
  70. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_diagrams.py +0 -0
  71. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_diff.py +0 -0
  72. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_exploitability.py +0 -0
  73. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_graph.py +0 -0
  74. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_hunt.py +0 -0
  75. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_injection_sinks.py +0 -0
  76. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_llm_review.py +0 -0
  77. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_merge.py +0 -0
  78. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_pr_comment.py +0 -0
  79. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_provenance.py +0 -0
  80. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_recon_to_analysis.py +0 -0
  81. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_remediate.py +0 -0
  82. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_report.py +0 -0
  83. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_review_eval.py +0 -0
  84. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_review_json.py +0 -0
  85. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_review_prompts.py +0 -0
  86. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_sarif.py +0 -0
  87. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_sbom.py +0 -0
  88. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_scanner.py +0 -0
  89. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_security_overlay.py +0 -0
  90. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_shared_contract_imports.py +0 -0
  91. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_signal_v2.py +0 -0
  92. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_srcpaths.py +0 -0
  93. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_suggest.py +0 -0
  94. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_taint.py +0 -0
  95. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_threat_intel.py +0 -0
  96. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_threat_model.py +0 -0
  97. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_topology.py +0 -0
  98. {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_weaknesses.py +0 -0
  99. {attackmap-0.4.0 → attackmap-0.4.1}/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.1
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.1"
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.1"
@@ -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,8 +153,10 @@ 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…")
@@ -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
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: attackmap
3
- Version: 0.4.0
3
+ Version: 0.4.1
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>
@@ -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