Compare commits

...

2 commits

Author SHA1 Message Date
Cursor Agent
9aa67bd806
fix(policy): preserve line-specific violation fingerprints
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
2026-08-04 00:26:10 +00:00
Cursor Agent
7399fb6577
fix(policy): reject uses block scalar action refs
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
2026-08-04 00:20:39 +00:00
2 changed files with 49 additions and 24 deletions

View file

@ -214,7 +214,7 @@ jobs:
// 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.
// fingerprint even when the violation remains on the same line.
function fnv1a32(str) {
let h = 2166136261;
for (let i = 0; i < str.length; i++) {
@ -236,8 +236,10 @@ jobs:
// starts a block scalar. Lines inside ANY block scalar are not parsed
// as structural YAML keys — they are content. For run: block scalars,
// the content is still scanned for expression injection (expressions
// must flow through env:). For non-run block scalars (e.g. script:,
// name:), the content is skipped entirely — no uses: or run: detection.
// must flow through env:). uses: block scalars are rejected because an
// action ref must be an inline scalar to validate its immutable pin.
// For other non-run block scalars (e.g. script:, name:), the content
// is skipped entirely — no uses: or run: detection.
//
// Quoted keys: "uses", 'uses', "run", 'run', "permissions" are all
// recognized in addition to their unquoted forms.
@ -373,6 +375,10 @@ jobs:
const blockM = line.match(/^([ \t]*)(?:-[ \t]+)?("([^"]*)"|'([^']*)'|([\w-]+)):[ \t]*([|>](?:[1-9][-+]?|[-+][1-9]?)?)[ \t]*(?:#.*)?$/);
if (blockM) {
const keyName = blockM[3] !== undefined ? blockM[3] : (blockM[4] !== undefined ? blockM[4] : (blockM[5] || ''));
if (keyName === 'uses') {
errs.push(filename + ':' + (i + 1) + ': uses: block scalar is not supported — action refs must be inline and pinned to an immutable SHA [fnv:' + fnv1a32(line.trim()) + ']');
continue;
}
inBlock = true;
blockIndent = blockM[1].length;
blockIsRun = (keyName === 'run');
@ -508,19 +514,18 @@ jobs:
return null;
}
// Normalize a validateWorkflowContent error string to a line-agnostic
// fingerprint. Removes the `:LINE:` segment produced by per-line errors
// so that a violation shifted to a different line in the head file does
// not appear new relative to the base. Content-identifying tokens (action
// ref, expression text) are preserved.
// Normalize a validateWorkflowContent error string to a diff fingerprint.
// Per-line locations are intentionally preserved so moving a grandfathered
// violation to a different execution path is treated as a new violation.
// Content-identifying tokens (action ref, expression text) are preserved.
function normalizeViolationFingerprint(err) {
return err.replace(/:\d+:/, ':');
return err;
}
// Diff head vs base violations using normalized fingerprints with
// multiplicity. For each fingerprint, up to base-count head violations
// of that fingerprint are considered pre-existing; the remainder are new.
// Violations are returned in head-file order.
// Diff head vs base violations using location-preserving fingerprints
// with multiplicity. For each fingerprint, up to base-count head
// violations of that fingerprint are considered pre-existing; the
// remainder are new. Violations are returned in head-file order.
function filterNewViolations(headErrs, baseErrs) {
const baseCounts = new Map();
for (const e of baseErrs) {

View file

@ -1089,6 +1089,26 @@ describe('validateWorkflowContent — quoted keys and block-scalar tracking', ()
assert.deepEqual(v.validateWorkflowContent(content, BASE), [], "single-quoted 'uses' with valid SHA should pass");
});
it('rejects uses block scalar before opaque block handling can hide it', () => {
const content = [
...wfHeader().split('\n'),
' - uses: >-',
' actions/checkout@v4',
].join('\n');
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('uses: block scalar')), 'uses: folded block scalar must fail: ' + errs.join('; '));
});
it('rejects quoted uses block scalar before pin validation can be bypassed', () => {
const content = [
...wfHeader().split('\n'),
' - "uses": |-',
' actions/checkout@v4',
].join('\n');
const errs = v.validateWorkflowContent(content, BASE);
assert.ok(errs.some(e => e.includes('uses: block scalar')), 'quoted uses: literal block scalar must fail: ' + errs.join('; '));
});
it('recognises sequence-form "- run: |" and does not flag uses: inside as action ref', () => {
// The sequence item `- run: |` opens a run block scalar.
// uses: lines inside must not be treated as action references.
@ -1487,9 +1507,9 @@ describe('static YAML assertions', () => {
// ── normalizeViolationFingerprint ────────────────────────────────────────────
describe('normalizeViolationFingerprint', () => {
it('strips :LINE: from per-line errors', () => {
it('preserves :LINE: in per-line errors', () => {
const err = 'f.yaml:42: run: block contains expression — expressions must go through env:';
assert.equal(v.normalizeViolationFingerprint(err), 'f.yaml: run: block contains expression — expressions must go through env:');
assert.equal(v.normalizeViolationFingerprint(err), err);
});
it('leaves uses: violations unchanged (no line number in error)', () => {
@ -1502,15 +1522,15 @@ describe('normalizeViolationFingerprint', () => {
assert.equal(v.normalizeViolationFingerprint(err), err);
});
it('removes only the first :LINE: occurrence', () => {
it('does not rewrite line-like message content', () => {
const err = 'f.yaml:10: escaped key: something';
assert.equal(v.normalizeViolationFingerprint(err), 'f.yaml: escaped key: something');
assert.equal(v.normalizeViolationFingerprint(err), err);
});
it('line 10 and line 200 normalize to the same fingerprint', () => {
it('line 10 and line 200 remain distinct fingerprints', () => {
const a = v.normalizeViolationFingerprint('f.yaml:10: run: block contains expression');
const b = v.normalizeViolationFingerprint('f.yaml:200: run: block contains expression');
assert.equal(a, b);
assert.notEqual(a, b);
});
});
@ -1551,10 +1571,10 @@ describe('filterNewViolations', () => {
assert.deepEqual(v.filterNewViolations([err], [err]), []);
});
it('line shift ignored — same run expression moved to different line', () => {
it('line shift is blocked when the same run expression moves', () => {
const head = FP_EXPR('w.yaml', 20);
const base = FP_EXPR('w.yaml', 10);
assert.deepEqual(v.filterNewViolations([head], [base]), []);
assert.deepEqual(v.filterNewViolations([head], [base]), [head]);
});
it('newly added run expression is blocked', () => {
@ -1566,7 +1586,7 @@ describe('filterNewViolations', () => {
it('unchanged run expression ignored; a second new one blocked', () => {
const base = FP_EXPR('w.yaml', 10);
const head1 = FP_EXPR('w.yaml', 12);
const head1 = FP_EXPR('w.yaml', 10);
const head2 = FP_EXPR('w.yaml', 50);
const result = v.filterNewViolations([head1, head2], [base]);
assert.equal(result.length, 1);
@ -1674,7 +1694,7 @@ describe('content fingerprint: changed payload is new', () => {
assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'changed expression must be reported as new');
});
it('unchanged run expression at a shifted line is grandfathered', () => {
it('unchanged run expression at a shifted line is blocked', () => {
const wfA = BASE_WF('echo ${{ github.ref }}');
// wfB inserts a blank step before the run step, shifting its line number
const wfB = [
@ -1693,7 +1713,7 @@ describe('content fingerprint: changed payload is new', () => {
].join('\n');
const headErrs = v.validateWorkflowContent(wfB, 'f.yaml');
const baseErrs = v.validateWorkflowContent(wfA, 'f.yaml');
assert.deepEqual(v.filterNewViolations(headErrs, baseErrs), [], 'same expression on a different line must be grandfathered');
assert.equal(v.filterNewViolations(headErrs, baseErrs).length, 1, 'same expression on a different line must be reported as new');
});
it('flow-style run changed to flow-style uses is new', () => {