security: add continuous scanning gates (#1180)

* ci: add continuous security gates

* fix: scope runner context to gitleaks step
This commit is contained in:
Brad Groux 2026-08-23 12:06:20 -05:00 committed by GitHub
parent 9c533c4345
commit b1367aa33c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 454 additions and 20 deletions

View file

@ -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
View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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",

View 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.');

View 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();
}

View 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',
]
);
});