Trust model¶
This page expands the trust model briefly described in SECURITY.md. Read both.
What this wrapper guarantees¶
- Hash-pinned upstream fetch.
linpeas-pin.jsonrecords an SRI hash for the upstreamlinpeas.shasset. Nix refuses to build on hash mismatch — a swapped or corrupted upstream asset fails the build. - Pin-shape validation at flake eval.
nix/linpeas.nixasserts thatpin.versionmatches[0-9]{8}-[0-9a-f]{7,40}, thatpin.urlstarts withhttps://github.com/peass-ng/PEASS-ng/releases/download/, and thatpin.versionis the release-tag path segment ofpin.url. An attacker-controlled pin file cannot smuggle arbitrary URLs into the derivation, and cannot advertise one version while fetching another release's artifact. - GitHub Releases API digest cross-check.
scripts/bump-linpeas.shreads the upstream release-asset.digestfield over the API and refuses to bump if it is missing, non-sha256:, or does not match the downloaded file. This is not silent on missing data — bump aborts. - Asset URL prefix validation. Same script refuses to record any asset URL outside the expected
peass-ng/PEASS-ng/releases/download/prefix. - Weekly upstream parity check.
verify-latest-release.ymlre-fetches the pinnedlinpeas.shweekly and re-checks the SRI hash. Detects upstream tag replacement (intentional or compromised); publish-time verification inrelease-on-bump.ymlcovers each release as it ships. - SLSA build-provenance + SBOM attestations. Each release attaches a build-provenance attestation binding the pin file to the
release-on-bump.ymlworkflow run; a release that publishes images (image-mode: full) additionally binds the per-arch OCI images and attaches CycloneDX-JSON SBOMs (per-arch images) generated byanchore/sbom-actionand attested viaactions/attest-sbom. Verifiable withgh attestation verify— see Verification. - Sigstore cosign keyless signatures. Each release additionally signs every published image digest (per-arch + multi-arch index, both registries) keyless via Sigstore Fulcio + Rekor, identity-bound to
release-on-bump.yml@refs/heads/main. Exposes the user-facingcosign verifyverification path that does not require theghCLI.
What this wrapper does not guarantee¶
- Content trust on upstream
linpeas.sh. Upstream PEASS-ng ships no GPG, cosign, or SLSA signatures. The SRI hash binds you to some upstream asset, but it cannot prove that upstream's release is benign. If upstream is compromised, this wrapper will faithfully ship the compromise. - Reproducibility of upstream. The wrapper is reproducible. The wrapped script is whatever upstream chose to publish.
- Site-level signing. The Pages site you are reading is documentation, not a verifiable artifact. Do not treat dashboard text as a substitute for
gh attestation verify.
Auto-merge surface¶
Four automations may auto-merge PRs into main when CI passes:
update-linpeas.yml— daily pin bumps. Opens a PR authored by thelinpeas-flake-bumperGitHub App bot, gated by all required CI checks, auto-merged on green.update-flake-lock.yml— weekly flake-input bump (nix flake updatemoves every input that is not rev-pinned, not onlynixpkgs;pre-commit-hooksis rev-pinned, and Renovate carries that one). Split into acontents: readcompute-lockjob (runsnix flake update) and apush-and-mergejob that usesactions/*SHAs plus the repo-widestep-security/harden-runnerpin to mint alinpeas-flake-bumperGitHub App installation token, keeping the bump credential out of any third-party action's env. Same CI gating.-
Renovate — Friday batch. Every dependency set Renovate tracks carries
automerge: GitHub Action SHA pins, the pinned Nix installer version, the octoscan pin, the tracked flake inputs (nixpkgsstable branch,cachix/git-hooks.nix), and the SchemaStore pin. No class waits on a reviewer; the required check set is the whole gate. Same gating, with three scoping rules:nixpkgs-unstableis deliberately absent: it is branch-tracked, so the weeklynix flake updatecron already floats it and a Renovate manager for it would only rewrite the branch name inflake.nixinto a fixed rev, freezing the input.- Each set carries its own per-manager
automergescope rather than a top-level key, so a manager this config does not name — npm activated by a newpackage.json, say — cannot inherit automerge. - All PRs honor a non-empty
minimumReleaseAge(7 days); therenovate-invariantsCI lint enforces that a release age is set and the per-manager scoping.
What a green check set does and does not prove for the wide-blast flake inputs is spelled out in
docs/architecture/flake-input-bumps.md. 4.release-on-bump.yml— per release. Thechangelogjob regeneratesCHANGELOG.mdand commits it through a PR it auto-merges under the samelinpeas-flake-bumperApp identity as the bump workflows. Same gating.
A compromise of the linpeas-flake-bumper GitHub App installation token used by update-linpeas.yml would let an attacker open a PR with arbitrary changes. The App is installed only on this repository with Contents: Read and write + Pull requests: Read and write permissions; the installation token is minted per job, lives one hour, and revokes at job end. The auto-merge bot would still gate on CI — so any malicious change would have to also pass all required checks (build, smoke, image-smoke, the lint groups, and the SRI cross-check — a tampered linpeas.sh fails the fixed-output fetch in flake-check and build-linpeas). Attestation and cosign re-verification are post-merge assurance, not merge gates: they run in release-on-bump.yml's verify job and weekly in verify-latest-release.yml. See docs/security/repo-config.md for the full credential model.
See SECURITY.md for the secret rotation policy.
Currently pinned¶
| Field | Value |
|---|---|
| Pin version | 20261017-c2b70018 |
| Pin URL | https://github.com/peass-ng/PEASS-ng/releases/download/20261017-c2b70018/linpeas.sh |
| Upstream latest | 20261017-c2b70018 |
| Drift | 0 days |
| Last parity check | success (2026-10-16T10:05:32Z) |
Bump credentials blast-radius¶
- Bump workflows authenticate as the
linpeas-flake-bumperGitHub App, not as a PAT. The App is installed only onrvenutolo/linPEAS-flakewithContents: Read and write+Pull requests: Read and writepermissions (Metadata: Readis implicit). NoWorkflowspermission. Switching to any PAT-based flow is a regression — web-flow signing of RESTPUT /contentsrequires an App installation token. - Storage:
vars.BUMP_APP_CLIENT_ID— public, GitHub App Client ID (e.g.Iv23...). Client ID (not the numeric App ID) is whatactions/create-github-app-tokenaccepts.secrets.BUMP_APP_PRIVATE_KEY— App's PEM private key. Rotate on suspected compromise; no forced cadence.- Tokens minted via
actions/create-github-app-tokenare scoped to one job, valid one hour, and revoked at job end. They live only as${{ steps.app-token.outputs.token }}passed viaGH_TOKEN. Never reach.git/config; there must never be agit pushusing them. Commits land via RESTPUT /repos/{owner}/{repo}/contents/{path}→ web-flow-signed by GitHub. BUMP_APP_PRIVATE_KEYenters env only in the credential-holding write jobs —push-and-merge(update-linpeas.yml,update-flake-lock.yml),push-refresh(renovate-flake-lock-refresh.yml) andchangelog(release-on-bump.yml) — never incompute-pin/compute-lock/compute-refresh. Those compute jobs must remainpermissions: contents: read, must not reference the secret, and must keep untrusted Nix actions inside their own boundary.docs/security/required-checks.mdrestates theprotect-mainbranch ruleset's required-check set. Update in same change as GitHub-side list changes.- No
paths:/paths-ignore:filter underon.pull_request:on any workflow listed indocs/security/required-checks.md. Enforced byrequired-checks-no-pathsCI job.
flake.lock input-provenance gate¶
The weekly update-flake-lock cron bumps every flake input that is not
rev-pinned and auto-merges the PR; no build-path gate verifies input
provenance — flake-check and
the build/smoke jobs all accept whatever the lock names.
check-flake-lock-provenance.sh (run in the lint-doc-invariants
required group) closes that gap: it diffs the PR's flake.lock against
origin/main, fails when an input's source identity changes without
flake.nix declaring it, and fails when an undeclared rev move does not
continue the history the old rev was on.
Only locked.rev, locked.narHash, and locked.lastModified may move on
their own. Per node the check compares original, the flake flag, and the
rest of locked (type/owner/repo/url/ref/…); inputs wiring is
excluded so that legitimate transitive graph churn is tolerated. Scope is
hybrid: a source-identity change on any node present in both base and head
fails, while a node add/remove fails only for a top-level input (the entry
node's inputs). On a normal bump the gate is invisible and the PR
auto-merges as before; it fails — pausing gh pr merge --auto — only on an
undeclared source repoint, an undeclared rev move that does not descend
from the old one, or a lock the resolver cannot read (a root-id change or
an unresolvable follows ref).
What flake.nix corroborates¶
The cron this gate bounds rewrites flake.lock alone and never edits
flake.nix, so a lock move with no matching declaration is the smuggling case
the gate exists for. The check therefore reads flake.nix on both sides too,
parses its top-level inputs block into an input-name-to-url map, and
tolerates a lock move for any input whose declared url differs between base
and head. Such a move is logged as a note naming the input and the flake.nix
move that vouched for it, and counted in the summary line.
Corroboration is per input name. A PR that legitimately repoints one input
cannot carry an undeclared repoint of another — the second input's own
declaration did not move, so its lock move still fails. Only top-level inputs
are reachable at all: flake.nix names no transitive node, so a transitive
repoint is never corroborated.
This is what lets a bump that Renovate proposes as a one-line flake.nix edit
— the NixOS/nixpkgs stable branch and the cachix/git-hooks.nix SHA —
reach green and auto-merge once renovate-flake-lock-refresh.yml supplies the
matching lockfile. Without corroboration both are a source repoint by
construction, and no such PR could ever pass.
What corroboration deliberately does not do is verify that the lock's new
original is the one nix would derive from the new flake.nix url. That
equivalence is flake-check's to enforce, and it does: a lock disagreeing
with flake.nix fails evaluation. Re-deriving a flake reference from a url
string here would duplicate that check against a hand-written parser, and a
parser that drifted would block legitimate bumps while claiming provenance
grounds.
The parser recognises the two shapes nixfmt produces — <name>.url = "…";
and <name> = { url = "…"; … }; — and reads a nested inputs.<name>.url
line inside a block as neither. A flake.nix with no top-level inputs block
is an operational error, not an empty map: an empty map corroborates nothing,
which would turn a parser that stopped working into a gate blocking every
legitimate bump under a message naming the wrong cause.
The entry point is each lock's own top-level .root field, validated as a
string independently for base and head — not a hardcoded "root" node id,
since a lock's root node can be named anything. A missing or non-string
.root is an operational error. The base/head root ids are then compared for
equality entirely inside the jq program that also does the node comparison:
the id is never round-tripped through a shell variable or passed as a jq
--arg, so a trailing newline or other control character smuggled into a node
id is read byte-exact and cannot desync the shell's view of the root from
jq's. If the base and head root ids differ, the check fails closed before
any node comparison runs, so a crafted lock cannot repoint .root at a decoy
node to dodge the top-level comparison.
Top-level input refs resolve through follows paths before that comparison: a
string ref names the target node id directly, and an array ref is a path
walked from the lock's root node through each node's inputs in turn, one hop
per array element, bounded twice — a nesting ceiling on depth and a step
budget on total resolution cost. A ref that cannot be resolved — a dangling
path element, an empty array, a cycle, an over-deep legal chain, or an
exhausted step budget — fails closed with a
top-level input unresolvable message rather than
comparing against nothing, and the message names what the resolver observed:
follows path names no such node, follows path exceeds nesting ceiling, or
follows step budget exhausted — with the bare message, no parenthetical,
reserved for an error the resolver did not raise itself. A cycle and a legal chain deeper than the
ceiling report the same way, because the resolver carries no visited set and
cannot tell them apart. A ref-shape change (string to array, or array
to a differently-shaped array) whose resolved source identity is unchanged
passes by design: the gate guards source identity, not follows-graph shape,
so a routine follows restructuring that still lands on the same source is
not a provenance event.
What the ancestry probe verifies¶
Source identity leaves one move unexamined: the same owner/repo/ref
pointing at a rev that does not descend from the old one. An upstream
force-push, a history rewrite under a taken-over maintainer account, and a
repository name claimed by someone else after the original owner renamed
away all produce exactly that — a new rev under unchanged coordinates —
and every one of them passes the identity comparison, because nothing but
rev changed. flake-check does not look either: it evaluates whatever the
lock names.
So once the identity verdict is clean, the check asks the GitHub compare API
about every GitHub-hosted node present on both sides whose identity is
unchanged but whose locked.rev moved without a flake.nix declaration:
GET /repos/{owner}/{repo}/compare/{old_rev}...{new_rev}. ahead is the
fast-forward a routine bump produces and passes, logged as a note naming the
node and how far it moved. behind or diverged means the new rev does not
descend from the old one and fails. An HTTP 404 fails too: the repository does
not hold one of the two commits, or is not the repository it was, and either
way the bump did not come from where the lock says. Any other API failure, a
malformed response, or a status the request cannot legitimately produce
(identical included — two distinct revs cannot be identical) is an
operational error, exit 2, never a pass. A rev that is not a 40-hex commit id
or an owner/repo that is not a plain name is refused before it reaches the
route, so a crafted lock cannot steer the token-bearing request elsewhere.
Two classes are skipped by name rather than silently, and both are counted in
the summary line. A move flake.nix corroborates is not probed: a channel move
lands on a different release branch and would compare diverged, a pin to an
older commit would compare behind, and both are the declared consequence of
the flake.nix edit that vouched for them — the same reasoning that exempts
them from the identity check. A node whose locked.type is not github has no
compare API to ask; it is logged per node as unprobed. Every input in this
repo's lock is GitHub-hosted, so today the probe covers all of them.
The probe runs only when a rev actually moved, so a PR that does not touch the
lock makes no API call, and a run that already fails on identity stops before
probing. The lint-doc-invariants job passes GH_TOKEN so the calls run under
the authenticated rate limit (unauthenticated calls cap at 60/hr, which a local
run against one lock diff still fits). There is no retry: a transient API
failure is a loud exit 2 on the required lint-doc-invariants job, cleared
by re-running it.
What ancestry does not see is a malicious commit fast-forwarded onto the
tracked branch. That is ahead like any other bump. The probe bounds rewrites
and repojacks — the cases where the new history is not the old one — not an
upstream that was compromised in place; nothing short of reviewing the
upstream diff covers that.
The lint-doc-invariants job fetches origin/main before the check runs
(actions/checkout does not create that ref on its own); if the base lock or
the base flake.nix cannot be resolved the check exits non-zero rather than
passing silently.
PR-triggered workflow secret allowlist¶
PR-triggered workflows (on: pull_request or on: pull_request_target) MUST NOT reference any secrets.* other than secrets.GITHUB_TOKEN. GitHub Actions reads a secret's name whatever its case, so a reference is matched with the ASCII letters of secrets and of the name in either case, in the index form secrets['NAME'] as in the dot form, and wherever it sits inside an expression on one line. Any other use of the secrets context in an expression is refused, since no name in the text says which secret it reads: the whole context passed to a function (toJSON(secrets)), a name the expression computes (secrets[format(...)], secrets[env.NAME]), the object filter (secrets.*), a double-quoted index, and the word secrets inside a string literal. An expression not closed on its own line is refused too, since the script reads one line at a time and GitHub Actions reads an expression broken across the lines of a block scalar. GitHub Actions evaluates an expression in a scalar as YAML decodes it, and an escape in a double-quoted scalar can hide a reference from the file text, so each double-quoted scalar, tagged or not, key or value, is read again as decoded. A job's secrets: inherit, which hands every secret to the reusable workflow it calls without an expression naming one, is refused as well; an explicit secrets: map is read through the expressions in its values. Enforced by scripts/check-pr-workflows-no-secrets.sh via pr-workflows-no-secrets required CI job.
The script decides whether a workflow is PR-triggered from on: after passing it through yq's explode a fixed number of times, so a pull_request or pull_request_target trigger written through an anchor puts the workflow in scope. It stops (exit 2) on a workflow whose on: still holds an alias after the passes, or whose root names on more than once. The script header states the mechanism.
Exception for a non-GITHUB_TOKEN secret requires documenting here BEFORE relaxing the script.
harden-runner¶
step-security/harden-runner is the first step in every job in every workflow, with egress-policy: block and a per-job allowed-endpoints: allowlist (a shared baseline plus job-specific hosts; rotating families wildcarded). Block mode drops egress to any host outside the allowlist, denying a compromised step an exfiltration path.
- Do not remove from any job.
- Keep as first step, before
actions/checkout,cachix/install-nix-action, or any network step. eBPF monitor must install before any I/O. - Do not add to composite actions — caller job already has it.
Enforced by scripts/check-harden-runner-first.sh via the lint-workflow-security CI job (member check harden-runner-first) and a pre-commit hook. Lint parses every job in .github/workflows/*.yml (or .yaml) with yq and rejects any job whose first step is not step-security/harden-runner@<40-hex-sha>. The owner and repository are matched with their ASCII letters in either case; the SHA is forty lower-case hex digits.
The block-mode contract is enforced by scripts/check-harden-runner-block.sh via the same lint-workflow-security CI job (member check harden-runner-block) and a pre-commit hook: it rejects any harden-runner step that is not egress-policy: block with a non-empty allowed-endpoints:, and reads a step as harden-runner when its uses: holds step-security/harden-runner@ with its ASCII letters in either case. A missed host surfaces as a blocked-egress CI failure and is fixed forward by adding the host to that job's allowlist.
Egress allowlist matches tool inventory¶
A hand-authored allowed-endpoints: list has nothing binding it to what the job's tooling actually reaches. A tool version bumps, a URL changes, or a fallback path activates, and the list is not updated — the resulting failure is latent: it surfaces only when the blocked path is finally exercised, sometimes long after the change that broke it. scripts/check-egress-allowlist.sh binds each job's allowlist to its tool inventory with seven assertions, keyed off the job's uses: and run: text:
-
Forward rules, mechanical and exact. A rule keyed on a remote action matches its owner and repository with their ASCII letters in either case:
github/codeql-action/initrequiresrelease-assets.githubusercontent.com— the CodeQL bundle falls back to a release asset when the toolcache misses, and agithub.comrelease-download URL 302s there unconditionally.aquasecurity/trivy-actionrequiresget.trivy.dev,mirror.gcr.io, andghcr.io. The action resolves the release tag againstgithub.combut downloads the binary fromget.trivy.dev, so a job carrying only the database hosts installs nothing and skips every later step.ghcr.iois the documented vulnerability-DB fallback; without it amirror.gcr.iooutage fails the scan instead of degrading.anchore/scan-actionrequiresget.anchore.io,grype.anchore.io, andraw.githubusercontent.com— the first two serve the grype install script and the vulnerability DB, and the install path reachesraw.githubusercontent.comnon-deterministically across architectures, which makes that gap latent rather than immediate.anchore/sbom-actionrequiresraw.githubusercontent.comandget.anchore.io— the syft-install path reaches the first non-deterministically across architectures; the second serves the install script.- A
run:step containing areleases/downloadURL requiresrelease-assets.githubusercontent.com— the same GitHub redirect as above, reached directly instead of through the CodeQL action. - A
run:step invokinggh release uploadrequiresuploads.github.com— asset bytes POST to the uploads host, not to the REST API host. - A
run:step invoking the scorecard CLI (keyed on the substringcommand scorecardorscorecard --repo) requiresapi.securityscorecards.dev,api.osv.dev, andapi.deps.dev— scorecard queries its own API plus the OSV and deps.dev datasets while evaluating checks. An invocation shaped differently than either keyed substring is not detected. - A
run:step invokinggh attestation verifyrequirestuf-repo.github.com— verification refreshes GitHub's TUF root before checking the bundle. peter-evans/dockerhub-descriptionrequireshub.docker.com— the action authenticates against and PATCHes the Docker Hub web API to publish the repository README; it never reaches a registry host../.github/actions/setup-nixrequirescache.nixos.organdreleases.nixos.org— the composite downloads the installer from the second, and every nix command it enables substitutes from the first, so a job running it without both either fails to install nix or rebuilds every derivation from source. This is the mirror of assertion 7: that one binds a present host to a tool that must be able to reach it, this one binds a present tool to the hosts it must be able to reach.DeterminateSystems/flakehub-cache-actionis banned outright: no job may use it, and no allowlist may carry a flakehub or Determinate Systems host.
These are instances of a general hazard, not the full set: a redirect to any host outside the allowlist is the defect class. A
github.com/.../releases/downloadURL 302s torelease-assets.githubusercontent.com(covered above); anixos.org/manualURL 302s tonix.dev(not covered —nixos.orgis a lychee link-check target host, outside this lint's tool-inventory scope). Extending the table for a new redirecting host means pinning both the source URL pattern and the host it lands on. -
ghcr blob-host consistency. Any job whose allowlist carries
ghcr.ioat all must also carrypkg-containers.githubusercontent.com, whatever tool putghcr.iothere.ghcr.ioserves manifests, but layer blobs come from the pkg-containers host, so a pull, a push, or a Trivy vulnerability-DB fallback stalls on the first blob without it. -
Docker Hub host-set consistency. Any job whose allowlist carries any Docker Hub registry host must carry the full pull floor:
auth.docker.io,registry-1.docker.io, andproduction.cloudfront.docker.com— the token service, the registry API, and the layer CDN, all three required together to pull even a single image. A job that additionally logs in or pushes (docker login/docker push, orbuildx imagetools create) must also carryindex.docker.io, the write-path host.docker buildx imagetools inspectdoes not trip this branch — it is a read, not a write, and pinning the write-path host into read-only jobs would leave the failure message claiming a login or push that never happens.hub.docker.comis never required by this assertion — it is the Docker Hub web API, not a registry host, and it doubles as a lychee link-check target. It is instead required directly by the forward rule above whereverpeter-evans/dockerhub-descriptionappears, since that action's writes land on the web API rather than the registry.Two detection limitations sit in this assertion's push-branch check. It matches
docker push/docker loginanywhere in a job's concatenatedrun:text, including inside a shell comment, so a job that merely mentions those words in a comment would be required to carryindex.docker.ioeven though it never reaches it. The same check is also blind to a push performed by tooling shaped differently —skopeo copy,crane push,regctl, or a wrapped script — which would silently under-guard the write path. -
Sigstore host-set consistency, keyed on hosts rather than on which cosign subcommand is detected. Any job whose allowlist carries any sigstore host, or whose
run:text invokes cosign, must carry a complete set for what it does:- Signing (
cosign sign/cosign sign-blob) requires all four:fulcio.sigstore.dev,rekor.sigstore.dev,tuf-repo-cdn.sigstore.dev, andtimestamp.sigstore.dev— cosign requests an RFC3161 timestamp when producing a bundle. - Verifying (
cosign verify/cosign verify-blob), when the job does not also sign, requirestuf-repo-cdn.sigstore.devonly, and must not carrytimestamp.sigstore.dev. A.sigstorebundle embeds the signing certificate and transparency-log entry, so verification is self-contained apart from the TUF root refresh; the timestamp authority is signing-only, and demanding it on a verify job would force an unjustified host into the allowlist. - When neither is detected in the workflow file but a sigstore host is present, the lint demands the conservative floor (
fulcio.sigstore.dev,rekor.sigstore.dev,tuf-repo-cdn.sigstore.dev). This is what catches a job that reaches cosign indirectly — through a script orjustrecipe, invisible to therun:-text rules — but was never given a complete host set.
- Signing (
-
Reverse check: a denylist of hosts nothing in this repo justifies (
cafe.github.com,magic-nix-cache*.amazonaws.com,api.flakehub.com,cache.flakehub.com,flakehub.com,install.determinate.systems). This is deliberately a denylist, not a full justification model — a missing host has broken this repo's egress posture repeatedly; an extra host never has. -
Notify-composite parity. A job that runs the
notify-workflow-resultcomposite is bound to the host set declared in.github/actions/notify-workflow-result/egress-allowlist.txt. That file is the single source of truth, and it lives beside the composite whose network behavior it describes, so giving the composite a new API host is one edit rather than one edit per calling workflow. The rule exists because this job shape is replicated across many workflows for identical work: hand-maintained copies of the same list drift, and a list carrying hosts the shape cannot reach stops describing the job at all.Subjects are discovered by property rather than by name — any job with a step whose
uses:names the composite. That covers the path-relative form, the SHA-pinned self-reference (used where apull_requestevent must not run a PR branch's copy of the composite), and the next notify job nobody has written yet, which is governed on the day it is written rather than on the day someone remembers to widen a lint.Two branches, keyed on whether the job does anything beyond notifying. A pure job — its
uses:set is exactly harden-runner,actions/checkout, and the composite — must carry the declared set exactly, extra hosts included in the failure, since nothing in that shape can reach one. A job carrying any other step, including a barerun:, must carry the declared set as a superset: a notify job that computes its own body is not forced to edit this lint to land, and whatever else it carries is still governed by assertions 1-5. A job that ran the composite without a harden-runner step would have no allowlist to compare, but that shape cannot exist —scripts/check-harden-runner-first.shrejects any job whose first step is not harden-runner.Breadth is asserted rather than inferred: the run reports how many notify jobs it discovered, and finding none on an unfiltered scan is reported as a could-not-run (exit 2), not as a clean tree. A discovery predicate that has stopped matching otherwise prints exactly the line a clean run prints.
WORKFLOW_FILE_FILTERsuppresses that guard because the fixture harness scans one file at a time, andLINT_ALLOW_EMPTY_SCAN=1suppresses it for a deliberately empty scan root. -
Nix-host reachability, the one assertion binding a host to a tool that must be able to reach it rather than a tool it must be present for: a job that allowlists
cache.nixos.orgorreleases.nixos.orgmust actually reach Nix. The two hosts are cheap to leave in an allowlist by copy-paste long after a job stops installing Nix, and nothing else in this repo's tooling notices. The rule's arms, its exemption marker and what it reports: Workflow hardening → nix-host reachability.
Known blind spot: detection reads the workflow file only, one level deep. A job that reaches cosign through a script or just recipe is invisible to the run:-text sign/verify rules — assertion 4's "neither detected" branch covers that case whenever the job's allowlist carries at least one sigstore host, and for that covered shape no call-graph resolver is warranted. The residual gap — a job with an empty sigstore host set that reaches cosign only indirectly — is matched by no arm at all and is accepted as a blind spot.
Assertion 6 is the one place where the unguarded-host problem below does not arise: an exact-match rule leaves no host in a pure notify job's allowlist that no rule requires.
Sweep method. The rules above are mechanical, but they only bind hosts to the tools this lint knows to look for. Every host in a job's allowlist is one of three things: repo infrastructure (the Actions runtime, checkout, caching), a documentation-link target (checked by the link-check job, never reached by a tool), or a tool host one of the assertions above requires. A tool host that no rule requires is unguarded — nothing stops it from being deleted, and the lint stays green until the job that still depends on it next runs and gets blocked. Finding that case is a manual sweep of the workflow files against this rule table, not an automated check: deciding which hosts count as infrastructure needs a curated list that would itself rot the same way a hand-authored allowlist does, and the documentation-link targets would need to be carved out of the tool-host set by name, which is the same rot risk one layer up.
The sweep does not conclude every tool host is required by a rule. One is deliberately allowed but not required: check.trivy.dev serves Trivy's version/VEX lookup, and blocking it degrades Trivy to a warning rather than failing the scan, so no rule demands it.
The sweep's other outcome is removal. A tool host that no rule requires because nothing reaches it does not need a rule — it needs to leave the allowlist, so the list keeps describing what the job actually does. The scorecard job's --checks list and its allowed-endpoints are coupled that way: each check reaches its own datasets, the curated set reaches the three the lint requires, and a host serving a check the job does not run belongs to neither the allowlist nor the rule table.
Enforced by scripts/check-egress-allowlist.sh via the lint-workflow-security CI job (member check egress-allowlist) and a pre-commit hook.