voyage/tests/hooks/otel-export-validators.test.mjs
Kjell Tore Guttormsen b208e4ee04 fix(voyage): S21 — close IPv4-mapped IPv6 SSRF bypass + security/safety audit (blind spot #2)
S21 = devil's-advocate blind spot #2 (security/safety), operator scope
"security-core": fix genuine defects, honestly record the rest.

SSRF fix (CWE-918). validateOtlpEndpoint classified the host by literal
string match. Decimal/hex/octal/trailing-dot encodings are already caught
(the WHATWG URL parser canonicalizes them), but IPv4-mapped IPv6 literals
(::ffff:127.0.0.1, ::ffff:192.168.x, ::ffff:169.254.169.254) render as
::ffff:HHHH:HHHH and matched neither the loopback set, the RFC-1918 regex,
link-local, nor HARD_BLOCKED_HOSTS — they passed over https and would reach
loopback / private / cloud-metadata, defeating the comment's "PERMANENTLY
block metadata" promise. Added mappedV4() to decode the embedded IPv4
(dotted + hex-pair forms) and classify on it. TDD: 4 tests failing-first —
mapped loopback/RFC-1918/metadata rejected (metadata stays HARD_BLOCKED even
with VOYAGE_OTEL_ALLOW_PRIVATE=1), mapped public ::ffff:8.8.8.8 still valid
(no over-block). Threat model narrow (endpoint is operator-set env; export
opt-in; a brief cannot set env).

Survivor #18 honest-residual. T2-bakeoff §3 "Classifier interference: 0"
read as satisfied, but the auto/bypass-mode re-run that matters for headless
trekreview was never run (mode is operator-set, not settable in-session) —
footnoted, not gating. Per recommendation #9, moved to an OPEN RESIDUAL
(untested), guarded by a new doc-consistency pin.

Audit record. ## S21 resolution block in devils-advocate-results.md
dispositions all four sub-questions: SSRF (fixed), hooks-block (verified;
advisory-rail residuals: Write-only matcher, Bash-redirect, regex gaps — by
design), malicious-brief-headless (catastrophe-blocked, exfil NOT blocked —
inherent limit). S21b flagged (NOT done): operations.md:15 mis-describes
autonomy-gate.mjs state machine + --gates table.

Test count: real baseline 700 (698 pass / 2 skip), NOT 715 — the node:test
headline carried in S20 commit msgs was stale/miscounted (census figures
were correct). Now 705 (703 pass / 2 skip / 0 fail); census behavior
601->605, doc-pins 71->72, total 672->677.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqBYc8Ltrk7LipyJmGxXiB
2026-06-19 20:02:56 +02:00

249 lines
11 KiB
JavaScript

// tests/hooks/otel-export-validators.test.mjs
// Step 11 validators: path, endpoint, field-allowlist.
// CWE-22, CWE-918, CWE-212 mitigation.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { validateTextfilePath, FORBIDDEN_PREFIXES } from '../../lib/exporters/path-validator.mjs';
import { validateOtlpEndpoint } from '../../lib/exporters/endpoint-validator.mjs';
import {
applyFieldAllowlist,
POST_BASH_STATS_ALLOWED,
EVENT_EMIT_PAYLOAD_ALLOWED,
} from '../../lib/exporters/field-allowlist.mjs';
// ---- path-validator: CWE-22 mitigation -------------------------------------
test('path-validator: rejects ../etc/passwd traversal (CWE-22)', () => {
const r = validateTextfilePath('../../etc/passwd');
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PATH_TRAVERSAL'));
});
test('path-validator: rejects /etc/voyage.prom (forbidden system prefix)', () => {
const r = validateTextfilePath('/etc/voyage.prom');
assert.equal(r.valid, false);
// Either forbidden-system or parent-missing (both are deny-paths)
const denied = r.errors.find(e =>
e.code === 'PATH_FORBIDDEN_SYSTEM' || e.code === 'PATH_PARENT_MISSING');
assert.ok(denied, `expected deny, got: ${JSON.stringify(r.errors)}`);
});
test('path-validator: rejects ~ home shorthand', () => {
const r = validateTextfilePath('~/voyage.prom');
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PATH_HOME_SHORTHAND'));
});
test('path-validator: accepts path under allowedRoots', () => {
const tmp = mkdtempSync(join(tmpdir(), 'voyage-path-allow-'));
try {
const target = join(tmp, 'voyage.prom');
const r = validateTextfilePath(target, { allowedRoots: [tmp] });
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.match(r.parsed.path, /voyage\.prom$/);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test('path-validator: rejects path outside allowedRoots', () => {
const tmp = mkdtempSync(join(tmpdir(), 'voyage-path-deny-'));
const otherTmp = mkdtempSync(join(tmpdir(), 'voyage-path-other-'));
try {
const target = join(otherTmp, 'voyage.prom');
const r = validateTextfilePath(target, { allowedRoots: [tmp] });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PATH_OUT_OF_ALLOWLIST'));
} finally {
rmSync(tmp, { recursive: true, force: true });
rmSync(otherTmp, { recursive: true, force: true });
}
});
test('path-validator: FORBIDDEN_PREFIXES exports drift-pin', () => {
// Ensure all the high-risk system paths are present
for (const prefix of ['/etc/', '/proc/', '/sys/', '/var/', '/usr/']) {
assert.ok(FORBIDDEN_PREFIXES.includes(prefix),
`FORBIDDEN_PREFIXES missing critical path: ${prefix}`);
}
});
// ---- endpoint-validator: CWE-918 mitigation -------------------------------
test('endpoint-validator: rejects http://169.254.169.254/ — PERMANENTLY blocked (CWE-918 cloud metadata)', () => {
const r = validateOtlpEndpoint('http://169.254.169.254/v1/metrics', { env: {} });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_HARD_BLOCKED'));
});
test('endpoint-validator: 169.254.169.254 stays blocked EVEN WITH VOYAGE_OTEL_ALLOW_PRIVATE=1', () => {
// Cloud metadata service is qualitatively different from RFC-1918 home-lab
// access — operator-trust is NOT extended here. AWS/GCP/Azure metadata
// exposes IAM credentials and can compromise the entire cloud account.
const r = validateOtlpEndpoint('http://169.254.169.254/v1/metrics',
{ env: { VOYAGE_OTEL_ALLOW_PRIVATE: '1' } });
assert.equal(r.valid, false, 'cloud metadata MUST stay blocked even with opt-in');
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_HARD_BLOCKED'));
});
test('endpoint-validator: AliCloud metadata 100.100.100.200 PERMANENTLY blocked', () => {
const r = validateOtlpEndpoint('http://100.100.100.200/latest/meta-data',
{ env: { VOYAGE_OTEL_ALLOW_PRIVATE: '1' } });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_HARD_BLOCKED'));
});
test('endpoint-validator: metadata.google.internal hostname PERMANENTLY blocked', () => {
const r = validateOtlpEndpoint('http://metadata.google.internal/computeMetadata/v1',
{ env: { VOYAGE_OTEL_ALLOW_PRIVATE: '1' } });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_HARD_BLOCKED'));
});
test('endpoint-validator: rejects http://example.com/ (requires https)', () => {
const r = validateOtlpEndpoint('http://example.com/v1/metrics', { env: {} });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_HTTPS_REQUIRED'));
});
test('endpoint-validator: rejects http://localhost without VOYAGE_OTEL_ALLOW_PRIVATE', () => {
const r = validateOtlpEndpoint('http://localhost:4318/v1/metrics', { env: {} });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_LOOPBACK_REJECTED'));
});
test('endpoint-validator: accepts http://localhost when VOYAGE_OTEL_ALLOW_PRIVATE=1 (home-lab opt-in)', () => {
const r = validateOtlpEndpoint('http://localhost:4318/v1/metrics',
{ env: { VOYAGE_OTEL_ALLOW_PRIVATE: '1' } });
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.equal(r.parsed.isPrivate, true);
});
test('endpoint-validator: accepts https://example.com/v1/metrics (public)', () => {
const r = validateOtlpEndpoint('https://otel.example.com/v1/metrics', { env: {} });
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.equal(r.parsed.isPrivate, false);
});
test('endpoint-validator: rejects RFC-1918 192.168.1.1 without opt-in', () => {
const r = validateOtlpEndpoint('http://192.168.1.1:4318/v1/metrics', { env: {} });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_RFC1918_REJECTED'));
});
// IPv4-mapped IPv6 bypass (S21): `::ffff:a.b.c.d` literals render as
// `::ffff:HHHH:HHHH` and route to the embedded IPv4. The host must be
// canonicalized to its embedded IPv4 BEFORE classification, or loopback /
// RFC-1918 / link-local / cloud-metadata guards are all bypassable over https.
test('endpoint-validator: rejects IPv4-mapped IPv6 loopback [::ffff:127.0.0.1] (S21 bypass)', () => {
const r = validateOtlpEndpoint('https://[::ffff:127.0.0.1]/v1/metrics', { env: {} });
assert.equal(r.valid, false, 'IPv4-mapped loopback must be rejected');
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_LOOPBACK_REJECTED'));
});
test('endpoint-validator: rejects IPv4-mapped IPv6 RFC-1918 [::ffff:192.168.0.5] (S21 bypass)', () => {
const r = validateOtlpEndpoint('https://[::ffff:192.168.0.5]/v1/metrics', { env: {} });
assert.equal(r.valid, false, 'IPv4-mapped RFC-1918 must be rejected');
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_RFC1918_REJECTED'));
});
test('endpoint-validator: IPv4-mapped metadata [::ffff:169.254.169.254] HARD_BLOCKED even with opt-in (S21 bypass)', () => {
const r = validateOtlpEndpoint('https://[::ffff:169.254.169.254]/latest/meta-data',
{ env: { VOYAGE_OTEL_ALLOW_PRIVATE: '1' } });
assert.equal(r.valid, false, 'IPv4-mapped cloud-metadata MUST stay hard-blocked');
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_HARD_BLOCKED'));
});
test('endpoint-validator: accepts IPv4-mapped IPv6 public [::ffff:8.8.8.8] over https (no over-block)', () => {
const r = validateOtlpEndpoint('https://[::ffff:8.8.8.8]/v1/metrics', { env: {} });
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.equal(r.parsed.isPrivate, false);
});
test('endpoint-validator: rejects empty / non-string', () => {
assert.equal(validateOtlpEndpoint('').valid, false);
assert.equal(validateOtlpEndpoint(null).valid, false);
assert.equal(validateOtlpEndpoint(undefined).valid, false);
});
// ---- field-allowlist: CWE-212 mitigation -----------------------------------
test('field-allowlist: post-bash-stats DROPS command_excerpt + session_id (CWE-212)', () => {
const record = {
ts: '2026-05-09T08:00:00.000Z',
session_id: 'uuid-12345',
command_excerpt: 'git clone https://example.com/secret/repo',
duration_ms: 152,
success: true,
};
const out = applyFieldAllowlist(record, 'post-bash-stats');
assert.equal('command_excerpt' in out, false, 'command_excerpt MUST be stripped');
assert.equal('session_id' in out, false, 'session_id MUST be stripped');
assert.equal(out.duration_ms, 152);
assert.equal(out.success, true);
assert.equal(out._schema_id, 'post-bash-stats');
});
test('field-allowlist: trekplan DROPS task / project_dir / brief_path (PII)', () => {
const record = {
ts: '2026-05-09T08:00:00.000Z',
task: 'private user prose with PII',
slug: 'add-auth',
project_dir: '/home/user/secret/project',
brief_path: '/home/user/secret/brief.md',
codebase_files: 156,
profile: 'premium',
};
const out = applyFieldAllowlist(record, 'trekplan');
assert.equal('task' in out, false);
assert.equal('project_dir' in out, false);
assert.equal('brief_path' in out, false);
assert.equal(out.slug, 'add-auth');
assert.equal(out.codebase_files, 156);
assert.equal(out.profile, 'premium');
});
test('field-allowlist: event-emit applies sub-allowlist to payload', () => {
const record = {
ts: '2026-05-09T08:00:00.000Z',
event: 'main-merge-gate',
known_event: true,
payload: {
profile: 'balanced',
profile_source: 'inheritance',
command_excerpt: 'should be stripped from payload',
raw_user_prose: 'should be stripped',
},
};
const out = applyFieldAllowlist(record, 'event-emit');
assert.equal(out.event, 'main-merge-gate');
assert.equal(out.payload.profile, 'balanced');
assert.equal(out.payload.profile_source, 'inheritance');
assert.equal('command_excerpt' in out.payload, false);
assert.equal('raw_user_prose' in out.payload, false);
});
test('field-allowlist: unknown schema-type returns conservative {ts, _schema_id} only', () => {
const out = applyFieldAllowlist(
{ ts: '2026-05-09T08:00:00.000Z', sensitive: 'secret' },
'totally-unknown-schema',
);
assert.equal('sensitive' in out, false);
assert.equal(out.ts, '2026-05-09T08:00:00.000Z');
assert.equal(out._schema_id, 'totally-unknown-schema');
});
test('field-allowlist: Object.freeze on allowlists (drift-pin)', () => {
assert.equal(Object.isFrozen(POST_BASH_STATS_ALLOWED), true,
'POST_BASH_STATS_ALLOWED must be frozen — runtime mutation prevention');
assert.equal(Object.isFrozen(EVENT_EMIT_PAYLOAD_ALLOWED), true);
});
test('field-allowlist: null/undefined record handled safely', () => {
assert.deepEqual(applyFieldAllowlist(null, 'trekplan'), {});
assert.deepEqual(applyFieldAllowlist(undefined, 'trekplan'), {});
});