Skip to content

Revival: explainable verdicts + Windows-safe reporting - #5

Merged
constripacity merged 4 commits into
mainfrom
revival/v-next
Sep 2, 2026
Merged

Revival: explainable verdicts + Windows-safe reporting#5
constripacity merged 4 commits into
mainfrom
revival/v-next

Conversation

@constripacity

Copy link
Copy Markdown
Owner

Revival: explainable verdicts + Windows-safe reporting

This branch revives the scanner after months of inactivity. It was chosen by an
audit of two independent overnight "go all in" rewrites (one by ChatGPT 5.6,
one by Claude/Opus 5); each was built, tested, linted and security-scanned, then
adjudicated head-to-head. The Claude rewrite won 86–69, and this PR adds the
Windows fixes it was missing.

What the revival delivers (commit e045db1)

  • Rebuilds triage around an explainable verdict: unchecked / oversized /
    errored / incomplete inputs become COULD_NOT_INSPECT and are never called
    "safe"
    — fixing the live tool's severity="Safe"-on-error behavior, the core
    safety bug in a tool teachers trust.
  • Hardened untrusted-XML parsing (defusedxml with forbid_dtd/forbid_entities
    • a byte-scan fallback) for Office documents.
  • Quarantine that never deletes, never overwrites, is reversible, and refuses
    symlinks; SHA-256 manifest.
  • Test suite grown to ~118 tests.

Why it won over the other rewrite

The ChatGPT rewrite held a temp file descriptor open across os.replace
(reporters.py) and across src.unlink (quarantine.py), which raises
PermissionError (WinError 32) on Windows — so --report-json/--report-html
wrote no file and quarantine couldn't remove the original. That's a
regression on the tool's primary platform. The Claude rewrite's durable JSON/HTML
writers use encoding="utf-8" and work on Windows; its only Windows defect was a
console-printer crash, fixed here.

What this PR adds on top (commit 25ac2bc)

  • scanner/reporters.py — the console report wrote Unicode glyphs
    (✖ ✓ • …) straight to stdout, raising UnicodeEncodeError on a default
    Windows cp1252 console (the scan aborted right after the headline). Now the
    reporter picks ASCII stand-ins (X ! ? +) per output stream when it can't
    encode the glyphs, and wraps the writer so no exotic filename can abort a scan.
    UTF-8 terminals and the HTML report keep the nicer symbols.
  • tests — the genuinely POSIX-only checks (symlink creation, os.geteuid,
    <>-in-filename) now skip on Windows via a tests/_platform.py capability
    probe (symlink tests still run wherever symlinks are permitted); dropped-path
    parsing compares with as_posix() so it holds on both separators.

Verification (Windows / CPython 3.11)

  • pytest: 112 passed, 6 skipped, 0 failed (was 109 passed, 8 failed)
  • ruff check .: clean
  • mypy scanner: clean (19 files)
  • Reproduced the original cp1252 crash and confirmed the fix prevents it.

Security gate

No real secrets and no critical/high vulnerabilities. The only credential-shaped
strings in the tree are fake fixtures in tests/ (this is a scanner — it needs
sample tokens to detect). Offline-only; no eval/exec/subprocess/pickle/yaml.load.

Before merge

  • CI (ubuntu / macos / windows × Python matrix) should confirm green on all
    three OSes — the numbers above are from a local Windows run.
  • The tkinter GUI wasn't exercised headless here.

🤖 Generated with Claude Code

claude and others added 4 commits September 2, 2026 04:24
…nchecked files safe

v0.2.0 reported `Safe` for files it had not managed to inspect: an oversized
file, a file it could not `stat`, and a `zipfile.BadZipFile` were all swallowed
and reported as clean. Eleven of twenty-two structurally risky corpus samples
came back Safe, including ZIP path traversal, a 48 KB → 48 MB bomb, an
encrypted archive, and a PDF with a ZIP appended after %%EOF. For a tool a
teacher trusts with a folder of student submissions, "I did not look" reported
as "this is fine" is the worst failure it can have.

The scoring was equally indefensible: an unsourced weight table that
double- and triple-counted a single fact (one macro produced four findings and
saturated at 100) and escalated a twenty-link bibliography to High.

What replaces it:

- scanner/findings.py, verdict.py — severity and confidence as separate axes,
  and a verdict from four stated rules. COULD_NOT_INSPECT is a first-class
  outcome that can never round down to safe. The risk score is capped per
  finding code and used only for sorting.
- scanner/detectors/ — archive, office, pdf, image and general detectors
  replacing heuristics.py and the four *_rules modules.
- scanner/limits.py — explicit bounds on size, archive members, nesting depth,
  total uncompressed bytes and compression ratio.
- scanner/quarantine.py — a JSONL manifest, 0600/0700, hash-verified restore,
  and refusal to follow symlinks.
- scanner/reporters.py — a self-contained HTML report with no script and no
  network, and sanitize_display() to neutralise the bidi and zero-width
  characters an attacker uses to make `invoice<U+202E>gpj.exe` read as a JPEG.
  The report was rendering the attack as its own disguise.
- scanner/gui_model.py + gui.py — the Tk-free model is tested; PySimpleGUI is
  gone.

Defects found and fixed while building this, each with a regression test:
every valid PNG was flagged (the IEND CRC was not counted); every real .docx
was flagged (a settings.xml rule); /EmbeddedFile was missed when it straddled
the 4096-byte read boundary; a symlink to /dev/zero never finished; quarantining
two files of the same name destroyed the first; a mistyped scan path exited 0;
and .rar/.7z/.tar/.rtf returned LIKELY_SAFE rather than COULD_NOT_INSPECT.

Deleting ruff.toml activated the stricter pyproject config, which surfaced a
real B023 loop-variable closure in office.py and a B904 in quarantine.py.

Verified on Linux/CPython 3.11: pytest 117 passed 1 skipped; ruff clean over
scanner tests examples scripts; mypy clean over 19 source files; corpus gate
31 samples, 15 correctly blocked, 9 correctly clean, no false positives.
Not verified: the PyInstaller binary, Windows, macOS, the Tk window, and the
optional YARA path. See docs/REVIVAL_AUDIT.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKVgvkabikjdEvThAqweov
…the suite on Windows

The revived console report wrote Unicode glyphs (X-mark, check, bullet, ellipsis)
straight to the output stream, which raises UnicodeEncodeError on a default
Windows cp1252 console -- the scan aborted right after the headline. Windows
teachers are the tool's primary audience and CI runs windows-latest, so this
matters. The durable JSON/HTML reports were already UTF-8 and unaffected.

- scanner/reporters.py: choose ASCII stand-ins (X ! ? +) per output stream when
  it cannot encode the glyphs, and wrap the writer so no filename the console
  cannot encode can abort a scan. UTF-8 terminals and the HTML report keep the
  nicer symbols.
- tests: skip the genuinely POSIX-only checks on Windows (symlink creation,
  os.geteuid, '<>' in a filename) via a small tests/_platform.py capability
  probe -- symlink tests still run wherever symlinks are permitted -- and compare
  dropped-path parsing with as_posix() so it holds on both path separators.

Verified on Windows/CPython 3.11: 112 passed, 6 skipped, 0 failed
(was 109 passed, 8 failed); ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJ4pLh3eqru38tYbNXbxty
…ndows CI)

The "runs with zero dependencies" step hardcoded /tmp/bare, /tmp/samples and
/tmp/r.json. On windows-latest bash and Python resolve /tmp differently, so the
scan wrote its report where the assertion could not find it -> FileNotFoundError
and a red Windows job (the scanner itself ran fine — see the verdict table in the
log). Switch to relative paths (.ci-bare / .ci-samples / ci-report.json), which
resolve identically for both on every runner.

Verified locally: scan exit 2, report written, counts.do_not_open == 15 (> 10).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJ4pLh3eqru38tYbNXbxty
… binary runs

The PyInstaller spec freezes scanner/__main__.py as the top-level __main__
script, which has no parent package, so `from .main import main` raised
"ImportError: attempted relative import with no known parent package" and every
standalone-binary job (macOS/Linux/Windows) failed the moment it ran the built
exe. (It had been masked because the job depends on the test job, which was red.)
Switch to `from scanner.main import main`, which works both under
`python -m scanner` and when frozen.

Verified locally: `python -m scanner --help` exit 0; built the spec with
PyInstaller and ran dist/teacher-safe-scan.exe --help -> exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJ4pLh3eqru38tYbNXbxty
@constripacity
constripacity merged commit e3511e1 into main Sep 2, 2026
10 checks passed
@constripacity
constripacity deleted the revival/v-next branch September 2, 2026 19:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants