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.
- {attackmap-0.4.0/src/attackmap.egg-info → attackmap-0.4.1}/PKG-INFO +1 -1
- {attackmap-0.4.0 → attackmap-0.4.1}/pyproject.toml +1 -1
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/__init__.py +1 -1
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/cli.py +9 -2
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/progress.py +99 -1
- {attackmap-0.4.0 → attackmap-0.4.1/src/attackmap.egg-info}/PKG-INFO +1 -1
- attackmap-0.4.1/tests/test_progress.py +183 -0
- attackmap-0.4.0/tests/test_progress.py +0 -97
- {attackmap-0.4.0 → attackmap-0.4.1}/LICENSE +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/README.md +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/setup.cfg +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/analyzer.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/analyzer_contracts.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/analyzers.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/anomalies.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/asset_model.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/attack_taxonomy.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/authz.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/config_scanner.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/context_pack.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/control_model.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/crypto.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/cve.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/defensive_review.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/detection_opportunities.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/diagrams.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/diff.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/exploitability.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/graph.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/insights.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/llm_review.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/merge.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/models.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/recon_models.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/recon_to_analysis.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/report.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/review_eval.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/review_json.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/review_prompts.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/sarif.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/sbom.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/scanner.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/sdk/__init__.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/sdk/contracts.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/sdk/models.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/security_overlay.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/srcpaths.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/suggest.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/taint.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/threat_model.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/topology.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/weaknesses.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap/webhardening.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap.egg-info/SOURCES.txt +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap.egg-info/dependency_links.txt +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap.egg-info/entry_points.txt +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap.egg-info/requires.txt +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/src/attackmap.egg-info/top_level.txt +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_analyzer.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_analyzer_metadata_schema.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_analyzers.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_anomalies.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_artifact_schemas.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_authz.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_config_scanner.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_context_pack.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_crypto.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_cve.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_defensive_review.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_diagrams.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_diff.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_exploitability.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_graph.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_hunt.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_injection_sinks.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_llm_review.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_merge.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_pr_comment.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_provenance.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_recon_to_analysis.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_remediate.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_report.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_review_eval.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_review_json.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_review_prompts.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_sarif.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_sbom.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_scanner.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_security_overlay.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_shared_contract_imports.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_signal_v2.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_srcpaths.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_suggest.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_taint.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_threat_intel.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_threat_model.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_topology.py +0 -0
- {attackmap-0.4.0 → attackmap-0.4.1}/tests/test_weaknesses.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|