ci: close the scanner findings, gate three analysers, and pin the supply chain - #133
Merged
Conversation
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
ci.ymlpasted${{ github.base_ref }}straight into arun:script. A${{ }}is expanded before any shell exists, so a branch name is not an argument there, it is source code. Both values travel throughenv:now, quoted - which the step four lines below had done since it was written.actions/dependency-review-action@v5resolved 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.cooldown: 7 days, which delays version updates and not security updates.tools/downloads.pyvalidates--repobefore it becomes a URL path.2. Three static analysers, wired as gates (5 commits)
F,B,S,ASYNC.FandBblock,Sreports. That split is measured, not soft:Sraises 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.pydefinedbind_classtwice, and a fixture had been feeding a nine-value row to a seventeen-column table since the columns were added.# type: ignoreneeded - 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.p/default, fetched anonymously (no account, no token). 🔴 The gate is a script, because the flag lies:--severityknows INFO/WARNING/ERROR only, while registry rules also carry HIGH and CRITICAL. Measured with a probe rule markedseverity: HIGH- unfiltered 19 findings, with--severity ERRORzero.tools/semgrep_gate.pyreads 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.txtandrequirements-build.txtnow 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-auditagainst 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.pyblocks onnullexactly like a denied licence.GPL-2.0-onlyis deliberately outside the allowlist.Driver bytes. The shipped WinDivert
.dlland.sysare pinned by sha256, which catches a file swapped insite-packagesafter 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
run:block parses underbash -n. All five workflows parse.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