feat(15-02): implement phishing pattern matcher + content hash

- KNOWN_PHISHING_PATTERNS: the 8 locked DETECT-01 strings
- matchesPhishingPatterns: case-insensitive substring match (toLowerCase +
  includes only, no RegExp/eval), mirrors robotic-classifier.evaluateContains
- computePhishingContentHash: sha256 over title+description only (D-04),
  excludes bump-prone fields like status/last_activity_date
This commit is contained in:
lorentz 2026-07-15 07:42:14 -04:00
parent 0e7daf9a6a
commit aabf5322e9

View file

@ -0,0 +1,61 @@
/**
* Phishing Detector
*
* Shared detection core called by both the webhook path (Plan 03) and the
* cron sweep (Plan 03). Matches a ticket's title+description against the 8
* locked DETECT-01 patterns, computes a content hash for D-04 idempotency.
*
* One deterministic, testable detector with no duplicated matching logic
* between callers (CONTEXT.md discretion: "both call the same underlying
* logic").
*/
import { createHash } from 'crypto';
// =============================================================================
// Pure detection logic — pattern matcher + content hash
// =============================================================================
/**
* The 8 locked DETECT-01 patterns (case-insensitive substring match).
*/
export const KNOWN_PHISHING_PATTERNS: readonly string[] = [
'Phishing Report',
'Spam Alert',
'Phishing Alert - Email Security Report',
'KnowBe4 Phish Alert Report',
'Source: KnowBe4 Phish Alert Button',
'userSubmissionsReportMessage',
'reported message destinations',
'Microsoft directly',
];
/**
* Case-insensitive substring match against the locked pattern list mirrors
* robotic-classifier.evaluateContains (.toLowerCase() + .includes() only,
* NO regex, NO eval).
*/
export function matchesPhishingPatterns(
title: string | null,
description: string | null
): { flagged: boolean; matched: string[] } {
const haystack = `${title ?? ''} ${description ?? ''}`.toLowerCase();
const matched = KNOWN_PHISHING_PATTERNS.filter((pattern) =>
haystack.includes(pattern.toLowerCase())
);
return { flagged: matched.length > 0, matched };
}
/**
* sha256 over title+description only (D-04) does NOT include
* last_activity_date, status, or any bump-prone field, so status/assignee
* churn never forces reprocessing.
*/
export function computePhishingContentHash(
title: string | null,
description: string | null
): string {
return createHash('sha256')
.update(JSON.stringify({ title: title ?? '', description: description ?? '' }))
.digest('hex');
}