Skip to content

Build the debug artifacts the session probe drives, and act on a scanner report - #6

Merged
donislawdev merged 5 commits into
mainfrom
fix/ci-debug-artifacts-for-dry-run-probe
Sep 8, 2026
Merged

Build the debug artifacts the session probe drives, and act on a scanner report#6
donislawdev merged 5 commits into
mainfrom
fix/ci-debug-artifacts-for-dry-run-probe

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

Why CI is red, and what makes it green

CI has been failing on main for four pushes, since the commit that added crates/cli/tests/dry_run.rs. One test fails: the counter-probe that drives a real session so the --dry-run assertion is known to be capable of failing.

The cause is neither the test nor the product. cargo test never emits chrono_hook.dll - a test build compiles chrono-hook as a test harness, so the cdylib is not uplifted into target/debug, and the session refuses before it starts anything. On a developer machine that file is present from an earlier cargo build, so the probe passed by hand and failed on every clean runner from the day it was written.

Measured rather than reasoned: parking the file locally reproduced the runner's failure word for word, and cargo test --workspace did not put it back. cargo build --workspace did, in 3,4 s.

Three changes, not one:

  1. CI builds before it tests. The cost is one cdylib link, because the dependencies were going to be compiled by the next step anyway.
  2. The probe asks for the library first, so a checkout without a build gets the file name and the command to run, rather than the product's message about an incomplete installation - the right words for a user holding half a package, the wrong ones for whoever is running the tests. It asks is_file, exactly what core::hook_dll_in asks.
  3. A new test reads the workflow. The precondition lives in another file and the local gate script builds too, so nothing else would notice the step leaving. That is how this was missed in the first place: the local gates reported all green while every push was red. The guard was watched failing with the step removed and with the step moved below the tests.

Rust tests go from 418 to 419, and the count is the point: a guard nobody has seen fail is decoration.

A matching gate was added to the local gate script so it can no longer be green while CI is red. That file is outside the repository, so it is not in this diff.

Not verified

Whether this test can pass on a hosted runner at all is unknown. The fix addresses the proven cause. This counter-probe would be the first real injection performed in CI, and runner policy could refuse it for a different reason. This run is how we find out.

Also in this branch

Four commits that were sitting unpushed, three of them acting on a static-analysis report.

  • Script injection in two workflows. Six run: blocks interpolated ${{ }} into the body of the script. Expansion is textual and happens before the shell, so the quotes in the YAML protect nothing, and a git ref name may contain a quote, a backtick and a semicolon - in jobs holding id-token: write and attestations: write. Proven with a probe: with a hostile tag the old form executed the payload and the variable held only the leading characters, the new form printed the whole tag and ran nothing. Every interpolation is gone from those scripts, not only the ones a scanner calls dangerous, because "no ${{ }} inside a run: block" is something one grep can check.
  • A cooldown on all three Dependabot ecosystems. Seven days, over the three applied by default, so a freshly published version ages before it is proposed. Security updates ignore the cooldown by design. Checked against the published schema rather than by eye, since a config error would stop Dependabot silently.
  • Eleven findings from rules aimed at other kinds of program. Path traversal and command injection from a web-framework ruleset, an insecure WebSocket rule written for another language. All read in the code, none of them reaches this program, and each is suppressed per rule id with the reason beside it. Ten were watched appearing and then disappearing under the scanner. The eleventh does not reproduce under the open ruleset, so its marker is unproven and says so.
  • A README correction from the previous session, saying that the release currently on the page is the unsigned one.

🤖 Generated with Claude Code

donislawdev and others added 5 commits September 8, 2026 11:32
The verification section described signed binaries in the present tense while the only release a
reader can download is v0.1.0, which predates all of it. Somebody following the README today would
have met an unknown-publisher warning and concluded the page was lying. It now says which release
carries what, and that neither a signature nor an attestation can be granted after the fact.

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

A `${{ }}` inside a `run:` block is expanded before the shell reads the script, so the
value lands in the source of the script and the quotes around it protect nothing. Git
allows a quote, a backtick and a semicolon in a ref name, so a tag could close the string
it was pasted into and continue as code - in jobs holding `id-token: write` and
`attestations: write`, which is enough to mint an attestation with the repository identity.

Measured rather than assumed. With the tag `v9.9.9';Set-Content ...;'` the old form ran the
payload and the variable held only `v9.9.9`, and the new form printed the whole tag and ran
nothing. The same probe reports INCONCLUSIVE if the old form ever stops firing, so it cannot
go quietly green.

Every interpolation left in these two scripts is gone, not only the ones a scanner calls
dangerous: `github.sha` and `github.repository` now come from the built-in environment
variables. "No `${{ }}` inside a `run:` block" is an invariant one grep can check, while a
list of contexts believed to be safe is a judgement that rots.

The shape was already in the repository - verify-release.yml has read `$env:TAG` since it
was written, and it is the one release workflow the scan had nothing to say about.

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

A newly published version is the shape a supply chain attack arrives in, and this project
has one maintainer, so the cheapest defence available is to let other people meet a release
first. Seven days on all three ecosystems, over the three Dependabot has applied by default
since 2026-07-14.

It delays no fix for a known vulnerability: security updates ignore the cooldown by design,
and `cargo deny check advisories` in audit.yml asks that question weekly on its own.

The actions entry gets one too, and there it is the point rather than a precaution - a
pinned SHA is exactly what an attacker wants moved, and that entry exists to move SHAs.
Only `default-days`, because the options reference marks GitHub Actions as supporting that
and not the per-SemVer-bump ones.

Checked against the published dependabot-2.0 schema rather than by eye: a config error would
stop Dependabot silently, which would cost more than the finding it closes. The check was
seen refusing a mistyped key and a wrong value type before it was believed on the real file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A scan reported eleven findings from rules written for a web framework and for another
language: path traversal and command injection from an Actix ruleset, and an insecure
WebSocket rule from a JavaScript one. All eleven were read in the code rather than by
severity, and none of them reaches this program.

The site crate ships with nothing, no other crate depends on it, and it is in neither
package. It runs from CI and from a maintainer's machine, and every path it opens is a
directory named on its own command line or a name read out of this repository. Its one
deleting path already refuses to empty a directory without the marker it wrote itself,
which is a stronger guard than the rule would have asked for.

The command that launches a target is the whole function of this tool, aimed by the user
at their own machine under their own token, and `std::process::Command` gives arguments
to the program literally, so there is no shell for a metacharacter to reach. The protocol
being read has no encrypted scheme to move to, and the risk that rule points at is
answered by `ws_endpoint_is_ours`, which pins the endpoint to the port we opened.

Suppressed per rule id rather than per file, so a different rule still reports at these
lines. Ten of the eleven were watched appearing and then disappearing: 3 and 3 in the site
crate and 4 in the protocol client, all to zero. The eleventh does not reproduce under the
open ruleset, so its marker names the reported rule and is unproven - said here rather
than left to look verified.

The first version of one of these notes tripped the very rule it explains, by spelling the
scheme out in prose. The prose was changed, not the marker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI has been red on main since the commit that added tests/dry_run.rs, four pushes ago, on
one test: the counter-probe that drives a real session to prove the dry-run assertion can
fail. It refused before starting anything, reporting chrono_hook.dll missing beside
chrono.exe.

The cause is not the test and not the product. `cargo test` never emits that library: a
test build compiles chrono-hook as a test harness, so the cdylib is not uplifted into
target/debug. On a developer machine it is there from an earlier `cargo build`, so the
probe passed by hand and failed on every clean runner from the day it was written.
Measured rather than reasoned: parking the file locally reproduced the runner's failure
word for word, and `cargo test --workspace` did not put it back.

CI now builds before it tests, which costs a cdylib link because the dependencies were
going to be compiled by the next step anyway.

Two things guard the fix. The probe asks for the library first, so a checkout without a
build gets the file name and the command to run, instead of the product's message about an
incomplete installation - true for a user holding half a package, misleading for whoever
is running the tests. And a second test reads the workflow, because the precondition lives
in another file and the local gates build too, so nothing else would notice the step
leaving. That is exactly how this was missed: the local gates reported all green while
every push was red. The guard was watched failing with the step removed and with the step
moved below the tests.

Rust tests 418 to 419, and the count is the point: a guard nobody has seen fail is
decoration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit 0d787a1 into main Sep 8, 2026
6 checks passed
@donislawdev
donislawdev deleted the fix/ci-debug-artifacts-for-dry-run-probe branch September 8, 2026 11:15
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