voyage/lib/exporters/field-allowlist.mjs

150 lines
5.8 KiB
JavaScript

// 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',
]));
// Source: tests/fixtures/jsonl-schemas.md row 9 (token-usage — SKAL-2)
// CWE-212: numeric + low-cardinality-label fields ONLY. DENY BY OMISSION:
// session_id (UUID), transcript_path (filesystem path), cwd (filesystem path)
// are written into the jsonl for upsert keying but MUST NOT reach the exporter.
const TOKEN_USAGE_ALLOWED = Object.freeze(new Set([
'ts', 'scope', 'model',
'tokens_input', 'tokens_output', 'tokens_cache_creation', 'tokens_cache_read',
'cost_usd', 'is_estimate', 'price_table_version',
]));
// 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,
'token-usage': TOKEN_USAGE_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,
TOKEN_USAGE_ALLOWED,
};