wulf-pulse/lib/services/triage-note-sanitize.ts
lorentz 226f300513 feat(21-01): implement triage-note sanitizer
- sanitizeUrl strips query+fragment, keeps scheme+host+path, never throws
- sanitizeNoteText redacts Bearer/Authorization tokens and credential
  query-param values while preserving sender emails and attachment hashes
- All 8 sanitizer tests pass
2026-07-16 12:05:34 -04:00

81 lines
3.2 KiB
TypeScript

/**
* 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 <token>` or `Authorization: <value>` 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;
}