fix(ci): close remaining workflow policy bypasses

Refs: PLAT-62
This commit is contained in:
Adam Moussa 2026-08-03 20:14:01 -04:00
parent 0bc443e766
commit 514464637e
No known key found for this signature in database
3 changed files with 645 additions and 23 deletions

View file

@ -194,6 +194,24 @@ jobs:
/^workflow-templates\/[^/]+\.ya?ml$/.test(filename);
}
// Matches action manifests at any depth (e.g. .github/actions/**/action.yml).
function isActionManifestFilename(filename) {
return /(?:^|\/)action\.ya?ml$/.test(filename);
}
// Combined predicate — any file that the supply-chain scanner must process.
function isPolicyFilename(filename) {
return isWorkflowFilename(filename) || isActionManifestFilename(filename);
}
// Returns 'workflow', 'action', or null for non-policy files.
// Used by classifyFileStatus to prevent cross-kind rename diffing.
function policyFileKind(filename) {
if (isWorkflowFilename(filename)) return 'workflow';
if (isActionManifestFilename(filename)) return 'action';
return null;
}
// FNV-1a 32-bit hash of a string — used to produce content fingerprints
// for line-specific violations so changing the payload changes the
// fingerprint while shifting the same payload to a different line does not.
@ -233,18 +251,23 @@ jobs:
// - YAML aliases/anchors on run:, uses:, or permissions: values
//
// All-zero SHAs and v0.0.0 placeholder pins are rejected.
function validateWorkflowContent(content, filename) {
// opts.requirePermissions (default true) — workflow files require a top-level
// permissions: key; action manifests do not support it and must pass false.
function validateWorkflowContent(content, filename, opts) {
const requirePermissions = !opts || opts.requirePermissions !== false;
const errs = [];
const EXPR_OPEN = '$' + '{{';
// 1. Top-level permissions key required at column 0 (may be quoted).
if (!/^(?:"permissions"|'permissions'|permissions):/m.test(content)) {
errs.push(filename + ': missing top-level "permissions:" key');
}
// 1. Top-level permissions key required (workflows only; not action manifests).
if (requirePermissions) {
if (!/^(?:"permissions"|'permissions'|permissions):/m.test(content)) {
errs.push(filename + ': missing top-level "permissions:" key');
}
// 1b. Top-level permissions alias check.
if (/^(?:"permissions"|'permissions'|permissions):[ \t]+\*/m.test(content)) {
errs.push(filename + ': YAML alias for top-level "permissions:" value is not supported — inline the permissions map');
// 1b. Top-level permissions alias check.
if (/^(?:"permissions"|'permissions'|permissions):[ \t]+\*/m.test(content)) {
errs.push(filename + ': YAML alias for top-level "permissions:" value is not supported — inline the permissions map');
}
}
// 2. Line-by-line scan.
@ -287,6 +310,16 @@ jobs:
continue;
}
// ── Structural key with whitespace before colon — fail closed ────
// YAML permits `key : value` but the scanner matches `key:` forms
// only; a space before the colon silently bypasses all checks.
// Reject any structural key (uses, run, steps — quoted or plain,
// sequence-item or mapping) that has whitespace before the colon.
if (/^[ \t]*(?:-[ \t]+)?(?:"(?:uses|run|steps)"|'(?:uses|run|steps)'|uses|run|steps)[ \t]+:/.test(line)) {
errs.push(filename + ':' + (i + 1) + ': structural key with whitespace before ":" is not supported — remove the space before the colon [fnv:' + fnv1a32(line.trim()) + ']');
continue;
}
// ── Sequence-item anchor declaration — fail closed ───────────────
// Any line of the form `- &anchor` (with or without a mapping on
// the same line) is rejected. A standalone `- &name` can be
@ -315,6 +348,20 @@ jobs:
continue;
}
// ── Flow-style steps array — fail closed ─────────────────────────
// `steps: [...]` and `steps: [` (multiline opener) cannot be
// safely resolved. Exception: `steps: []` is an empty array
// with no execution and is explicitly allowed.
// Quoted ("steps") and unquoted forms are both detected.
const stepsFlowM = line.match(/^[ \t]*(?:"steps"|'steps'|steps):[ \t]*\[(.*)$/);
if (stepsFlowM) {
const inner = stepsFlowM[1].trimStart();
if (!/^\]\s*(#.*)?$/.test(inner)) {
errs.push(filename + ':' + (i + 1) + ': flow-style "steps" array is not supported — use block-style steps list [fnv:' + fnv1a32(line.trim()) + ']');
}
continue;
}
// ── Any block scalar key detection (| or >) ──────────────────────
// Matches quoted ("key", 'key') and unquoted (key) key names,
// with optional sequence-item prefix (- ), followed by a block
@ -383,8 +430,14 @@ jobs:
ref = rawVal;
}
// Local refs (./) are exempt from SHA pinning.
if (ref.startsWith('./')) continue;
// Local action references cannot be validated — the scanner
// does not recursively resolve action manifests. Inline the
// action logic or replace with an immutable remote SHA pin.
if (ref.startsWith('./')) {
const short = ref.length > 80 ? ref.slice(0, 77) + '\u2026' : ref;
errs.push(filename + ': "uses: ' + short + '" local action reference is not supported — inline the action or use an immutable remote SHA pin');
continue;
}
// Docker refs require an immutable sha256 digest pin.
// Mutable tags, :latest, and bare image names are rejected.
@ -502,7 +555,9 @@ jobs:
if (s === 'added' || s === 'copied') return { action: 'full' };
if (s === 'modified' || s === 'changed') return { action: 'diff', basePath: file.filename };
if (s === 'renamed') {
if (file.previous_filename && isWfFn(file.previous_filename)) {
if (file.previous_filename &&
isWfFn(file.previous_filename) &&
policyFileKind(file.previous_filename) === policyFileKind(file.filename)) {
return { action: 'diff', basePath: file.previous_filename };
}
return { action: 'full' };
@ -521,6 +576,9 @@ jobs:
validateCommitSubject,
detectAiFooter,
isWorkflowFilename,
isActionManifestFilename,
isPolicyFilename,
policyFileKind,
validateWorkflowContent,
parseRetryAfterMs,
checkCommitLimit,
@ -746,9 +804,9 @@ jobs:
totalFilesFetched += filesResp.data.length;
if (!filesLink.includes('rel="next"') || filesResp.data.length === 0) filesMore = false;
for (const file of filesResp.data) {
if (!isWorkflowFilename(file.filename)) continue;
if (!isPolicyFilename(file.filename)) continue;
const cls = classifyFileStatus(file, isWorkflowFilename);
const cls = classifyFileStatus(file, isPolicyFilename);
if (cls.action === 'skip') continue;
if (cls.action === 'infra') {
addInfra('POLICY-INFRA: ' + cls.reason + '; skipping workflow validation');
@ -767,13 +825,15 @@ jobs:
continue;
}
const headErrs = validateWorkflowContent(headContent, file.filename);
// Action manifests do not support top-level permissions:.
const wfOpts = policyFileKind(file.filename) === 'action' ? { requirePermissions: false } : {};
const headErrs = validateWorkflowContent(headContent, file.filename, wfOpts);
if (cls.action === 'full') {
// No baseline — added, copied, or renamed-from-non-workflow.
// No baseline — added, copied, or renamed-from-non-policy path.
for (const e of headErrs) addViolation(e);
} else {
// diff — modified, changed, or renamed-from-workflow.
// diff — modified, changed, or renamed-from-policy path.
// Both head and base violations use file.filename so fingerprints match.
let baseErrs = [];
try {
@ -781,7 +841,7 @@ jobs:
const baseRaw = baseResp.data;
const baseEnc = baseRaw.encoding === 'base64' ? 'base64' : 'utf8';
const baseContent = Buffer.from(baseRaw.content, baseEnc).toString('utf8');
baseErrs = validateWorkflowContent(baseContent, file.filename);
baseErrs = validateWorkflowContent(baseContent, file.filename, wfOpts);
} catch (baseErr) {
addInfra('POLICY-INFRA: Cannot fetch base content for ' + file.filename + ' at ' + pr.base.sha + ': ' + baseErr.message);
continue;

View file

@ -54,7 +54,7 @@ The two sanctioned deploy paths are merge to `main` triggering the pipeline and
The supply-chain check operates in **diff mode**: for modified or renamed workflow files, the gate fetches the base-branch version at `pr.base.sha` and reports only violations whose normalized fingerprint is absent from the base. Added files must be fully compliant. Historical drift already present in the base branch is handled by the drift audit/remediation backlog, not by this gate. A failure to fetch the base version is a `POLICY-INFRA` error and the file is not silently grandfathered.
> **Workflow file constraints enforced by the supply-chain scanner.** Changed workflow files scanned by this policy must use block-style structural keys and inline `run:`/`uses:` values. The scanner fails closed on YAML forms it cannot safely resolve: flow-style step mappings (`- { uses: ... }`, `- { run: ... }`), sequence-item anchor declarations (`- &anchor { uses: ... }` and the multiline form `- &anchor` followed by a flow mapping on the next line), escaped or Unicode-encoded structural keys in double-quoted strings (`"u\u0073es"`, `"r\u0075n"`), and YAML aliases or anchors on `run:`, `uses:`, or `permissions:` values (`run: *cmd`, `uses: &anchor ...`). `docker://` action refs must carry an immutable sha256 digest pin (`docker://<image>@sha256:<64 lowercase hex>`); mutable tags and bare image names are rejected. Use the literal unquoted key forms and inline values in all workflow steps.
> **Supply-chain scanner.** Changed workflow files and action manifests (`action.yml` / `action.yaml` at any path) are scanned. Workflow files must use block-style structural keys and inline `run:`/`uses:` values. Action manifests follow the same constraints except that top-level `permissions:` is not required (action manifests do not support it). The scanner fails closed on YAML forms it cannot safely resolve: flow-style step mappings (`- { uses: ... }`, `- { run: ... }`), flow-style `steps` arrays (`steps: [...]` with any content — `steps: []` is allowed), sequence-item anchor declarations (`- &anchor { uses: ... }` and the multiline form `- &anchor`), escaped or Unicode-encoded structural keys in double-quoted strings (`"u\u0073es"`, `"r\u0075n"`), YAML aliases or anchors on `run:`, `uses:`, or `permissions:` values (`run: *cmd`, `uses: &anchor ...`), and local action references (`uses: ./...` — the scanner cannot recursively validate action manifests; inline the logic or replace with an immutable remote SHA pin). `docker://` action refs must carry an immutable sha256 digest pin (`docker://<image>@sha256:<64 lowercase hex>`); mutable tags and bare image names are rejected. Use literal unquoted key forms and inline values in all workflow steps.
**`.github/workflows/callable-labeler.yaml`** — Org-wide PR auto-labeler. Label rules live inline here (single source of truth) — consumer repos need only a thin caller with `contents: read`, `pull-requests: write`, and `issues: write`; no per-repo labeler.yml.

View file

@ -510,9 +510,10 @@ describe('validateWorkflowContent', () => {
assert.deepEqual(v.validateWorkflowContent(content, BASE), []);
});
it('accepts local ./ ref without SHA requirement', () => {
it('rejects local ./ ref (unsupported until recursive action-manifest validation)', () => {
const content = wf('uses: ./.github/workflows/sub.yaml');
assert.deepEqual(v.validateWorkflowContent(content, BASE), []);
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('local action') || e.includes('not supported')), 'local ref must fail: ' + errs.join('; '));
});
it('accepts docker:// ref with valid sha256 digest', () => {
@ -582,7 +583,7 @@ describe('validateWorkflowContent', () => {
' job:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: ./.github/workflows/sub.yaml',
' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1',
].join('\n');
assert.deepEqual(v.validateWorkflowContent(content, BASE), []);
});
@ -1156,7 +1157,7 @@ describe('validateWorkflowContent — quoted keys and block-scalar tracking', ()
' job:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: ./.github/workflows/sub.yaml',
' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1',
].join('\n');
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'double-quoted "permissions": at col 0 should count');
});
@ -1306,7 +1307,7 @@ describe('validateWorkflowContent — scanner fail-closed cases', () => {
' job:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: ./.github/workflows/sub.yaml',
' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1',
].join('\n');
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], 'permissions: {} must not be rejected');
});
@ -1834,3 +1835,564 @@ describe('classifyFileStatus', () => {
assert.ok(infra.includes('POLICY-INFRA'), 'infra message must have prefix: ' + infra);
});
});
// ── validateWorkflowContent — local action refs ───────────────────────────────
describe('validateWorkflowContent — local action refs', () => {
const BASE = 'f.yaml';
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
function wf(uses) {
return [
'on:',
' workflow_call:',
'permissions:',
' contents: read',
'jobs:',
' j:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: ' + uses,
].join('\n');
}
it('new local ref is blocked', () => {
const errs = v.validateWorkflowContent(wf('./.github/actions/my-action'), BASE);
assert.ok(errs.some(e => e.includes('local action')), 'local ref must fail: ' + errs.join('; '));
});
it('local ref error includes the path for content fingerprinting', () => {
const errs = v.validateWorkflowContent(wf('./.github/actions/my-action'), BASE);
assert.ok(errs.some(e => e.includes('./.github/actions/my-action')), 'path must appear in error: ' + errs.join('; '));
});
it('changed local path fingerprints differently from original', () => {
const headErrs = v.validateWorkflowContent(wf('./.github/actions/new-action'), BASE);
const baseErrs = v.validateWorkflowContent(wf('./.github/actions/old-action'), BASE);
// Different paths → different fingerprints → changed path is new
assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed local path must be reported as new');
});
it('unchanged local ref is grandfathered by diff mode', () => {
const headErrs = v.validateWorkflowContent(wf('./.github/actions/my-action'), BASE);
const baseErrs = v.validateWorkflowContent(wf('./.github/actions/my-action'), BASE);
assert.deepEqual(v.filterNewViolations(headErrs, baseErrs), [], 'same local ref must be grandfathered');
});
it('remote pinned ref is still accepted', () => {
assert.deepEqual(v.validateWorkflowContent(wf(PIN), BASE), []);
});
});
// ── validateWorkflowContent — flow steps array ────────────────────────────────
describe('validateWorkflowContent — flow steps array', () => {
const BASE = 'f.yaml';
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
function wfSteps(stepsLine) {
return [
'permissions: {}',
'jobs:',
' j:',
' runs-on: ubuntu-latest',
' ' + stepsLine,
].join('\n');
}
it('rejects steps: [{ uses: unpinned }] flow array', () => {
const errs = v.validateWorkflowContent(wfSteps('steps: [{ uses: actions/checkout@v4 }]'), BASE);
assert.ok(errs.some(e => e.includes('flow-style')), 'inline uses flow steps must fail: ' + errs.join('; '));
});
it('rejects steps: [{ run: echo }] flow array with run', () => {
const errs = v.validateWorkflowContent(wfSteps('steps: [{ run: echo hi }]'), BASE);
assert.ok(errs.some(e => e.includes('flow-style')), 'inline run flow steps must fail: ' + errs.join('; '));
});
it('rejects "steps": [...] with double-quoted key', () => {
const errs = v.validateWorkflowContent(wfSteps('"steps": [{ uses: actions/checkout@v4 }]'), BASE);
assert.ok(errs.some(e => e.includes('flow-style')), 'double-quoted steps flow array must fail: ' + errs.join('; '));
});
it('rejects single-quoted \'steps\': [...] key', () => {
const errs = v.validateWorkflowContent(wfSteps("'steps': [{ uses: actions/checkout@v4 }]"), BASE);
assert.ok(errs.some(e => e.includes('flow-style')), 'single-quoted steps flow array must fail: ' + errs.join('; '));
});
it('rejects multiline flow opener steps: [', () => {
const content = [
'permissions: {}',
'jobs:',
' j:',
' runs-on: ubuntu-latest',
' steps: [',
' { uses: actions/checkout@v4 }',
' ]',
].join('\n');
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('flow-style')), 'multiline flow steps opener must fail: ' + errs.join('; '));
});
it('allows steps: [] (empty array, no execution)', () => {
const errs = v.validateWorkflowContent(wfSteps('steps: []'), BASE);
assert.ok(!errs.some(e => e.includes('flow-style') && e.includes('steps')), 'empty steps: [] must pass: ' + errs.join('; '));
});
it('allows steps: [] with trailing comment', () => {
const errs = v.validateWorkflowContent(wfSteps('steps: [] # placeholder'), BASE);
assert.ok(!errs.some(e => e.includes('flow-style') && e.includes('steps')), 'steps: [] with comment must pass: ' + errs.join('; '));
});
it('block-style steps list is not affected', () => {
const content = [
'permissions: {}',
'jobs:',
' j:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: ' + PIN,
].join('\n');
assert.deepEqual(v.validateWorkflowContent(content, BASE), []);
});
it('changed flow steps payload fingerprints differently (content hash)', () => {
// Two different flow steps lines produce different FNV hashes → different fingerprints
const headErrs = v.validateWorkflowContent(wfSteps('steps: [{ uses: actions/checkout@v4 }]'), BASE);
const baseErrs = v.validateWorkflowContent(wfSteps('steps: [{ uses: actions/setup-node@v4 }]'), BASE);
assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed flow payload must be new');
});
});
// ── isActionManifestFilename ──────────────────────────────────────────────────
describe('isActionManifestFilename', () => {
it('matches action.yml at repo root', () => {
assert.ok(v.isActionManifestFilename('action.yml'));
});
it('matches action.yaml at repo root', () => {
assert.ok(v.isActionManifestFilename('action.yaml'));
});
it('matches .github/actions/my-action/action.yml', () => {
assert.ok(v.isActionManifestFilename('.github/actions/my-action/action.yml'));
});
it('matches nested .github/actions/sub/deep/action.yaml', () => {
assert.ok(v.isActionManifestFilename('.github/actions/sub/deep/action.yaml'));
});
it('rejects workflow files', () => {
assert.ok(!v.isActionManifestFilename('.github/workflows/ci.yaml'));
});
it('rejects action-like filenames that are not action.yml/yaml', () => {
assert.ok(!v.isActionManifestFilename('.github/actions/my-action/entrypoint.js'));
assert.ok(!v.isActionManifestFilename('actions.yml'));
});
});
// ── isPolicyFilename ──────────────────────────────────────────────────────────
describe('isPolicyFilename', () => {
it('accepts workflow files', () => {
assert.ok(v.isPolicyFilename('.github/workflows/ci.yaml'));
assert.ok(v.isPolicyFilename('workflow-templates/pr-policy.yml'));
});
it('accepts action manifests', () => {
assert.ok(v.isPolicyFilename('action.yml'));
assert.ok(v.isPolicyFilename('.github/actions/setup/action.yaml'));
});
it('rejects unrelated files', () => {
assert.ok(!v.isPolicyFilename('src/index.js'));
assert.ok(!v.isPolicyFilename('.github/actions/setup/entrypoint.js'));
});
});
// ── validateWorkflowContent — action manifests ────────────────────────────────
describe('validateWorkflowContent — action manifests', () => {
const BASE = '.github/actions/my-action/action.yml';
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
function manifest(usesLine) {
return [
'name: My Action',
'description: Does something.',
'runs:',
' using: composite',
' steps:',
' - uses: ' + usesLine,
].join('\n');
}
it('does NOT require top-level permissions: in action manifests', () => {
const content = manifest(PIN);
assert.deepEqual(v.validateWorkflowContent(content, BASE, { requirePermissions: false }), []);
});
it('still requires permissions: in workflow files (default)', () => {
const content = [
'on: workflow_call',
'jobs:',
' j:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: ' + PIN,
].join('\n');
const errs = v.validateWorkflowContent(content, '.github/workflows/test.yaml');
assert.ok(errs.some(e => e.includes('permissions')), 'workflow must require permissions: ' + errs.join('; '));
});
it('rejects unpinned remote uses in action manifest', () => {
const content = manifest('actions/checkout@v4');
const errs = v.validateWorkflowContent(content, BASE, { requirePermissions: false });
assert.ok(errs.some(e => e.includes('40-char SHA') || e.includes('pinned')), 'unpinned must fail: ' + errs.join('; '));
});
it('accepts fully pinned remote uses in action manifest', () => {
const content = manifest(PIN);
assert.deepEqual(v.validateWorkflowContent(content, BASE, { requirePermissions: false }), []);
});
it('rejects unsafe run expression in action manifest', () => {
const content = [
'name: My Action',
'description: Does something.',
'runs:',
' using: composite',
' steps:',
' - run: echo ${{ github.event.pull_request.title }}',
].join('\n');
const errs = v.validateWorkflowContent(content, BASE, { requirePermissions: false });
assert.ok(errs.some(e => e.includes('expression')), 'run expression must fail: ' + errs.join('; '));
});
it('rejects local action ref in action manifest (unsupported)', () => {
const content = manifest('./.github/actions/nested');
const errs = v.validateWorkflowContent(content, BASE, { requirePermissions: false });
assert.ok(errs.some(e => e.includes('local action')), 'local ref in manifest must fail: ' + errs.join('; '));
});
it('diff mode: modified manifest adding unpinned ref is blocked', () => {
const headContent = manifest('actions/checkout@v4');
const baseContent = manifest(PIN);
const headErrs = v.validateWorkflowContent(headContent, BASE, { requirePermissions: false });
const baseErrs = v.validateWorkflowContent(baseContent, BASE, { requirePermissions: false });
assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'new unpinned ref must block');
});
it('diff mode: unchanged unpinned ref in manifest is grandfathered', () => {
const content = manifest('actions/checkout@v4');
const headErrs = v.validateWorkflowContent(content, BASE, { requirePermissions: false });
const baseErrs = v.validateWorkflowContent(content, BASE, { requirePermissions: false });
assert.deepEqual(v.filterNewViolations(headErrs, baseErrs), [], 'unchanged violation must be grandfathered');
});
});
// ── classifyFileStatus — action manifests ─────────────────────────────────────
describe('classifyFileStatus — action manifests', () => {
function isWf(f) { return v.isPolicyFilename(f); }
it('added action manifest → full', () => {
const result = v.classifyFileStatus({ filename: '.github/actions/setup/action.yml', status: 'added' }, isWf);
assert.equal(result.action, 'full');
});
it('copied action manifest → full', () => {
const result = v.classifyFileStatus({ filename: '.github/actions/new/action.yml', status: 'copied', previous_filename: '.github/actions/old/action.yml' }, isWf);
assert.equal(result.action, 'full');
});
it('modified action manifest → diff with same path', () => {
const result = v.classifyFileStatus({ filename: '.github/actions/setup/action.yml', status: 'modified' }, isWf);
assert.deepEqual(result, { action: 'diff', basePath: '.github/actions/setup/action.yml' });
});
it('renamed from action manifest path → diff with previous_filename', () => {
const result = v.classifyFileStatus({
filename: '.github/actions/new-name/action.yml',
status: 'renamed',
previous_filename: '.github/actions/old-name/action.yml',
}, isWf);
assert.deepEqual(result, { action: 'diff', basePath: '.github/actions/old-name/action.yml' });
});
it('renamed from non-policy path → full (treat as added)', () => {
const result = v.classifyFileStatus({
filename: '.github/actions/setup/action.yml',
status: 'renamed',
previous_filename: 'docs/action-template.yml',
}, isWf);
assert.equal(result.action, 'full');
});
});
// ── Exact bypass scenario ─────────────────────────────────────────────────────
// A modified composite action.yml that adds an unpinned uses: must block even
// when the referencing workflow file and its local uses: ./... line are unchanged.
describe('bypass scenario: modified action manifest adds unpinned ref', () => {
const ACTION_FILE = '.github/actions/setup/action.yml';
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
const baseManifest = [
'name: Setup',
'description: Sets up the environment.',
'runs:',
' using: composite',
' steps:',
' - uses: ' + PIN,
].join('\n');
const headManifest = [
'name: Setup',
'description: Sets up the environment.',
'runs:',
' using: composite',
' steps:',
' - uses: ' + PIN,
' - uses: actions/setup-node@v4',
].join('\n');
it('base manifest (pinned only) has no violations', () => {
assert.deepEqual(v.validateWorkflowContent(baseManifest, ACTION_FILE, { requirePermissions: false }), []);
});
it('head manifest (adds unpinned step) has a violation', () => {
const errs = v.validateWorkflowContent(headManifest, ACTION_FILE, { requirePermissions: false });
assert.ok(errs.length > 0, 'head must have violations: ' + errs.join('; '));
});
it('diff mode reports the new unpinned ref as a new violation', () => {
const headErrs = v.validateWorkflowContent(headManifest, ACTION_FILE, { requirePermissions: false });
const baseErrs = v.validateWorkflowContent(baseManifest, ACTION_FILE, { requirePermissions: false });
const newViolations = v.filterNewViolations(headErrs, baseErrs);
assert.equal(newViolations.length, 1, 'exactly one new violation expected: ' + newViolations.join('; '));
assert.ok(newViolations[0].includes('setup-node'), 'violation must name the offending ref: ' + newViolations[0]);
});
it('new local ref inside composite action also fails closed', () => {
const manifest = [
'name: Setup',
'description: Sets up the environment.',
'runs:',
' using: composite',
' steps:',
' - uses: ./.github/actions/nested',
].join('\n');
const errs = v.validateWorkflowContent(manifest, ACTION_FILE, { requirePermissions: false });
assert.ok(errs.some(e => e.includes('local action')), 'local ref in composite must fail: ' + errs.join('; '));
});
});
// ── policyFileKind ────────────────────────────────────────────────────────────
describe('policyFileKind', () => {
it('returns "workflow" for workflow files', () => {
assert.equal(v.policyFileKind('.github/workflows/ci.yaml'), 'workflow');
assert.equal(v.policyFileKind('workflow-templates/pr.yml'), 'workflow');
});
it('returns "action" for action manifests', () => {
assert.equal(v.policyFileKind('action.yml'), 'action');
assert.equal(v.policyFileKind('.github/actions/setup/action.yaml'), 'action');
});
it('returns null for non-policy files', () => {
assert.equal(v.policyFileKind('src/index.js'), null);
assert.equal(v.policyFileKind('.github/actions/setup/entrypoint.js'), null);
});
});
// ── classifyFileStatus — cross-type rename grandfathering ─────────────────────
describe('classifyFileStatus — cross-type rename grandfathering', () => {
function isWf(f) { return v.isPolicyFilename(f); }
it('action -> workflow rename → full (cross-kind, not grandfathered)', () => {
const result = v.classifyFileStatus({
filename: '.github/workflows/imported.yml',
status: 'renamed',
previous_filename: 'action.yml',
}, isWf);
assert.equal(result.action, 'full', 'action→workflow must be full, got: ' + JSON.stringify(result));
});
it('workflow -> action rename → full (cross-kind, not grandfathered)', () => {
const result = v.classifyFileStatus({
filename: '.github/actions/setup/action.yml',
status: 'renamed',
previous_filename: '.github/workflows/ci.yml',
}, isWf);
assert.equal(result.action, 'full', 'workflow→action must be full, got: ' + JSON.stringify(result));
});
it('workflow -> workflow rename → diff (same kind)', () => {
const result = v.classifyFileStatus({
filename: '.github/workflows/new.yml',
status: 'renamed',
previous_filename: '.github/workflows/old.yml',
}, isWf);
assert.deepEqual(result, { action: 'diff', basePath: '.github/workflows/old.yml' });
});
it('action -> action rename → diff (same kind)', () => {
const result = v.classifyFileStatus({
filename: '.github/actions/new/action.yml',
status: 'renamed',
previous_filename: '.github/actions/old/action.yml',
}, isWf);
assert.deepEqual(result, { action: 'diff', basePath: '.github/actions/old/action.yml' });
});
it('non-policy -> workflow rename → full', () => {
const result = v.classifyFileStatus({
filename: '.github/workflows/imported.yml',
status: 'renamed',
previous_filename: 'docs/template.yml',
}, isWf);
assert.equal(result.action, 'full');
});
});
// ── Exact bypass reproduction: action.yml renamed to workflow ─────────────────
describe('bypass reproduction: action renamed to workflow reports missing permissions', () => {
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
// Simulates: base is action.yml (no permissions, valid for action), head is
// .github/workflows/imported.yml (same content, but now a workflow file).
// Full validation must run because the policy kind changed (action → workflow).
it('action.yml content re-validated as workflow reports missing permissions', () => {
const actionContent = [
'name: Setup',
'description: Sets up the environment.',
'runs:',
' using: composite',
' steps:',
' - uses: ' + PIN,
].join('\n');
// classifyFileStatus returns full → no baseline.
const isWf = f => v.isPolicyFilename(f);
const cls = v.classifyFileStatus({
filename: '.github/workflows/imported.yml',
status: 'renamed',
previous_filename: 'action.yml',
}, isWf);
assert.equal(cls.action, 'full', 'cross-kind rename must be full');
// Full validation as a workflow file — no opts.requirePermissions: false.
const errs = v.validateWorkflowContent(actionContent, '.github/workflows/imported.yml');
assert.ok(errs.some(e => e.includes('permissions')), 'missing permissions must be reported: ' + errs.join('; '));
});
it('workflow.yml renamed to workflow.yml stays in diff mode and grandfathers unchanged violations', () => {
const content = [
'permissions: {}',
'jobs:',
' j:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: ' + PIN,
].join('\n');
const isWf = f => v.isPolicyFilename(f);
const cls = v.classifyFileStatus({
filename: '.github/workflows/new.yml',
status: 'renamed',
previous_filename: '.github/workflows/old.yml',
}, isWf);
assert.deepEqual(cls, { action: 'diff', basePath: '.github/workflows/old.yml' }, 'same-kind rename must be diff');
// Use file.filename for both head/base so fingerprints match.
const headErrs = v.validateWorkflowContent(content, '.github/workflows/new.yml');
const baseErrs = v.validateWorkflowContent(content, '.github/workflows/new.yml');
assert.deepEqual(v.filterNewViolations(headErrs, baseErrs), [], 'unchanged content must be grandfathered');
});
});
// ── Whitespace-before-colon bypass ───────────────────────────────────────────
describe('validateWorkflowContent — whitespace before colon', () => {
const BASE = '.github/workflows/test.yaml';
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
function wfHeader() {
return 'permissions: {}\njobs:\n j:\n runs-on: ubuntu-latest\n steps:';
}
it('rejects "uses : actions/checkout@v4" (space before colon)', () => {
const content = wfHeader() + '\n - uses : actions/checkout@v4';
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('whitespace before')), 'must reject uses with space: ' + errs.join('; '));
});
it('rejects sequence-form "- uses : ..." (space before colon)', () => {
const content = wfHeader() + '\n - uses : actions/checkout@v4';
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('whitespace before') || e.includes('space before')), 'sequence uses space must fail: ' + errs.join('; '));
});
it('rejects "run : echo ${{ github.token }}" (space before colon with expression)', () => {
const content = wfHeader() + '\n - run : echo ${{ github.token }}';
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('whitespace before') || e.includes('space before')), 'run with space must fail: ' + errs.join('; '));
});
it('rejects "steps : [{ uses : actions/checkout@v4 }]" (space before colon on steps)', () => {
const content = 'permissions: {}\njobs:\n j:\n runs-on: ubuntu-latest\n steps : [{ uses : actions/checkout@v4 }]';
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('whitespace before') || e.includes('space before')), 'steps with space must fail: ' + errs.join('; '));
});
it('normal keys without space still work correctly', () => {
const content = [
'permissions: {}',
'jobs:',
' j:',
' runs-on: ubuntu-latest',
' steps:',
' - uses: ' + PIN,
].join('\n');
assert.deepEqual(v.validateWorkflowContent(content, BASE), []);
});
it('changed whitespace-before-colon payload fingerprints differently', () => {
const h = wfHeader() + '\n - uses : actions/checkout@v4';
const b = wfHeader() + '\n - uses : actions/setup-node@v4';
const headErrs = v.validateWorkflowContent(h, BASE);
const baseErrs = v.validateWorkflowContent(b, BASE);
assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed payload must be new');
});
});
// ── Workflow/action overlap: .github/workflows/action.yml ─────────────────────
describe('policyFileKind: workflow takes precedence over action-manifest pattern', () => {
it('policyFileKind returns "workflow" for .github/workflows/action.yml', () => {
assert.equal(v.policyFileKind('.github/workflows/action.yml'), 'workflow');
});
it('.github/workflows/action.yml requires permissions (workflow semantics)', () => {
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
const content = [
'name: Inline Action',
'description: Does something.',
'runs:',
' using: composite',
' steps:',
' - uses: ' + PIN,
].join('\n');
// Default opts (requirePermissions: true) because policyFileKind === 'workflow'
const errs = v.validateWorkflowContent(content, '.github/workflows/action.yml');
assert.ok(errs.some(e => e.includes('permissions')), 'workflow-path action.yml must require permissions: ' + errs.join('; '));
});
it('.github/actions/foo/action.yml does NOT require permissions (action semantics)', () => {
const PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b3 # v7.0.1';
const content = [
'name: Inline Action',
'description: Does something.',
'runs:',
' using: composite',
' steps:',
' - uses: ' + PIN,
].join('\n');
assert.deepEqual(v.validateWorkflowContent(content, '.github/actions/foo/action.yml', { requirePermissions: false }), []);
});
it('isActionManifestFilename still matches .github/workflows/action.yml (pattern overlap acknowledged)', () => {
assert.ok(v.isActionManifestFilename('.github/workflows/action.yml'), 'pattern overlap exists');
assert.equal(v.policyFileKind('.github/workflows/action.yml'), 'workflow', 'but kind is workflow');
});
});