From 9e01ce30b54443a23199508c589d2ee8693787e0 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 9 May 2026 09:36:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(voyage):=20add=20lib/exporters/{path,endpo?= =?UTF-8?q?int,field-allowlist}-validators=20=E2=80=94=20CWE-22,=20CWE-918?= =?UTF-8?q?,=20CWE-212=20mitigering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../lib/exporters/endpoint-validator.mjs | 91 ++++++++ .../voyage/lib/exporters/field-allowlist.mjs | 138 ++++++++++++ .../voyage/lib/exporters/path-validator.mjs | 105 ++++++++++ .../hooks/otel-export-validators.test.mjs | 196 ++++++++++++++++++ 4 files changed, 530 insertions(+) create mode 100644 plugins/voyage/lib/exporters/endpoint-validator.mjs create mode 100644 plugins/voyage/lib/exporters/field-allowlist.mjs create mode 100644 plugins/voyage/lib/exporters/path-validator.mjs create mode 100644 plugins/voyage/tests/hooks/otel-export-validators.test.mjs diff --git a/plugins/voyage/lib/exporters/endpoint-validator.mjs b/plugins/voyage/lib/exporters/endpoint-validator.mjs new file mode 100644 index 0000000..da1a767 --- /dev/null +++ b/plugins/voyage/lib/exporters/endpoint-validator.mjs @@ -0,0 +1,91 @@ +// lib/exporters/endpoint-validator.mjs +// Validate OTLP/HTTP endpoint URLs for the OTel exporter. +// +// CWE-918 (Server-Side Request Forgery) mitigation: reject loopback, RFC-1918, +// link-local (cloud metadata 169.254.169.254), and require HTTPS for non-loopback. +// Operator opt-in for private endpoints via VOYAGE_OTEL_ALLOW_PRIVATE=1 +// (legitimate home-lab / docker-compose operator scenario). + +import { ok, fail, issue } from '../util/result.mjs'; + +const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost', '0.0.0.0']); +const LINK_LOCAL_PREFIXES = ['169.254.', 'fe80:']; + +function isRfc1918(host) { + // 10.0.0.0/8 + if (/^10\./.test(host)) return true; + // 172.16.0.0/12 + if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true; + // 192.168.0.0/16 + if (/^192\.168\./.test(host)) return true; + return false; +} + +function isLoopback(host) { + return LOOPBACK_HOSTS.has(host); +} + +function isLinkLocal(host) { + return LINK_LOCAL_PREFIXES.some(p => host.startsWith(p)); +} + +/** + * Validate an OTLP/HTTP endpoint URL. + * + * @param {string} url + * @param {{env?: object}} [opts] + * @returns {import('../util/result.mjs').Result} + */ +export function validateOtlpEndpoint(url, opts = {}) { + const env = opts.env || process.env; + const allowPrivate = env.VOYAGE_OTEL_ALLOW_PRIVATE === '1'; + + if (typeof url !== 'string' || url.length === 0) { + return fail(issue('ENDPOINT_EMPTY', 'Endpoint must be a non-empty string')); + } + + let parsed; + try { parsed = new URL(url); } + catch (e) { + return fail(issue('ENDPOINT_PARSE_ERROR', `Invalid URL: ${e.message}`)); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return fail(issue('ENDPOINT_BAD_PROTOCOL', + `Endpoint protocol must be http or https, got ${parsed.protocol}`)); + } + + // Strip brackets from IPv6 + const host = parsed.hostname.replace(/^\[|\]$/g, ''); + + // Cloud metadata IP (link-local) — always rejected unless explicit opt-in + if (isLinkLocal(host) && !allowPrivate) { + return fail(issue('ENDPOINT_LINK_LOCAL_REJECTED', + `Link-local endpoint ${host} rejected (potential cloud-metadata access). ` + + `Set VOYAGE_OTEL_ALLOW_PRIVATE=1 to allow.`)); + } + + // Loopback / RFC-1918 — rejected unless opt-in + if (isLoopback(host) && !allowPrivate) { + return fail(issue('ENDPOINT_LOOPBACK_REJECTED', + `Loopback endpoint ${host} rejected. Set VOYAGE_OTEL_ALLOW_PRIVATE=1 for ` + + `home-lab / docker-compose scenarios.`)); + } + + if (isRfc1918(host) && !allowPrivate) { + return fail(issue('ENDPOINT_RFC1918_REJECTED', + `RFC-1918 private endpoint ${host} rejected. ` + + `Set VOYAGE_OTEL_ALLOW_PRIVATE=1 for home-lab scenarios.`)); + } + + // For non-loopback, non-private endpoints: require HTTPS + const isPrivate = isLoopback(host) || isRfc1918(host) || isLinkLocal(host); + if (!isPrivate && parsed.protocol === 'http:') { + return fail(issue('ENDPOINT_HTTPS_REQUIRED', + `Public endpoint ${host} requires https:// (got http://)`)); + } + + return ok({ url: parsed.href, host, isPrivate }); +} + +export { LOOPBACK_HOSTS, LINK_LOCAL_PREFIXES }; diff --git a/plugins/voyage/lib/exporters/field-allowlist.mjs b/plugins/voyage/lib/exporters/field-allowlist.mjs new file mode 100644 index 0000000..d5226e0 --- /dev/null +++ b/plugins/voyage/lib/exporters/field-allowlist.mjs @@ -0,0 +1,138 @@ +// lib/exporters/field-allowlist.mjs +// CWE-212 (Improper Cross-boundary Removal of Sensitive Data) mitigation: +// inline static allowlist that strips PII / high-cardinality fields before +// records reach the OTel exporter. +// +// The allowlist is INLINE STATIC at module-scope (Object.freeze). NEVER read +// runtime from tests/fixtures/ — fixture file is AUTHORING reference only +// (Source: tests/fixtures/jsonl-schemas.md per Step 1 audit). +// +// Per-schema allowlists drop: +// - command_excerpt (post-bash-stats: arbitrary Bash slice, CWE-212) +// - session_id (UUID, high-cardinality, log-only) +// - task / question / project_dir / brief_path / plan / project (PII-ish prose, paths) +// - payload (event-emit: open-ended object, allowlist payload-keys instead) +// - phase_models (structured object, summarize via `profile` label instead) +// - counts (review nested object, flatten to per-severity metrics elsewhere) + +// ---- Per-schema allowlists (Source: tests/fixtures/jsonl-schemas.md) ------- + +// Source: tests/fixtures/jsonl-schemas.md row 1 (trekbrief) +const TREKBRIEF_ALLOWED = Object.freeze(new Set([ + 'ts', 'slug', 'mode', 'interview_turns', 'review_iterations', + 'brief_quality', 'research_topics', 'auto_research', 'auto_result', + 'profile', 'profile_source', +])); + +// Source: tests/fixtures/jsonl-schemas.md row 2 (trekresearch) +const TREKRESEARCH_ALLOWED = Object.freeze(new Set([ + 'ts', 'slug', 'mode', 'scope', 'dimensions', 'agents_local', + 'agents_external', 'gemini_used', 'confidence', 'contradictions', + 'open_questions', 'profile', 'parallel_agents', + 'external_research_enabled', 'profile_source', +])); + +// Source: tests/fixtures/jsonl-schemas.md row 3 (trekplan) +const TREKPLAN_ALLOWED = Object.freeze(new Set([ + 'ts', 'slug', 'mode', 'codebase_size', 'codebase_files', + 'agents_deployed', 'deep_dives', 'research_briefs_used', + 'research_scout_used', 'critic_verdict', 'guardian_verdict', 'outcome', + 'profile', 'parallel_agents', 'profile_source', +])); + +// Source: tests/fixtures/jsonl-schemas.md row 4 (trekexecute Phase 9 record) +const TREKEXECUTE_ALLOWED = Object.freeze(new Set([ + 'ts', 'plan_type', 'mode', 'result', 'steps_total', 'steps_passed', + 'steps_failed', 'steps_skipped', 'failed_at_step', + 'profile', 'profile_source', +])); + +// Source: tests/fixtures/jsonl-schemas.md row 5 (trekexecute autonomy events) +const EVENT_EMIT_ALLOWED = Object.freeze(new Set([ + 'ts', 'event', 'known_event', +])); + +// Source: tests/fixtures/jsonl-schemas.md row 5 (event-emit payload sub-allowlist) +const EVENT_EMIT_PAYLOAD_ALLOWED = Object.freeze(new Set([ + 'profile', 'phase_models', 'parallel_agents', + 'external_research_enabled', 'profile_source', + 'brief_quality', 'plan_grade', +])); + +// Source: tests/fixtures/jsonl-schemas.md row 6 (post-bash-stats — PII-flag) +// CWE-212: command_excerpt + session_id MUST be stripped. +const POST_BASH_STATS_ALLOWED = Object.freeze(new Set([ + 'ts', 'duration_ms', 'success', +])); + +// Source: tests/fixtures/jsonl-schemas.md row 7 (trekreview) +const TREKREVIEW_ALLOWED = Object.freeze(new Set([ + 'ts', 'slug', 'verdict', 'reviewed_files_count', 'mode', + 'duration_ms', 'profile', 'profile_source', +])); + +// Source: tests/fixtures/jsonl-schemas.md row 8 (trekcontinue) +const TREKCONTINUE_ALLOWED = Object.freeze(new Set([ + 'ts', 'next_session_label', 'status', 'profile', 'profile_source', +])); + +// Schema-id → allowlist set +const SCHEMA_ALLOWLISTS = Object.freeze({ + 'trekbrief': TREKBRIEF_ALLOWED, + 'trekresearch': TREKRESEARCH_ALLOWED, + 'trekplan': TREKPLAN_ALLOWED, + 'trekexecute': TREKEXECUTE_ALLOWED, + 'event-emit': EVENT_EMIT_ALLOWED, + 'post-bash-stats': POST_BASH_STATS_ALLOWED, + 'post_bash_stats': POST_BASH_STATS_ALLOWED, // common alt-spelling + 'trekreview': TREKREVIEW_ALLOWED, + 'trekcontinue': TREKCONTINUE_ALLOWED, +}); + +/** + * Apply field-allowlist to a record. Drops any field not in the schema's + * allowlist. Returns a new object (does not mutate input). + * + * Always preserves `_schema_id` (caller-supplied identifier used by exporters + * to group records by source). + * + * @param {object} record + * @param {string} schemaType Schema-id (e.g. 'trekplan', 'post-bash-stats') + * @returns {object} Redacted record with only allowlisted fields. + */ +export function applyFieldAllowlist(record, schemaType) { + if (!record || typeof record !== 'object') return {}; + const allowed = SCHEMA_ALLOWLISTS[schemaType]; + if (!allowed) { + // Unknown schema-type: be conservative — keep ts only + _schema_id. + return record.ts ? { _schema_id: schemaType, ts: record.ts } : { _schema_id: schemaType }; + } + const out = { _schema_id: schemaType }; + for (const k of allowed) { + if (k in record) out[k] = record[k]; + } + + // Special: event-emit records have a `payload` sub-object that needs its + // own allowlist applied recursively. + if (schemaType === 'event-emit' && record.payload && typeof record.payload === 'object') { + const subOut = {}; + for (const k of EVENT_EMIT_PAYLOAD_ALLOWED) { + if (k in record.payload) subOut[k] = record.payload[k]; + } + out.payload = subOut; + } + + return out; +} + +export { + SCHEMA_ALLOWLISTS, + EVENT_EMIT_PAYLOAD_ALLOWED, + POST_BASH_STATS_ALLOWED, + TREKBRIEF_ALLOWED, + TREKRESEARCH_ALLOWED, + TREKPLAN_ALLOWED, + TREKEXECUTE_ALLOWED, + TREKREVIEW_ALLOWED, + TREKCONTINUE_ALLOWED, +}; diff --git a/plugins/voyage/lib/exporters/path-validator.mjs b/plugins/voyage/lib/exporters/path-validator.mjs new file mode 100644 index 0000000..06262f6 --- /dev/null +++ b/plugins/voyage/lib/exporters/path-validator.mjs @@ -0,0 +1,105 @@ +// lib/exporters/path-validator.mjs +// Validate textfile output paths for the OTel exporter. +// +// CWE-22 (Path Traversal) mitigation: restrict writes to allowlist-anchored +// directories only. Reject `..`, absolute system paths (`/etc`, `/proc`, `/sys`, +// `/var/`, `/usr/`), home-shorthand `~`, and resolve symlinks via +// `fs.realpathSync` before checking. + +import { realpathSync, existsSync, statSync } from 'node:fs'; +import { resolve, normalize, sep } from 'node:path'; +import { ok, fail, issue } from '../util/result.mjs'; + +// macOS quirk: /etc, /tmp, /var are symlinks to /private/etc, /private/tmp, +// /private/var; realpathSync resolves the symlink and adds the /private prefix. +// Include both forms so the deny check works on macOS + Linux. +const FORBIDDEN_PREFIXES = [ + '/etc/', '/private/etc/', + '/proc/', + '/sys/', + '/var/', '/private/var/', + '/usr/', + '/bin/', + '/sbin/', + '/boot/', + '/dev/', +]; + +/** + * Validate that a path is safe for the OTel textfile exporter to write. + * + * @param {string} path Caller-supplied path. + * @param {{ + * allowedRoots?: string[] // additional allow-list roots (e.g. CLAUDE_PLUGIN_DATA, VOYAGE_TEXTFILE_DIR) + * }} [opts] + * @returns {import('../util/result.mjs').Result} + */ +export function validateTextfilePath(path, opts = {}) { + if (typeof path !== 'string' || path.length === 0) { + return fail(issue('PATH_EMPTY', 'Path must be a non-empty string')); + } + + // Reject home-shorthand — caller must expand explicitly + if (path.startsWith('~')) { + return fail(issue('PATH_HOME_SHORTHAND', `Path uses ~ shorthand (caller must expand): ${path}`)); + } + + // Normalize to absolute (relative becomes resolved against cwd) + const normalized = normalize(path); + + // Reject any path component containing `..` (traversal attempt) + // Even after normalize, if `..` survives, the path leaves intended root. + const segments = normalized.split(sep); + if (segments.some(s => s === '..')) { + return fail(issue('PATH_TRAVERSAL', `Path contains traversal segment "..": ${path}`)); + } + + const absolute = resolve(normalized); + + // Resolve symlinks if file exists; if it doesn't exist yet, resolve parent + let resolved; + try { + if (existsSync(absolute)) { + resolved = realpathSync(absolute); + } else { + // Resolve parent dir (which must exist for any meaningful write target) + const parent = absolute.split(sep).slice(0, -1).join(sep) || '/'; + if (!existsSync(parent)) { + return fail(issue('PATH_PARENT_MISSING', `Parent directory does not exist: ${parent}`)); + } + resolved = realpathSync(parent) + sep + absolute.split(sep).pop(); + } + } catch (e) { + return fail(issue('PATH_RESOLVE_ERROR', `realpath failed: ${e.message}`)); + } + + // If allowedRoots is provided, that's the primary defense — caller has + // explicitly opted into a root. Reject anything outside; accept anything + // inside (callers vetting their roots is the threat model). + if (Array.isArray(opts.allowedRoots) && opts.allowedRoots.length > 0) { + const inside = opts.allowedRoots.some(root => { + if (typeof root !== 'string' || root.length === 0) return false; + let resolvedRoot; + try { resolvedRoot = realpathSync(root); } + catch { resolvedRoot = resolve(root); } + return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + sep); + }); + if (!inside) { + return fail(issue('PATH_OUT_OF_ALLOWLIST', + `Path ${resolved} is not under any allowed root: ${opts.allowedRoots.join(', ')}`)); + } + return ok({ path: resolved }); + } + + // No allowedRoots: fall back to forbidden-system-prefix denylist. + for (const prefix of FORBIDDEN_PREFIXES) { + if (resolved.startsWith(prefix)) { + return fail(issue('PATH_FORBIDDEN_SYSTEM', + `Path resolves into forbidden system directory ${prefix}: ${resolved}`)); + } + } + + return ok({ path: resolved }); +} + +export { FORBIDDEN_PREFIXES }; diff --git a/plugins/voyage/tests/hooks/otel-export-validators.test.mjs b/plugins/voyage/tests/hooks/otel-export-validators.test.mjs new file mode 100644 index 0000000..43e4974 --- /dev/null +++ b/plugins/voyage/tests/hooks/otel-export-validators.test.mjs @@ -0,0 +1,196 @@ +// 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'), {}); +});