Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
93 changes: 93 additions & 0 deletions scripts/check-fixture-identifiers.mjs
Original file line number Diff line number Diff line change
@@ -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(', ')})`);
Loading