mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
security: add continuous scanning gates (#1180)
* ci: add continuous security gates * fix: scope runner context to gitleaks step
This commit is contained in:
parent
9c533c4345
commit
b1367aa33c
10 changed files with 454 additions and 20 deletions
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -76,6 +76,7 @@ jobs:
|
|||
node --test
|
||||
scripts/check-actions-pinned.test.mjs
|
||||
scripts/check-delivery-cadence.test.mjs
|
||||
scripts/check-security-gates.test.mjs
|
||||
scripts/select-ci-test-scope.test.mjs
|
||||
scripts/verify-full-suite-job-evidence.test.mjs
|
||||
|
||||
|
|
@ -85,6 +86,9 @@ jobs:
|
|||
- name: Guard immutable GitHub Actions references
|
||||
run: node scripts/check-actions-pinned.mjs
|
||||
|
||||
- name: Guard continuous security gates
|
||||
run: node scripts/check-security-gates.mjs
|
||||
|
||||
- name: Find reviewed full-suite evidence
|
||||
id: reviewed_full
|
||||
if: github.event_name == 'push'
|
||||
|
|
|
|||
65
.github/workflows/security.yml
vendored
Normal file
65
.github/workflows/security.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
name: Security Gates
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '17 9 * * 3'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
codeql:
|
||||
name: CodeQL
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
queries: security-extended
|
||||
- name: Analyze JavaScript and TypeScript
|
||||
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
category: '/language:javascript-typescript'
|
||||
|
||||
gitleaks:
|
||||
name: Gitleaks
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
GITLEAKS_VERSION: 8.30.1
|
||||
GITLEAKS_LINUX_X64_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- name: Download verified gitleaks release
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive="$RUNNER_TEMP/gitleaks.tar.gz"
|
||||
curl --fail --silent --show-error --location \
|
||||
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
|
||||
--output "$archive"
|
||||
echo "${GITLEAKS_LINUX_X64_SHA256} ${archive}" | sha256sum --check --status
|
||||
tar -xzf "$archive" -C "$RUNNER_TEMP" gitleaks
|
||||
- name: Guard security workflow policy
|
||||
run: pnpm check:security-gates
|
||||
- name: Scan reviewed tree and test detection
|
||||
env:
|
||||
GITLEAKS_BIN: ${{ runner.temp }}/gitleaks
|
||||
run: pnpm check:gitleaks
|
||||
|
|
@ -1,22 +1,53 @@
|
|||
# Gitleaks False Positives
|
||||
# Updated: 2026-01-29 (post-history-rewrite)
|
||||
# All entries below are placeholder/example/test values, NOT real secrets.
|
||||
# CLI snapshot uses an intentionally synthetic API key in serialized output.
|
||||
cli/src/__tests__/snapshot.test.ts:generic-api-key:220
|
||||
|
||||
# Documentation example: "your-admin-key" placeholder in deployment guide
|
||||
39423f74cf3849684e8de4ebf746156a6be0ea00:docs/DEPLOYMENT.md:curl-auth-header:557
|
||||
# API documentation contains non-functional response examples.
|
||||
docs/API-REFERENCE.md:generic-api-key:991
|
||||
docs/API-WORKFLOWS.md:generic-api-key:1460
|
||||
|
||||
# Documentation example: "dev-admin-key" placeholder in security audit
|
||||
f01a0157f1d1612ab7bea646cf7265ec018e1d9a:docs/SECURITY_AUDIT_2026-01-28.md:curl-auth-header:135
|
||||
# Operator documentation uses placeholders in curl authentication examples.
|
||||
docs/DEPLOYMENT.md:curl-auth-header:891
|
||||
docs/TROUBLESHOOTING.md:curl-auth-header:229
|
||||
docs/TROUBLESHOOTING.md:curl-auth-header:257
|
||||
docs/TROUBLESHOOTING.md:curl-auth-header:260
|
||||
docs/features/prd-driven-development.md:curl-auth-header:95
|
||||
docs/features/prd-driven-development.md:curl-auth-header:775
|
||||
docs/guides/SELF_HOST.md:curl-auth-header:738
|
||||
docs/security.md:curl-auth-header:51
|
||||
docs/security.md:curl-auth-header:58
|
||||
|
||||
# Test fixture: hardcoded test JWT secret (not used in production)
|
||||
f01a0157f1d1612ab7bea646cf7265ec018e1d9a:server/src/__tests__/routes/auth.test.ts:generic-api-key:28
|
||||
f01a0157f1d1612ab7bea646cf7265ec018e1d9a:server/src/__tests__/routes/auth.test.ts:generic-api-key:29
|
||||
f01a0157f1d1612ab7bea646cf7265ec018e1d9a:server/src/__tests__/routes/auth.test.ts:generic-api-key:60
|
||||
# Demo seeding passes the operator-provided key variable to curl.
|
||||
seed-demo-data.sh:curl-auth-header:45
|
||||
|
||||
# .env.example placeholder values ("your-api-key")
|
||||
55c742c2dfd731069505e1baab16bb4c328234bd:server/.env.example:curl-auth-header:43
|
||||
55c742c2dfd731069505e1baab16bb4c328234bd:server/.env.example:curl-auth-header:46
|
||||
# Environment template documents shell-variable authentication examples.
|
||||
server/.env.example:curl-auth-header:127
|
||||
server/.env.example:curl-auth-header:130
|
||||
|
||||
# Documentation placeholder values ("your-api-key")
|
||||
55c742c2dfd731069505e1baab16bb4c328234bd:docs/security.md:curl-auth-header:40
|
||||
55c742c2dfd731069505e1baab16bb4c328234bd:docs/security.md:curl-auth-header:47
|
||||
# Compatibility test verifies redaction of a deliberately synthetic value.
|
||||
server/src/__tests__/buzz-compatibility-service.test.ts:generic-api-key:477
|
||||
|
||||
# Governance trace test verifies Stripe-shaped token redaction.
|
||||
server/src/__tests__/governance-trace-service.test.ts:stripe-access-token:22
|
||||
|
||||
# Log redaction tests require JWT- and Stripe-shaped synthetic fixtures.
|
||||
server/src/__tests__/log-redaction.test.ts:jwt:17
|
||||
server/src/__tests__/log-redaction.test.ts:stripe-access-token:30
|
||||
server/src/__tests__/log-redaction.test.ts:stripe-access-token:31
|
||||
|
||||
# Completion service test verifies JWT-shaped output redaction.
|
||||
server/src/__tests__/provider-completion-service.test.ts:jwt:314
|
||||
|
||||
# Local admission tests use synthetic idempotency keys, not credentials.
|
||||
server/src/__tests__/routes/agents-local-capability.test.ts:generic-api-key:606
|
||||
server/src/__tests__/routes/agents-local-capability.test.ts:generic-api-key:619
|
||||
|
||||
# Authentication route tests require a synthetic JWT signing value.
|
||||
server/src/__tests__/routes/auth.test.ts:generic-api-key:29
|
||||
server/src/__tests__/routes/auth.test.ts:generic-api-key:31
|
||||
server/src/__tests__/routes/auth.test.ts:generic-api-key:80
|
||||
|
||||
# Skill capability test verifies Stripe-shaped token redaction.
|
||||
server/src/__tests__/skill-capability-service.test.ts:stripe-access-token:63
|
||||
|
||||
# Multi-user UI test renders a non-secret token prefix fixture.
|
||||
web/src/__tests__/multi-user-tab.test.tsx:generic-api-key:123
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
# Pre-commit hooks for veritas-kanban
|
||||
# Install: pip install pre-commit && pre-commit install
|
||||
# Or standalone gitleaks hook (no pre-commit framework needed):
|
||||
# gitleaks protect --staged --verbose
|
||||
# Or scan the reviewed tree without the pre-commit framework:
|
||||
# gitleaks dir . --redact=100
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.21.2
|
||||
rev: 83d9cd684c87d95d656c1458ef04895a7f1cbd8e # v8.30.1
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
|
|
|
|||
|
|
@ -73,6 +73,8 @@ pnpm lint:fix
|
|||
pnpm check:actions-pinned # Rejects mutable external GitHub Action references
|
||||
pnpm check:pnpm-settings # Validates package manager fields match this file
|
||||
pnpm check:delivery-cadence # Prevents verification and review policy drift
|
||||
pnpm check:security-gates # Validates CodeQL/gitleaks workflow and exact suppressions
|
||||
pnpm check:gitleaks # Scans reviewed tree and proves new-secret detection
|
||||
pnpm check:vite-native-config # Loads web build and test configs with Vite's native loader
|
||||
pnpm test:ci-scope # Validates path-aware CI test selection
|
||||
pnpm smoke:cli-mcp # CLI ↔ MCP compatibility smoke test
|
||||
|
|
|
|||
28
SECURITY.md
28
SECURITY.md
|
|
@ -69,6 +69,34 @@ pnpm check:actions-pinned
|
|||
Dependabot retains the `github-actions` ecosystem entry so reviewed updates can
|
||||
advance both the immutable commit and its release comment.
|
||||
|
||||
## Continuous Security Gates
|
||||
|
||||
The `Security Gates` workflow runs CodeQL and gitleaks for pull requests, main
|
||||
branch updates, and a weekly schedule. CodeQL uses the extended JavaScript and
|
||||
TypeScript security query suite. Repository merge protection blocks CodeQL
|
||||
errors and high-or-critical security alerts. Gitleaks scans the current tree,
|
||||
accepts only the exact reviewed fingerprints in `.gitleaksignore`, and proves
|
||||
that a newly introduced synthetic secret is still rejected.
|
||||
|
||||
Brad Groux owns Dependabot and GitHub security alert triage. New dependency,
|
||||
code-scanning, or secret-scanning alerts must be reviewed privately within two
|
||||
working days. Confirm exploitability and affected releases before opening a
|
||||
public issue. Track confirmed vulnerabilities in a private GitHub security
|
||||
advisory, prioritize critical and high findings for the next safe patch, and
|
||||
record false positives at the narrowest available fingerprint or path. Do not
|
||||
disable a detector class to clear a gate.
|
||||
|
||||
Dependabot vulnerability alerts and security updates, GitHub secret scanning,
|
||||
and push protection must remain enabled. Security-update pull requests use the
|
||||
existing `BradGroux` reviewer assignment in `.github/dependabot.yml`.
|
||||
|
||||
Run the repository controls locally with:
|
||||
|
||||
```bash
|
||||
pnpm check:security-gates
|
||||
pnpm check:gitleaks
|
||||
```
|
||||
|
||||
## Scope
|
||||
|
||||
This policy applies to:
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@
|
|||
"lint:fix": "eslint . --fix",
|
||||
"check:delivery-cadence": "node --test scripts/check-delivery-cadence.test.mjs && node scripts/check-delivery-cadence.mjs",
|
||||
"check:actions-pinned": "node --test scripts/check-actions-pinned.test.mjs && node scripts/check-actions-pinned.mjs",
|
||||
"check:security-gates": "node --test scripts/check-security-gates.test.mjs && node scripts/check-security-gates.mjs",
|
||||
"check:gitleaks": "node scripts/check-gitleaks.mjs",
|
||||
"check:vite-native-config": "pnpm --filter @veritas-kanban/web exec vite build --configLoader native && vitest run --configLoader native web/src/lib/__tests__/client-policy.test.ts",
|
||||
"check:pnpm-settings": "node scripts/check-pnpm-settings.mjs",
|
||||
"check:security-artifacts": "node scripts/check-security-artifacts.mjs",
|
||||
|
|
|
|||
84
scripts/check-gitleaks.mjs
Normal file
84
scripts/check-gitleaks.mjs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
copyFileSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const binary = process.env.GITLEAKS_BIN || 'gitleaks';
|
||||
|
||||
function runGitleaks(args, cwd) {
|
||||
return spawnSync(binary, args, { cwd, encoding: 'utf8', env: process.env });
|
||||
}
|
||||
|
||||
function fail(message, result) {
|
||||
console.error(message);
|
||||
if (result?.error?.code === 'ENOENT') {
|
||||
console.error(`Gitleaks binary not found at ${binary}. Install gitleaks or set GITLEAKS_BIN.`);
|
||||
} else if (result?.stderr) {
|
||||
console.error(result.stderr.trim());
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function stageScannableSource() {
|
||||
const repositoryRoot = process.cwd();
|
||||
const listing = spawnSync(
|
||||
'git',
|
||||
['ls-files', '--cached', '--others', '--exclude-standard', '-z'],
|
||||
{ cwd: repositoryRoot, encoding: 'utf8' }
|
||||
);
|
||||
if (listing.status !== 0) fail('Could not inventory Git-managed source files.', listing);
|
||||
|
||||
const stagingDirectory = mkdtempSync(join(tmpdir(), 'veritas-gitleaks-source-'));
|
||||
for (const relativePath of listing.stdout.split('\0').filter(Boolean)) {
|
||||
const source = resolve(repositoryRoot, relativePath);
|
||||
const destination = resolve(stagingDirectory, relativePath);
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
const stats = lstatSync(source);
|
||||
if (stats.isSymbolicLink()) {
|
||||
writeFileSync(destination, readlinkSync(source));
|
||||
} else if (stats.isFile()) {
|
||||
copyFileSync(source, destination);
|
||||
}
|
||||
}
|
||||
return stagingDirectory;
|
||||
}
|
||||
|
||||
const sourceDirectory = stageScannableSource();
|
||||
const fixtureDirectory = mkdtempSync(join(tmpdir(), 'veritas-gitleaks-regression-'));
|
||||
try {
|
||||
const repositoryScan = runGitleaks(
|
||||
['dir', '.', '--no-banner', '--redact=100', '--gitleaks-ignore-path', '.gitleaksignore'],
|
||||
sourceDirectory
|
||||
);
|
||||
if (repositoryScan.status !== 0) {
|
||||
fail('Gitleaks found a secret outside the reviewed fingerprint baseline.', repositoryScan);
|
||||
}
|
||||
|
||||
const syntheticToken = ['sk', '_live_', '51Z9Y8X7W6V5U4T3S2Q1P0N9'].join('');
|
||||
writeFileSync(join(fixtureDirectory, 'new-secret.env'), `payment_token=${syntheticToken}\n`);
|
||||
const regressionScan = runGitleaks(
|
||||
['dir', '.', '--no-banner', '--redact=100', '--gitleaks-ignore-path', '/dev/null'],
|
||||
fixtureDirectory
|
||||
);
|
||||
if (regressionScan.status !== 1) {
|
||||
fail(
|
||||
`Gitleaks regression fixture was not rejected (expected exit 1, got ${String(regressionScan.status)}).`,
|
||||
regressionScan
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
rmSync(sourceDirectory, { recursive: true, force: true });
|
||||
rmSync(fixtureDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('Gitleaks accepted the reviewed baseline and rejected a newly introduced test secret.');
|
||||
135
scripts/check-security-gates.mjs
Normal file
135
scripts/check-security-gates.mjs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
#!/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();
|
||||
}
|
||||
83
scripts/check-security-gates.test.mjs
Normal file
83
scripts/check-security-gates.test.mjs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
findGitleaksIgnoreViolations,
|
||||
findSecurityWorkflowViolations,
|
||||
} from './check-security-gates.mjs';
|
||||
|
||||
const SHA = 'db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28';
|
||||
|
||||
test('accepts a least-privilege scheduled CodeQL and gitleaks workflow', () => {
|
||||
const workflow = `
|
||||
name: Security Gates
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '17 9 * * 3'
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
codeql:
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: github/codeql-action/init@${SHA} # v4.37.8
|
||||
- uses: github/codeql-action/analyze@${SHA} # v4.37.8
|
||||
gitleaks:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- run: pnpm check:gitleaks
|
||||
`;
|
||||
|
||||
assert.deepEqual(findSecurityWorkflowViolations(workflow), []);
|
||||
});
|
||||
|
||||
test('rejects missing schedules, broad permissions, and incomplete gates', () => {
|
||||
assert.deepEqual(
|
||||
findSecurityWorkflowViolations(`
|
||||
name: Security Gates
|
||||
on: [pull_request]
|
||||
permissions: write-all
|
||||
jobs:
|
||||
codeql:
|
||||
steps:
|
||||
- uses: github/codeql-action/init@v4
|
||||
gitleaks:
|
||||
env:
|
||||
GITLEAKS_BIN: \${{ runner.temp }}/gitleaks
|
||||
`),
|
||||
[
|
||||
'security workflow must run for pull requests, main pushes, and a schedule',
|
||||
'top-level workflow permissions must be contents: read only',
|
||||
'CodeQL job must grant only contents: read and security-events: write',
|
||||
'CodeQL init and analyze actions must both be present',
|
||||
'runner context must not be used in job-level environment values',
|
||||
'gitleaks job must run pnpm check:gitleaks with contents: read permission',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts exact gitleaks fingerprints with review comments', () => {
|
||||
assert.deepEqual(
|
||||
findGitleaksIgnoreViolations(
|
||||
`# Reviewed synthetic fixture\nserver/src/example.test.ts:generic-api-key:42\n`
|
||||
),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects broad or undocumented gitleaks suppressions', () => {
|
||||
assert.deepEqual(
|
||||
findGitleaksIgnoreViolations(`server/.*\n# documented\n.*:generic-api-key:42\n`),
|
||||
[
|
||||
'line 1: every ignored fingerprint needs a preceding review comment',
|
||||
'line 1: ignore must be an exact path:rule:line fingerprint',
|
||||
'line 3: ignore must be an exact path:rule:line fingerprint',
|
||||
]
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue