Skip to content

Trust model

This page expands the trust model briefly described in SECURITY.md. Read both.

What this wrapper guarantees

  1. Hash-pinned upstream fetch. linpeas-pin.json records an SRI hash for the upstream linpeas.sh asset. Nix refuses to build on hash mismatch — a swapped or corrupted upstream asset fails the build.
  2. Pin-shape validation at flake eval. nix/linpeas.nix asserts that pin.version matches [0-9]{8}-[0-9a-f]{7,40}, that pin.url starts with https://github.com/peass-ng/PEASS-ng/releases/download/, and that pin.version is the release-tag path segment of pin.url. An attacker-controlled pin file cannot smuggle arbitrary URLs into the derivation, and cannot advertise one version while fetching another release's artifact.
  3. GitHub Releases API digest cross-check. scripts/bump-linpeas.sh reads the upstream release-asset .digest field 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.
  4. Asset URL prefix validation. Same script refuses to record any asset URL outside the expected peass-ng/PEASS-ng/releases/download/ prefix.
  5. Weekly upstream parity check. verify-latest-release.yml re-fetches the pinned linpeas.sh weekly and re-checks the SRI hash. Detects upstream tag replacement (intentional or compromised); publish-time verification in release-on-bump.yml covers each release as it ships.
  6. SLSA build-provenance + SBOM attestations. Each release attaches a build-provenance attestation binding the pin file to the release-on-bump.yml workflow 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 by anchore/sbom-action and attested via actions/attest-sbom. Verifiable with gh attestation verify — see Verification.
  7. 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-facing cosign verify verification path that does not require the gh CLI.

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:

  1. update-linpeas.yml — daily pin bumps. Opens a PR authored by the linpeas-flake-bumper GitHub App bot, gated by all required CI checks, auto-merged on green.
  2. update-flake-lock.yml — weekly flake-input bump (nix flake update moves every input that is not rev-pinned, not only nixpkgs; pre-commit-hooks is rev-pinned, and Renovate carries that one). Split into a contents: read compute-lock job (runs nix flake update) and a push-and-merge job that uses actions/* SHAs plus the repo-wide step-security/harden-runner pin to mint a linpeas-flake-bumper GitHub App installation token, keeping the bump credential out of any third-party action's env. Same CI gating.
  3. 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 (nixpkgs stable 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-unstable is deliberately absent: it is branch-tracked, so the weekly nix flake update cron already floats it and a Renovate manager for it would only rewrite the branch name in flake.nix into a fixed rev, freezing the input.
    • Each set carries its own per-manager automerge scope rather than a top-level key, so a manager this config does not name — npm activated by a new package.json, say — cannot inherit automerge.
    • All PRs honor a non-empty minimumReleaseAge (7 days); the renovate-invariants CI 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. The changelog job regenerates CHANGELOG.md and commits it through a PR it auto-merges under the same linpeas-flake-bumper App 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-bumper GitHub App, not as a PAT. The App is installed only on rvenutolo/linPEAS-flake with Contents: Read and write + Pull requests: Read and write permissions (Metadata: Read is implicit). No Workflows permission. Switching to any PAT-based flow is a regression — web-flow signing of REST PUT /contents requires 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 what actions/create-github-app-token accepts.
  • secrets.BUMP_APP_PRIVATE_KEY — App's PEM private key. Rotate on suspected compromise; no forced cadence.
  • Tokens minted via actions/create-github-app-token are scoped to one job, valid one hour, and revoked at job end. They live only as ${{ steps.app-token.outputs.token }} passed via GH_TOKEN. Never reach .git/config; there must never be a git push using them. Commits land via REST PUT /repos/{owner}/{repo}/contents/{path} → web-flow-signed by GitHub.
  • BUMP_APP_PRIVATE_KEY enters 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) and changelog (release-on-bump.yml) — never in compute-pin / compute-lock / compute-refresh. Those compute jobs must remain permissions: contents: read, must not reference the secret, and must keep untrusted Nix actions inside their own boundary.
  • docs/security/required-checks.md restates the protect-main branch ruleset's required-check set. Update in same change as GitHub-side list changes.
  • No paths: / paths-ignore: filter under on.pull_request: on any workflow listed in docs/security/required-checks.md. Enforced by required-checks-no-paths CI 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:

  1. 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/init requires release-assets.githubusercontent.com — the CodeQL bundle falls back to a release asset when the toolcache misses, and a github.com release-download URL 302s there unconditionally.
    • aquasecurity/trivy-action requires get.trivy.dev, mirror.gcr.io, and ghcr.io. The action resolves the release tag against github.com but downloads the binary from get.trivy.dev, so a job carrying only the database hosts installs nothing and skips every later step. ghcr.io is the documented vulnerability-DB fallback; without it a mirror.gcr.io outage fails the scan instead of degrading.
    • anchore/scan-action requires get.anchore.io, grype.anchore.io, and raw.githubusercontent.com — the first two serve the grype install script and the vulnerability DB, and the install path reaches raw.githubusercontent.com non-deterministically across architectures, which makes that gap latent rather than immediate.
    • anchore/sbom-action requires raw.githubusercontent.com and get.anchore.io — the syft-install path reaches the first non-deterministically across architectures; the second serves the install script.
    • A run: step containing a releases/download URL requires release-assets.githubusercontent.com — the same GitHub redirect as above, reached directly instead of through the CodeQL action.
    • A run: step invoking gh release upload requires uploads.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 substring command scorecard or scorecard --repo) requires api.securityscorecards.dev, api.osv.dev, and api.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 invoking gh attestation verify requires tuf-repo.github.com — verification refreshes GitHub's TUF root before checking the bundle.
    • peter-evans/dockerhub-description requires hub.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-nix requires cache.nixos.org and releases.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-action is 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/download URL 302s to release-assets.githubusercontent.com (covered above); a nixos.org/manual URL 302s to nix.dev (not covered — nixos.org is 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.

  2. ghcr blob-host consistency. Any job whose allowlist carries ghcr.io at all must also carry pkg-containers.githubusercontent.com, whatever tool put ghcr.io there. ghcr.io serves 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.

  3. 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, and production.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, or buildx imagetools create) must also carry index.docker.io, the write-path host. docker buildx imagetools inspect does 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.com is 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 wherever peter-evans/dockerhub-description appears, 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 login anywhere in a job's concatenated run: text, including inside a shell comment, so a job that merely mentions those words in a comment would be required to carry index.docker.io even 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.

  4. 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, and timestamp.sigstore.dev — cosign requests an RFC3161 timestamp when producing a bundle.
    • Verifying (cosign verify / cosign verify-blob), when the job does not also sign, requires tuf-repo-cdn.sigstore.dev only, and must not carry timestamp.sigstore.dev. A .sigstore bundle 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 or just recipe, invisible to the run:-text rules — but was never given a complete host set.
  5. 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.

  6. Notify-composite parity. A job that runs the notify-workflow-result composite 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 a pull_request event 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 bare run:, 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.sh rejects 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_FILTER suppresses that guard because the fixture harness scans one file at a time, and LINT_ALLOW_EMPTY_SCAN=1 suppresses it for a deliberately empty scan root.

  7. 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.org or releases.nixos.org must 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.