veritas-kanban/scripts/check-security-gates.mjs
Brad Groux b1367aa33c
security: add continuous scanning gates (#1180)
* ci: add continuous security gates

* fix: scope runner context to gitleaks step
2026-08-23 12:06:20 -05:00

135 lines
4.4 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');
}
if (
!/uses:\s*github\/codeql-action\/init@[0-9a-f]{40}\s+#\s*\S+/.test(codeqlJob) ||
!/uses:\s*github\/codeql-action\/analyze@[0-9a-f]{40}\s+#\s*\S+/.test(codeqlJob)
) {
violations.push('CodeQL init and analyze actions must both be present');
}
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();
}