diff --git a/packages/telemetry/test/telemetry-v1.test.mjs b/packages/telemetry/test/telemetry-v1.test.mjs index e8aaa7fc..d4a53d02 100644 --- a/packages/telemetry/test/telemetry-v1.test.mjs +++ b/packages/telemetry/test/telemetry-v1.test.mjs @@ -97,7 +97,13 @@ test('telemetry rejects proxies that fail during reflection without exposing tra assert.deepEqual(sanitizeTelemetryAttributesV1(hostileAttributes), {}); assert.throws( () => assertSafeTelemetryAttributesV1(hostileAttributes), - UnsafeTelemetryAttributeErrorV1, + (error) => { + assert.ok(error instanceof UnsafeTelemetryAttributeErrorV1); + assert.equal(error.key, 'unreadable'); + assert.equal(error.message, 'Telemetry attribute is not allowed: unreadable'); + assert.doesNotMatch(error.message, /attribute trap cause/u); + return true; + }, ); const hostileHeaders = new Proxy( @@ -108,7 +114,14 @@ test('telemetry rejects proxies that fail during reflection without exposing tra }, }, ); - assert.throws(() => correlationFromHeadersV1(hostileHeaders), /Unreadable telemetry/u); + assert.throws( + () => correlationFromHeadersV1(hostileHeaders), + (error) => { + assert.equal(error.message, 'Unreadable telemetry x-correlation-id header'); + assert.doesNotMatch(error.message, /header trap cause/u); + return true; + }, + ); }); test('correlation headers round-trip without accepting malformed identifiers', () => { @@ -146,7 +159,10 @@ test('correlation headers round-trip without accepting malformed identifiers', ( assert.throws(() => correlationFromHeadersV1({})); assert.throws( () => correlationFromHeadersV1({ 'x-correlation-id': 1 }), - /Unreadable telemetry x-correlation-id header/u, + (error) => { + assert.equal(error.message, 'Unreadable telemetry x-correlation-id header'); + return true; + }, ); assert.throws(() => correlationFromHeadersV1({ 'x-correlation-id': [correlationId, correlationId] }), diff --git a/tools/repo-cli/src/check-aws-infrastructure.mjs b/tools/repo-cli/src/check-aws-infrastructure.mjs index 3fd0fd52..5cbf42f2 100644 --- a/tools/repo-cli/src/check-aws-infrastructure.mjs +++ b/tools/repo-cli/src/check-aws-infrastructure.mjs @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { balancedBlocks } from './terraform-safety.mjs'; const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); const infrastructureRoot = path.join(repositoryRoot, 'infrastructure', 'aws'); @@ -32,39 +33,6 @@ const allTerraform = requiredFiles .map((relativePath) => readFileSync(path.join(infrastructureRoot, relativePath), 'utf8')) .join('\n'); -function balancedBlocks(text, keyword) { - const blocks = []; - const startPattern = new RegExp(`\\b${keyword}\\s*\\{`, 'g'); - for (const match of text.matchAll(startPattern)) { - const openingBrace = text.indexOf('{', match.index); - let depth = 0; - let quoted = false; - let escaped = false; - for (let index = openingBrace; index < text.length; index += 1) { - const character = text[index]; - if (quoted) { - if (escaped) escaped = false; - else if (character === '\\') escaped = true; - else if (character === '"') quoted = false; - continue; - } - if (character === '"') { - quoted = true; - continue; - } - if (character === '{') depth += 1; - if (character === '}') { - depth -= 1; - if (depth === 0) { - blocks.push(text.slice(openingBrace, index + 1)); - break; - } - } - } - } - return blocks; -} - for (const requiredText of [ 'ap-southeast-1', 'hashicorp/aws', diff --git a/tools/repo-cli/src/check-ci-policy.mjs b/tools/repo-cli/src/check-ci-policy.mjs index 6c97dd12..d75d426b 100644 --- a/tools/repo-cli/src/check-ci-policy.mjs +++ b/tools/repo-cli/src/check-ci-policy.mjs @@ -38,14 +38,10 @@ function walk(value, visit) { for (const child of Object.values(value)) walk(child, visit); } -function containsText(value, expected) { +function containsRunText(value, expected) { let found = false; walk(value, (node) => { - if ( - Object.values(node).some((child) => typeof child === 'string' && child.includes(expected)) - ) { - found = true; - } + if (typeof node.run === 'string' && node.run.includes(expected)) found = true; }); return found; } @@ -128,13 +124,14 @@ export function checkCiPolicy(root = process.cwd()) { 'check-container-policy.mjs', 'generate-sbom.mjs', ]) { - if (!containsText(security, required)) throw new Error(`security.yml is missing ${required}`); + if (!containsRunText(security, required)) + throw new Error(`security.yml is missing ${required}`); } const release = workflows['release.yml']; if (!isRecord(release.permissions) || release.permissions['id-token'] !== 'write') { throw new Error('release.yml must request OIDC id-token permission explicitly'); } - if (!containsText(release, 'generate-provenance.mjs')) { + if (!containsRunText(release, 'generate-provenance.mjs')) { throw new Error('release.yml must generate a provenance record'); } let hasReleaseEnvironment = false; diff --git a/tools/repo-cli/src/terraform-safety.mjs b/tools/repo-cli/src/terraform-safety.mjs new file mode 100644 index 00000000..f5aceed8 --- /dev/null +++ b/tools/repo-cli/src/terraform-safety.mjs @@ -0,0 +1,139 @@ +function maskTerraformNonCode(text) { + const masked = text.split(''); + let quoted = false; + let escaped = false; + let lineComment = false; + let blockComment = false; + let heredoc = undefined; + let lineStart = true; + + const mask = (index) => { + if (masked[index] !== '\n') masked[index] = ' '; + }; + + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + const next = text[index + 1]; + + if (heredoc !== undefined) { + if (lineStart) { + const lineEnd = text.indexOf('\n', index) === -1 ? text.length : text.indexOf('\n', index); + const rawLine = text.slice(index, lineEnd).replace(/\r$/u, ''); + const candidate = heredoc.allowIndent ? rawLine.trim() : rawLine; + for (let position = index; position < lineEnd; position += 1) mask(position); + if (candidate === heredoc.delimiter) heredoc = undefined; + lineStart = false; + index = lineEnd - 1; + continue; + } + mask(index); + if (character === '\n') lineStart = true; + continue; + } + + if (lineComment) { + mask(index); + if (character === '\n') { + lineComment = false; + lineStart = true; + } + continue; + } + + if (blockComment) { + mask(index); + if (character === '*' && next === '/') { + mask(index + 1); + index += 1; + blockComment = false; + } + lineStart = character === '\n'; + continue; + } + + if (quoted) { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === '"') quoted = false; + if (character === '\n') lineStart = true; + else lineStart = false; + continue; + } + + if (character === '"') { + quoted = true; + lineStart = false; + continue; + } + if (character === '#') { + lineComment = true; + mask(index); + lineStart = false; + continue; + } + if (character === '/' && next === '/') { + lineComment = true; + mask(index); + mask(index + 1); + index += 1; + lineStart = false; + continue; + } + if (character === '/' && next === '*') { + blockComment = true; + mask(index); + mask(index + 1); + index += 1; + lineStart = false; + continue; + } + if (character === '<' && next === '<') { + const match = text.slice(index).match(/^<<(-?)([A-Za-z_][A-Za-z0-9_-]*)/u); + if (match) { + for (let position = index; position < index + match[0].length; position += 1) { + mask(position); + } + heredoc = { allowIndent: match[1] === '-', delimiter: match[2] }; + index += match[0].length - 1; + lineStart = false; + continue; + } + } + lineStart = character === '\n'; + } + return masked.join(''); +} + +export function balancedBlocks(text, keyword) { + const masked = maskTerraformNonCode(text); + const blocks = []; + const startPattern = new RegExp(`\\b${keyword}\\s*\\{`, 'g'); + for (const match of masked.matchAll(startPattern)) { + const openingBrace = masked.indexOf('{', match.index); + let depth = 0; + let quoted = false; + let escaped = false; + for (let index = openingBrace; index < masked.length; index += 1) { + const character = masked[index]; + if (quoted) { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === '"') quoted = false; + continue; + } + if (character === '"') { + quoted = true; + continue; + } + if (character === '{') depth += 1; + if (character === '}') { + depth -= 1; + if (depth === 0) { + blocks.push(masked.slice(openingBrace, index + 1)); + break; + } + } + } + } + return blocks; +} diff --git a/tools/repo-cli/test/ci-policy.test.mjs b/tools/repo-cli/test/ci-policy.test.mjs index 526347c1..b8e0b8df 100644 --- a/tools/repo-cli/test/ci-policy.test.mjs +++ b/tools/repo-cli/test/ci-policy.test.mjs @@ -167,3 +167,48 @@ test('CI policy rejects artifact steps without missing-output failure', () => { fs.writeFileSync(path.join(root, '.github/workflows', name), text); assert.throws(() => checkCiPolicy(root), /artifact uploads must fail/u); }); + +test('CI policy requires security and release commands in run values', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'databreeze-ci-command-fields-')); + fs.mkdirSync(path.join(root, '.github/workflows'), { recursive: true }); + const checkout = 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683'; + const checkedOutJob = [ + 'jobs:', + ' check:', + ' runs-on: ubuntu-24.04', + ' timeout-minutes: 10', + ' steps:', + ` - uses: ${checkout}`, + ' with:', + ' persist-credentials: false', + ].join('\n'); + const security = [ + 'name: "pnpm audit check-secret-patterns.mjs check-license-policy.mjs check-container-policy.mjs generate-sbom.mjs"', + 'permissions:', + ' contents: read', + checkedOutJob, + ].join('\n'); + const release = [ + 'name: "generate-provenance.mjs"', + 'permissions:', + ' contents: read', + ' id-token: write', + 'jobs:', + ' release:', + ' runs-on: ubuntu-24.04', + ' timeout-minutes: 10', + ' environment: release', + ' steps:', + ` - uses: ${checkout}`, + ' with:', + ' persist-credentials: false', + ].join('\n'); + const quality = ['name: q', 'permissions:', ' contents: read', checkedOutJob].join('\n'); + for (const [name, text] of Object.entries({ + 'quality.yml': quality, + 'security.yml': security, + 'release.yml': release, + })) + fs.writeFileSync(path.join(root, '.github/workflows', name), text); + assert.throws(() => checkCiPolicy(root), /security\.yml is missing pnpm audit/u); +}); diff --git a/tools/repo-cli/test/terraform-safety.test.mjs b/tools/repo-cli/test/terraform-safety.test.mjs new file mode 100644 index 00000000..b29e0816 --- /dev/null +++ b/tools/repo-cli/test/terraform-safety.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { balancedBlocks } from '../src/terraform-safety.mjs'; + +test('Terraform block scanning ignores comments, heredocs, and braces in strings', () => { + const source = ` +# ingress { cidr_blocks = ["0.0.0.0/0"] } +/* ingress { cidr_blocks = ["0.0.0.0/0"] } */ +locals { + description = <<-EOT + ingress { + cidr_blocks = ["0.0.0.0/0"] + } + braces: { }; + EOT +} +ingress { + description = "literal } brace" + ${' '.repeat(500)} + cidr_blocks = ["10.0.0.0/8"] +} +`; + const blocks = balancedBlocks(source, 'ingress'); + assert.equal(blocks.length, 1); + assert.match(blocks[0], /10\.0\.0\.0\/8/u); + assert.doesNotMatch(blocks[0], /0\.0\.0\.0\/0/u); +}); + +test('Terraform principal scanning ignores commented wildcard identifiers', () => { + const source = ` +/* principals { identifiers = ["*"] } */ +principals { + type = "Service" + identifiers = ["example.amazonaws.com"] +} +`; + const blocks = balancedBlocks(source, 'principals'); + assert.equal(blocks.length, 1); + assert.doesNotMatch(blocks[0], /identifiers\s*=\s*\[[^\]]*"\*"/u); +});