Skip to content

ci: close the scanner findings, gate three analysers, and pin the supply chain - #133

Merged
donislawdev merged 22 commits into
masterfrom
fix/semgrep-hardening
Aug 19, 2026
Merged

ci: close the scanner findings, gate three analysers, and pin the supply chain#133
donislawdev merged 22 commits into
masterfrom
fix/semgrep-hardening

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

Eighteen commits in three batches: the Semgrep report from the local scan, three static analysers in CI, and then the supply-chain and quality work built on top of them. Every commit stands on its own.

1. The scanner findings (3 commits)

A local Semgrep scan produced 23 findings. 20 are fixed, 2 changed even though the rule was substantively wrong, 1 left with the reason written down.

  • The only High: ci.yml pasted ${{ github.base_ref }} straight into a run: script. A ${{ }} is expanded before any shell exists, so a branch name is not an argument there, it is source code. Both values travel through env: now, quoted - which the step four lines below had done since it was written.
  • 17 mutable action tags are pinned to full commit SHAs with the version in a comment. One of them was not even a tag: actions/dependency-review-action@v5 resolved to a branch of that name. Immutable releases do not retire this - they lock the release tag, while the floating major is designed to move.
  • Dependabot gains cooldown: 7 days, which delays version updates and not security updates.
  • The crash fingerprint moves from sha1 to sha256 (a dedup key, not a signature - changed so the same false alarm stops arriving every quarter), and tools/downloads.py validates --repo before it becomes a URL path.

2. Three static analysers, wired as gates (5 commits)

  • ruff with F,B,S,ASYNC. F and B block, S reports. That split is measured, not soft: S raises 306 findings here and 287 of them are in tests, doing on purpose what a packet-mangler's test suite does. 56 findings closed, including two the tool earned its place with: tests/fake_tk.py defined bind_class twice, and a fixture had been feeding a nine-value row to a seventeen-column table since the columns were added.
  • mypy over the package. Thirty findings, no # type: ignore needed - fifteen of them one block of attributes grown onto a class from outside its body, now a loop that also took ten lines off the largest module.
  • semgrep with p/default, fetched anonymously (no account, no token). 🔴 The gate is a script, because the flag lies: --severity knows INFO/WARNING/ERROR only, while registry rules also carry HIGH and CRITICAL. Measured with a probe rule marked severity: HIGH - unfiltered 19 findings, with --severity ERROR zero. tools/semgrep_gate.py reads the JSON and blocks on ERROR/HIGH/CRITICAL, and on a scan error, because a rule that could not run is not a rule that found nothing.

Scanning the commit before this branch with that exact config reproduces the platform report finding for finding.

3. Supply chain and quality (10 commits)

Provenance. The release archive gains a build-provenance attestation beside the SBOM one. A signature says who signed a binary, provenance says which source and which build produced it: gh attestation verify <zip> -R donislawdev/BeanNetworkTester.

Hashes. requirements.txt and requirements-build.txt now pin artefact hashes, and the build file carries the freezer's whole seven-package closure rather than just the freezer. 🔴 Measured: corrupting one artefact hash does not fail an install - pip falls back to another artefact of the same version - which is why every published artefact is listed.

Weekly audit. pip-audit against the pinned set, on Windows, opening an issue on a finding. 🔴 Measured: auditing the requirement files covers 7 of 9 packages and still prints "No known vulnerabilities found"; auditing the installed environment covers 10. This deliberately reverses the "no automatic issues" decision for advisories only.

Licence gate. The official action cannot fail on a licence it could not resolve - its documentation says so. For a GPL-3.0 project shipping a binary that is the one answer nobody can act on, so tools/dependency_gate.py blocks on null exactly like a denied licence. GPL-2.0-only is deliberately outside the allowlist.

Driver bytes. The shipped WinDivert .dll and .sys are pinned by sha256, which catches a file swapped in site-packages after the install - something a version resource cannot notice.

Scorecard. OpenSSF Scorecard weekly, with the badge in both READMEs.

Four new ratchets. The mutation registry now runs in CI (per-PR on what the branch touched, weekly in full) instead of being quoted; a complexity ceiling of 29, measured; a CI job table in both READMEs with a guard comparing it to the workflow in both directions; a mypy strict-module list recorded twice so it can only grow; and diff coverage at 80% on changed lines.

Verification

  • Full suite 1195 passed; ruff and mypy clean; GUI smoke and the real-Tk render check (EN + PL) OK.
  • Mutation registry: 119 caught, 0 survived, canary BROKEN as required.
  • Every one of the 12 pinned action SHAs re-verified against the API. Every run: block parses under bash -n. All five workflows parse.
  • The semgrep config, the hashed installs, the pip-audit modes and diff-cover were each run for real before being wired in.

What still needs a human

Nothing here can block a merge until the checks are required in branch protection - that is a repository setting, not something a workflow can grant itself.

🤖 Generated with Claude Code

donislawdev and others added 22 commits August 18, 2026 23:36
`ci.yml` pasted `${{ github.base_ref }}` and `${{ ... head.sha }}` into a
`run:` script. A `${{ }}` is expanded before any shell exists, so the value
is not an argument there - it is source code, and a branch name may carry
shell metacharacters. Both travel through `env:` now and are quoted where
they are used, which is what the step four lines below - "check the
pull-request description" - has done since it was written, comment and all.

Narrow rather than harmless, and worth naming which: `base_ref` has to be a
branch that already exists in this repository, `head.sha` arrives as hex, and
the job holds `contents: read` and no secrets. The exposure is "whoever may
create a branch here may run code on the runner". It is fixed because it
costs three lines.

New guard: tests/test_repo_conventions.py collects the lines belonging to a
`run:` block and refuses a `${{` among them. It also asserts the scan found
script lines at all - a hand-written parser that stops matching passes every
check it makes. A mutation puts the original expression back and is caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seventeen references across four workflows named a floating tag, so each one
resolved to whatever commit its owner pointed it at that morning - which is
exactly what the tj-actions and trivy-action compromises did. Seven distinct
actions now read owner/action@<40 hex> with a comment naming the version, the
format Dependabot writes and rewrites when it bumps a pin.

This was an inconsistency rather than an unmade decision: the two references
somebody thought about, anchore/sbom-action and actions/attest, were already
pinned by SHA, while everything first-party sat on a major tag. One of those
was not even a tag - actions/dependency-review-action@v5 resolves to a BRANCH
named v5, checked against the API rather than assumed.

Immutable releases do not retire this, which is worth saying because they
sound like they should: they lock the release tag (v7.0.1), while the
floating major is designed to move and GitHub tells action authors to move
it. The hardening guide still calls a full-length SHA the only immutable
reference.

dependabot.yml also gains cooldown: default-days: 7 for both ecosystems, so a
version published in the last week is not proposed here. Read in the options
reference rather than assumed, because the obvious objection is that this
delays security work: it does not. Cooldown applies to version updates only,
and security updates are a separate mechanism. github-actions supports
default-days alone; the semver-*-days variants are pip's.

New guard: every `uses:` must carry a 40-character SHA and a comment naming
its version. Two mutations - a floating tag put back, and the comment
stripped - are caught by it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The crash fingerprint moves from sha1 to sha256. Substantively the finding is
wrong - this is a dedup key built from an exception type and four frames, cut
to twelve characters, where a collision costs one merged crash record and
nothing else. It changes anyway because "sha1" in a source file is a finding
in every scanner that looks, and answering the same false alarm every quarter
costs more than the one word does. Nothing persists these ids between runs,
so no stored record is invalidated.

tools/downloads.py now checks that --repo is an owner/name before it becomes
part of a URL. The rule's usual worry, a smuggled file:// scheme, cannot
happen here because the scheme is a literal - but the value does land in the
path, so "../../gists" would ask a different endpoint and print the answer as
though those were releases. Checked in fetch_releases, so every caller is
covered rather than only the CLI.

Left alone, with the reason written down rather than remembered:
legal._module_version passes a name to importlib.import_module, and that name
comes from a hardcoded table in the same module. There is no path from input
to that argument, so the rule is seeing a shape it cannot resolve.

Guards: the downloads argument gets six shapes that must be refused and three
that must pass, and the crash-id test now pins the SHAPE of the id - twelve
hex characters - which is what a person reads off a record. Both have a
mutation and both are caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four rule families, decided rather than defaulted: F (pyflakes), B (bugbear),
S (bandit) and ASYNC. F and B are meant to block a pull request; S is a
report, and that split is what the two measure rather than softness. Measured
here: S raises 306 findings, 287 of them in tests, and they are things this
project does on purpose - a test asserts (180), a packet mangler randomises
(89), a CI script runs git (18). Per-file ignores name each with its reason,
so a NEW subprocess call or eval in the package still lands as a finding.
ASYNC was zero from the start and is selected to keep it that way.

The findings were not all dead imports:

* tests/fake_tk.py defined bind_class twice - an alias, and a real method 145
  lines below whose docstring explains why the recording version must exist.
  The def won; the alias was dead.
* tests/test_site.py used a backslash inside an f-string expression, legal
  only from Python 3.12, while requires-python says 3.10. CI runs 3.14, so
  nothing caught it; ruff reads the floor from pyproject.
* Eight zip() calls now say which they are: strict=True where the two sides
  are the same length by contract, strict=False where they deliberately
  differ (pairwise over a list and its own tail, an AST tuple assignment).
* Thirteen raises inside except keep their cause. Each replaces a parse error
  with a translated message; nothing user-visible changes, and a crash record
  that names the original failure is worth more than one that does not.
* Ten findings were FALSE and are annotated rather than "fixed": closures in
  loops that return in the same iteration, and bare expressions whose value is
  discarded on purpose - one re-arms the target resolver, the other forces the
  tkinter-backed import.

Two things the tool caught the moment it ran, which is the argument for it:
a rename landed on the wrong loop and came back as F821, and strict=True
exposed a fixture that had been feeding a nine-value row to a
seventeen-column table since the columns were added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two steps, because the families answer different questions. `--select F,B` is
the gate: dead code and bug shapes fail the run. `--select S,ASYNC
--exit-zero` is the report: it annotates the diff and never fails it.
`--exit-zero` rather than continue-on-error on the step, because a step that
"succeeded but failed" reads as a broken job to whoever looks at it later.

`--select` on the command line replaces the families named in pyproject.toml
and nothing else, so the per-file ignores and the target version still come
from there - the CI run cannot drift from what a developer sees locally. One
platform rather than the test matrix: ruff reads source, and source does not
differ between the runners.

requirements-lint.txt pins the tool, and the pin means something different
from the one in requirements.txt: there it records which library a release
shipped, here it records which rules a pull request was judged by. A linter's
rule set moves between releases, and an unpinned tool turns "green yesterday,
red today" into a mystery with no commit behind it.

Also rewrites two explanatory comments that began with "# noqa:" - a comment
starting that way is parsed as a directive, and ruff warned about an invalid
code list on every run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Thirty findings on 66 modules, and none of them needed a type: ignore.

Fifteen were one block: App._export_csv = App.export_csv and fourteen more,
growing attributes onto a class from outside its body. They are a loop over
the names now - which is what the property block ten lines above them already
did, and which takes ten lines out of the largest module in the package. The
file ceiling in tests/test_code_shape.py moves 1202 -> 1192 with it, because
down is the routine door.

Nine were empty containers whose comment already said what goes in them
(_translations = {}  # language code -> {key: text}). The comment is the
annotation now, and a wrong one gets caught rather than read.

Six were one base class declaring kind = None and BLAST_PROBES = (), which
mypy reads as "always None" and "always empty" - making every subclass that
fills them an error instead of the point of the base class.

Nothing strict is turned on, and that is a decision. This package carries no
annotations, so disallow_untyped_defs would mean annotating 66 modules before
anything could be checked at all. The default level found the three classes of
thing above on its own. Tighten per module as modules gain annotations, never
repository-wide in one go: a check that has to be silenced everywhere teaches
that silence is normal.

Scope is the package. tests/ and tools/ hold same-named modules under no
package, which mypy refuses without --explicit-package-bases - a later
decision, not this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Its own job rather than a step inside the ruff one, so the two gates carry two
check names: a branch-protection rule can require either on its own, and a red
one says which it was without opening the log.

The job installs no project dependencies, and that is the part worth checking
rather than assuming. pydivert is Windows-only, psutil is not needed to read
this package, and both are ignored by name in [tool.mypy] - so mypy was run in
exactly that shape before this job was written: Linux, neither installed, 66
modules, no issues. A missing module and an installed-but-untyped one are
different code paths in mypy, and only the second had been seen on the
developer machine.

mypy is pinned next to ruff for the same reason: a release can start reporting
a class of error the previous one did not, and that is a decision about what
blocks a pull request rather than a routine bump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scan is `semgrep scan --config p/default --metrics=off --oss-only --json`
and tools/semgrep_gate.py decides the run. Measured before any of it was
written: the registry serves p/default anonymously - no account, no token,
343 rules over 184 files in 35 seconds - and scanning the commit before this
branch reproduces the platform report exactly, the same 23 findings with the
same rule ids. The free path gives what the dashboard gave.

The gate is a script and not a flag, because the flag lies. --severity accepts
INFO, WARNING and ERROR only, while registry rules also carry the newer scale:
two findings in this repository's own report came back as MEDIUM. Measured
with a probe rule declaring severity: HIGH - unfiltered it produced 19
findings, and --severity ERROR produced zero. A gate built on that flag would
silently ignore the severities it was asked to block. The script reads the
JSON and blocks on ERROR, HIGH or CRITICAL, and on a scan error, because a
rule that could not run is not a rule that found nothing.

Verified end to end on both scans: the pre-branch commit gives "1 blocking, 22
other" and exit 1, today's tree gives "0 blocking, 2 other" and exit 0.

Licensing is why the rules are fetched and never carried here. The CLI is
LGPL-2.1, which we run rather than ship. The rules are under the Semgrep Rules
License v1.0, which permits use for our own purposes and forbids
redistributing them, and vendoring p/default into a public repository would be
exactly that. So the pin fixes the engine and not the ruleset, and a re-graded
rule can turn a pull request red with no commit behind it. That is the cost of
not vendoring, and it is the cheaper side.

The Windows marker on the pin is measured as well: the wheel installs and then
semgrep-core fails to scan at all on this machine, an OCaml backtrace on a
three-line local rule. Local scans go through WSL.

Both READMEs and CONTRIBUTING now say what the three static gates do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SBOM attestation already said what is inside the release zip. This adds
the other half: a build-provenance attestation saying the zip came out of this
repository, from this commit, through this workflow, on a GitHub-hosted
runner. Both are signed with the same short-lived OIDC identity and land in
the same store, so a user answers both questions with one command:

    gh attestation verify BeanNetworkTester-<tag>-windows-x64.zip \
       -R donislawdev/BeanNetworkTester

This is a different question from the code signature being prepared
separately, and that is why both are worth having. A signature says who signed
the binary. Provenance says which source and which build produced it. A stolen
signing key cannot forge provenance, and a forked workflow cannot claim to be
this repository.

A separate action from actions/attest, checked rather than assumed: that one
attaches a predicate you hand it, while the provenance predicate is built by
the action from the workflow's own context. There is nothing to pass in, and
nothing we could pass in that would be worth trusting. The permissions were
already there, so nothing new is granted.

New guard: both subject-path values must name the same variable, and that
variable must be what `gh release create` uploads. A statement signed about a
file nobody downloads is invisible on the release page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ersion

pydivert==3.1.3 pins a number and says nothing about what comes back. An
index, or a publishing account that has been taken over, can serve different
bytes under the same version - and that wheel carries the WinDivert kernel
driver this tool installs on a user's machine. requirements.txt now carries
the artefact hashes (2 for pydivert, 21 for psutil), generated by
tools/pin_hashes.py from the PyPI JSON API.

Measured against the real index, because the failure mode is not the obvious
one. Corrupting one artefact's hash does NOT fail the install: pip falls back
to another artefact of the same version and succeeds. That is why every
published artefact is listed. Corrupting all of them is what produces "THESE
PACKAGES DO NOT MATCH THE HASHES" and exit 1.

requirements-dev.txt no longer includes -r requirements.txt, and that is pip's
rule rather than a preference: hash-checking applies to the whole install as
soon as one requirement carries a hash, so including the hashed file would
demand hashes for pytest, hypothesis and everything underneath - which are
deliberately unpinned so the weekly run can watch them drift. Every install
site is two commands now, in CI, in the release, on Pages and in both READMEs.

The build file is not hashed yet, so the release installs it separately. That
is the next step rather than an oversight: the freezer pulls eight transitive
dependencies, one of which ships monthly.

Guards: every pinned requirement must carry at least two hashes in the shape
pip reads, and the dev file must not include the hashed one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pinning pyinstaller alone left seven packages free to move underneath it, one
of which - pyinstaller-hooks-contrib - ships monthly and decides which files
end up inside the bundle handed to users. A pin that stops at the top of the
tree pins the name of the tool, not the tool.

requirements-build.txt now carries the closure pip resolved on Windows, which
is the only platform that installs it, with artefact hashes for every version.
Verified by downloading the whole set under --require-hashes: 7 packages, exit
0.

With both requirement files hashed, the split from the previous commit merges
back into one hash-checked resolution in the build job and in the release, so
a release cannot be built from a different set of bytes than the one CI
proved.

The parity guard was rewritten rather than patched: it asserted that the file
pins exactly one package, which was right while that was true and would now
forbid the closure it is meant to protect. It checks that every entry is
pinned with == and that the freezer is still among them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This asks a different question from dependency-review. That one looks at what
a pull request adds. This one asks whether an advisory has been published
since, against versions that have not moved - which no pull request can make
red, so it runs on the schedule and on demand rather than per PR.

It audits an installed environment rather than the requirement files, and
that is measured rather than assumed: pip-audit -r over both files reports on
7 of the 9 pinned packages and prints "No known vulnerabilities found",
silently skipping packaging and setuptools. The same set installed into a venv
and audited with --path comes back with 10. A scanner that quietly covers less
than it was asked to is worse than no scanner, because its clean report is the
one that gets quoted.

It runs on Windows, not Linux: pydivert carries a win32 marker, so a Linux
runner would skip the one dependency that puts a kernel driver on a user's
machine.

Opening an issue reverses the decision of 2026-08-17 on purpose, and only for
this job. A red cron usually means drift, and drift is read when somebody
looks. A published advisory has a clock on it and does not resolve itself. The
title carries the advisory ids, so the same finding cannot open fifty-two
issues a year and a new advisory arriving while the old issue is open is not
swallowed by it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three analysers in ci.yml read the code. OpenSSF Scorecard reads the
project: whether actions are pinned, whether tokens are least-privilege,
whether releases are signed, whether a branch is protected, whether dangerous
workflow patterns are present. It is the only check here that can name
something we did not think to test, and it says it in a form a stranger can
read before installing a tool that loads a kernel driver.

Nothing it finds fails a pull request. It reports into the Security tab as
SARIF and keeps the full grading as an artefact, including the checks that
passed.

persist-credentials: false on the checkout, because the analyser walks
repository data and has no business holding a token it could push with.
publish_results: true is what turns the score into a badge, and id-token:
write is what makes the badge mean anything - without the OIDC proof anybody
could publish a score for any repository. It also runs on
branch_protection_rule, since protection is one of the things it grades.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… bytes

The dependency-review action stays where it is good - known vulnerabilities -
and a second gate reads the same API for the half it cannot do. Its own
documentation: "If we can't detect the license for a dependency we will inform
you, but the action won't fail." For a GPL-3.0 project that ships a binary,
an unidentified licence is the one answer nobody can act on, so a null licence
blocks exactly like a denied one.

The allowlist is SPDX ids a GPL-3.0 project may distribute alongside.
GPL-2.0-only is deliberately absent: it is incompatible with GPL-3.0 and is
exactly the kind of entry that looks fine in a list of open source licences. A
compound expression is judged by its worst half, because being generous with
an OR means accepting the worse one. Only what the pull request adds is
judged, and a gate that cannot read its input exits 2 rather than reporting an
empty list of problems.

Separately, the WinDivert driver is now pinned by its bytes.
WINDIVERT_VERSION answers which driver the notices describe, and the existing
test proves the driver agrees - but neither notices a file changing while the
version still reads 2.2, which is what a swapped kernel driver looks like. The
wheel is already hash-pinned, so this is the second line: it catches a file
replaced in site-packages after the install, on the machine that builds the
release.

Both new guards have a mutation and both are caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What ships is a registry whose entries the suite checks for pointing at code
that exists. Nothing in the repository ever ran one. So "116 caught, 0
survived" was true on the day somebody typed it and unverifiable every day
after - the same class of claim the registry exists to refuse. The runner
moves from internal_tools/ into tools/, which makes it a tracked CI
dependency, and a job executes it.

Two modes. A pull request runs --changed against its base: only the entries
whose file the branch touched, which is seconds. The weekly run does the lot,
which is about thirteen minutes for 117 entries on a developer machine. The
canary rides along on the full run only, because demanding it on a filtered
run would fail a pull request for the shape of the run rather than its result.

It runs on Windows, and not for tidiness: pydivert carries a win32 marker, so
on Linux an entry whose test needs the driver comes back SURVIVED - the test
passes there by returning early, which is indistinguishable from a guard that
does not guard.

The old path still works and redirects, because it is in muscle memory and in
the notes. This reverses the "it is a rig, not a dependency of anything" rule
deliberately, and the repository's own guard against ${{ }} inside a run block
refused the first version of the job - which is the cheapest possible evidence
that the guard works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two ratchets that were missing.

The size ratchet watches how long a function is. Complexity watches how many
ways through it there are, and the two do not move together: a hundred lines
of straight-line setup is readable, forty lines with eight nested branches is
not. The ceiling is 29 because that is core.decide today, the twelve-step
packet pipeline, with cli._run_session at 27 behind it and nothing else in the
package past 24. The number IS the maximum, so the guard asserts both
directions - nothing exceeds it, and lowering it by one must produce a
finding. It skips itself when ruff is absent, because a red that means "you
did not install a tool" teaches people to ignore red.

The workflow gained four jobs in two days and nothing was watching what the
READMEs say about them. Both now carry a job table between explicit markers,
and the guard compares it with the workflow in both directions: an
undocumented job fails, and so does a documented job that no longer exists.
The markers exist because the first version of the guard read every table in
the file - both READMEs are full of tables whose first column is a backticked
lowercase name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gradual typing without a ratchet is a plan, not a property. disallow_untyped_
defs is on for beantester.utils, gui.rates and gui.scope - and nothing stopped
the next change from dropping one out of pyproject.toml to turn a red build
green, with nothing to say so afterwards, because a check that vanishes cannot
fail.

So the list lives in two places, the configuration and the test, and the test
compares them in both directions: growing it is free, shrinking it reddens. A
mutation removes one module from the configured list and is caught.

Those three because they are pure, small and headless, and utils is read by
every layer so a wrong type there travels furthest. Annotating them turned up
nothing broken, which is the expected result for three hundred lines of
arithmetic and exactly why the ratchet starts there rather than in app.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The whole-repository gate answers "is this project tested". It cannot answer
"is this change tested": at around 1200 tests the average barely moves, so a
pull request can add a hundred untested lines and still pass it - and those
are the lines nobody has run twice yet.

diff-cover reads the coverage.xml the suite already produces and reports on
the changed lines only, at 80 percent. Not 100, deliberately: parts of the GUI
are reachable only through a real Tk event loop the fake tkinter cannot drive,
and demanding every changed line would push people towards writing the test
that is easy instead of the one that matters.

Linux only, because both matrix legs would give the same answer, and pull
requests only, because a push to master has nothing to diff against. The job's
checkout gains fetch-depth: 0, since a shallow clone has no base branch.
Verified locally against a real coverage.xml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pth zero

Two things the first real run found, and neither could have failed locally.

The licence gate blocked four GitHub Actions. Actions are dependencies in that
API too, and GitHub reports license: null for every one of them - measured on
this pull request, where checkout, upload-artifact, codeql-action and
scorecard-action all came back unknown while all five pip packages came back
with real licences. The actions ecosystem is skipped now, and not for
convenience: an action is CI machinery that never reaches a user, so it
creates none of the distribution obligation an unknown licence is dangerous
for, and keeping it would block every pull request that touches a workflow for
ever. A gate that always fires is a gate people learn to bypass. Actions are
held to a stricter rule elsewhere: pinned to a commit SHA, checked by the
suite, graded weekly.

`git fetch --depth=0` is an error rather than a no-op - "depth 0 is not a
positive number" - so the mutation job died on its first command in 25
seconds. The checkout already takes the full history, so the flag was never
needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mutation registry job came back with one SURVIVED, and there was no defect
behind it. test_the_repository_scanners_stay_out_of_what_is_not_in_the_
repository asserts that internal_tools/, .claude/ and crashes/ are never
scanned - and those directories exist on the maintainer's machine and in no
checkout. On a runner there was nothing under them to leak, so the assertion
passed whatever SKIP_DIRS held, and dropping internal_tools from it changed
nothing observable.

The same test already fixed this exact fault once, for HANDOFF-*.md, and its
docstring says so: an assertion that could not fail, and only a mutation run
said so. So the fix follows that precedent - plant a file in each of those
directories for the length of the scan, so the skip has something to skip -
and clean up afterwards, including a directory the test created.

Verified the way the failure was found rather than by argument: a clean git
archive export of HEAD, which is what a runner checks out, passes with this
fix and reddens with the mutation applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tests (ubuntu) hit its 20-minute timeout three times in a row, always in the
GUI render step, and never got as far as starting the render. The timestamps
settle it: the last line of apt-get update was at 08:49:50 and the job was
cancelled at 09:05:29 - fifteen minutes and thirty-nine seconds of silence,
after four "Ign: http://azure.archive.ubuntu.com/..." per index list. The same
step took seventeen seconds on master the day before. Nothing here changed:
the runner image points apt at a mirror that stopped answering, and apt-get
update has no timeout of its own.

So the step now touches apt only when a package is actually missing, rewrites
that mirror to the one apt was already limping towards, bounds every apt call,
and bounds the render itself - because a hanging GUI must not look identical
to a hanging mirror.

It also runs Python unbuffered. The first diagnosis of this was wrong, and
that is why: the archived log of a cancelled job holds only what was flushed,
and Python buffers into a pipe, so "no render output" could equally have meant
"the render hung with its output still in the buffer". Proving which took a
WSL run, a comparison against master and the line timestamps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n it can

The previous fix bounded the damage - the job died at four minutes with exit
124 instead of eating twenty - but left the cause alone, because it rewrote
the wrong file. The log had already said where the mirror lives: "Get:1
file:/etc/apt/apt-mirrors.txt Mirrorlist [144 B]". On these images
sources.list points at a mirrorlist, so rewriting sources.list changes
nothing.

That file is rewritten now. The step also tries the cheap path first: the
image ships package lists, so apt-get install is attempted without refreshing
them, and apt-get update - the network-bound half that hung - happens only if
that fails.

Both branches were proved with a stubbed shell harness rather than by reading
the code: one install and no update on the cheap path, update plus a second
install on the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit 1bbc464 into master Aug 19, 2026
13 of 14 checks passed
@donislawdev
donislawdev deleted the fix/semgrep-hardening branch August 19, 2026 09:51
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.

1 participant