diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08db91a..86a8445 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,15 +53,8 @@ jobs: echo "expected at least 17 golden vectors, found $count" >&2 exit 1 fi - - name: Fixtures must not carry customer or internal identifiers - run: | - set -e - # This repository is public. Fixtures use neutral identifiers only. - if grep -rniE 'ajj|finops-core|debtor_aging|autocount' testdata README.md src; then - echo "found a real customer or internal identifier in public files" >&2 - exit 1 - fi - echo "no real identifiers present" + - name: Fixture identifiers must be neutral + run: node scripts/check-fixture-identifiers.mjs sast: name: SAST (CodeQL) @@ -80,10 +73,21 @@ jobs: secret-scan: name: Secret scan runs-on: ubuntu-latest + env: + # The gitleaks GitHub Action requires a paid licence for organisation + # repositories; the CLI it wraps does not. Version pinned deliberately. + GITLEAKS_VERSION: 8.28.0 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Install gitleaks + run: | + set -euo pipefail + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar -xz gitleaks + ./gitleaks version + - name: Scan working tree + run: ./gitleaks dir . --no-banner --redact + - name: Scan git history + run: ./gitleaks git . --no-banner --redact diff --git a/scripts/check-fixture-identifiers.mjs b/scripts/check-fixture-identifiers.mjs new file mode 100644 index 0000000..f6f4c55 --- /dev/null +++ b/scripts/check-fixture-identifiers.mjs @@ -0,0 +1,93 @@ +// Fixture identifiers must stay neutral: this repository is public. +// +// An allowlist is used rather than a list of forbidden strings. A denylist would +// have to name the very identifiers that must not appear here, and it only ever +// catches the names someone remembered to add. The allowlist catches any +// unexpected identifier, including ones introduced later. +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const TENANT_OK = /^(test-tenant|tenant-[a-z0-9-]+)$/; +const AGENT_OK = /^agent-[a-z0-9-]+$/; +// Kid may carry dots and underscores because one vector pins kid quoting. Written as a +// single character class rather than a repeated group: with '-' in both a group body and +// its separator class, the input can be partitioned ambiguously and the match backtracks +// exponentially. +const KID_OK = /^agent-[a-z0-9._-]+$/; + +// Identifiers appear both as JSON fields and inside HTTP header values, so both +// shapes are inspected. A field-only check missed a planted name in the header set. +const FIELD_RULES = [ + ['tenant_id', TENANT_OK], + ['agent_id', AGENT_OK], + ['kid', KID_OK], + ['X-Openmax-Tenant', TENANT_OK], + ['X-Openmax-Agent', AGENT_OK], +]; + +// keyid="..." inside Signature-Input carries the kid. +const KEYID_IN_SIGNATURE_INPUT = /keyid="([^"]+)"/g; + +const violations = []; + +function walk(value, path) { + if (Array.isArray(value)) { + value.forEach((v, i) => walk(v, `${path}[${i}]`)); + return; + } + if (value === null || typeof value !== 'object') return; + for (const [key, v] of Object.entries(value)) { + for (const [field, ok] of FIELD_RULES) { + if (key === field && typeof v === 'string' && !ok.test(v)) { + violations.push(`${path}.${key} = ${JSON.stringify(v)} (expected ${ok})`); + } + } + if (key === 'Signature-Input' && typeof v === 'string') { + for (const m of v.matchAll(KEYID_IN_SIGNATURE_INPUT)) { + if (!KID_OK.test(m[1])) { + violations.push(`${path}.${key} keyid = ${JSON.stringify(m[1])} (expected ${KID_OK})`); + } + } + } + walk(v, `${path}.${key}`); + } +} + +const dir = 'testdata'; +const files = readdirSync(dir).filter((f) => f.endsWith('.json')); +if (files.length === 0) { + console.error('no fixture files found in testdata/ — the check would pass vacuously'); + process.exit(1); +} + +let inspected = 0; +for (const f of files) { + const parsed = JSON.parse(readFileSync(join(dir, f), 'utf8')); + walk(parsed, f); + inspected += 1; +} + +// Guard against a rule set that matches nothing: at least one value per field must exist. +const seen = new Set(); +function collect(value) { + if (Array.isArray(value)) return value.forEach(collect); + if (value === null || typeof value !== 'object') return; + for (const [key, v] of Object.entries(value)) { + if (FIELD_RULES.some(([field]) => field === key) && typeof v === 'string') seen.add(key); + collect(v); + } +} +for (const f of files) collect(JSON.parse(readFileSync(join(dir, f), 'utf8'))); +for (const [field] of FIELD_RULES) { + if (!seen.has(field)) { + console.error(`no ${field} values found in any fixture — the check is not actually inspecting anything`); + process.exit(1); + } +} + +if (violations.length > 0) { + console.error('non-neutral identifiers in public fixtures:'); + for (const v of violations) console.error(` ${v}`); + process.exit(1); +} +console.log(`fixture identifiers neutral (${inspected} file(s), fields checked: ${[...seen].join(', ')})`);