Review finding 91e21c1f (MAJOR emitted, catalogue tier BLOCKER). Live in every
session regardless of the STORM flag.
The hard-block tier is the one thing no operator flag unlocks - not `strict`,
not `--soft`, not VOYAGE_QUERY_PRIVACY_ALLOW=1 - so a format it misses is a
secret leaving the machine in an outbound query with no second gate behind it.
Two patterns keyed on a run of consecutive alphanumerics, which a `-` or `_`
inside the token body breaks:
- /\bsk-[A-Za-z0-9]{20,}\b/ was commented "OpenAI/Anthropic-style" but the run
ends after `api03` (3 chars) in an Anthropic Console key, so
sk-ant-api03-<~95> passed through. Measured by execution, not read.
- /\bghp_[A-Za-z0-9]{36,}\b/ covered only the classic prefix: github_pat_<...>
and gho_<36> passed through; ghp_<36> was blocked.
Widened with one pattern per real-world format rather than one loose pattern,
so each stays readable and its length floor stays honest:
- sk-ant-<scheme>-<20+ base64url> (covers api03 and oat01)
- gh[pousr]_<36+> (classic PAT, OAuth, user, server, refresh)
- github_pat_<20+ incl. underscore> (fine-grained, real format is <22>_<59>)
Patterns whose body class includes `-`/`_` carry no trailing \b - it would not
fire on a non-word final character. The existing sk-/AKIA/xox/PEM patterns are
unchanged; the AWS comment is accurate as written, so it was left alone.
Formats verified against GitHub's token-format documentation and Anthropic key
anatomy before the patterns were written, not from memory:
- github.blog/engineering/platform-security/behind-githubs-new-authentication-token-formats/
- gh[pousr]_ + 36 chars; github_pat_ + <22>_<59> = 93 total
- sk-ant-api03- + ~95 base64url chars (base64url includes _ and -)
6 new tests: three table rows for the missed formats, and three that pin each
one blocked with `strict: false` AND the opt-in env var set at once - the
property that makes this tier meaningful.
Suite 952 (950/0/2, baseline 937 + 15 across both Track A fixes).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zNqxP8qTWgJhn3wMYUFEh
174 lines
8.1 KiB
JavaScript
174 lines
8.1 KiB
JavaScript
// tests/validators/query-privacy-gate.test.mjs
|
|
// Cover lib/validators/query-privacy-gate.mjs: two-sided code table
|
|
// (absolute path / repo-internal identifier / secret-shaped token), a
|
|
// benign query passing untouched, the opt-in env var reaching only the
|
|
// warn tier (never the hard-block tier), strict/soft severity, and the
|
|
// CLI shim.
|
|
//
|
|
// Secret-shaped fixtures are built via string concatenation/repeat, never
|
|
// as literal tokens — the repo's own secrets pre-edit hook (correctly)
|
|
// treats a literal AKIA/sk-/ghp_ string as a real credential.
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
validateOutboundQuery,
|
|
ABSOLUTE_PATH_PATTERNS,
|
|
REPO_IDENTIFIER_PATTERNS,
|
|
SECRET_SHAPED_PATTERNS,
|
|
} from '../../lib/validators/query-privacy-gate.mjs';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const SHIM = join(HERE, '..', '..', 'lib', 'validators', 'query-privacy-gate.mjs');
|
|
|
|
const FAKE_OPENAI_KEY = 'sk-' + 'a'.repeat(24);
|
|
const FAKE_AWS_KEY = 'AKIA' + 'Q'.repeat(16);
|
|
const FAKE_GITHUB_PAT = 'ghp_' + 'b'.repeat(36);
|
|
|
|
// Real-world formats whose token body contains hyphens/underscores. A run of
|
|
// plain alphanumerics is broken by those separators, so a naive
|
|
// `[A-Za-z0-9]{20,}` run-length pattern lets them through — the gap this file
|
|
// pins. Anthropic Console keys are `sk-ant-api03-` + ~95 base64url chars;
|
|
// GitHub fine-grained PATs are `github_pat_<22>_<59>`; `gho_` is the OAuth
|
|
// sibling of the classic `ghp_` token.
|
|
const FAKE_ANTHROPIC_KEY = 'sk-' + 'ant-' + 'api03-' + 'A1b2_-x9'.repeat(12);
|
|
const FAKE_GITHUB_FINE_GRAINED = 'github' + '_pat_' + 'A'.repeat(22) + '_' + 'c'.repeat(59);
|
|
const FAKE_GITHUB_OAUTH = 'gho' + '_' + 'd'.repeat(36);
|
|
|
|
function runShim(args) {
|
|
try {
|
|
const out = execFileSync(process.execPath, [SHIM, ...args], {
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
return { code: 0, out };
|
|
} catch (e) {
|
|
return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' };
|
|
}
|
|
}
|
|
|
|
// ---- two-sided code table ----------------------------------------------------
|
|
|
|
const TABLE = [
|
|
{ label: 'absolute path (/Users/...)', text: 'find every caller of foo in /Users/ktg/repos/voyage/lib/util/foo.mjs', code: 'PRIVACY_ABSOLUTE_PATH' },
|
|
{ label: 'absolute path (/home/...)', text: 'trace /home/alice/projects/app/src/index.js for imports', code: 'PRIVACY_ABSOLUTE_PATH' },
|
|
{ label: 'repo-internal identifier (forgejo host)', text: 'what changed recently on git.fromaitochitta.com/open/voyage', code: 'PRIVACY_REPO_IDENTIFIER' },
|
|
{ label: 'repo-internal identifier (repo name)', text: 'search issues for ktg-plugin-marketplace regressions', code: 'PRIVACY_REPO_IDENTIFIER' },
|
|
{ label: 'secret-shaped (OpenAI/Anthropic-style key)', text: `auth failing with key ${FAKE_OPENAI_KEY}`, code: 'PRIVACY_SECRET_SHAPED' },
|
|
{ label: 'secret-shaped (AWS access key)', text: `rotate ${FAKE_AWS_KEY} now`, code: 'PRIVACY_SECRET_SHAPED' },
|
|
{ label: 'secret-shaped (GitHub PAT)', text: `token leaked: ${FAKE_GITHUB_PAT}`, code: 'PRIVACY_SECRET_SHAPED' },
|
|
{ label: 'secret-shaped (Anthropic Console key)', text: `why does ${FAKE_ANTHROPIC_KEY} 401`, code: 'PRIVACY_SECRET_SHAPED' },
|
|
{ label: 'secret-shaped (GitHub fine-grained PAT)', text: `pushed with ${FAKE_GITHUB_FINE_GRAINED}`, code: 'PRIVACY_SECRET_SHAPED' },
|
|
{ label: 'secret-shaped (GitHub OAuth token)', text: `oauth flow returned ${FAKE_GITHUB_OAUTH}`, code: 'PRIVACY_SECRET_SHAPED' },
|
|
];
|
|
|
|
for (const { label, text, code } of TABLE) {
|
|
test(`validateOutboundQuery — ${label} → ${code} (strict, error)`, () => {
|
|
const r = validateOutboundQuery(text, { strict: true, env: {} });
|
|
assert.equal(r.valid, false);
|
|
assert.ok(r.errors.find(e => e.code === code), JSON.stringify(r.errors));
|
|
});
|
|
}
|
|
|
|
test('validateOutboundQuery — benign generic query passes untouched', () => {
|
|
const r = validateOutboundQuery('What are the tradeoffs between optimistic and pessimistic locking?', { env: {} });
|
|
assert.equal(r.valid, true);
|
|
assert.deepEqual(r.errors, []);
|
|
assert.deepEqual(r.warnings, []);
|
|
});
|
|
|
|
// ---- strict vs soft (warn tier only) -----------------------------------------
|
|
|
|
test('validateOutboundQuery — soft mode downgrades warn-tier findings to warnings, stays valid', () => {
|
|
const r = validateOutboundQuery('inspect /Users/ktg/repos/voyage', { strict: false, env: {} });
|
|
assert.equal(r.valid, true);
|
|
assert.equal(r.errors.length, 0);
|
|
assert.ok(r.warnings.find(w => w.code === 'PRIVACY_ABSOLUTE_PATH'));
|
|
});
|
|
|
|
test('validateOutboundQuery — soft mode does NOT downgrade the hard-block tier', () => {
|
|
const r = validateOutboundQuery(`leaked ${FAKE_OPENAI_KEY}`, { strict: false, env: {} });
|
|
assert.equal(r.valid, false);
|
|
assert.ok(r.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED'));
|
|
});
|
|
|
|
// ---- opt-in env var reaches only the warn tier -------------------------------
|
|
|
|
test('validateOutboundQuery — VOYAGE_QUERY_PRIVACY_ALLOW=1 bypasses the warn tier entirely', () => {
|
|
const r = validateOutboundQuery('inspect /Users/ktg/repos/voyage', { env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' } });
|
|
assert.equal(r.valid, true);
|
|
assert.equal(r.errors.length, 0);
|
|
assert.equal(r.warnings.length, 0);
|
|
});
|
|
|
|
test('validateOutboundQuery — VOYAGE_QUERY_PRIVACY_ALLOW=1 does NOT open the hard-block tier', () => {
|
|
const r = validateOutboundQuery(`leaked ${FAKE_OPENAI_KEY}`, { env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' } });
|
|
assert.equal(r.valid, false);
|
|
assert.ok(r.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED'), 'opt-in must never unlock the hard-block tier');
|
|
});
|
|
|
|
test('validateOutboundQuery — VOYAGE_QUERY_PRIVACY_ALLOW=1 combined with a secret still denies', () => {
|
|
const r = validateOutboundQuery(`/Users/ktg/x leaked ${FAKE_OPENAI_KEY}`, { env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' } });
|
|
assert.equal(r.valid, false);
|
|
assert.equal(r.errors.length, 1);
|
|
assert.equal(r.errors[0].code, 'PRIVACY_SECRET_SHAPED');
|
|
});
|
|
|
|
// The hard-block tier is the one thing no operator flag unlocks, so a format
|
|
// it misses is a secret leaving the machine with no second gate behind it.
|
|
// Pin every real-world format against BOTH escape hatches at once.
|
|
for (const [label, token] of [
|
|
['Anthropic Console key', FAKE_ANTHROPIC_KEY],
|
|
['GitHub fine-grained PAT', FAKE_GITHUB_FINE_GRAINED],
|
|
['GitHub OAuth token', FAKE_GITHUB_OAUTH],
|
|
]) {
|
|
test(`validateOutboundQuery — ${label} stays blocked under --soft and the opt-in`, () => {
|
|
const r = validateOutboundQuery(`leaked ${token}`, {
|
|
strict: false,
|
|
env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' },
|
|
});
|
|
assert.equal(r.valid, false, 'hard-block tier must never be overridable');
|
|
assert.ok(r.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED'));
|
|
});
|
|
}
|
|
|
|
// ---- empty input --------------------------------------------------------------
|
|
|
|
test('validateOutboundQuery — empty string is invalid', () => {
|
|
const r = validateOutboundQuery('', { env: {} });
|
|
assert.equal(r.valid, false);
|
|
assert.ok(r.errors.find(e => e.code === 'PRIVACY_EMPTY_QUERY'));
|
|
});
|
|
|
|
// ---- pattern set is frozen ----------------------------------------------------
|
|
|
|
test('pattern sets are Object.frozen', () => {
|
|
assert.equal(Object.isFrozen(ABSOLUTE_PATH_PATTERNS), true);
|
|
assert.equal(Object.isFrozen(REPO_IDENTIFIER_PATTERNS), true);
|
|
assert.equal(Object.isFrozen(SECRET_SHAPED_PATTERNS), true);
|
|
});
|
|
|
|
// ---- CLI shim -----------------------------------------------------------------
|
|
|
|
test('CLI shim — benign query exits 0 with valid:true', () => {
|
|
const r = runShim(['harmless generic question about caching strategies']);
|
|
assert.equal(r.code, 0);
|
|
const parsed = JSON.parse(r.out.trim());
|
|
assert.equal(parsed.valid, true);
|
|
});
|
|
|
|
test('CLI shim — secret-shaped query exits 1 even with --soft', () => {
|
|
const r = runShim(['--soft', `leaked ${FAKE_OPENAI_KEY}`]);
|
|
assert.equal(r.code, 1);
|
|
const parsed = JSON.parse(r.out.trim());
|
|
assert.equal(parsed.valid, false);
|
|
assert.ok(parsed.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED'));
|
|
});
|
|
|
|
test('CLI shim — missing query argument exits 2 (usage error)', () => {
|
|
const r = runShim([]);
|
|
assert.equal(r.code, 2);
|
|
});
|