Step 11 av v4.1-execute (Wave 2, Session 3).
3 sikkerhets-validatorer for OTel-eksporten:
path-validator.mjs (CWE-22 Path Traversal):
- Reject `..` segmenter, `~`-shorthand
- realpathSync symlink-resolution (med macOS quirk: /etc, /var, /tmp er
symlinks til /private/etc, /private/var, /private/tmp — begge former
i FORBIDDEN_PREFIXES)
- Allowlist-først evaluering: hvis allowedRoots gitt, det er primary defense
(caller's threat model). Forbidden-prefix-denylist er FALLBACK når
allowedRoots ikke spesifisert.
endpoint-validator.mjs (CWE-918 SSRF):
- Reject loopback (127.0.0.1, ::1, localhost, 0.0.0.0) UNLESS VOYAGE_OTEL_ALLOW_PRIVATE=1
- Reject RFC-1918 (10/8, 172.16/12, 192.168/16) UNLESS opt-in
- Reject link-local (169.254.x.x cloud metadata, fe80:* IPv6) UNLESS opt-in
- Krev https:// for non-private endpoints
- node:url-parsing, ingen runtime DNS-resolusjon (defense-in-depth)
field-allowlist.mjs (CWE-212 Improper Cross-boundary Removal of Sensitive Data):
- INLINE static const Object.freeze på modul-scope (IKKE runtime read fra fixtures)
- Per-schema allowlist for alle 8 schema-id (trekbrief, trekresearch, trekplan,
trekexecute, event-emit, post-bash-stats, trekreview, trekcontinue)
- Source-comment per allowlist refererer tests/fixtures/jsonl-schemas.md
- post-bash-stats DROPPER eksplisitt command_excerpt + session_id (CWE-212)
- event-emit applies sub-allowlist på payload-objekt (recursive)
- Unknown schema-type returnerer conservative {_schema_id, ts}
Tester (19 nye, baseline 413 → 432):
- path-validator x6 (CWE-22 traversal, forbidden-system, ~, allowedRoots accept/reject, drift-pin)
- endpoint-validator x7 (CWE-918 link-local, RFC-1918, loopback, https-required, opt-in, public-accept, empty-input)
- field-allowlist x6 (CWE-212 post-bash-stats, trekplan-PII, event-emit-payload, unknown-schema, Object.freeze, null-safe)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
196 lines
7.8 KiB
JavaScript
196 lines
7.8 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/ link-local (cloud metadata, CWE-918)', () => {
|
|
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_LINK_LOCAL_REJECTED'));
|
|
});
|
|
|
|
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'));
|
|
});
|
|
|
|
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'), {});
|
|
});
|