Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,46 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- run: cargo clippy --workspace --all-targets --all-features -- -D warnings

zizmor:
name: zizmor
runs-on: ubuntu-latest
timeout-minutes: 10
# Fails the run on any `high` finding. Not yet one of `protect-main`'s
# required contexts, so it reddens a pull request without refusing the
# merge; adding it to that ruleset is a one-line change and the reason
# this is a separately-named job rather than a step inside `rustfmt`.
#
# Every finding it reports at `high` is either fixed or carries a
# written exception on the line that raises it — five on the
# Dependabot auto-merge workflow, whose triggers and write scopes are what
# auto-merge is, and one low-confidence cache finding on the release
# build. Adding the tool in an advisory mode would have meant a red check
# on every pull request, which teaches people to ignore red checks; that
# is worse than not running it.
#
# Gated at `high` rather than everything: the 16 `low` findings are
# `artipacked` (checkout persisting credentials) and are worth their own
# pass, not a blocking gate adopted in the same commit as the tool.
#
# Scans the published composite action as well as the workflows — the
# same file set `workflow_action_pin_test` reads, and the repository's
# public entry point. Adding it surfaced a finding the workflow-only
# scan could not see.
permissions:
contents: read
steps:
- uses: actions/checkout@v7
# Installed the way every other CI tool here is, rather than through a
# second mechanism: `install-action` fetches a prebuilt binary from the
# tool's own GitHub Releases, and the action is already SHA-pinned, so
# this adds no new pin to maintain. Version-pinned so a new audit
# landing upstream changes this job's verdict through a visible bump
# rather than silently on someone else's pull request.
- uses: taiki-e/install-action@6c6fd71fe4fb72c3697d269963d0e15df8adedad # v2.85.10
with:
tool: zizmor@1.29.0
- run: zizmor --min-severity high --format plain .github/workflows/ action.yml

test:
# Explicit display name so the matrix `include` flags (run_tests) don't
# leak into the rendered check name. Without this, GitHub appends every
Expand Down
16 changes: 12 additions & 4 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,25 @@ name: Dependabot auto-merge
#
# Neither stage checks out or executes PR code.

on:
# Both triggers are inherent to Dependabot auto-merge and are mitigated the
# documented way, in the header above: neither stage checks out or executes
# pull-request code. The danger `pull_request_target` carries is running
# untrusted code with a privileged token; nothing here runs it.
on: # zizmor: ignore[dangerous-triggers]
pull_request_target:
workflow_run:
workflows: [CI]
types: [completed]

# These three are what auto-merge is: approving and merging a pull request,
# and re-dispatching CI on main afterwards. They cannot be narrowed without
# removing the capability. They are workflow-level because both jobs need
# them, and the workflow contains nothing else.
permissions:
contents: write
pull-requests: write
contents: write # zizmor: ignore[excessive-permissions]
pull-requests: write # zizmor: ignore[excessive-permissions]
# merge-on-green re-dispatches main's CI after a merge (see below).
actions: write
actions: write # zizmor: ignore[excessive-permissions]

jobs:
mark-eligible:
Expand Down
23 changes: 18 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,20 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

# Workflow-level default. Signing permissions are deliberately ABSENT: only
# the `attest` job may hold them, and it declares them on its own
# reusable-workflow call. Keeping them out of the default means a job added
# to this file later cannot silently inherit the ability to sign provenance.
# Workflow-level default, minimal in two directions for the same reason: a
# job added to this file later inherits it, and should inherit nothing it can
# publish or sign with.
#
# Signing permissions are absent — only `attest` may hold them, and it
# declares them on its own reusable-workflow call.
#
# Write is absent — only `release` creates the GitHub Release, and it declares
# `contents: write` on itself. The three jobs that used to inherit a write
# default do not need it: `plan` reads the ref, `crates-publish` authenticates
# to crates.io with a token, and `homebrew-publish` checks out the tap
# repository with its own deploy key and pushes there, not here.
permissions:
contents: write # create the GitHub Release + upload artifacts
contents: read

env:
CARGO_TERM_COLOR: always
Expand Down Expand Up @@ -136,6 +144,11 @@ jobs:
targets: ${{ matrix.target }}

- name: Cache cargo
# zizmor: ignore[cache-poisoning]
# The audit's own confidence is Low. A cache entry is written under
# the ref that produced it, so poisoning what a tag-triggered release
# build restores requires push access to this repository — at which
# point the cache is not the exposure.
uses: actions/cache@v6
with:
path: |
Expand Down
28 changes: 28 additions & 0 deletions .github/zizmor.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# zizmor configuration — static analysis for the workflows in this repository.
#
# Only one audit is configured, and it is configured to match a policy this
# repository already enforces in `workflow_action_pin_test` rather than to
# silence it. Divergence between the two would be worse than either alone: a
# contributor would be told to pin by one gate and told it is fine by the
# other.
rules:
unpinned-uses:
config:
policies:
# GitHub's own first-party namespace. A compromise here is a
# compromise of the platform running the job, which a pinned SHA does
# not survive either. OpenSSF Scorecard asks for these to be pinned
# too; this is a deliberate, narrow divergence, and the reasoning is
# the same one recorded in `workflow_action_pin_test::is_exempt`.
"actions/*": ref-pin
# The tag names the TOOLCHAIN to install, not a release of the action.
# A SHA would freeze the action while changing nothing about the Rust
# version — the opposite of the intent — and `rust_version_pins_test`
# reads this tag as a version, so pinning it breaks that guard.
"dtolnay/rust-toolchain": ref-pin
# Everything else: full commit SHA — the half that stops
# tag-repointing, the shape of the tj-actions and reviewdog
# compromises. This restates zizmor's own default rather than
# changing it; it is written out so the policy can be read here in
# full, and so an upstream change of default cannot quietly relax it.
"*": hash-pin
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Conventional Commits format. All notable changes documented here.

### Changed

- **`zizmor` now audits the workflows on every pull request.** It is the standard static analyser for GitHub Actions, and running it against this repository found three live template-injection sites that nothing here would have caught — which is the argument for adopting it. Exactly one audit is configured, and configured to agree with a policy this repository already enforces: full commit SHAs for third-party actions, tags permitted for GitHub's own namespace and for the toolchain action whose tag names the Rust version rather than a release. Two gates disagreeing about pinning would be worse than either alone. That alignment drops the report from 111 findings to 74, and from 44 high-severity to 7, without suppressing any of them. The gate blocks, because the seven findings it reported were resolved rather than deferred: the release pipeline's workflow-level write permission was reduced to read — the three jobs inheriting it write nothing here, and the one job that creates the release already declared its own — while the Dependabot auto-merge workflow's two triggers and three write scopes, and one low-confidence cache finding, carry written exceptions on the lines that raise them. An advisory version was written first and discarded on seeing it: a check that is red on every pull request teaches people to ignore red checks, which is worse than not running the tool.

- **The bot-filter guard now matches the query's meaning rather than one spelling of it.** It forbids collapsing bot-ness to a single value per canonical identity outside the shared alias-resolving CTE — a canonical mixing human and bot aliases is silently misclassified by that collapse, which moves the author and ownership numbers other analyses read. It looked for two exact literals, case-sensitively. SQL is case-insensitive and indifferent to whitespace, so the lowercase form, a spaced form, and a table-qualified column are the same query and the same misclassification, and all three were invisible to it. Matching now runs against a normalised copy with the qualifier stripped. The tree was clean either way — the only occurrences are the documented exemption in the CTE's own explanatory comment — so nothing was misclassified; what was missing was detection. Verified by planting a comment that is lowercase, spaced and qualified at once and confirming the guard names its file and line where the previous matcher saw nothing.

- **Workflow refs reach the shell through the environment, and the container publish is gated on tag refs.** Three release and CI steps substituted a ref name into a `run:` block. An expression is substituted into the script text before bash parses it, so a shell metacharacter in the value is executed rather than quoted — and ref names are the vector, since `git check-ref-format` permits far more than it appears to and a `|` in a tag name has already corrupted output here once. All three now pass through `env:` and are read as quoted variables. Separately, the container workflow's tag rules are all release-shaped except the short-SHA fallback, which matches any ref, so a manual run from a branch built, pushed, tagged and attested a genuine publicly-pullable image from unreleased code — quietly, because nothing else about the run looked like a release. Publishing now requires a tag ref on every job: dispatching from a tag remains the retry path for a publish that failed after the tag was pushed and still publishes, while dispatching from a branch skips. The condition is stated per job rather than inherited through `needs:`, so a dependency edit cannot silently re-open it.
Expand Down
7 changes: 6 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,12 @@ runs:
STEP_VERSION: ${{ steps.resolve.outputs.version }}
STEP_TARGET: ${{ steps.target.outputs.target }}
STEP_EXT: ${{ steps.target.outputs.ext }}
run: |
# This step writes the install directory to GITHUB_PATH, which is what
# a setup action does. `DEST` is `$RUNNER_TEMP/codelore` — a
# runner-provided directory with a constant suffix, not built from any
# input — so there is nothing for a caller to redirect. The audit's own
# confidence is Low.
run: | # zizmor: ignore[github-env]
set -euo pipefail
TAG="$STEP_TAG"
VERSION="$STEP_VERSION"
Expand Down
60 changes: 60 additions & 0 deletions crates/codelore-lib/tests/workflow_action_pin_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,63 @@ fn the_pin_guard_rejects_a_tag_and_accepts_a_sha() {
"an ordinary third-party action is not exempt"
);
}

/// Patterns `.github/zizmor.yml` permits to be referenced by tag.
///
/// Scanned textually, the way `rust_version_pins_test` reads the other pin
/// sites, rather than by pulling in a YAML parser the workspace does not
/// otherwise need.
fn zizmor_ref_pin_patterns(config: &str) -> Vec<String> {
config
.lines()
.filter_map(|line| line.trim().strip_suffix(": ref-pin"))
.map(|pattern| pattern.trim_matches('"').to_owned())
.collect()
}

#[test]
fn the_external_auditor_permits_exactly_what_this_guard_exempts() {
// Two gates now enforce this one policy: this test, and `zizmor`'s
// `unpinned-uses` audit via `.github/zizmor.yml`. Two gates that can
// disagree are worse than either alone, because a contributor gets told
// to pin by one and told it is fine by the other, and neither says which
// is right. The config argues in prose that it is kept in step with this
// function; this is that claim, checked.
//
// The same shape `rust_version_pins_test` uses for the toolchain pinned
// in five places: name a source of truth, read the other statements of
// it, and fail listing the disagreements.
let root = workspace_root();
let config =
std::fs::read_to_string(root.join(".github/zizmor.yml")).expect("read .github/zizmor.yml");

let mut permitted = zizmor_ref_pin_patterns(&config);
permitted.sort();
assert!(
!permitted.is_empty(),
"parsed no `ref-pin` patterns out of .github/zizmor.yml — the config \
changed shape and this guard is reading nothing"
);

// `actions/*` is zizmor's glob for the namespace `is_exempt` matches by
// prefix; the local-action exemption has no counterpart, because
// zizmor's patterns are `owner/repo`-shaped and `./…` is not in that space.
let disagreements: Vec<&String> = permitted
.iter()
.filter(|pattern| !is_exempt(&pattern.replace('*', "")))
.collect();
assert!(
disagreements.is_empty(),
"`.github/zizmor.yml` permits {disagreements:?} to be tag-referenced, \
but `is_exempt` does not. Whichever is right, they must say the same \
thing — a contributor reads whichever gate fails first."
);

for exempt in ["actions/", "dtolnay/rust-toolchain"] {
assert!(
permitted.iter().any(|p| p.replace('*', "") == exempt),
"`is_exempt` allows `{exempt}` by tag but `.github/zizmor.yml` \
does not, so zizmor will fail a reference this guard accepts"
);
}
}
2 changes: 1 addition & 1 deletion docs/reports/2026-08-13-hardening-cycle-12.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ This is the guard cycle 6 filed as **M17**, recommending it be broadened to a to

**Unchanged and open:** the gitlink differential fixture (0 refs — now by a wide margin the oldest untouched item, carried since cycle 6 M12); `outputSchema` at 1 of 11 tools (per-tool design work, E8); M8 MCP cancellation (0 `RequestContext`; a design question per E9, not a wiring one); `zizmor` unadopted; crates.io Trusted Publishing unadopted.

**Currency, re-verified live 2026-08-13** (per the standing rule — at report time, not engagement time): rmcp lockfile `3.1.2` = live latest ✅. Rust pin remains `1.96.0`; 1.97.1 is current and 1.98 lands ~2026-08-27, so the pin is now two releases behind and the six-site bump is guarded by `rust_version_pins_test` — this is the cheapest open item on the list.
**Currency, re-verified live 2026-08-13** (per the standing rule — at report time, not engagement time): rmcp lockfile `3.1.2` = live latest ✅. Rust pin remains `1.96.0`; 1.97.1 is current and 1.98 lands ~2026-08-27, so the pin is now two releases behind and the bump is guarded by `rust_version_pins_test` across the five sites it names (`rust-toolchain.toml`, the workspace `rust-version`, `clippy.toml`'s msrv, the `Containerfile` ARG, and the action tags) — corrected from "six-site" on verification — this is the cheapest open item on the list.

---

Expand Down
59 changes: 58 additions & 1 deletion docs/reports/deep_analysis_report.md
Original file line number Diff line number Diff line change
Expand Up @@ -1778,4 +1778,61 @@ applies to its own gate.
only thing keeping bots out of the pair counts. A documented design
decision, not an oversight.

The next sweep re-opens at **F306**.
### F306 (Fixed — Unreleased) — zizmor adopted, and the seven findings it still reports

* **Location**: `.github/zizmor.yml`, `ci.yml`
* **Severity**: MED · **Category**: CI coverage / supply chain
* **Why now**: `zizmor` is the de facto standard auditor for GitHub
Actions, and running it against this tree found three live
template-injection sites (F304) that nothing here would have caught. That
is the argument for adopting it: not that the tool is popular, but that
it found something on first contact.
* **Configured, not silenced**: exactly one audit is configured.
`unpinned-uses` is given the policy this repository *already* enforces in
`workflow_action_pin_test` — SHA for third-party actions, tag permitted
for `actions/*` and `dtolnay/rust-toolchain`. Two gates disagreeing about
pinning would be worse than either alone, because a contributor would be
told to pin by one and told it is fine by the other. That drops the count
from 111 findings / 44 high to 74 / 7 without suppressing a single
finding.
* **On the `actions/*` divergence**: OpenSSF Scorecard asks for first-party
actions to be SHA-pinned too, and the tj-actions and reviewdog
compromises are why. The exemption is kept because a compromise inside
GitHub's own namespace is a compromise of the platform running the job,
which a pinned SHA does not survive either — and because the repository
made this call explicitly, with that reasoning recorded in the guard.
Overriding a documented, guarded policy on a general principle, when the
stricter half is already enforced, is not an improvement.
* **Blocking, not advisory** — the first draft of this job was advisory
during bake-in, on `dogfood`'s pattern. Running it proved that wrong: with
findings outstanding the check is red on every pull request, and a
permanently red check teaches people to ignore red checks. That is worse
than not running the tool. So the seven were resolved instead, and the
gate is real:
* **`excessive-permissions` on `release.yml` — fixed.** The
workflow-level default was `contents: write`, inherited by `plan`,
`crates-publish` and `homebrew-publish`. None of them write to this
repository: `plan` reads the ref, `crates-publish` authenticates to
crates.io with a token, and `homebrew-publish` checks out the tap with
its own deploy key and pushes there. The default is now `contents:
read`; `release`, which creates the GitHub Release, already declared
its own write.
* **`dangerous-triggers` ×2 and `excessive-permissions` ×3 on the
Dependabot auto-merge workflow — written exceptions.** The triggers
are what that pattern is, mitigated the documented way: neither stage
checks out or executes pull-request code. The three write scopes are
the capability itself — approving, merging, and re-dispatching CI —
and cannot be narrowed without removing it.
* **`cache-poisoning` — written exception.** The audit rates its own
confidence Low, and a cache entry is written under the ref that
produced it, so poisoning what a tag-triggered release build restores
needs push access to the repository.
* **Exceptions live on the line that raises them**, as
`# zizmor: ignore[rule]` comments with the reasoning beside them, rather
than as line numbers in a config file that drift the moment the file is
edited.
* **Gated at `high`.** The 16 remaining `low` findings are `artipacked` —
`actions/checkout` persisting credentials — and deserve their own pass
rather than a blocking gate adopted in the same commit as the tool.

The next sweep re-opens at **F307**.
8 changes: 7 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,18 @@ fmt:
deny:
cargo deny check

# Audit the GitHub Actions workflows — requires: cargo install zizmor@1.29.0
# (CI pins that version; a different one can disagree, which is the whole
# reason this recipe exists rather than leaving the audit to CI alone.)
zizmor:
zizmor --min-severity high --format plain .github/workflows/ action.yml

# Coverage report — requires: cargo install cargo-llvm-cov
coverage:
cargo llvm-cov --workspace --html

# All CI checks
ci: fmt-check lint deny test
ci: fmt-check lint zizmor deny test

# Run the binary
codelore *ARGS:
Expand Down
Loading