chore: merge executor worktree (21-01)
This commit is contained in:
commit
818f1fe0ae
5 changed files with 485 additions and 0 deletions
109
.planning/phases/21-autotask-triage-note/21-01-SUMMARY.md
Normal file
109
.planning/phases/21-autotask-triage-note/21-01-SUMMARY.md
Normal 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.
|
||||
104
lib/services/triage-note-format.test.ts
Normal file
104
lib/services/triage-note-format.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
133
lib/services/triage-note-format.ts
Normal file
133
lib/services/triage-note-format.ts
Normal 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);
|
||||
}
|
||||
58
lib/services/triage-note-sanitize.test.ts
Normal file
58
lib/services/triage-note-sanitize.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
81
lib/services/triage-note-sanitize.ts
Normal file
81
lib/services/triage-note-sanitize.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue