From 2b851f97eefdc80de002434ba2315062f536ed25 Mon Sep 17 00:00:00 2001 From: reesebuilt <126643625+reesepj@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:45:26 -0500 Subject: [PATCH 1/2] Pin workflow actions to commit SHAs and scope job tokens `uses: owner/repo@v2` resolves a mutable git ref when the job starts. Whoever owns that repository can repoint the tag at any commit, and the next run executes it with this workflow's GITHUB_TOKEN. What this repository ships is provisioning scripts a reader runs as root on their own hardware, so an action that changes under its tag can alter what a reader is told to execute. Both references now name a 40-character commit SHA with a trailing version comment, so the code reviewed is the code that runs and a reader can still see which release a SHA is. Both checkouts set persist-credentials: false. The default leaves the job token in .git/config, where any later step, including the link checker's own tooling, can read it and act as this workflow. Each job declares contents: read rather than inheriting the repository default, which on many repositories is read-write. scripts/check-workflow-pins.js enforces both properties on every push and pull request, so a pin that decays back to a tag fails the build instead of passing review as an ordinary line in a diff. It is stdlib-only and reads no YAML: a guard on the supply chain that runs third-party code to do its job has moved the problem rather than solved it. scripts/check.sh runs it too, because that script promises the same checks as CI and a pin is cheaper to fix before the push. --- .github/workflows/ci.yml | 36 +++++++- scripts/check-workflow-pins.js | 149 +++++++++++++++++++++++++++++++++ scripts/check.sh | 15 +++- 3 files changed, 196 insertions(+), 4 deletions(-) create mode 100755 scripts/check-workflow-pins.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0480c19..0cc40ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,14 +4,25 @@ on: push: pull_request: +# Workflow-wide floor. A job added later that forgets its own block inherits read-only rather +# than the repository default, which on many repositories is read-write: a compromised step +# would otherwise hold a token that can push commits, move tags, or publish a release. permissions: contents: read jobs: lint-and-links: runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + # Nothing here pushes. Without this the checkout leaves the job's GITHUB_TOKEN in + # .git/config, where any later step, including the link checker's own tooling, can + # read it and act as this workflow. + persist-credentials: false - name: Syntax-check provisioning scripts run: bash -n provisioning/*.sh @@ -20,7 +31,7 @@ jobs: run: shellcheck provisioning/*.sh - name: Check internal markdown links - uses: lycheeverse/lychee-action@v2 + uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0 with: args: >- --offline @@ -28,3 +39,24 @@ jobs: --include-fragments '*.md' 'labs/*.md' 'hardware/*.md' fail: true + + workflows: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + # Every `uses:` in this directory resolves a git ref at run time. A tag is mutable, so a + # tag pin lets whoever owns that action choose what executes in our jobs, holding our + # token, after the code was reviewed. The pins already in these files are only worth as + # much as their upkeep, and a pin that decays back to a tag looks like an ordinary line + # in a diff, so a check reads all of them on every pull request instead of trusting that + # nobody forgets. The script needs no install and no dependencies: a guard on the supply + # chain that runs third-party code to do its job has moved the problem, not solved it. + # Node is preinstalled on the runner image, so a checkout is the whole setup. + - name: Actions are pinned to commit SHAs + run: node scripts/check-workflow-pins.js diff --git a/scripts/check-workflow-pins.js b/scripts/check-workflow-pins.js new file mode 100755 index 0000000..65429fc --- /dev/null +++ b/scripts/check-workflow-pins.js @@ -0,0 +1,149 @@ +#!/usr/bin/env node +// Fails when a workflow can run code we did not review, or holds a token we did not grant. +// +// Two lexical facts about .github/workflows/*.yml decide both: +// +// 1. `uses: owner/repo@v4` resolves a MUTABLE git ref at run time. Whoever controls that +// repository can repoint the tag at any commit, and the next run executes it inside our +// job with our GITHUB_TOKEN and our secrets. A 40-character commit SHA names immutable +// content, so the code that ran yesterday is the code that runs today. This matters more +// here than in most repositories: what this repository ships is provisioning scripts a +// reader runs as root on their own hardware, so an action that changed under its tag can +// alter what a reader is told to execute. +// +// 2. A workflow with no top-level `permissions:` block inherits the repository default +// token scope. On repositories created before GitHub changed the default, and on any +// repository where an admin sets it back, that default is read-write: a compromised step +// then holds a token that can push commits, move tags, or publish a release. Declaring +// the floor once per file means a job added later cannot forget it into write access. +// +// Both checks are line-level, so this script parses no YAML and needs no dependencies. It +// runs from a bare checkout with nothing installed, which is the point: the check that guards +// the supply chain must not itself depend on the supply chain. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const workflowsDir = path.resolve(__dirname, '..', '.github', 'workflows'); + +// Refs that cannot be pinned to a commit SHA, each with the reason it cannot. An entry is a +// standing exception, not a waiver: the reason is printed on every run so a reader sees the +// exception and can judge it. Keys are `owner/repo`; any subpath under that repository is +// covered by the entry. Add one only when the action itself rejects a SHA. +const ALLOWLIST = {}; + +const SHA_REF = /^[\w.-]+\/[\w.-]+(?:\/[\w.\-/]+)?@[0-9a-f]{40}$/; +const VERSION_COMMENT = /#\s*v?\d[\w.\-+]*/; +const USES_LINE = /^\s*(?:-\s+)?uses:\s*(.+)$/; +const TOP_LEVEL_PERMISSIONS = /^permissions:/m; +const DOCKER_DIGEST = /^docker:\/\/[^\s]+@sha256:[0-9a-f]{64}$/; + +function refValue(rest) { + // Strip a trailing comment and surrounding quotes to get the bare ref. A `#` inside a ref + // is not legal in an action reference, so the first one starts the comment. + const hash = rest.indexOf('#'); + const value = (hash === -1 ? rest : rest.slice(0, hash)).trim(); + return value.replace(/^['"]|['"]$/g, ''); +} + +function ownerRepo(ref) { + const parts = ref.split('@')[0].split('/'); + return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : ref; +} + +const problems = []; +const pinned = []; +const exempt = []; + +let entries; +try { + entries = fs.readdirSync(workflowsDir); +} catch (error) { + console.error(`cannot read ${workflowsDir}: ${error.message}`); + process.exit(2); +} + +const files = entries.filter((name) => /\.ya?ml$/.test(name)).sort(); +if (files.length === 0) { + console.error(`no workflow files under ${workflowsDir}`); + process.exit(2); +} + +for (const file of files) { + const full = path.join(workflowsDir, file); + const text = fs.readFileSync(full, 'utf8'); + + if (!TOP_LEVEL_PERMISSIONS.test(text)) { + problems.push( + `${file}: no top-level \`permissions:\` block, so every job inherits the repository ` + + 'default token scope, which may be read-write' + ); + } + + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i += 1) { + const match = USES_LINE.exec(lines[i]); + if (!match) continue; + + const where = `${file}:${i + 1}`; + const ref = refValue(match[1]); + + if (ref.startsWith('./') || ref.startsWith('.\\')) { + // A path into this repository is already as pinned as the commit under review. + exempt.push(`${where} ${ref} (local to this repository)`); + continue; + } + + if (ref.startsWith('docker://')) { + if (DOCKER_DIGEST.test(ref)) { + pinned.push(`${where} ${ref}`); + } else { + problems.push(`${where}: container ref \`${ref}\` is not pinned to an image digest`); + } + continue; + } + + const reason = ALLOWLIST[ownerRepo(ref)]; + if (reason) { + exempt.push(`${where} ${ref} (allowlisted: ${reason})`); + continue; + } + + if (!SHA_REF.test(ref)) { + problems.push( + `${where}: \`${ref}\` is not pinned to a 40-character commit SHA. Resolve one with ` + + `\`gh api repos/${ownerRepo(ref)}/commits/ --jq .sha\`` + ); + continue; + } + + if (!VERSION_COMMENT.test(match[1])) { + problems.push( + `${where}: \`${ref}\` is pinned but carries no trailing version comment. Without ` + + '`# vX.Y.Z` a reader cannot tell what the SHA is, and Dependabot has no version ' + + 'to bump from' + ); + continue; + } + + pinned.push(`${where} ${ref} ${VERSION_COMMENT.exec(match[1])[0]}`); + } +} + +for (const line of pinned) console.log(`pinned ${line}`); +for (const line of exempt) console.log(`exempt ${line}`); + +if (problems.length > 0) { + console.error(''); + console.error(`${problems.length} workflow pinning problem(s):`); + for (const problem of problems) console.error(` ${problem}`); + process.exit(1); +} + +console.log(''); +console.log( + `${files.length} workflow file(s), ${pinned.length} pinned action reference(s), ` + + `${exempt.length} exempt, every file declares a top-level permissions block.` +); diff --git a/scripts/check.sh b/scripts/check.sh index 33103d6..e682094 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail # -# check.sh — run the same checks as CI, locally, before you push. +# check.sh: run the same checks as CI, locally, before you push. # ./scripts/check.sh # HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -17,7 +17,18 @@ echo ">> shellcheck on provisioning scripts..." if command -v shellcheck >/dev/null; then shellcheck provisioning/*.sh || FAIL=1 else - echo " !! shellcheck not installed — SKIPPED (install: sudo apt install -y shellcheck)" + echo " !! shellcheck not installed, SKIPPED (install: sudo apt install -y shellcheck)" +fi + +# The CI job of the same name reads every `uses:` in .github/workflows. A tag pin there lets +# whoever owns that action choose what runs in our jobs holding our token, so the pins are +# checked rather than remembered. Run it here too, because a pin that decays back to a tag +# should fail before the push rather than after it. +echo ">> Checking that workflow actions are pinned to commit SHAs..." +if command -v node >/dev/null; then + node scripts/check-workflow-pins.js || FAIL=1 +else + echo " !! node not installed, SKIPPED (install: sudo apt install -y nodejs)" fi echo ">> Checking relative markdown links (labs/*.md, README.md, hardware/README.md)..." From 0a3ba1ec75a91bfa8f9409a65e5bceae310d94ea Mon Sep 17 00:00:00 2001 From: reesebuilt <126643625+reesepj@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:54:05 -0500 Subject: [PATCH 2/2] Mark unused loop counters so shellcheck passes CI's Shellcheck step has failed on every run since 2026-07-03. Both hits are SC2034 on `for i in $(seq 1 N)` retry loops where the counter is genuinely unused, which is idiomatic; the underscore is shellcheck's own convention for a variable that exists only to make the loop repeat. A lint gate that is permanently red teaches everyone to ignore it, which costs more than the warnings it reports. Signed-off-by: reesebuilt <126643625+reesepj@users.noreply.github.com> --- provisioning/install-tools.sh | 4 ++-- provisioning/lab-connect.sh | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/provisioning/install-tools.sh b/provisioning/install-tools.sh index b0d39fa..8474940 100755 --- a/provisioning/install-tools.sh +++ b/provisioning/install-tools.sh @@ -10,7 +10,7 @@ PKGS="sudo vim tmux htop git curl wget less lsof strace mdadm lvm2 parted gdisk echo ">> Waiting for VM IP..." IP="" -for i in $(seq 1 45); do +for _ in $(seq 1 45); do IP=$(sudo virsh domifaddr "$VM" --source lease 2>/dev/null | awk '/ipv4/{print $4}' | cut -d/ -f1 | head -1) [ -n "$IP" ] && break sleep 3 @@ -18,7 +18,7 @@ done [ -z "$IP" ] && { echo "!! No IP yet. Wait a moment and re-run."; exit 1; } echo ">> VM IP: $IP — waiting for SSH..." -for i in $(seq 1 40); do timeout 2 bash -c "echo > /dev/tcp/$IP/22" 2>/dev/null && break; sleep 3; done +for _ in $(seq 1 40); do timeout 2 bash -c "echo > /dev/tcp/$IP/22" 2>/dev/null && break; sleep 3; done SSHOPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10) if command -v sshpass >/dev/null; then diff --git a/provisioning/lab-connect.sh b/provisioning/lab-connect.sh index 74aa5b2..618d6c9 100755 --- a/provisioning/lab-connect.sh +++ b/provisioning/lab-connect.sh @@ -22,7 +22,7 @@ sudo virsh start "$VM" 2>/dev/null || true echo ">> Locating $VM ..." IP="" -for i in $(seq 1 30); do +for _ in $(seq 1 30); do IP=$(sudo virsh domifaddr "$VM" --source lease 2>/dev/null | awk '/ipv4/{print $4}' | cut -d/ -f1 | head -1) [ -n "$IP" ] && break sleep 2