diff --git a/lib/services/triage-note-sanitize.ts b/lib/services/triage-note-sanitize.ts new file mode 100644 index 0000000..1b7ed4d --- /dev/null +++ b/lib/services/triage-note-sanitize.ts @@ -0,0 +1,81 @@ +/** + * 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; +}