voyage/lib/validators/query-privacy-gate.mjs
Kjell Tore Guttormsen 21a96b9e31 fix(validators): hard-block the token formats the run-length patterns missed
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
2026-08-12 22:03:43 +02:00

119 lines
5 KiB
JavaScript

// lib/validators/query-privacy-gate.mjs
// Inspect an outbound research query before it leaves the machine. Called
// only from the new high-effort steps (Phase 4.5 dimension discovery + the
// bounded Phase 5 loop turns) — the existing single-pass Phase 5 path is
// unchanged (Step 6, plan-v2).
//
// Two-tier, same shape as lib/exporters/endpoint-validator.mjs's SSRF gate:
// - WARN tier — absolute filesystem paths, repo-internal identifiers.
// Operator-overridable via `strict: false` / `--soft` (matches
// lib/validators/research-validator.mjs's strict/soft convention), and
// fully bypassable via the VOYAGE_QUERY_PRIVACY_ALLOW=1 opt-in.
// - HARD-BLOCK tier — secret-shaped tokens. NEVER overridable by strict,
// --soft, or the opt-in env var — mirrors endpoint-validator.mjs's
// HARD_BLOCKED_HOSTS, where an opt-in widens the warn tier but never
// unlocks the permanently-blocked one.
//
// CLI shim:
// node lib/validators/query-privacy-gate.mjs [--soft] "<query text>"
// → JSON {valid, errors, warnings}; exit 0 valid, 1 invalid.
import { issue } from '../util/result.mjs';
// WARN tier — absolute filesystem paths (leaks local directory layout).
export const ABSOLUTE_PATH_PATTERNS = Object.freeze([
/\/Users\/[^\s"'`]+/,
/\/home\/[^\s"'`]+/,
/[A-Za-z]:\\[^\s"'`]+/,
/\$\{?HOME\}?\/[^\s"'`]+/,
]);
// WARN tier — repo-internal identifiers that don't need to leave the
// machine in a generic research query.
export const REPO_IDENTIFIER_PATTERNS = Object.freeze([
/git\.fromaitochitta\.com[^\s"'`]*/,
/\bktg-plugin-marketplace\b/,
/\bplugins\/cache\/[^\s"'`]+/,
]);
// HARD-BLOCK tier — secret-shaped strings. Never operator-overridable.
//
// Token bodies that contain `-` or `_` break a plain `[A-Za-z0-9]{n,}` run, so
// each such format needs its own pattern rather than relying on run length:
// an Anthropic Console key runs out after `api03` (3 alphanumerics), and a
// fine-grained GitHub PAT after its 22-character segment. Patterns whose body
// class includes `-`/`_` carry no trailing `\b`, which would not fire on a
// non-word final character.
export const SECRET_SHAPED_PATTERNS = Object.freeze([
/\bsk-[A-Za-z0-9]{20,}\b/, // OpenAI-style API key (sk-<48>)
/\bsk-ant-[a-z0-9]+-[A-Za-z0-9_-]{20,}/, // Anthropic Console key (sk-ant-api03-/-oat01- + ~95 base64url)
/\bAKIA[0-9A-Z]{16}\b/, // AWS access key ID
/\bgh[pousr]_[A-Za-z0-9]{36,}\b/, // GitHub classic PAT / OAuth / user / server / refresh token
/\bgithub_pat_[A-Za-z0-9_]{20,}/, // GitHub fine-grained PAT (github_pat_<22>_<59>)
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, // Slack token
/-----BEGIN [A-Z ]*PRIVATE KEY-----/, // PEM private key block
]);
function findMatch(patterns, text) {
for (const re of patterns) {
const m = re.exec(text);
if (m) return m[0];
}
return null;
}
/**
* @param {string} text
* @param {{strict?: boolean, env?: object}} [opts]
* @returns {{valid: boolean, errors: import('../util/result.mjs').Issue[], warnings: import('../util/result.mjs').Issue[]}}
*/
export function validateOutboundQuery(text, opts = {}) {
const strict = opts.strict !== false;
const env = opts.env || process.env;
// Bypasses the WARN tier entirely — never affects the hard-block tier below.
const allowWarnTier = env.VOYAGE_QUERY_PRIVACY_ALLOW === '1';
if (typeof text !== 'string' || text.length === 0) {
return { valid: false, errors: [issue('PRIVACY_EMPTY_QUERY', 'Outbound query must be a non-empty string')], warnings: [] };
}
const errors = [];
const warnings = [];
// Hard-block tier — checked unconditionally; no opt-in reaches this branch.
const secretMatch = findMatch(SECRET_SHAPED_PATTERNS, text);
if (secretMatch) {
errors.push(issue('PRIVACY_SECRET_SHAPED', `Outbound query contains a secret-shaped token: ${secretMatch}`));
}
if (!allowWarnTier) {
const pathMatch = findMatch(ABSOLUTE_PATH_PATTERNS, text);
if (pathMatch) {
const issueObj = issue('PRIVACY_ABSOLUTE_PATH', `Outbound query contains an absolute filesystem path: ${pathMatch}`);
if (strict) errors.push(issueObj); else warnings.push(issueObj);
}
const repoMatch = findMatch(REPO_IDENTIFIER_PATTERNS, text);
if (repoMatch) {
const issueObj = issue('PRIVACY_REPO_IDENTIFIER', `Outbound query contains a repo-internal identifier: ${repoMatch}`);
if (strict) errors.push(issueObj); else warnings.push(issueObj);
}
}
return { valid: errors.length === 0, errors, warnings };
}
// ---- CLI shim ----------------------------------------------------------------
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const strict = !args.includes('--soft');
const text = args.find(a => !a.startsWith('--'));
if (text === undefined) {
process.stderr.write('Usage: query-privacy-gate.mjs [--soft] "<query text>"\n');
process.exit(2);
}
const r = validateOutboundQuery(text, { strict });
process.stdout.write(JSON.stringify(r) + '\n');
process.exit(r.valid ? 0 : 1);
}