Skip to content
Open
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
21 changes: 20 additions & 1 deletion .github/actions/verify-sha-pinning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ steps:
## PR Comments

On pull requests, the action posts a sticky comment (updated in place):
- **Failure**: Warning table listing every unpinned reference and drift mismatch, with forensic details
- **Failure**: Warning table showing up to 20 unresolved or invalid references, with shortened details and a link to the complete action log
- **Resolved**: Updated to show all references covered (SHA-pinned) and all tag-pins verified

No comment is posted on clean PRs that have never had a violation.
Expand All @@ -104,8 +104,27 @@ uses: owner/repo[/sub]@<tag-or-branch> → unpinned — the coverage ga

Handles sub-actions and reusable workflow calls (`owner/repo/sub@sha # tag`), tag comments with or without a leading `v` (e.g. `# 0.1.1`), and both annotated and lightweight git tags.

## Rate limits and diagnostic size

When GitHub returns HTTP 403 with `x-ratelimit-remaining: 0`, the verifier stops
new tag lookups for that run. Previously verified tags remain cached; unresolved
references fail verification. A later run can retry after the quota resets.

The verification step writes complete results to a temporary JSON file. The
comment step reads that file instead of passing the payload through an environment
variable, which can exceed the runner's process-launch limit during widespread
failures. PR comments bound both row count and field length.

## Local testing

Run the actual action scripts against mocked GitHub responses without network access:

```bash
node --test .github/actions/verify-sha-pinning/offline.test.mjs
```

The existing live API smoke test is also available:

```bash
GITHUB_TOKEN=$(gh auth token) node .github/actions/verify-sha-pinning/test.mjs
```
36 changes: 28 additions & 8 deletions .github/actions/verify-sha-pinning/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ runs:

const workflowDir = process.env.WORKFLOW_DIR;
const tagShaCache = new Map();
let rateLimitError;

// Keep potentially large diagnostics out of process environment variables.
function writeResults(results) {
const dir = fs.mkdtempSync(path.join(process.env.RUNNER_TEMP, 'sha-pin-verification-'));
const file = path.join(dir, 'results.json');
fs.writeFileSync(file, JSON.stringify(results));
core.setOutput('results_file', file);
}
const nonRetryableStatuses = new Set([400, 401, 403, 404, 422]);

// A non-empty string, trimmed.
Expand Down Expand Up @@ -142,7 +151,7 @@ runs:
core.setOutput('unpinned_count', 0);
core.setOutput('allowlisted_count', 0);
core.setOutput('status', 'pass');
core.setOutput('results_json', '[]');
writeResults([]);
return;
}

Expand Down Expand Up @@ -194,7 +203,7 @@ runs:
core.setOutput('unpinned_count', 0);
core.setOutput('allowlisted_count', 0);
core.setOutput('status', 'pass');
core.setOutput('results_json', '[]');
writeResults([]);
return;
}

Expand All @@ -206,6 +215,9 @@ runs:
if (tagShaCache.has(cacheKey)) {
return tagShaCache.get(cacheKey);
}
// An exhausted installation token cannot resolve any further tags.
// Cached successes remain usable; unresolved references still fail.
if (rateLimitError) throw rateLimitError;

try {
const ref = await withRetries(
Expand Down Expand Up @@ -258,6 +270,7 @@ runs:
// error's message text.
const rateLimitRemaining = err.response?.headers?.['x-ratelimit-remaining'];
if (rateLimitRemaining === '0') {
rateLimitError = err;
throw err;
}
throw new AccessBlockedError(
Expand Down Expand Up @@ -416,7 +429,7 @@ runs:
core.setOutput('unpinned_count', unpinned);
core.setOutput('allowlisted_count', allowlisted);
core.setOutput('status', (failed > 0 || unpinned > 0) ? 'fail' : 'pass');
core.setOutput('results_json', JSON.stringify(results));
writeResults(results);

if (failed > 0 || unpinned > 0) {
core.setFailed(
Expand All @@ -427,10 +440,10 @@ runs:
}

- name: Post or update PR comment
if: always() && github.event_name == 'pull_request'
if: always() && github.event_name == 'pull_request' && steps.verify.outputs.results_file != ''
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
RESULTS_JSON: ${{ steps.verify.outputs.results_json }}
RESULTS_FILE: ${{ steps.verify.outputs.results_file }}
VERIFIED_COUNT: ${{ steps.verify.outputs.verified_count }}
FAILED_COUNT: ${{ steps.verify.outputs.failed_count }}
UNPINNED_COUNT: ${{ steps.verify.outputs.unpinned_count }}
Expand Down Expand Up @@ -467,7 +480,7 @@ runs:
return;
}

const results = JSON.parse(process.env.RESULTS_JSON || '[]');
const results = JSON.parse(require('fs').readFileSync(process.env.RESULTS_FILE, 'utf8'));
const verified = parseInt(process.env.VERIFIED_COUNT || '0', 10);
const failed = parseInt(process.env.FAILED_COUNT || '0', 10);
const unpinned = parseInt(process.env.UNPINNED_COUNT || '0', 10);
Expand All @@ -490,7 +503,13 @@ runs:
// Shared row renderer — used by both the failure table and the
// allowlisted-only warning table below, so the two stay in sync.
function renderRows(rs) {
return rs.map(r => {
const visible = rs.slice(0, 20).map(r => ({
...r,
action: r.action.slice(0, 200),
file: r.file.slice(0, 200),
detail: (r.detail || '').replaceAll('|', '&#124;').replaceAll('\n', ' ').slice(0, 300),
}));
const rows = visible.map(r => {
const loc = `\`${r.file}:${r.line}\``;
if (r.status === 'verified') {
return `| \`${r.action}\` | ${loc} | ✅ Verified | |`;
Expand All @@ -506,6 +525,7 @@ runs:
}
return `| \`${r.action}\` | ${loc} | ❌ ${r.status === 'error' ? 'Error' : 'Mismatch'} | ${r.detail || ''} |`;
}).join('\n');
return rows + (rs.length > visible.length ? `\n\nShowing ${visible.length} of ${rs.length} references; see the action run for all results.` : '');
}

const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
Expand All @@ -520,7 +540,7 @@ runs:

| Action | Location | Status | Details |
|--------|----------|--------|---------|
${renderRows(results)}
${renderRows(results.filter(r => ['error', 'mismatch', 'unpinned'].includes(r.status)))}

See the [action run](${runUrl}) for full details.`;
} else if (total === 0) {
Expand Down
109 changes: 109 additions & 0 deletions .github/actions/verify-sha-pinning/offline.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
import { test } from 'node:test';

const require = createRequire(import.meta.url);
const action = fs.readFileSync(new URL('./action.yml', import.meta.url), 'utf8');
const scripts = action.split(' script: |\n').slice(1).map(section =>
section.split('\n - name:')[0].split('\n').map(line => line.replace(/^ /, '')).join('\n'));
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;

function fixture(t, lines, allowlist = []) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sha-pin-test-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const workflows = path.join(root, 'workflows');
fs.mkdirSync(workflows);
fs.writeFileSync(path.join(workflows, 'test.yml'), lines.join('\n'));
const allowlistFile = path.join(root, 'allowlist.json');
fs.writeFileSync(allowlistFile, JSON.stringify(allowlist));
const outputs = {};
const failures = [];
const core = {
info() {}, warning() {}, error() {},
setOutput: (key, value) => { outputs[key] = value; },
setFailed: message => failures.push(message),
};
const env = { RUNNER_TEMP: root, WORKFLOW_DIR: workflows, ALLOWLIST_FILE: allowlistFile };
return { env, outputs, failures, core };
}

async function verify(state, getRef) {
await new AsyncFunction('require', 'process', 'github', 'core', scripts[0])(
require, { env: state.env }, { rest: { git: { getRef } } }, state.core);
return JSON.parse(fs.readFileSync(state.outputs.results_file, 'utf8'));
}

const sha = 'a'.repeat(40);
const ref = repo => ` - uses: owner/${repo}@${sha} # v1.0.0`;
const quotaError = () => Object.assign(new Error('API rate limit exceeded: ' + 'x'.repeat(1000)), {
status: 403, response: { headers: { 'x-ratelimit-remaining': '0' } },
});

test('quota exhaustion stops lookups, fails closed, and writes large results to a file', async t => {
const state = fixture(t, Array.from({ length: 300 }, (_, i) => ref(`repo-${i}`)), [{
action: 'owner/repo-0', description: 'Reviewed access restriction', references: ['https://example.com/review'],
}]);
let requests = 0;
const results = await verify(state, async () => { requests++; throw quotaError(); });
assert.equal(requests, 1);
assert.equal(state.outputs.failed_count, 300);
assert.equal(state.outputs.allowlisted_count, 0);
assert.equal(state.outputs.status, 'fail');
assert.equal(state.failures.length, 1);
assert.equal(results.length, 300);
assert.ok(fs.statSync(state.outputs.results_file).size > 131072);
assert.equal(state.outputs.results_json, undefined);

let comment;
const github = { rest: { issues: {
listComments: async () => ({ data: [] }),
createComment: async args => { comment = args.body; },
} } };
const env = {
RESULTS_FILE: state.outputs.results_file, VERIFIED_COUNT: '0', FAILED_COUNT: '300',
UNPINNED_COUNT: '0', STATUS: 'fail',
};
await new AsyncFunction('require', 'process', 'github', 'core', 'context', scripts[1])(
require, { env }, github, state.core,
{ issue: { number: 1 }, repo: { owner: 'owner', repo: 'repo' }, runId: 42 });
assert.ok(comment.includes('Showing 20 of 300 references'));
assert.ok(comment.includes('SHA Pin Verification Failed'));
assert.ok(Buffer.byteLength(comment) < 65536);
});

test('cached successes remain verified after another lookup exhausts the quota', async t => {
const state = fixture(t, [ref('cached'), ref('limited'), ref('cached'), ref('unresolved')]);
let requests = 0;
const results = await verify(state, async ({ repo }) => {
requests++;
if (repo === 'limited') throw quotaError();
return { data: { object: { type: 'commit', sha } } };
});
assert.equal(requests, 2);
assert.equal(state.outputs.verified_count, 2);
assert.equal(state.outputs.failed_count, 2);
assert.deepEqual(results.map(r => r.status), ['verified', 'error', 'verified', 'error']);
});

test('empty workflows write an empty result file', async t => {
const state = fixture(t, ['name: no actions']);
const results = await verify(state, async () => assert.fail('unexpected API request'));
assert.deepEqual(results, []);
assert.equal(state.outputs.status, 'pass');
});

test('ordinary lookup failures do not suppress verification of other repositories', async t => {
const state = fixture(t, [ref('missing'), ref('valid')]);
let requests = 0;
await verify(state, async ({ repo }) => {
requests++;
if (repo === 'missing') throw Object.assign(new Error('Not Found'), { status: 404 });
return { data: { object: { type: 'commit', sha } } };
});
assert.equal(requests, 2);
assert.equal(state.outputs.verified_count, 1);
assert.equal(state.outputs.failed_count, 1);
});
46 changes: 16 additions & 30 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2019,10 +2019,12 @@ jobs:
egress-policy: block
allowed-endpoints: >
api.github.com:443
get.helm.sh:443
github.com:443
raw.githubusercontent.com:443
rekor.sigstore.dev:443
release-assets.githubusercontent.com:443
releases.hashicorp.com:443
tuf-repo-cdn.sigstore.dev:443
tuf-repo.github.com:443
us.i.posthog.com:443
Expand All @@ -2037,22 +2039,13 @@ jobs:
with:
target: linux

# Restore the toolchain directory first so the installs below skip
# tools already on disk instead of downloading them on every run
# (restore-only: the build job's linux leg is this key's writer).
- name: Restore the Atmos toolchain cache
continue-on-error: true
uses: ./actions/cache
# The build job already installed and verified the pinned tools. The
# shared action skips cached binaries and retries installs on a miss.
- name: Set up the CI toolchain
uses: ./.github/actions/ci-toolchain
with:
mode: restore-only

- name: Install OpenTofu and TFLint with Atmos toolchain
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
atmos toolchain install opentofu/opentofu
atmos toolchain install terraform-linters/tflint
atmos toolchain env --format=github
cache: restore-only
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Verify OpenTofu and TFLint
run: |
Expand All @@ -2077,10 +2070,12 @@ jobs:
egress-policy: block
allowed-endpoints: >
api.github.com:443
get.helm.sh:443
github.com:443
raw.githubusercontent.com:443
rekor.sigstore.dev:443
release-assets.githubusercontent.com:443
releases.hashicorp.com:443
tuf-repo-cdn.sigstore.dev:443
tuf-repo.github.com:443
us.i.posthog.com:443
Expand All @@ -2095,22 +2090,13 @@ jobs:
with:
target: linux

# Restore the toolchain directory first so the installs below skip
# tools already on disk instead of downloading them on every run
# (restore-only: the build job's linux leg is this key's writer).
- name: Restore the Atmos toolchain cache
continue-on-error: true
uses: ./actions/cache
# The build job already installed and verified the pinned tools. The
# shared action skips cached binaries and retries installs on a miss.
- name: Set up the CI toolchain
uses: ./.github/actions/ci-toolchain
with:
mode: restore-only

- name: Install OpenTofu and TFLint with Atmos toolchain
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
atmos toolchain install opentofu/opentofu
atmos toolchain install terraform-linters/tflint
atmos toolchain env --format=github
cache: restore-only
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Run the TFLint hook example
working-directory: examples/hooks-tflint
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/verify-sha-pinning.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ jobs:
with:
persist-credentials: false

- name: Test verifier error handling
run: node --test .github/actions/verify-sha-pinning/offline.test.mjs

# Verifier code and allowlist.json are read from the PR's own head, same
# as gitleaks/git-secrets read their allowlist config from the diff
# being scanned — protection against a self-serving allowlist entry (or
Expand Down
2 changes: 1 addition & 1 deletion cmd/standalone_script.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,5 +154,5 @@ func readStandaloneSource(file *script.File, input io.Reader) ([]byte, error) {
return io.ReadAll(input)
}
// Standalone scripts intentionally accept user-selected file paths, including outside cwd.
return os.ReadFile(file.Path) //nolint:gosec // The caller explicitly selected this source file.
return os.ReadFile(file.Path) // The caller explicitly selected this source file.
}
Loading
Loading