/** * Triage-note sanitization (Phase 21, NOTE-01). * * SECURITY-CRITICAL. This module exists so that no raw secret, token, or full * malicious URL query string ever reaches an Autotask note posted by Pulse. * `formatTriageNote()` (triage-note-format.ts) routes every URL and its final * assembled output through these two pure functions before returning text * that gets written to `TicketNotes`. * * Two things this module deliberately does NOT redact, per * `.planning/phases/21-autotask-triage-note/21-CONTEXT.md` (Claude's * Discretion): bare sender email addresses and attachment hashes. Those are * evidence about the phishing campaign itself, not credentials belonging to * Pulse or its operators — an analyst needs to see them to triage. */ export const REDACTED_MARKER = '[REDACTED]'; /** Keys that indicate a credential-bearing query param. Broad on purpose — * false positives (redacting a benign param) are acceptable; leaking a real * token is not. */ const CREDENTIAL_QUERY_KEY_PATTERN = /token|secret|password|api[_-]?key|key|credential/i; /** Matches a `key=value` pair inside a query string / free text where `key` * looks like a credential. Value is greedy up to the next `&`, whitespace, or * end of string. */ const CREDENTIAL_PARAM_REGEX = /\b([A-Za-z_][A-Za-z0-9_-]*)=([^&\s]+)/g; /** Matches `Bearer ` or `Authorization: ` sequences. */ const BEARER_AUTH_REGEX = /\b(Bearer\s+|Authorization:\s*)([^\s,;]+)/gi; /** Matches a full http(s) URL substring embedded in free text. */ const URL_IN_TEXT_REGEX = /\bhttps?:\/\/[^\s)]+/gi; /** * Strip a URL down to scheme+host+path — query string and fragment are ALWAYS * removed, never selectively kept, even for seemingly-benign params. This * satisfies NOTE-01's "no full malicious URL query strings" requirement * without needing to distinguish safe from unsafe params. * * Never throws: a malformed/non-URL string falls back to truncating at the * first `?` or `#`, returning the head unchanged if neither is present. */ export function sanitizeUrl(value: string): string { if (!value) return value; try { const url = new URL(value); return url.origin + url.pathname; } catch { const cutIndex = value.search(/[?#]/); return cutIndex === -1 ? value : value.slice(0, cutIndex); } } /** * Redact secrets/tokens from free text destined for an Autotask note, and * strip query strings from any embedded URLs. Order matters: Bearer/ * Authorization sequences first (they don't look like `key=value` pairs so * they wouldn't otherwise be caught), then credential query-param * assignments, then any remaining full URL substrings via `sanitizeUrl`. * * Bare email addresses and hex attachment hashes are intentionally left * untouched — see module doc-comment. */ export function sanitizeNoteText(text: string): string { if (!text) return text; let out = text.replace(BEARER_AUTH_REGEX, REDACTED_MARKER); out = out.replace(CREDENTIAL_PARAM_REGEX, (match, key: string, val: string) => { if (CREDENTIAL_QUERY_KEY_PATTERN.test(key)) { return `${key}=${REDACTED_MARKER}`; } return match; }); out = out.replace(URL_IN_TEXT_REGEX, (match) => sanitizeUrl(match)); return out; }