secure defaults as ci
in this post, we examine the ci we run in every
repo.cargo install sbom-diff unsafe-budget
last year i wrote on my personal blog that defaults and guardrails are cheaper and scale better than manuals and training. i won’t make that argument again here.
this post is what’s missing from that argument, which is the action. in this post, we walk through the ci we actually run in every cyberwitchery repo, gate by gate, in a form you can paste, modify, and own. it is rust-shaped, because we are. the shape should transfer beyond the ecosystem, however. if you want us to share our python setup in a similar manner, let us know!
the skeleton
every repo’s ci.yml has the same jobs: fmt,
clippy with -D warnings, test,
doc, deny, coverage, whatever
gates that repo needs, and then one more:
success:
name: success
needs: [fmt, clippy, test, doc, deny, coverage, unsafe-budget, sbom]
runs-on: ubuntu-latest
if: always()
steps:
- name: check results
run: |
if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }}" == "true" ]]; then
exit 1
fisuccess does no work. it’s plumbing that exists for
automation, such that exactly one gate exists to point branch protection
at when you wire it up1.
every dependency all of the time
cargo deny check runs in every rust repo we release
from. the config is boring (that’s a good thing):
[licenses]
version = 2
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "CDLA-Permissive-2.0", "ISC", "Unicode-3.0"]
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]the job is four steps:
deny:
name: deny
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 # v1
with:
toolchain: stable
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
with:
tool: cargo-deny
- run: cargo deny checkthose are horrible hashes on purpose, more on that below.
a good day looks like this, and nobody reads it:
advisories ok, bans ok, licenses ok, sources ok
a bad day looks like the one we had in september. rustls shipped RUSTSEC-2026-0285,
and it reached us transitively through reqwest, in every
client crate we maintain at once:
error[vulnerability]: TLS 1.3 handshake messages incorrectly accepted across encryption level boundaries
┌─ Cargo.lock:134:1
│
134 │ rustls 0.23.40 registry+https://github.com/rust-lang/crates.io-index
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ security vulnerability detected
│
├ ID: RUSTSEC-2026-0285
├ Solution: Upgrade to >=0.23.45 (try `cargo update -p rustls`)
├ rustls v0.23.40
├── hyper-rustls v0.27.9
│ └── reqwest v0.12.28
│ ├── netbox v0.9.0
exit code 1, build red, nobody had to be watching a mailing list. that is the whole value add: the control is a default, and it started annoying me while i was working on something else, blissfully unaware that there was an issue.
it makes everything red, so it’s quite aggressive. we run infra and security tooling, so it’d honestly be a bit ironic if we didn’t do this. you might want to be less aggressive, especially if your team is bigger than two, occasionally three people.
one maintenance note: cargo-deny warns when an allowed
license doesn’t match anything in the graph. those warnings accumulate.
we cut the stale entries out of the client crates in september. it took
ten minutes and should probably be on a calendar, but i’m not that
disciplined. maybe some day.
the accidental dependency
cargo deny tells you a dependency is known-bad, but not
that it’s new to the project. for that every repo commits an approved
sbom per package to sbom/, and a shared workflow in our
.github repo diffs against it using our own tool.
the call site is short:
sbom:
name: sbom
uses: cyberwitchery/.github/.github/workflows/sbom.yml@64ffd6a08c3e9626a681c8be1c8ad056765f3b85 # main
with:
mode: reporton a pull request it generates a cyclonedx sbom per package, writes
the diff against the last release tag into the job summary, and warns
about every component that isn’t in the approved baseline. on a tag
push, mode: release turns those warnings into a failure, so
nothing ships with a dependency nobody approved. the tool versions are
pinned as well, because a new cargo-cyclonedx changes what
counts as a component, and a baseline only means something if it was
generated the same way.
here is sbom-diff’s own last release, 0.8.0 to 0.9.0, a
release that shipped cyclonedx 1.6 support and a pile of parser fixes
(generated before we added --target all, hence the smaller
numbers):
Diff Summary
============
Old total: 104 components
New total: 104 components
Unchanged: 97
Added: 0
Removed: 0
Changed: 7
Edge changes: 1
[~] Changed
-----------
pkg:cargo/indexmap@2.14.2
Version: 2.14.0 -> 2.14.2
Hashes:
~ sha-256: d466e945…49d9 -> cc4e190f…4c855
a patch bump of one transitive dependency and one new edge to a crate that was already somewhere in the graph. from the supply chain’s point of view, a totally ordinary month. also legible to any compliance work you’ll ever need to do in the sbom space.
and here is what one line in a Cargo.toml looks like. if
i add ureq = "3.4.2" right now and diff against the
committed baseline, i get this:
error: added component pkg:cargo/rustls@0.23.45 (--fail-on added-components)
error: added component pkg:cargo/ring@0.17.14 (--fail-on added-components)
error: added component pkg:cargo/webpki-roots@1.0.9 (--fail-on added-components)
…
Diff Summary
============
Old total: 129 components
New total: 162 components
Added: 33
exit code 3. one dependency, thirty-three components, including a tls stack, a compression library, and ten crates that only exist for windows targets. seems a lot scarier that way, but maybe it should. at least this way i actually think about what i’m adding to my dependency graph.
again, a maintenance note: approving a dependency means committing the new sbom as the baseline. until then the pull request carries a warning and the release refuses to go out. i’d argue that puts the friction in the right spot: you read the list once, when you accept it. you do have to actually read it, though.
budgeted unsafe
where a crate can simply refuse unsafe code, the cheapest control is the lint table:
[lints.rust]
unsafe_code = "forbid"that is what contextual-encoder does, because a security
library that introduces unsafe code better have a real good reason to do
so (and we don’t). for every other kind of tool there is a budget,
checked against a committed baseline, again via a house
tool:
unsafe-budget:
name: unsafe-budget
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cyberwitchery/unsafe-budget@d10776ba7fe8043f31b19ec40bcff26b4da6b94e # v0.5.1
with:
mode: check
version: v0.5.1unsafe-budget check
===================
Analyzer: rustc_unsafe_lint
Status: PASSED
Totals:
Workspace: 0 unsafe
Dependencies: 0 unsafe
Overall: 0 unsafe
the baseline lives in unsafe-budget.lock, so the number
can only go down without someone deliberately updating it. ratchet mode
is the one we tend to use. caps mode, where you name an explicit ceiling
instead, makes more sense in a codebase that has real unsafe to manage
(not ours if i can help it).
a floor instead of a target
coverage is a gate too, with a number per repo:
cargo llvm-cov --workspace --all-features --lcov --output-path lcov.info --fail-under-lines 80the floors differ per repo, anywhere from 70 to 95 percent. 95
percent is, again, contextual-encoder, because an encoder
whose entire job is getting escaping right needs more scrutiny than a local cli for a
lepiter knowledge base. a single org-wide percentage usually strikes
me as theatrical, and the metric starts to invite gaming it. sure, i
could have checked all the cli entrypoints, but what assurance does that
really give us?
the ci itself
every gate above runs other people’s code with access to our repositories, so the workflows get two of their own defaults. every action is pinned to a commit hash, with the tag kept as a comment for humans. and every job gets only the permissions it asks for. ci workflows start from read-only:
permissions:
contents: readrelease workflows start from nothing and grant per job, so the job that mints attestations is the only one that can attest:
permissions: {}
jobs:
attest:
name: attest
needs: sbom
uses: cyberwitchery/.github/.github/workflows/attest.yml@64ffd6a08c3e9626a681c8be1c8ad056765f3b85 # main
permissions:
contents: read
id-token: write
attestations: writethe same goes for the job that publishes the release, which is the only one allowed to write to the repository. a tag can be moved after you review it, a hash cannot (i suppose it could theoretically, but that’s not in the threat model). dependabot bumps the hashes weekly along with everything else, so pinning is handled by the machine at the cost of a pr i can approve from bed.
releases
every release generates a cyclonedx sbom per crate, checks it against the approved baseline, attests the package and its sbom, and attaches the sbom to the github release. that means anyone consuming our crates can check where a package was built and run the same comparison we do, without asking us for anything.
you may call it overengineering, but a few years of tech due diligence work really made me crave automated checks and documentation. call it too many stale notion pages in my past.
missing pieces
you might spot a few missing pieces.
- we do no secret scanning or static analysis of our own. github’s secret scanning, push protection and codeql default setup are on for every public repo, and i have not felt the need to add a second layer2. the dependency bumps are dependabot’s too, not ours.
- there is no container scanning, because we ship crates and binaries.
none of this is clever, because i enjoy boring security measures. every one of these is under fifteen lines of yaml at the call site and a bunch of standard (or in-house) tooling.
still, it lets us rest easy, and it doesn’t get any better than that.