Verification walkthrough¶
How to verify a release of this wrapper yourself. None of this trusts the Pages site you are reading.
- Tools needed
- Verify the OCI image's build provenance
- Multi-arch attestations
- Verify the weekly parity check is current
- Bump-script integrity guards
- verify-latest-release upstream parity
- verify-latest-release failure attribution
- Ladder coverage is linted
- Gitleaks secret scanning
- TruffleHog secret scanning
- Dependency review
- OCI image CVE scan (Trivy)
- OCI image CVE scan (Grype)
- SBOM attestation
- Cosign keyless signatures
- Identity pinning
- User-facing verification commands
- Release-asset blob signatures
- Release-asset provenance sidecars
- gh-attestation-repo invariant
- Word-position pinning
- Quoted regions read as command sources
- Bare-triple payloads: mention or invocation
- Recognized shells and known misses
- Markdown lexing
- cosign-identity-pinned invariant
Tools needed¶
gh(GitHub CLI), a recent release —gh attestation verifychecks each bundle against Sigstore's live trusted root. A client that cannot load that root, because it cannot fetch it or is too old to parse it, fails before it verifies any attestation, and the error says nothing about whether the attestation is valid. The error names the trusted root, the TUF client or the verifier, not the attestation. The wording varies by release and by cause. Measured examples: an older release that cannot parse the root printsfailed to get trusted root: unsupported tlog public key type: PKIX_ED25519, and one that cannot fetch it printsfailed to create TUF client; a current release printspublic good verifier is not available (initialization may have failed)for either cause, orno valid Sigstore verifiers could be initializedwhen neither the Sigstore nor the GitHub TUF repository loads. The oldest working release can rise when the root changes upstream (for example, when it adds a key type older clients cannot parse), so this page states no version number.gh release downloadfetches the signed release assets.cosign≥ 3.0, the major the release pipeline signs with —cosign verifyfor image signatures andcosign verify-blobfor the.sigstorerelease-asset bundles. cosign 3's defaults write a protobuf Sigstore bundle and attach an image's signature as an OCI 1.1 referrer rather than a.sigtag (see Cosign keyless signatures). An older client that looks only for a.sigtag reports no signatures found. cosign also loads Sigstore's live trusted root; when it cannot fetch or parse it, the error containsgetting trusted root from TUFrather than a signature or certificate-identity error, and verifies nothing.dockerwithbuildx—docker buildx imagetools inspect … --rawresolves the per-arch image digest from the multi-arch index, for thegh attestation verifypath.jq— reads that digest out of the raw index, for the same path.
Either signing toolchain alone is enough to verify the image:
gh attestation verify needs the per-arch digest resolved first, since
attestations are per-arch, while cosign verify accepts the tag, since the
multi-arch index itself is signed. The two check different signatures over
the same image.
Verify the OCI image's build provenance¶
DIGEST=$(docker buildx imagetools inspect \
ghcr.io/rvenutolo/linpeas:20261017-c2b70018 --raw |
jq --raw-output '.manifests[] | select(.platform.os == "linux" and .platform.architecture == "amd64").digest')
gh attestation verify \
"oci://ghcr.io/rvenutolo/linpeas@${DIGEST}" \
--repo rvenutolo/linPEAS-flake
The first command resolves the linux/amd64 arch-image digest from the
multi-arch index (substitute arm64 as needed) — attestations are
per-arch, so the verify must target that digest, not the tag (see
Multi-arch attestations). This proves the
image was built by this repo's release-on-bump.yml workflow
run. --repo pins the attestation to this repository, so a bundle issued by
any other repo cannot satisfy it.
Multi-arch attestations¶
The published OCI image is a multi-arch manifest covering linux/amd64
and linux/arm64. SLSA attestations are per-arch, not per-manifest.
This means:
gh attestation verify oci://docker.io/rvenutolo/linpeas:<tag> --repo rvenutolo/linPEAS-flakefails with a not-found error: the tag resolves to the manifest index, which carries no attestation. TheRepoDigestsvalue recorded by a tag pull is that same index digest, so it fails the same way. Resolve the arch-image digest from the index viadocker buildx imagetools inspect <ref> --rawand verify that digest instead.- Each arch image was independently built from the same commit of this repo, so the attestations cover the same source provenance.
- The manifest index itself is not attested. An attacker with push
to either registry could repoint the manifest at unattested images;
the verify step in
release-on-bump.ymlwould catch this at release time, but a consumer who checks only the attestation against the manifest pointer (not the arch image) would miss it. Forgh attestation verify, target the resolved arch-image digest.cosign verifyagainst the tag covers the index itself, since the index digest is signed — see Cosign keyless signatures.
Verify the weekly parity check is current¶
gh run list \
--workflow verify-latest-release.yml \
--repo rvenutolo/linPEAS-flake \
--limit 1 \
--json conclusion,updatedAt,url
Look for "conclusion": "success" within the last 7 days. Current state on the Pages site: success at 2026-10-16T10:05:32Z.
Bump-script integrity guards¶
scripts/bump-linpeas.sh:
- Asset URL must start with
https://github.com/peass-ng/PEASS-ng/releases/download/. Hard fail. - GitHub-API
.digestfield never silently skipped. Absent, non-sha256:prefix, or a digest that does not match the downloaded asset is a hard fail. - Pin file written via
make_temp(scripts/lib/temp.sh) +mv(atomic). Never>, and never a baremktemp— the script-hygiene lint rejects that shape underscripts/; the guarded helper inscripts/lib/temp.shholds the one sanctioned bare invocation. - Every
gh apicall inscripts/*.shmust pass--header "X-GitHub-Api-Version: 2022-11-28". Apply to any new security-sensitive GitHub-REST caller.
The bump-script tokens of guards 1–3 are lint-enforced by
scripts/check-bump-script-integrity.sh (regex-presence over
scripts/bump-linpeas.sh); the tree-wide bare-mktemp ban in guard 3
by scripts/check-guard-exit-code.sh; guard 4 by
scripts/check-gh-api-version-header.sh. All three run in the
lint-script-hygiene CI job; the first and last also run as pre-commit
hooks.
verify-latest-release upstream parity¶
The weekly verify cron re-fetches the pinned linpeas.sh URL, recomputes the SRI
hash via openssl dgst -sha256 -binary <file> | base64 --wrap=0, compares against
linpeas-pin.json. A hash mismatch = security incident; the same step
also fails on a fetch error or a pin it cannot use, and the step log tells
them apart (the notify body names both).
verify-latest-release failure attribution¶
verify-latest-release.yml's notify body distinguishes failure
reasons via per-step id: outcomes mapped to a reason token by
the attribute failure reason step. Reasons:
upstream-sri-drift— the upstream fetch step failed: the fetchedlinpeas.shSHA-256 no longer matches the pinned SRI — a security incident — or the step failed before comparing (acurlerror, an unreadable pin, or a pin whose hash is notsha256-); the step log says which.manifest-tag-drift—:latestno longer resolves to the same manifest as:VERSIONon ghcr.io or docker.io, or one of the two tags could not be fetched from a registry.cross-registry-manifest-mismatch— ghcr.io and docker.io serve different multi-arch manifests for the release tag (security incident), or the:VERSIONmanifest could not be fetched from one registry. Identical bytes are published to both at release time, so a divergence means post-publication tag rewriting on one registry — typically a rollback to an older, still-validly-signed release, which no signature or attestation check can detect. Triage via dockerhub-recovery.md.ghcr-attest-failed/hub-attest-failed/pin-attest-failed— attestation verification failed for a specific artifact, or — for the two image tokens — the registry fetch that resolves the per-arch digest failed first (the step log shows adocker manifest inspecterror orunexpected … digest). Treat a verification failure as tampering until ruled out, unless the step log showsghcould not load Sigstore's trusted root (the errors under Tools needed). That run verified nothing: re-run it. If the error persists, either the TUF repositories are unreachable from the runner or itsghcannot parse the root. For the second, re-run once the runner image ships a newergh(the runner-images README links each image's installed software), and treat a failure then as tampering.release-tag-fetch-failed/release-asset-download-failed— transient GitHub API / asset visibility lag.pin-blob-sig-failed— cosign verify-blob failed forlinpeas-pin.jsonon the latest release. The.sigstoresidecar is missing, it no longer verifies against the pinned workflow identity, or the step's owngh release downloadornix shell .#cosignfailed first; the step log says which. Triage: re-triggerrelease-on-bump.ymlviaworkflow_dispatchwithforce-republish: trueif the sidecar is missing. A trusted-root load error in the step log is handled as forimages-cosign-failed.sbom-amd64-blob-sig-failed— same failure for the amd64 CycloneDX SBOM asset. Responsibility lives in theimage-amd64job ofrelease-on-bump.yml.sbom-arm64-blob-sig-failed— same failure for the arm64 CycloneDX SBOM asset. Responsibility lives in theimage-arm64job ofrelease-on-bump.yml.images-cosign-failed—cosign verifyof the published per-arch and index images failed against the pinned workflow identity and OIDC issuer, or the step could not reach cosign (nix shellerror) or the registries; the step log says which. Treat a cosign verification error as a signing-chain incident, adjacent in severity to the*-attest-failedreasons. A trusted-root load error (see Tools needed) verified nothing: re-run. The step runs the lock-pinned.#cosign, not a runner tool, so a re-run uses the same client. If the error persists and the TUF repository is reachable, the pinned cosign cannot parse the root, and only a flake change that updates cosign (normally aflake.lockbump) fixes it.unattributed— the job failed but no ladder arm matched the failed step: either a step before the first verification step (harden-runner, checkout, setup-nix) failed, or a verification step has no ladder arm. The failure is real and unexplained, so triage it by hand; in the second case fix the attribution step. This is the token to watch for after adding a verification step without wiring itsid:into the ladder.unknown— theverifyjob produced noreasonoutput at all: it was cancelled, or the runner was lost, before the attribution step ran. Not a verification result; re-run the cron.
upstream-sri-drift on a hash mismatch and
cross-registry-manifest-mismatch on a mismatch warrant the
"treat as security incident" framing outright; the *-attest-failed
family on a verification failure and images-cosign-failed on a cosign
verification error warrant it too, except when the step log shows the
client could not load Sigstore's trusted root: then it applies only if
the failure persists on a client that loads the root;
manifest-tag-drift on a mismatch is a
lower-confidence security signal, and the body tells the maintainer to
hold pin bumps for it too.
Folding all reasons into a single failure body trains
the maintainer to skim-read auto-filed issues — exactly the wrong reflex
when the failure is a real SRI drift or a one-sided registry rollback.
Cron-notify callers whose job can fail more than one way tell the ways apart, in the body or by label, so a reader acts on the failure in front of them. Alert fatigue is a security risk.
Ladder coverage is linted¶
The step ids, the attribution step's env: block, the elif ladder,
and the reason list above are four hand-synced copies of one set.
scripts/check-verify-reason-ladder.sh binds them: every id:-carrying
step in the verify job other than the attribution step itself is
referenced by a steps.<id>.outcome entry
in the attribution env:, every such env var is read by the ladder,
every reason token the ladder emits appears somewhere on this page (a
fixed-string match, not a check against the list above), and the
ladder's branch order matches the steps' execution order — the order the
"first failed step wins" rule depends on.
Without that binding, a step added without a ladder branch reports
unattributed, whose documented meaning is a gap in the ladder —
so a real verification failure would be auto-filed as a self-diagnosed
tooling bug, and the triage reflex would be wrong for exactly the case
that matters.
A step that legitimately carries an id: without being a verification
outcome is excluded with a # reason-ladder-exempt: <reason> comment on
the same line as its id:. An empty rationale is drift, not an
exemption.
Gitleaks secret scanning¶
gitleaks.yml scans the full git history (fetch-depth: 0) on push to
main, every PR, a weekly Friday cron, and manual dispatch. Required
check named gitleaks in the protect-main ruleset.
- Uses only
secrets.GITHUB_TOKEN— PR-triggered workflow secret allowlist invariant holds. - New leaked-secret finding = security incident. Triage:
rotate → purge with
git filter-repo→ force-push. The force-push needs theprotect-mainruleset set todisabledviagh apifor the duration: itsbypass_actorslist is empty, so thenon_fast_forwardrule blocks a repository admin as well. Re-sign the rewritten commits before pushing — history rewriting drops the original signatures, and every commit onmainmust be signed — then re-enable the ruleset. - The
gitleaks/gitleaks-action@*pattern is in theallowed_actionsallowlist; do not remove it without replacing the workflow.
TruffleHog secret scanning¶
trufflehog.yml scans the full git history (fetch-depth: 0) on push
to main, every PR, a weekly Friday cron, and manual dispatch. Required
check named trufflehog in the protect-main ruleset. It runs with
extra_args: --only-verified, so a finding is a credential TruffleHog
reached the issuing provider to confirm is live — not a pattern match.
Gitleaks and TruffleHog are a deliberate pair: they carry different detector sets, and a secret shape one misses is the reason the other runs. Neither substitutes for the other, and dropping either needs a security-review entry.
- References no secret — PR-triggered workflow secret allowlist invariant holds.
- A verified finding is a live credential and therefore a security
incident. Triage is the same as for gitleaks:
rotate → purge with
git filter-repo→ force-push behind a temporarily disabledprotect-mainruleset, with the same re-signing requirement. Rotate first — the secret is confirmed valid, so history rewriting is the slower half of the response. - The
trufflesecurity/trufflehog@*pattern is in theallowed_actionsallowlist; do not remove it without replacing the workflow.
Dependency review¶
dependency-review.yml runs on every PR via
actions/dependency-review-action. Required check named
dependency-review. fail-on-severity: moderate,
comment-summary-in-pr: on-failure.
- The repo carries no traditional package manifests, so the action
mostly scans
.github/workflows/**uses:against the GitHub Advisory DB + license policy. Belt-and-braces backup to SHA-pinning + Renovate + zizmor. - If a future PR adds a real manifest (npm/cargo/pip/etc.), the action begins scanning it without any workflow change.
OCI image CVE scan (Trivy)¶
image-cve-scan.yml's image-cve-scan-trivy job (weekly cron +
path-filtered push to main + dispatch) uploads SARIF
(CRITICAL + HIGH) to the Security tab, then
post-processes the SARIF to count CRITICAL findings and fails the
job when count > 0. The job emits an outputs.has-finding boolean
('true' iff the count step ran and returned a non-zero count) so
notify jobs can distinguish a real CRITICAL CVE from an
infrastructure failure that prevented a CRITICAL count from being
produced.
Two follow-on jobs (needs: image-cve-scan-trivy) gate on that
output and open / update deduped issues via
notify-workflow-result:
-
image-cve-scan-trivy-notify-finding(label:image-cve-critical-trivy) — real CRITICAL CVE reported by Trivy . Remediation: bumpnixpkgs. -
image-cve-scan-trivy-notify-infra(label:image-cve-infra-trivy) — job failed before Trivy produced a CRITICAL count (build, scan, SARIF upload, or the count step itself broke — Trivy may or may not have found a CRITICAL), or the job was cancelled (no runner, or its timeout) . Remediation: inspect the failing step; if transient, close once the next scheduled run is green. -
NOT in required-checks (intentional —
update-flake-lockmust still land even if a CVE is present, with explicit maintainer awareness). -
Trivy's own
exit-code: "0"+ignore-unfixed: trueintentional; the CRITICAL-fail decision lives in thefail on CRITICAL findingsstep so a CRITICAL finding does not stop the SARIF upload. -
Prevention path: nixpkgs auto-bump via
update-flake-lock. CRITICAL finding → bump nixpkgs, then rebuild the OCI image. -
CRITICAL threshold is hardcoded to CVSS
>= 9.0per GitHub's current Code Scanning mapping. Thejq tonumber? // 0guard drops non-numeric SARIF severities (e.g. textual"high"from some scanners) instead of erroring underset -Eeuo pipefail. Revisit the threshold if GitHub revises the CVSS-to-bucket mapping.
OCI image CVE scan (Grype)¶
image-cve-scan.yml's image-cve-scan-grype job (weekly cron +
path-filtered push to main + dispatch) uploads SARIF (fixed-only
findings, all severities; results below HIGH carry warning level
because severity-cutoff: high sets the result level rather than
filtering) to the Security tab under
category grype-image-cve, using Grype as a second-opinion scanner
alongside Trivy. The job fails (and emits a notify issue) on a CRITICAL
finding, and — like Trivy — on any infrastructure failure ahead of the
count step; the two notify jobs below distinguish the cases. Advisory only — not
a required status check; prevention path is a nixpkgs bump via
update-flake-lock.
Two follow-on jobs (needs: image-cve-scan-grype) open or update a
deduped issue via the notify-workflow-result composite:
image-cve-scan-grype-notify-finding(label:image-cve-critical-grype) — real CRITICAL CVE was identified by Grype .image-cve-scan-grype-notify-infra(label:image-cve-infra-grype) — job failed before producing a CRITICAL count (build / scan / SARIF upload / the count step itself — Grype may or may not have found a CRITICAL), or the job was cancelled (no runner, or its timeout) .
SBOM attestation¶
release-on-bump.yml generates CycloneDX-JSON SBOMs via anchore/sbom-action,
attests via actions/attest-sbom.
- Per-arch image SBOMs: attested + pushed to ghcr.io and docker.io with
push-to-registry: true, and uploaded as release assets with.sigstore+.intoto.jsonlsidecars. verify-latest-release.ymlverifies the SBOM release assets withcosign verify-blob(thesbom-*-blob-sig-failedreasons). Itsgh attestation verifycalls check build provenance only — they pass no--predicate-type, so the registry-pushed SBOM attestations are not re-verified by the cron.
Cosign keyless signatures¶
In addition to actions/attest-build-provenance + actions/attest-sbom
(verifiable via gh attestation verify), release-on-bump.yml signs
every published image digest with Sigstore cosign in keyless mode.
Signatures are minted with an ephemeral Fulcio cert issued against the
workflow's OIDC token, then recorded in Rekor.
Signed artifacts per release:
- Per-arch images:
cosign sign <reg>/rvenutolo/linpeas@<digest>on bothghcr.ioanddocker.io. The signature is a Sigstore bundle attached to the digest as an OCI 1.1 referrer.docker.ioserves it through the referrers API;ghcr.io, which has no referrers API, holds it in thesha256-<digest>fallback tag. No.sigtag is written. - Multi-arch index: same
cosign signinvocation against the OCI index digest of:VERSION. When the run also writes:latest(the release is the newest one, not a historic backfill), that tag resolves to the same index digest, so the one signature per registry covers both tags.
Identity pinning¶
Verification must pin both:
--certificate-identity https://github.com/rvenutolo/linPEAS-flake/.github/workflows/release-on-bump.yml@refs/heads/main--certificate-oidc-issuer https://token.actions.githubusercontent.com
A signature minted by any other workflow, branch ref, or OIDC issuer
fails verification. The release pipeline's verify job and the weekly
verify-latest-release.yml cron both enforce these exact values.
User-facing verification commands¶
Image (any tag or digest works; both registries are signed):
cosign verify \
--certificate-identity 'https://github.com/rvenutolo/linPEAS-flake/.github/workflows/release-on-bump.yml@refs/heads/main' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
ghcr.io/rvenutolo/linpeas:latest
No gh CLI required. Pairs with the existing gh attestation verify
path — pick whichever toolchain fits the consumer's pipeline.
Release-asset blob signatures¶
Each primary release asset — the pin file and each SBOM the release
publishes — has a sibling .sigstore bundle:
linpeas-pin.json+linpeas-pin.json.sigstorelinpeas-image-amd64.cdx.json+linpeas-image-amd64.cdx.json.sigstorelinpeas-image-arm64.cdx.json+linpeas-image-arm64.cdx.json.sigstore
Download both files for the asset you want to verify, then run
cosign verify-blob with the same identity pin used for image
signatures:
gh release download <tag> \
--repo rvenutolo/linPEAS-flake \
--pattern linpeas-pin.json \
--pattern linpeas-pin.json.sigstore
cosign verify-blob \
--certificate-identity 'https://github.com/rvenutolo/linPEAS-flake/.github/workflows/release-on-bump.yml@refs/heads/main' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
--bundle linpeas-pin.json.sigstore \
linpeas-pin.json
Same shape for both SBOM sidecars — swap the asset names.
Why blob sigs in addition to the existing GitHub Attestations? The
attestations live in the GitHub Attestations API + Rekor and verify
via gh attestation verify. The .sigstore sidecars are file-system
sibling artifacts on the GitHub Release, which is what OpenSSF
Scorecard's Signed-Releases check scans for — and what consumers
without gh CLI can verify with just cosign.
Release-asset provenance sidecars¶
Every primary release asset — linpeas-pin.json and the per-arch
CycloneDX SBOMs — is published alongside a sibling
<asset>.intoto.jsonl file containing a SLSA Build Provenance v1
attestation whose subject is the asset's content hash. The bundle is
captured from actions/attest-build-provenance at release time and
uploaded as a release asset.
The sidecar serves two consumers:
- OSSF Scorecard
Signed-Releases— itsreleasesHaveProvenanceprobe matches release-asset suffix.intoto.jsonlonly, and does not query the GitHub Attestations API. Without the sidecar, the per-release score caps at 8 (signed but no provenance). - Manual verification — consumers can verify the bundle with
gh attestation verify <asset> --bundle <asset>.intoto.jsonl --repo rvenutolo/linPEAS-flakeagainst the same Fulcio + Rekor trust root used by the.sigstoresignatures.
The contract is: for every primary <asset> in a release — the pin
file and each SBOM the release publishes — both <asset>.sigstore
(cosign signature bundle) and <asset>.intoto.jsonl (build provenance
bundle) MUST be present; the sidecars themselves carry no further
sidecars.
A release missing either sidecar is a regression. Nothing in-tree
asserts this contract — the weekly parity cron's reason ladder covers
the .sigstore blob signatures only, and the enforcement matrix records
no enforcer for the sidecar rows — so detection is indirect: a missing
sidecar surfaces as a drop in the Scorecard Signed-Releases score. The
recovery procedure lives in
docs/runbooks/scorecard-signed-releases-backfill.md.
gh-attestation-repo invariant¶
Every gh attestation verify invocation across workflows, composite actions, scripts, nix modules, the justfile, and documentation — in fenced shell blocks and in inline code alike — must pass --repo rvenutolo/linPEAS-flake. gh attestation verify requires either --owner or --repo, so an unpinned invocation does not run at all. The bypass the pin forecloses is the --owner-only spelling: it binds verification to the account, so an attestation issued by any other repository under rvenutolo satisfies it. --repo narrows that to this repository, so a bundle issued elsewhere fails.
Enforced by scripts/check-gh-attestation-repo.sh, with parsing in scripts/_attestation_invocations.awk. The lint splits every source into shell words — runnable lines after joining their backslash continuations, backtick spans as written — honouring single quotes, double quotes, and backslash escapes, and treating a backtick as a word delimiter rather than part of a word. A record runs from the gh attestation verify word triple to the next unquoted shell separator or comment, or to the end of the string. A redirection operator is not a separator — 2>&1, >&2, and &>f keep the record open — so a pin written after a redirection still counts, while a bare & backgrounds the command and ends the record. The pin satisfies the check only as a word --repo or -R whose next word is the slug, or a word --repo=<slug>, -R=<slug>, or -R<slug> — the glued short form gh itself accepts. Wired as the lint-workflow-security CI job (member check gh-attestation-repo) and as a pre-commit hook.
Word-position pinning¶
Binding the pin to a word position rather than to a substring is what stops text that merely sits near the command from vouching for it: a pin in a trailing comment, a pin belonging to a chained command, and a slug inside a quoted argument all leave the invocation unpinned. A record carrying the command plus at least one further word is always an invocation. A record holding the bare command triple with no further word depends on where it came from: on a runnable line it is an unpinned invocation and is flagged, while inside a code span it is a prose mention and is ignored. A runnable line is inspected with its code spans removed, so a quoted occurrence is counted once, by the span scan.
Quoted regions read as command sources¶
A quoted region is ordinarily literal word text, which is what shell means by it: "a b" is one argument. Where the quotes belong to the enclosing document rather than to shell — a YAML run: scalar, a nix entry = "…" attribute, an eval or -c argument — the payload is a command line, and reading it as one word would hide the invocation it carries.
The lint re-parses a quoted region as a command source, in either quote character, when the text immediately before the opening quote marks command position:
- an attribute or mapping key whose value is a command —
run,entry,text,script,command,cmd,entrypoint,shell— written with its:or=; - the word
eval; - a flag whose argument is a command:
--command, with or without a glued=, or a short-option cluster ending inc(-c,-ec,-lc,-xc) where the command the cluster belongs to names a shell.
A bare key word with no punctuation does not introduce a command, which is what keeps npm run "…" and nix shell "…" literal. Structural punctuation around the preceding text is stripped first, so the YAML flow forms - {run: "…"} and ["sh", "-c", "…"] read the same as their block forms. Reading past the = to the attribute name is what keeps a prose string such as a hook description literal. The payload also keeps its place in the enclosing word, so a slug inside a quoted argument still fails to pin the invocation it sits in.
The c-cluster lookback¶
The lookback for a c cluster reads the words of the command carrying it back to that command's start, and any of those words naming a shell — by its last path component, after structural flow punctuation is stripped — makes the cluster a command introducer. A subshell paren and a code-span backtick bound commands on both sides, so a shell before them cannot vouch for a cluster after them, and a cluster standing immediately after one opens its command and is not an introducer.
A substitution paren ($(, <(, >() produces a word in the enclosing command, so a lookback outside the substitution steps over it and keeps reading the enclosing command's own words — the interior is not read from there. A c cluster written inside a substitution reads its own command's words normally, unaffected by that outer skip, which is what keeps $(bash -c '…') a command source: bash is an ordinary same-depth word of the cluster's own command. The one exception to the outer skip is a command substitution standing as the enclosing command's first word: its direct words are read too, since its output becomes the command word, which is what keeps $(command -v bash) -c '…' a command source.
That reads a shell wherever it stands in its own command, whether an option argument, an operand, an intervening substitution, or nothing at all separates it from the cluster. All of these are command sources:
bash -x -cbash -o pipefail -csh $(x) -cbash $((1+1)) -cenv -i bash -c/bin/sh -c${pkgs.bash}/bin/bash -cdocker run --entrypoint /bin/sh img -c
The same cluster in a command naming no shell takes a pattern, and its quoted argument stays literal word text.
Bare-triple payloads: mention or invocation¶
A payload holding the bare command triple and nothing else is a mention rather than an invocation: a quoted string naming no artifact cannot be a verification, and reading one as an invocation would report a shell-carried triple such as sh -c 'gh attestation verify' as a violation. That scoping is withdrawn when the quoted region does not end its word: eval 'gh attestation verify --repo rvenutolo/linPEAS-flake '"${ART}" splits one command line across a quote boundary, so its payload is a fragment of a longer command line rather than a whole argument, and is judged as an invocation rather than a mention — which is what makes the pin inside it count. A fragment's own payloads inherit that judgement only where they sit at its end, so a complete quoted argument nested inside a fragment stays a mention. Scoping is withdrawn a second time for eval, which concatenates its arguments before executing them: a bare-triple payload introduced by eval and followed by a further word is judged an invocation, because the artifact can sit outside the quotes. A separator rather than a word follows nothing into the command, so that form stays a mention, and the withdrawal is scoped to eval alone — every other introducer passes its next word to the program rather than to the command line, which is what keeps a shell-carried bare triple such as sh -c 'gh attestation verify' f a mention.
Recognized shells and known misses¶
The rule sees the shapes it names. An attribute outside that set still hides its payload. A c cluster no word of whose command names a recognized shell is not read as a command source, so an invocation written that way is missed. Three shapes reach that state: the shell is named through a variable, as in $SHELL -c; it is one outside the recognized set (bash, sh, dash, ash, zsh, ksh, mksh, fish, su, runuser); or the cluster opens its command with no word before it at all. A quoted expansion such as bash "$(dirname x)/f" -c is ordinary word text and keeps the shell in the cluster's own command. That miss is preferred to the false positive reading every c cluster carries: a match key such as grep -Ec 'gh attestation verify …' names the command plus a further word, and a payload with a further word past the bare triple is always judged an invocation, so that reading fails a correct file. Reading the whole command rather than only the cluster's own flags widens the introducer in one direction: a command naming a shell as an ordinary word while carrying a c cluster of its own has that cluster's argument read as a command line. Nothing in this repository writes that shape. A legacy backtick substitution standing as the command word, as in `command -v bash` -c '…', is not read as a shell — a backtick is ambiguous between substitution and code span, and the miss is preferred to reading prose spans as command sources. A shell named only inside a nested substitution of a command-word substitution, as in $($(sh)) -c '…', is not read either: the command-initial exception reads a group's direct words only, so a shell one substitution further in stays unread. A payload whose own text is split across a backslash-continued line, or across a code span the markdown scan has already removed, reads as ending its word and stays mention-scoped. A double-quoted YAML scalar folded across source lines is read only to the end of its first line: backslash continuation is joined before splitting, YAML line folding is not. An attribute in the introducer set is read the same way whether its value is a command or prose about one, so prose that names the command and goes on to further words is judged an invocation exactly as a real one would be.
Markdown lexing¶
Markdown is lexed before splitting. Fences are tracked by fence character and run length, so a backtick run cannot close a tilde fence; the info string is normalized to its first word with attribute punctuation stripped, so ```{.sh} reads as sh. A backtick fence whose info string contains a backtick is not a fence at all, so a line-leading inline code span cannot desynchronize fence tracking for the rest of the file. Fenced blocks (sh, bash, shell, console, text, or unlabeled) and indented (four-space or tab) code blocks are shell source, and non-shell fences (mermaid, dot) are skipped entirely. Prose contributes only its code spans, matched by backtick run length so a doubled-backtick span carries its payload intact. A prose span left open at the end of a line continues onto the next and is abandoned at a blank line or at end of file; on a runnable line the open span's text is returned to that line's own runnable text instead, so an unterminated backtick cannot swallow the lines after it. A line whose first non-space character is # is a comment wherever the line is shell source — a script, a workflow, a fenced shell block, or an indented code block — and is skipped before the span scan, so a backticked command inside it stays prose. Markdown prose is not shell source, so a # there opens a heading and its code spans are inspected normally.
The uniform comment rule means an invocation shown after a root # prompt is not inspected. Nothing in the line itself distinguishes a root prompt from ordinary prose, and treating every # as a prompt reports documentation that merely mentions the command as an unpinned invocation. The miss is preferred to that false positive, because a false positive blocks a correct commit while this gap only affects a shape no tracked doc uses: every invocation in this repository is shown either unprefixed or after a $ prompt, both of which are inspected normally.
cosign-identity-pinned invariant¶
Every cosign verify* invocation (verify, verify-blob, verify-attestation, verify-blob-attestation) the lint reads across workflows, scripts, and documentation fences must pin BOTH --certificate-identity (or --certificate-identity-regexp) AND --certificate-oidc-issuer. Which documentation fences it reads is stated in the header of scripts/check-cosign-identity-pinned.sh. The nix shell .#cosign --command cosign -- verify shape (a cosign word, an optional --, then the subcommand) is recognized as well.
Without identity pinning, cosign accepts any keyless Sigstore signature for the artifact digest — including one minted by a different workflow, branch, or OIDC issuer. The two flags bind verification to a specific signer chain.
Enforced by scripts/check-cosign-identity-pinned.sh. Wired as the lint-workflow-security CI job (member check cosign-identity-pinned) and as a pre-commit hook.