Repository navigation
Pin GitHub Actions to full-length commit SHAs #998
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
59bec5c
1b06344
38d1f10
372cda2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| const uses = /^( *-? *uses: *)(["']?)([\w./-]+)@([^ \t"'\r\n]+)\2([^\r\n]*)/gm; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Local actions get skipped since they don't have an
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
| 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'); | ||
| }); |
There was a problem hiding this comment.
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 🫣
There was a problem hiding this comment.
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.