From 801cf866a40d69212ac5aa8dd0f41e006fe10d92 Mon Sep 17 00:00:00 2001 From: mikezylos Date: Tue, 4 Aug 2026 19:11:16 +0800 Subject: [PATCH 1/3] ci: check fixture identifiers by allowlist instead of denylist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous check grepped for known-bad strings, which meant the workflow file itself had to spell out the customer and internal identifiers it was guarding against — publishing them in a public repo to prevent them being published. Replaces it with scripts/check-fixture-identifiers.mjs, which asserts that tenant/agent/kid values match neutral patterns. An allowlist cannot leak what it forbids, and it also catches identifiers nobody thought to enumerate. Inspects both JSON fields and HTTP header values, plus the keyid inside Signature-Input; a field-only version missed a name planted in the header set. Fails loudly if no fixtures or no matching fields are found, so it cannot pass vacuously. --- .github/workflows/ci.yml | 11 +--- scripts/check-fixture-identifiers.mjs | 90 +++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 scripts/check-fixture-identifiers.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08db91a..700e676 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) diff --git a/scripts/check-fixture-identifiers.mjs b/scripts/check-fixture-identifiers.mjs new file mode 100644 index 0000000..48f3a38 --- /dev/null +++ b/scripts/check-fixture-identifiers.mjs @@ -0,0 +1,90 @@ +// 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 a suffix shape (dots, underscores) because one vector pins kid quoting. +const KID_OK = /^agent-[a-z0-9-]+([._-][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(', ')})`); From 2031a9b2594e8df6d2daf87602aae02d5db853aa Mon Sep 17 00:00:00 2001 From: mikezylos Date: Tue, 4 Aug 2026 19:13:32 +0800 Subject: [PATCH 2/3] ci: run gitleaks via its pinned CLI instead of the Action The gitleaks GitHub Action requires a paid licence for organisation repositories and failed in 4s with 'License key is required', so the job reported failure without scanning anything. The CLI it wraps has no such requirement. Scans the working tree and git history separately: 'gitleaks detect' only walks commit history, so a secret present in the tree but not yet committed passes it silently -- verified by planting a token that the working-tree scan catches and the history scan does not. --- .github/workflows/ci.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 700e676..86a8445 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,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 From 24b42633f6e5f91cea2d4d605eb21a777e269135 Mon Sep 17 00:00:00 2001 From: mikezylos Date: Tue, 4 Aug 2026 19:16:59 +0800 Subject: [PATCH 3/3] fix: remove exponential backtracking from the kid pattern CodeQL flagged the kid regex as high severity: with '-' present in both the group body and its separator class, an input like 'agent-' + '--' * n can be partitioned ambiguously and the match backtracks exponentially. Reproduced locally -- a 50 character input did not finish within 8 seconds. Rewritten as a single character class, so there is one quantifier and no ambiguity. A 50,000 character adversarial input now matches in under a millisecond. All real kid shapes are still accepted, including the dotted/underscored variant that pins kid quoting, and real identifiers are still rejected. --- scripts/check-fixture-identifiers.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/check-fixture-identifiers.mjs b/scripts/check-fixture-identifiers.mjs index 48f3a38..f6f4c55 100644 --- a/scripts/check-fixture-identifiers.mjs +++ b/scripts/check-fixture-identifiers.mjs @@ -9,8 +9,11 @@ import { join } from 'node:path'; const TENANT_OK = /^(test-tenant|tenant-[a-z0-9-]+)$/; const AGENT_OK = /^agent-[a-z0-9-]+$/; -// Kid may carry a suffix shape (dots, underscores) because one vector pins kid quoting. -const KID_OK = /^agent-[a-z0-9-]+([._-][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.