diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4247deaf..5a3f31e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 00000000..a123dd12 --- /dev/null +++ b/.github/workflows/security.yml @@ -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 diff --git a/.gitleaksignore b/.gitleaksignore index 4e679601..8472e9b6 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6553ffbe..5a8d2dd9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index df6cb50f..2e473b8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/SECURITY.md b/SECURITY.md index 4ff32500..33a0080a 100644 --- a/SECURITY.md +++ b/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: diff --git a/package.json b/package.json index 2594fa43..6a82b092 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-gitleaks.mjs b/scripts/check-gitleaks.mjs new file mode 100644 index 00000000..b487e968 --- /dev/null +++ b/scripts/check-gitleaks.mjs @@ -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.'); diff --git a/scripts/check-security-gates.mjs b/scripts/check-security-gates.mjs new file mode 100644 index 00000000..dfdb8933 --- /dev/null +++ b/scripts/check-security-gates.mjs @@ -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(); +} diff --git a/scripts/check-security-gates.test.mjs b/scripts/check-security-gates.test.mjs new file mode 100644 index 00000000..019629a0 --- /dev/null +++ b/scripts/check-security-gates.test.mjs @@ -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', + ] + ); +});