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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,11 +31,32 @@ 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
--no-progress
--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
4 changes: 2 additions & 2 deletions provisioning/install-tools.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ 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
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
Expand Down
2 changes: 1 addition & 1 deletion provisioning/lab-connect.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
149 changes: 149 additions & 0 deletions scripts/check-workflow-pins.js
Original file line number Diff line number Diff line change
@@ -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/<tag> --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.`
);
15 changes: 13 additions & 2 deletions scripts/check.sh
Original file line number Diff line number Diff line change
@@ -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)"
Expand All @@ -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)..."
Expand Down
Loading