chore: merge executor worktree (21-01)

This commit is contained in:
lorentz 2026-07-16 12:08:01 -04:00
commit 818f1fe0ae
5 changed files with 485 additions and 0 deletions

View file

@ -0,0 +1,109 @@
---
phase: 21-autotask-triage-note
plan: 01
subsystem: api
tags: [phishing-triage, sanitization, autotask, vitest, pure-functions]
# Dependency graph
requires:
- phase: 17-mimecast-blast-radius-lookup
provides: BlastRadiusResult discriminated union imported as a type
- phase: 19-classification-engine
provides: Verdict type and classification shape (verdict/confidence/summary/reasons/recommended_actions) this formatter summarizes
- phase: 20-remediation-approval-audit-safety
provides: remediation_actions status lifecycle (proposed/approved/completed) this formatter renders
provides:
- "sanitizeUrl / sanitizeNoteText pure functions (lib/services/triage-note-sanitize.ts)"
- "formatTriageNote(evidence) pure function + TriageNoteEvidence contract (lib/services/triage-note-format.ts)"
affects: [21-02-triage-note-service-and-endpoint]
# Tech tracking
tech-stack:
added: []
patterns:
- "SECURITY-CRITICAL module-header convention mirrored from lib/services/analyzer/itglue-redact.ts (why-it-exists doc-comment + exported-pure-function-for-tests shape)"
- "Formatter routes all free text and URLs through a shared sanitizer before returning, so no source field can bypass redaction"
key-files:
created:
- lib/services/triage-note-sanitize.ts
- lib/services/triage-note-sanitize.test.ts
- lib/services/triage-note-format.ts
- lib/services/triage-note-format.test.ts
modified: []
key-decisions:
- "sanitizeUrl always strips query+fragment wholesale (never selectively keeps benign params) per NOTE-01's 'full malicious URL query strings' requirement"
- "sanitizeNoteText intentionally does NOT redact bare emails or hex attachment hashes — those are evidence per 21-CONTEXT.md Claude's Discretion, not credentials"
- "formatTriageNote passes its entire assembled string through sanitizeNoteText (not just discrete fields) so a secret embedded anywhere in free text is caught regardless of source field"
patterns-established:
- "Pure-function-first design for security-critical text transforms: no I/O, fully unit-testable, consumed later by a thin service layer (Plan 02)"
requirements-completed: [NOTE-01]
# Metrics
duration: 2min
completed: 2026-07-16
---
# Phase 21 Plan 01: Triage Note Sanitizer + Formatter Summary
**Pure sanitizer (URL query/fragment stripping + Bearer/credential redaction) and pure formatter (`formatTriageNote`) turning classification + blast-radius + remediation-state evidence into human-readable, secret-free Autotask note text, both fully unit-tested with Vitest.**
## Performance
- **Duration:** ~2 min
- **Started:** 2026-07-16T12:04:58-04:00
- **Completed:** 2026-07-16T12:06:31-04:00
- **Tasks:** 2 completed
- **Files modified:** 4 (all newly created)
## Accomplishments
- `sanitizeUrl` strips every URL down to scheme+host+path (query + fragment always removed), never throws on malformed input, falls back to truncating at `?`/`#`
- `sanitizeNoteText` redacts Bearer/Authorization tokens and credential-style query-param values (`token`, `access_token`, `password`, `api_key`, etc.) while explicitly preserving bare sender emails and attachment hashes as evidence
- `formatTriageNote` renders a `TriageNoteEvidence` object into labeled prose sections (header, Classification, Reasons, Blast Radius — both `ok`/`unavailable` branches, Recommended Actions, Current Remediation State, Indicator URLs), routing every URL through `sanitizeUrl` and the whole assembled string through `sanitizeNoteText`
- `TriageNoteEvidence` interface exported for Plan 02 to build its evidence-gathering service against
## Task Commits
Each task followed RED → GREEN (TDD):
1. **Task 1: Sanitizer** - `cc93707` (test: RED, 8 failing tests) → `226f300` (feat: GREEN, all 8 pass)
2. **Task 2: Note formatter + TriageNoteEvidence contract** - `b4e05eb` (test: RED, 9 failing tests) → `17ba0e8` (feat: GREEN, all 9 pass)
_TDD gate compliance: both tasks have a `test(...)` commit followed by a `feat(...)` commit; no refactor step was needed._
## Files Created/Modified
- `lib/services/triage-note-sanitize.ts` - `sanitizeUrl` + `sanitizeNoteText` pure functions with `REDACTED_MARKER` constant
- `lib/services/triage-note-sanitize.test.ts` - 8 Vitest cases covering query/fragment stripping, malformed-input safety, token/credential redaction, email/hash preservation
- `lib/services/triage-note-format.ts` - `TriageNoteEvidence` interface + `formatTriageNote(evidence)` pure function
- `lib/services/triage-note-format.test.ts` - 9 Vitest cases covering verdict/confidence rendering, both blast-radius branches, empty/populated remediation state, URL sanitization, whole-output secret redaction, null-verdict handling
## Decisions Made
- Query strings are stripped unconditionally in `sanitizeUrl` — no allowlist of "safe" params, since selectively keeping some params risks missing a novel credential-param name (matches NOTE-01's wording exactly: "full malicious URL query strings").
- `sanitizeNoteText` explicitly does not touch email addresses or hex hashes — confirmed as intentional in `21-CONTEXT.md` Claude's Discretion block (these are attacker-identity evidence, not secrets belonging to Pulse/its operators).
- `formatTriageNote` sanitizes the entire assembled output string (not just individual fields) as a defense-in-depth measure — if a future evidence field carries an embedded secret, it's still caught at the final sanitize pass regardless of which section it landed in.
## Deviations from Plan
None - plan executed exactly as written. Both tasks' `<behavior>`, `<action>`, and `<acceptance_criteria>` blocks were implemented as specified; no architectural changes, no missing critical functionality found, no blocking issues encountered.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required. Both modules are pure functions with no DB/network/Autotask dependency.
## Next Phase Readiness
- `TriageNoteEvidence` interface and `formatTriageNote()` are ready for Plan 02 to import and build its evidence-gathering service + `POST /api/phishing/campaigns/{id}/triage-note` endpoint against.
- `sanitizeUrl`/`sanitizeNoteText` are exported and stable for direct reuse if Plan 02 needs to sanitize any additional free text outside the formatter's own URL list.
- No blockers.
---
*Phase: 21-autotask-triage-note*
*Completed: 2026-07-16*
## Self-Check: PASSED
All created files and task commit hashes verified present on disk / in git history.

View file

@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest';
import { formatTriageNote, type TriageNoteEvidence } from './triage-note-format';
import type { BlastRadiusResult } from './mimecast-blast-radius';
function baseEvidence(overrides: Partial<TriageNoteEvidence> = {}): TriageNoteEvidence {
return {
campaignId: 'campaign-1',
reportCount: 3,
companyName: 'Acme Corp',
subject: 'Your invoice is ready',
verdict: 'THREAT',
confidence: 0.85,
summary: 'Credential-harvesting link found in message body.',
reasons: ['Sender domain not in allowlist', 'URL matches known phishing indicator'],
recommendedActions: ['block_sender', 'purge_message'],
requiresApproval: true,
blastRadius: { status: 'unavailable', reason: 'not_configured' },
remediationActions: [],
urls: [],
...overrides,
};
}
describe('formatTriageNote', () => {
it('includes the verdict label and confidence value', () => {
const output = formatTriageNote(baseEvidence());
expect(output).toContain('THREAT');
expect(output).toContain('0.85');
});
it('includes a Summary line and a Reasons section listing each reason', () => {
const output = formatTriageNote(baseEvidence());
expect(output).toContain('Credential-harvesting link found in message body.');
expect(output).toContain('Sender domain not in allowlist');
expect(output).toContain('URL matches known phishing indicator');
});
it('shows delivered/held/rejected/clicked counts when blast radius is ok', () => {
const ok: BlastRadiusResult = {
status: 'ok',
matched: 5,
delivered: 3,
held: 1,
rejected: 1,
clicked: 2,
perRecipient: [{ recipient: 'user@acme.example', status: 'delivered' }],
source: 'fan-out',
};
const output = formatTriageNote(baseEvidence({ blastRadius: ok }));
expect(output).toContain('3');
expect(output).toContain('1');
expect(output).toContain('2');
});
it('shows an explicit unavailable reason when blast radius is unavailable', () => {
const output = formatTriageNote(
baseEvidence({ blastRadius: { status: 'unavailable', reason: 'not_configured' } })
);
expect(output).toContain('unavailable');
expect(output).toContain('not_configured');
});
it('states no action taken when remediationActions is empty', () => {
const output = formatTriageNote(baseEvidence({ remediationActions: [] }));
expect(output.toLowerCase()).toMatch(/proposed|no action/);
});
it('lists each remediation action with status and approver when present', () => {
const output = formatTriageNote(
baseEvidence({
remediationActions: [
{ actionType: 'block_sender', status: 'approved', approvedBy: 'operator@example.com', approvedAt: '2026-07-01T00:00:00Z' },
{ actionType: 'purge_message', status: 'completed', approvedBy: 'operator@example.com', approvedAt: '2026-07-01T00:00:00Z' },
],
})
);
expect(output).toContain('block_sender');
expect(output).toContain('approved');
expect(output).toContain('purge_message');
expect(output).toContain('completed');
expect(output).toContain('operator@example.com');
});
it('renders indicator URLs in sanitized form, dropping query strings', () => {
const output = formatTriageNote(baseEvidence({ urls: ['http://evil.example/p?token=leak'] }));
expect(output).not.toContain('token=leak');
expect(output).toContain('http://evil.example/p');
});
it('never leaks a secret embedded in free-text fields (sanitizeNoteText applied to whole output)', () => {
const output = formatTriageNote(
baseEvidence({ summary: 'Found link http://evil.example/x?access_token=SECRETVALUE in body' })
);
expect(output).not.toContain('access_token=SECRETVALUE');
});
it('handles null verdict/confidence gracefully without throwing', () => {
expect(() =>
formatTriageNote(baseEvidence({ verdict: null, confidence: null }))
).not.toThrow();
const output = formatTriageNote(baseEvidence({ verdict: null, confidence: null }));
expect(output.toLowerCase()).toContain('not yet classified');
});
});

View file

@ -0,0 +1,133 @@
/**
* Triage-note formatter (Phase 21, NOTE-01).
*
* Pure, deterministic function that turns a structured campaign-evidence
* object (`TriageNoteEvidence`) into human-readable prose suitable for
* posting as an internal Autotask `TicketNotes` entry. No DB, network, or
* Autotask dependency Plan 02's service gathers evidence and calls
* `formatTriageNote()`, then writes the resulting text.
*
* Every URL and the entire assembled string are routed through
* `triage-note-sanitize.ts` before being returned, so a secret/token/full
* malicious query string embedded in ANY source field (summary, reasons,
* URLs) is stripped regardless of which field it came from (D-04 "full
* picture" + NOTE-01 sanitization requirement).
*/
import { sanitizeUrl, sanitizeNoteText } from './triage-note-sanitize';
import type { BlastRadiusResult } from './mimecast-blast-radius';
export interface TriageNoteEvidence {
campaignId: string;
reportCount: number;
companyName?: string | null;
subject?: string | null;
verdict: 'SPAM' | 'UNWANTED' | 'THREAT' | null;
confidence: number | null;
summary: string | null;
reasons: string[];
recommendedActions: string[];
requiresApproval: boolean;
blastRadius: BlastRadiusResult;
remediationActions: Array<{
actionType: string;
status: string;
approvedBy: string | null;
approvedAt: string | null;
}>;
urls: string[];
}
function formatBlastRadiusSection(blastRadius: BlastRadiusResult): string {
if (blastRadius.status === 'unavailable') {
const detail = blastRadius.error ? `${blastRadius.error}` : '';
return `Blast Radius: unavailable (reason: ${blastRadius.reason})${detail}`;
}
return [
'Blast Radius:',
` Matched: ${blastRadius.matched}`,
` Delivered: ${blastRadius.delivered}`,
` Held: ${blastRadius.held}`,
` Rejected: ${blastRadius.rejected}`,
` Clicked: ${blastRadius.clicked}`,
].join('\n');
}
function formatRemediationSection(
remediationActions: TriageNoteEvidence['remediationActions']
): string {
if (remediationActions.length === 0) {
return 'Current Remediation State: No action has been taken yet — recommended actions are proposed-only.';
}
const lines = remediationActions.map((action) => {
const approver =
action.approvedBy || action.approvedAt
? ` (approver: ${action.approvedBy ?? 'unknown'}, approved at: ${action.approvedAt ?? 'unknown'})`
: '';
return ` - ${action.actionType}${action.status}${approver}`;
});
return ['Current Remediation State:', ...lines].join('\n');
}
/**
* Assemble the full triage note as human-readable prose, sanitized so no
* secret/token/full malicious URL query string survives regardless of which
* evidence field it originated from.
*/
export function formatTriageNote(evidence: TriageNoteEvidence): string {
const verdictLabel = evidence.verdict ?? 'not yet classified';
const confidenceLabel =
evidence.confidence === null || evidence.confidence === undefined
? 'not yet classified'
: String(evidence.confidence);
const headerLine = `Phishing Triage Summary — Campaign ${evidence.campaignId} (${evidence.reportCount} report${evidence.reportCount === 1 ? '' : 's'})`;
const classificationSection = [
'Classification:',
` Verdict: ${verdictLabel}`,
` Confidence: ${confidenceLabel}`,
` Summary: ${evidence.summary ?? 'not yet classified'}`,
].join('\n');
const reasonsSection =
evidence.reasons.length > 0
? ['Reasons:', ...evidence.reasons.map((r) => ` - ${r}`)].join('\n')
: 'Reasons: none recorded';
const blastRadiusSection = formatBlastRadiusSection(evidence.blastRadius);
const recommendedActionsSection = [
'Recommended Actions:',
evidence.recommendedActions.length > 0
? evidence.recommendedActions.map((a) => ` - ${a}`).join('\n')
: ' - none',
evidence.requiresApproval ? ' (requires operator approval)' : ' (no approval required)',
].join('\n');
const remediationSection = formatRemediationSection(evidence.remediationActions);
const urlsSection =
evidence.urls.length > 0
? ['Indicator URLs:', ...evidence.urls.map((u) => ` - ${sanitizeUrl(u)}`)].join('\n')
: null;
const sections = [
headerLine,
evidence.companyName ? `Company: ${evidence.companyName}` : null,
evidence.subject ? `Subject: ${evidence.subject}` : null,
classificationSection,
reasonsSection,
blastRadiusSection,
recommendedActionsSection,
remediationSection,
urlsSection,
].filter((s): s is string => s !== null);
const assembled = sections.join('\n\n');
return sanitizeNoteText(assembled);
}

View file

@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { sanitizeUrl, sanitizeNoteText } from './triage-note-sanitize';
describe('sanitizeUrl', () => {
it('strips query string and fragment, keeping scheme+host+path', () => {
expect(sanitizeUrl('https://evil.example/login?token=abc123&next=/x#frag')).toBe(
'https://evil.example/login'
);
});
it('returns exactly the scheme+host+path for a token+fragment URL', () => {
expect(sanitizeUrl('https://evil.example/a?token=abc#f')).toBe('https://evil.example/a');
});
it('does not throw on a malformed/non-URL string and strips any ?/# tail', () => {
expect(() => sanitizeUrl('not a url')).not.toThrow();
expect(sanitizeUrl('not a url')).toBe('not a url');
expect(() => sanitizeUrl('')).not.toThrow();
expect(sanitizeUrl('not-a-url?foo=bar#baz')).toBe('not-a-url');
});
it('always strips query even for benign params (not selective)', () => {
expect(sanitizeUrl('https://good.example/path?utm_source=newsletter')).toBe(
'https://good.example/path'
);
});
});
describe('sanitizeNoteText', () => {
it('redacts an Authorization/Bearer token', () => {
const input = 'Header seen: Bearer eyJabc.def.ghi in the request';
const output = sanitizeNoteText(input);
expect(output).not.toContain('eyJabc.def.ghi');
expect(output).toContain('[REDACTED]');
});
it('redacts credential-style query-param values inside free text', () => {
const input = 'see http://x/y?access_token=SECRET&password=p';
const output = sanitizeNoteText(input);
expect(output).not.toContain('access_token=SECRET');
expect(output).not.toContain('password=p');
});
it('preserves a bare sender email and a 64-char hex attachment hash unchanged', () => {
const hash = 'a'.repeat(64);
const input = `Reported by attacker@evil.example with attachment hash ${hash}`;
const output = sanitizeNoteText(input);
expect(output).toContain('attacker@evil.example');
expect(output).toContain(hash);
});
it('is pure and deterministic', () => {
const input = 'plain text with no secrets';
expect(sanitizeNoteText(input)).toBe(sanitizeNoteText(input));
});
});

View file

@ -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 <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;
}