veritas-kanban/scripts/check-security-gates.mjs
2026-09-02 15:47:26 -05:00

138 lines
4.6 KiB
JavaScript

#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
function yamlBlock(content, key, indentation) {
const prefix = ' '.repeat(indentation);
const startPattern = new RegExp(`^${prefix}${key}:\\s*$`, 'm');
const match = startPattern.exec(content);
if (!match) return '';
const rest = content.slice(match.index + match[0].length + 1);
const endPattern = new RegExp(`^${prefix}\\S`, 'm');
const end = endPattern.exec(rest);
return end ? rest.slice(0, end.index) : rest;
}
function normalizedPermissionLines(block) {
return block
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.sort();
}
export function findSecurityWorkflowViolations(content) {
const violations = [];
const hasRequiredEvents =
/^on:\s*$/m.test(content) &&
/^\s{2}pull_request:\s*$/m.test(content) &&
/^\s{2}push:\s*$/m.test(content) &&
/^\s{4}branches:\s*\[main\]\s*$/m.test(content) &&
/^\s{2}schedule:\s*$/m.test(content) &&
/^\s{4}- cron:\s*['"][^'"]+['"]\s*$/m.test(content);
if (!hasRequiredEvents) {
violations.push('security workflow must run for pull requests, main pushes, and a schedule');
}
const topPermissions = normalizedPermissionLines(yamlBlock(content, 'permissions', 0));
if (
topPermissions.length !== 1 ||
topPermissions[0] !== 'contents: read' ||
/^permissions:\s+write-all\s*$/m.test(content)
) {
violations.push('top-level workflow permissions must be contents: read only');
}
const codeqlJob = yamlBlock(content, 'codeql', 2);
const codeqlPermissions = normalizedPermissionLines(yamlBlock(codeqlJob, 'permissions', 4));
if (
codeqlPermissions.length !== 2 ||
!codeqlPermissions.includes('contents: read') ||
!codeqlPermissions.includes('security-events: write')
) {
violations.push('CodeQL job must grant only contents: read and security-events: write');
}
const codeqlInit =
/uses:\s*github\/codeql-action\/init@([0-9a-f]{40})\s+#\s*(\S+)/.exec(codeqlJob);
const codeqlAnalyze =
/uses:\s*github\/codeql-action\/analyze@([0-9a-f]{40})\s+#\s*(\S+)/.exec(codeqlJob);
if (!codeqlInit || !codeqlAnalyze) {
violations.push('CodeQL init and analyze actions must both be present');
} else if (codeqlInit[1] !== codeqlAnalyze[1] || codeqlInit[2] !== codeqlAnalyze[2]) {
violations.push('CodeQL init and analyze actions must use the same pinned revision');
}
const gitleaksJob = yamlBlock(content, 'gitleaks', 2);
const gitleaksJobEnvironment = yamlBlock(gitleaksJob, 'env', 4);
if (/\$\{\{\s*runner\./.test(gitleaksJobEnvironment)) {
violations.push('runner context must not be used in job-level environment values');
}
const gitleaksPermissions = normalizedPermissionLines(yamlBlock(gitleaksJob, 'permissions', 4));
if (
gitleaksPermissions.length !== 1 ||
gitleaksPermissions[0] !== 'contents: read' ||
!/run:\s*pnpm check:gitleaks\s*$/.test(gitleaksJob)
) {
violations.push('gitleaks job must run pnpm check:gitleaks with contents: read permission');
}
return violations;
}
export function findGitleaksIgnoreViolations(content) {
const violations = [];
let hasReviewComment = false;
content.split('\n').forEach((line, index) => {
const trimmed = line.trim();
if (!trimmed) {
hasReviewComment = false;
return;
}
if (trimmed.startsWith('#')) {
hasReviewComment = true;
return;
}
if (!hasReviewComment) {
violations.push(
`line ${index + 1}: every ignored fingerprint needs a preceding review comment`
);
}
if (!/^[A-Za-z0-9_.\u002f-]+:[a-z0-9-]+:[1-9][0-9]*$/.test(trimmed)) {
violations.push(`line ${index + 1}: ignore must be an exact path:rule:line fingerprint`);
}
});
return violations;
}
export function checkSecurityGates({ workflow, gitleaksIgnore }) {
return [
...findSecurityWorkflowViolations(workflow).map(
(message) => `.github/workflows/security.yml: ${message}`
),
...findGitleaksIgnoreViolations(gitleaksIgnore).map((message) => `.gitleaksignore: ${message}`),
];
}
function main() {
const violations = checkSecurityGates({
workflow: readFileSync('.github/workflows/security.yml', 'utf8'),
gitleaksIgnore: readFileSync('.gitleaksignore', 'utf8'),
});
if (violations.length > 0) {
console.error('Security gate policy violations:');
for (const violation of violations) console.error(`- ${violation}`);
process.exitCode = 1;
return;
}
console.log('Security workflow and exact gitleaks fingerprint policy verified.');
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
main();
}