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
137 changes: 137 additions & 0 deletions .github/pin-github-actions-shas.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env node

const { execFileSync } = require('node:child_process');
const fs = require('node:fs');

const pinningMessage = `ci: pin GitHub Actions to satisfy downstream policy

Microsoft Git inherits workflows that upstream maintains using symbolic
Action refs. Downstream organizational policy requires full commit IDs
instead, adding pin maintenance to this project's maintainers' workload.

Keep pins aligned with the original upstream Action refs in a fresh
downstream snapshot, independent of any earlier pinning commit.
`;

function git(...args) {
return execFileSync('git', ['--no-pager', ...args], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 16 * 1024 * 1024
}).trim();
}

const isSHA = value => /^[0-9a-f]{40}$/i.test(value);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't wait for SHA-256 🫣

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, SHA-256 should make the pinning more secure, anyway: A dedicated attacker with $10k to spare could feasibly create a SHA-1 collision for a slow-moving Action, which means that SHA-1 pinning is most likely providing more of a false than of a true sense of security.

const uses = /^( *-? *uses: *)(["']?)([\w./-]+)@([^ \t"'\r\n]+)\2([^\r\n]*)/gm;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Local actions get skipped since they don't have an @ (e.g., ./myaction, which I guess is OK here since we only care about unpinned actions, but a local action is already pinned to the current workflow file/repo ref.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Precisely, they are implicitly pinned to the current revision (i.e. safer than any pinned SHA-1).

const useLine = new RegExp(uses.source);

function findOriginalActionRef(file, actionName, workflowHistory) {
for (const patch of workflowHistory.split('\0')) {
const removed = patch.replace(/^-/gm, '');
const versions = [...removed.matchAll(uses)]
.filter(use => use[3] === actionName &&
!isSHA(use[4]))
.map(use => use[4]);
if (!versions.length)
continue;
const [version] = versions;
if (versions.some(candidate => candidate !== version))
throw new Error('Ambiguous Action ref: ' +
`${file}, ${actionName}`);
return version;
}
throw new Error(`Cannot recover Action ref: ${file}, ${actionName}`);
}

function readWorkflow(file) {
const text = fs.readFileSync(file, 'utf8');
const parts = text.split(/(^ *-? *uses:[^\r\n]*)/m);
const matches = parts.map(part => part.match(useLine))
.filter(use => use && !use[3].startsWith('./'));
const workflowHistory = matches.some(use => isSHA(use[4]))
? git('log', '--follow', '-p', '-U0',
'-Guses:.*@', '--no-ext-diff', '--no-textconv',
'--format=%x00', '--', file)
: '';
const actionRefs = matches.map(use => {
const actionName = use[3];
const version = isSHA(use[4]) ?
findOriginalActionRef(
file, actionName, workflowHistory) :
use[4];
const actionRepo = actionName.split('/').slice(0, 2).join('/');
return {
use, actionName, version,
pinKey: `${actionRepo}@${version}`
};
});
return { file, text, parts, actionRefs };
}

function resolveActionSHA(actionName, version) {
const actionRepo = actionName.split('/').slice(0, 2).join('/');
const actionTagRef = `refs/tags/${version}`;
const output = git('ls-remote', '--quiet',
`https://github.com/${actionRepo}`,
actionTagRef, `${actionTagRef}^{}`, `refs/heads/${version}`);
const actionRepoRefs = Object.fromEntries(output.split('\n')
.map(line => line.split('\t').reverse()));
const sha = actionRepoRefs[`${actionTagRef}^{}`] ||
actionRepoRefs[actionTagRef] ||
actionRepoRefs[`refs/heads/${version}`];
if (!sha || !isSHA(sha))
throw new Error('Cannot resolve Action ref: ' +
`${actionName}@${version}`);
return sha;
}

function substitute({ file, text, parts, actionRefs }, pins) {
const replacements = new Map(actionRefs.map(actionRef => {
const { use, version, pinKey } = actionRef;
const [, prefix, quote, actionName, , suffix] = use;
const sha = pins[pinKey];
let note = suffix.trim().replace(/^#\s*/, '');
if (note.startsWith(`${version}; `))
note = note.slice(version.length + 2);
else if (note === version || /^v?\d[\w.+-]*$/.test(note))
note = '';
const pinned = `${prefix}${quote}${actionName}@${sha}${quote}` +
` # ${version}` + (note ? `; ${note}` : '');
return [use[0], pinned];
}));
const updated = parts.map(part => replacements.get(part) ?? part)
.join('');
return updated === text ? null : [file, updated];
}

try {
process.chdir(git('rev-parse', '--show-toplevel'));
const files = git('ls-files', '-z', '--', '.github/workflows')
.split('\0').filter(file => /\.ya?ml$/.test(file));
if (git('status', '--porcelain', '--untracked-files=no',
'--', '.github/workflows'))
throw new Error('Workflow files have uncommitted changes');
const workflows = files.map(readWorkflow);
const actionRefs = workflows.flatMap(workflow => workflow.actionRefs);
const uniquePins = new Map(actionRefs.map(actionRef =>
[actionRef.pinKey, actionRef]));
const pins = Object.fromEntries([...uniquePins.values()].map(
({ pinKey, actionName, version }) =>
[pinKey, resolveActionSHA(actionName, version)]));
const changed = workflows.map(workflow => substitute(workflow, pins))
.filter(Boolean);
if (changed.length) {
const message = git('rev-parse', '--git-path',
'COMMIT_EDITMSG');
fs.writeFileSync(message, pinningMessage);
for (const [file, text] of changed)
fs.writeFileSync(file, text);
console.log(git('commit', '--signoff', '-F', message,
'--', ...changed.map(([file]) => file)));
} else {
console.log('Action pins are up to date.');
}
} catch (error) {
console.error(`error: ${error.message}`);
process.exitCode = 1;
}
88 changes: 88 additions & 0 deletions .github/pin-github-actions-shas.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
const { pathToFileURL } = require('node:url');

test('fresh pinning commits preserve unrelated work', t => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'pin test-'));
t.after(() => fs.rmSync(directory, { recursive: true }));
const env = Object.fromEntries(Object.entries(process.env)
.filter(([key]) => !/^GIT_/i.test(key)));
env.GIT_CONFIG_NOSYSTEM = '1';
env.GIT_CONFIG_GLOBAL = os.devNull;
const git = (...args) => execFileSync('git', args, {
cwd: directory, env, encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
}).trim();
git('init', '-q', '--initial-branch=test', '--object-format=sha1');
git('config', 'user.name', 'Pin Test');
git('config', 'user.email', 'pins@example.com');
const workflows = path.join(directory, '.github/workflows');
fs.mkdirSync(workflows, { recursive: true });
const file = path.join(workflows, 'main.yml');
const write = text => fs.writeFileSync(file, text);
const read = () => fs.readFileSync(file, 'utf8');
const run = () => execFileSync(process.execPath,
[path.join(__dirname, 'pin-github-actions-shas.js')],
{ cwd: directory, env, stdio: ['ignore', 'pipe', 'pipe'] });
const workflow = `on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: 'example/action@v1'
- uses: example/action/subpath@v2
if: false
- uses: ./local-action
reusable:
uses: example/action/.github/workflows/test.yaml@master
`.replace(/\n/g, '\r\n');
write(workflow);
const another = path.join(workflows, 'another.yaml');
fs.writeFileSync(another, workflow.replace('@v1', '@v2'));
const unrelated = path.join(directory, 'unrelated');
fs.writeFileSync(unrelated, 'original\n');
git('add', '--', '.github/workflows', 'unrelated');
git('commit', '-qm', 'Upstream workflows');
const first = git('rev-parse', 'HEAD');
git('tag', 'v1');
git('tag', '-am', 'Annotated version', 'v2');
git('commit', '--allow-empty', '-qm', 'Update action');
const second = git('rev-parse', 'HEAD');
const pinned = v1 => workflow
.replace(/@v1'/, `@${v1}' # v1`)
.replace('@v2', `@${first} # v2`)
.replace('@master', `@${second} # master`);
git('branch', 'master');
git('branch', 'v1');
git('config', `url.${pathToFileURL(directory).href}.insteadOf`,
'https://github.com/example/action');
fs.writeFileSync(unrelated, 'staged change\n');
git('add', '--', 'unrelated');
const expectedSubject =
'ci: pin GitHub Actions to satisfy downstream policy';
run();
assert.equal(git('log', '-1', '--format=%s'), expectedSubject);
assert.equal(git('rev-parse', 'HEAD^'), second);
assert.equal(read(), pinned(first));
assert.match(git('log', '-1', '--format=%B'),
/Signed-off-by: Pin Test <pins@example.com>/);
write(read().replace(/# v[12]/g, '# v0.0.0'));
git('commit', '-qm', 'Release labels', '--', '.github/workflows');
git('tag', '-f', 'v1', second);
const beforeRefresh = git('rev-parse', 'HEAD');
run();
assert.equal(git('log', '-1', '--format=%s'), expectedSubject);
assert.equal(git('rev-parse', 'HEAD^'), beforeRefresh);
assert.equal(read(), pinned(second));
assert.equal(fs.readFileSync(another, 'utf8'),
pinned(first).replace('# v1', '# v2'));
const head = git('rev-parse', 'HEAD');
run();
assert.equal(git('rev-parse', 'HEAD'), head);
assert.equal(git('diff', '--cached', '--name-only'), 'unrelated');
assert.equal(git('show', 'HEAD:unrelated'), 'original');
});
Loading
Loading