diff --git a/scripts/ci/validate-workflow-security.js b/scripts/ci/validate-workflow-security.js index 4e65cdef..ec7f82ef 100644 --- a/scripts/ci/validate-workflow-security.js +++ b/scripts/ci/validate-workflow-security.js @@ -38,8 +38,9 @@ const WRITE_PERMISSION_PATTERN = /^\s*(?:contents|issues|pull-requests|actions|c // scope write access. The named-scope pattern above misses it because there // is no scope name on the left of the colon — just the literal `write-all` // value at the permissions key. Treat both as equivalent for the purposes -// of the persist-credentials and lifecycle-script gates below. -const WRITE_ALL_PATTERN = /^\s*permissions:\s*write-all\b/m; +// of the persist-credentials gate below. The optional single/double quotes +// match valid YAML `permissions: "write-all"` / `'write-all'` forms. +const WRITE_ALL_PATTERN = /^\s*permissions:\s*["']?write-all["']?\s*$/m; const NPM_AUDIT_PATTERN = /\bnpm\s+audit\b(?!\s+signatures\b)/; const NPM_AUDIT_SIGNATURES_PATTERN = /\bnpm\s+audit\s+signatures\b/; const ACTIONS_CACHE_PATTERN = /uses:\s*['"]?actions\/cache@/m; @@ -127,6 +128,13 @@ function findViolations(filePath, source) { } for (const step of checkoutSteps) { + // Track whether the expression-based rule already produced a + // violation for this step. If it did, skip the refPattern fallback + // — a `refs/pull/${{ github.event.pull_request.head.sha }}/merge` + // value matches both patterns under the same rule, and the second + // push would print a duplicate ERROR line that says exactly the + // same thing with a different `expression:` echo. + let stepFlagged = false; for (const match of step.text.matchAll(rule.expressionPattern)) { violations.push({ filePath, @@ -135,8 +143,9 @@ function findViolations(filePath, source) { expression: match[0], line: step.startLine + getLineNumber(step.text, match.index) - 1, }); + stepFlagged = true; } - if (rule.refPattern) { + if (rule.refPattern && !stepFlagged) { const refMatch = step.text.match(rule.refPattern); if (refMatch) { violations.push({