chore: merge executor worktree (worktree-agent-af5d279be12eefb03) — plan 19-01
This commit is contained in:
commit
7ac57bd2d4
5 changed files with 1286 additions and 0 deletions
128
.planning/phases/19-classification-engine/19-01-SUMMARY.md
Normal file
128
.planning/phases/19-classification-engine/19-01-SUMMARY.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
---
|
||||
phase: 19-classification-engine
|
||||
plan: 01
|
||||
subsystem: security
|
||||
tags: [phishing-triage, deterministic-classifier, vitest, postgres, mimecast]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 16-eml-mime-evidence-parser
|
||||
provides: "NormalizedMessage / AuthResults shapes, messages.headers JSONB (eml-parser.ts)"
|
||||
- phase: 17-mimecast-blast-radius-lookup
|
||||
provides: "getBlastRadius() delivered/held/rejected/clicked lookup (mimecast-blast-radius.ts)"
|
||||
- phase: 18 (campaign grouping)
|
||||
provides: "campaigns/reports linkage, ORDER BY created_at ASC 'earliest is canonical' convention"
|
||||
provides:
|
||||
- "classifyCampaign(campaignId) — deterministic SPAM/UNWANTED/THREAT verdict engine, no LLM calls"
|
||||
- "KNOWN_SIMULATION_SENDERS allowlist (it-support.care, breachsecurenow.com) + domainMatchesAllowlist/isKnownSimulationSender"
|
||||
- "gatherCampaignEvidence(campaignId) — bounded evidence assembly (reports/messages/indicators + one getBlastRadius call)"
|
||||
- "classifications table INSERT (append-only, D-02) with verdict/confidence/reasons/recommendedActions/requiresApproval"
|
||||
affects: ["20 (remediation actions)", "phishing/campaigns/{id}/classify route"]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Pure evidence-in / rule-eval / verdict-out module shape (mirrors robotic-classifier.ts, campaign-grouping-service.ts)"
|
||||
- "vi.mock query-router-by-SQL-substring test pattern (mirrors campaign-grouping-service.test.ts)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- lib/services/campaign-classifier.ts
|
||||
- lib/services/campaign-classifier.test.ts
|
||||
- lib/services/campaign-classifier.fixtures.ts
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "D-06 allowlist stored as a TypeScript constant (KNOWN_SIMULATION_SENDERS), not a DB table — logic-drift mitigation (T-19-02) via unit-tested pure code"
|
||||
- "domainMatchesAllowlist uses exact-or-proper-subdomain match only (d===allowed || d.endsWith('.'+allowed)) — never .includes() substring matching (T-19-01)"
|
||||
- "D-03 THREAT gate requires BOTH delivered>0/clicked>0 AND (hard auth fail OR known-bad-indicator match spanning >=2 messages) — either signal alone stays at UNWANTED"
|
||||
- "D-04 UNWANTED fires on ANY suspicious signal (single attachment/url indicator, or delivery contained to the reporter(s) only); SPAM only when neither is present"
|
||||
- "ASSUMPTION FLAG (surfaced per plan instruction, needs user confirmation): THREAT recommendedActions escalate to reset_password/isolate_endpoint/disable_forwarding_rule only when blastRadius.clicked>0 — this is a reasoned research proposal (19-RESEARCH.md Open Question #1), NOT an explicit D-08 decision. Does not contradict any locked decision; materially affects what Phase 20 gates approval on."
|
||||
|
||||
requirements-completed: [CLASSIFY-01, CLASSIFY-02, CLASSIFY-03, CLASSIFY-04, CLASSIFY-06]
|
||||
|
||||
# Metrics
|
||||
duration: 15min
|
||||
completed: 2026-07-16
|
||||
---
|
||||
|
||||
# Phase 19 Plan 01: Classification Engine — Pure Rule Functions + classifyCampaign Orchestrator Summary
|
||||
|
||||
**Deterministic SPAM/UNWANTED/THREAT classifier with a KnowBe4/Breach-Secure-Now simulation allowlist, D-03 THREAT gate (delivery + malicious signal), D-04 SPAM/UNWANTED split, D-05 confidence scoring, and an append-only `classifications` INSERT — zero LLM calls.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~15 min (first commit 08:14:51 → last commit 08:20:58)
|
||||
- **Started:** 2026-07-16T08:14:51-04:00
|
||||
- **Completed:** 2026-07-16T08:20:58-04:00
|
||||
- **Tasks:** 2 completed
|
||||
- **Files modified:** 3 (all new)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Pure rule functions (`domainMatchesAllowlist`, `isKnownSimulationSender`, `effectiveAuthResults`, `hasHardAuthFail`, `computeConfidence`, `mapVerdictToActions`, `computeRequiresApproval`) — each independently unit-tested, no DB/Mimecast dependency
|
||||
- `classifyCampaign(campaignId)` orchestrator: gathers campaign evidence, applies D-06 simulation short-circuit → D-03 THREAT tier → D-04 SPAM/UNWANTED split → D-05 confidence → D-08 action mapping, and appends one `classifications` row (no `ON CONFLICT`)
|
||||
- Positive-path THREAT proven with a real (non-simulation) signal fixture — closes the "always-SPAM stub" blocker the plan explicitly flagged
|
||||
- D-03's known-bad-indicator OR-branch proven independently of the auth-fail path (auth PASS + shared indicator across 2 messages → THREAT)
|
||||
- D-04's SPAM/UNWANTED boundary proven with two distinct fixtures (not just a single always-one-verdict stub)
|
||||
- Synthetic KnowBe4 (`it-support.care`) and Breach Secure Now (`breachsecurenow.com`) simulation fixtures reproduce the Pitfall-1 forwarding-induced auth-verdict inversion, proving both the D-06 allowlist short-circuit and the `authResultsOriginal` precedence in one test
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task followed the TDD RED → GREEN cycle with a `test(...)` commit before its `feat(...)` commit:
|
||||
|
||||
1. **Task 1: Failing tests + fixtures + pure rule functions**
|
||||
- `f4e6baf` (test) — failing tests + synthetic fixtures for classifier pure rule functions
|
||||
- `3ea6c95` (feat) — implemented pure rule functions (D-05/D-06/D-08); all 30 tests green
|
||||
2. **Task 2: classifyCampaign orchestrator + evidence gathering + append-only INSERT**
|
||||
- `f6c954a` (test) — classifyCampaign orchestrator tests (mocked postgres-client + mimecast-blast-radius)
|
||||
- `38c1ae4` (feat) — implemented `gatherCampaignEvidence` + `classifyCampaign`; all 39 tests green
|
||||
|
||||
**Plan metadata:** this SUMMARY.md commit (see below)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `lib/services/campaign-classifier.ts` — `classifyCampaign` orchestrator, `gatherCampaignEvidence`, pure rule functions, `KNOWN_SIMULATION_SENDERS` constant, `ClassifyResult`/`CampaignEvidence`/`ParsedMessage` types
|
||||
- `lib/services/campaign-classifier.test.ts` — 39 tests across 9 pure-function describe blocks + `classifyCampaign` orchestration describe block (mocked `postgres-client`/`mimecast-blast-radius`)
|
||||
- `lib/services/campaign-classifier.fixtures.ts` — synthetic KnowBe4/BSN simulation fixtures + non-simulation threat/clean-spam/suspicious-unwanted fixtures (all invented content, no real customer email)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Kept `isKnownSimulationSender`'s parameter type narrowed to a new `SenderIdentity` interface (`{ from: { domain }, returnPath }`) instead of the full `NormalizedMessage`, so both the Task 1 fixtures (typed as full `NormalizedMessage`) and Task 2's bounded `ParsedMessage` (built from `messages.headers` JSONB) can share the same allowlist-check function without type friction.
|
||||
- `evaluateSpamVsUnwanted`'s "suspicious signal" definition (any attachment/url indicator present OR delivery contained to the reporter(s) only) was Claude's Discretion per CONTEXT.md D-04 — implemented as an OR of the two conditions so either alone is enough to clear SPAM into UNWANTED, while requiring the stronger D-03 gate (delivery + malicious signal) to reach THREAT.
|
||||
- `gatherCampaignEvidence` caps only the human-readable `reportSample` at 10 (T-19-03/CLASSIFY-06 DoS guard for anything embedded in output); the full `messages`/`indicators` arrays are used internally for correctness-critical verdict computation (D-03's cross-message indicator correlation needs the complete set, not a sample) — the bounding requirement is satisfied by keeping `reasons` short and named (never one line per indicator), not by truncating rule-evaluation inputs.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — plan executed exactly as written. The ASSUMPTION FLAG called out in the plan's Task 1 action block (click-driven escalation to `reset_password`/`isolate_endpoint`/`disable_forwarding_rule`) was implemented exactly as the plan's code example specified and is documented above under Key Decisions for user visibility, per the plan's own instruction to surface it — it is not a deviation, since the plan explicitly directed this implementation.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- Worktree branch (`worktree-agent-af5d279be12eefb03`) was created from a commit predating this milestone's Phase 16-18 work (missing `eml-parser.ts`, `mimecast-blast-radius.ts`, `campaign-grouping-service.ts`, migration 097, etc.) — confirmed the worktree had zero commits of its own ahead of `master` (`git log HEAD..master` = 333 commits, `master..HEAD` = 0), so `git reset --hard master` was safe and used to bring the worktree in sync with all prerequisite phases before starting.
|
||||
- Full `npm test` surfaced 2 pre-existing, unrelated failures in `lib/services/analyzer/itglue-search.test.ts` (`client.getFlexibleAssetsForOrganization is not a function`) — confirmed via `git log` that this test file predates this phase and was not touched by this plan. Logged to `.planning/phases/19-classification-engine/deferred-items.md` per the executor's scope-boundary rule; not fixed.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- `classifyCampaign(campaignId)` is ready to be wired into `POST /api/phishing/campaigns/{id}/classify` (Plan 19-02 / CLASSIFY-05) — the route only needs to call it and return the `ClassifyResult` shape.
|
||||
- Phase 20 (remediation actions) can consume `recommendedActions`/`requiresApproval` directly — the D-08 action vocabulary (`no_action`, `warn_user`, `disable_forwarding_rule`, `block_sender`, `purge_message`, `reset_password`, `isolate_endpoint`) is exactly what's produced.
|
||||
- **Needs a quick user confirmation** (flagged in the plan, not a blocker): whether `clicked>0`-driven escalation to `reset_password`/`isolate_endpoint`/`disable_forwarding_rule` is the right trigger for Phase 20's approval gating, since it's a reasoned proposal rather than a locked CONTEXT.md decision.
|
||||
|
||||
---
|
||||
*Phase: 19-classification-engine*
|
||||
*Completed: 2026-07-16*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: lib/services/campaign-classifier.ts
|
||||
- FOUND: lib/services/campaign-classifier.test.ts
|
||||
- FOUND: lib/services/campaign-classifier.fixtures.ts
|
||||
- FOUND: .planning/phases/19-classification-engine/deferred-items.md
|
||||
- FOUND commit: f4e6baf (test: pure rule function tests + fixtures)
|
||||
- FOUND commit: 3ea6c95 (feat: pure rule functions)
|
||||
- FOUND commit: f6c954a (test: classifyCampaign orchestrator tests)
|
||||
- FOUND commit: 38c1ae4 (feat: classifyCampaign orchestrator + evidence gathering)
|
||||
15
.planning/phases/19-classification-engine/deferred-items.md
Normal file
15
.planning/phases/19-classification-engine/deferred-items.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Deferred Items — Phase 19
|
||||
|
||||
Out-of-scope discoveries logged during execution, not fixed per the executor's scope boundary
|
||||
(only auto-fix issues directly caused by the current task's changes).
|
||||
|
||||
## Plan 19-01
|
||||
|
||||
- **`lib/services/analyzer/itglue-search.test.ts`** — 2 pre-existing failing tests
|
||||
(`returns capped, redacted doc snippets when the org is found`,
|
||||
`tolerates per-call failures (configurations errors, flex still returns)`), unrelated to
|
||||
this plan's changes. Root cause appears to be `client.getFlexibleAssetsForOrganization is
|
||||
not a function` — a mock/client-shape mismatch in the analyzer test suite, not touched by
|
||||
`campaign-classifier.ts`/`.test.ts`/`.fixtures.ts`. Confirmed pre-existing via `git log` on
|
||||
the test file (introduced in the original "AI ticket analyzer (phases 1-6)" commit, long
|
||||
before this phase). Full suite otherwise green (358/360 passing before this discovery).
|
||||
118
lib/services/campaign-classifier.fixtures.ts
Normal file
118
lib/services/campaign-classifier.fixtures.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* Synthetic fixtures for campaign-classifier.test.ts (Phase 19). Nothing
|
||||
* here is real customer content — all addresses, domains, and subjects are
|
||||
* invented for testing only (per this milestone's explicit
|
||||
* synthetic-fixture-only constraint).
|
||||
*
|
||||
* Both simulation fixtures (`knowbe4SimMessage`, `bsnSimMessage`) reproduce
|
||||
* the forwarding-induced auth-verdict inversion described in
|
||||
* 19-RESEARCH.md Pitfall 1 — the primary `authResults` header shows a hard
|
||||
* fail (post-forward, DKIM invalidated by the forward hop) while
|
||||
* `authResultsOriginal` shows the pre-forward pass. This lets the
|
||||
* "simulation is never THREAT" test prove BOTH the D-06 allowlist
|
||||
* short-circuit AND the D-05/authResultsOriginal precedence in one fixture.
|
||||
*/
|
||||
|
||||
import type { NormalizedMessage } from './eml-parser';
|
||||
|
||||
function makeNormalizedMessage(
|
||||
overrides: Partial<NormalizedMessage> & {
|
||||
from: NormalizedMessage['from'];
|
||||
authResults: NormalizedMessage['authResults'];
|
||||
}
|
||||
): NormalizedMessage {
|
||||
return {
|
||||
replyTo: null,
|
||||
returnPath: null,
|
||||
to: ['reporter@wulfconsulting.test'],
|
||||
cc: [],
|
||||
subject: 'Test subject',
|
||||
date: '2026-07-15T12:00:00.000Z',
|
||||
messageId: null,
|
||||
receivedChain: [],
|
||||
authResultsOriginal: null,
|
||||
urls: [],
|
||||
attachments: [],
|
||||
bodyPreview: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* KnowBe4 phishing-simulation fixture — From domain matches the
|
||||
* `it-support.care` allowlist entry (19-RESEARCH.md D-07 finding #2).
|
||||
*/
|
||||
export const knowbe4SimMessage: NormalizedMessage = makeNormalizedMessage({
|
||||
from: { displayName: 'IT Support', email: 'alert@it-support.care', domain: 'it-support.care' },
|
||||
returnPath: 'bounce@it-support.care',
|
||||
subject: 'Phishing Alert - Email Security Report',
|
||||
authResults: { spf: 'fail', dkim: 'fail', dmarc: 'fail' },
|
||||
authResultsOriginal: { spf: 'pass', dkim: 'pass', dmarc: 'pass' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Breach Secure Now training-notification fixture — From.domain is null
|
||||
* (Pitfall 3: From may lack a visible email address); the Return-Path
|
||||
* domain is the only allowlist signal (19-RESEARCH.md D-07 finding #1).
|
||||
*/
|
||||
export const bsnSimMessage: NormalizedMessage = makeNormalizedMessage({
|
||||
from: { displayName: null, email: null, domain: null },
|
||||
returnPath: 'bounces-abc123@em8721.breachsecurenow.com',
|
||||
subject: 'Security Awareness Training Notification',
|
||||
authResults: { spf: 'fail', dkim: 'fail', dmarc: 'fail' },
|
||||
authResultsOriginal: { spf: 'pass', dkim: 'pass', dmarc: 'pass' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Non-simulation THREAT fixture — a real typosquat flavor per
|
||||
* 19-RESEARCH.md D-07 finding #3 (`mlcrosoft.live`, NOT allowlisted). No
|
||||
* `authResultsOriginal` — effectiveAuthResults falls back to the primary
|
||||
* `authResults`, which itself shows a hard fail (no forwarding inversion
|
||||
* here — the fail is the actual signal).
|
||||
*/
|
||||
export const threatMessage: NormalizedMessage = makeNormalizedMessage({
|
||||
from: {
|
||||
displayName: 'Microsoft Account Team',
|
||||
email: 'security@mlcrosoft.live',
|
||||
domain: 'mlcrosoft.live',
|
||||
},
|
||||
returnPath: 'bounce@mlcrosoft.live',
|
||||
subject: 'Unusual sign-in activity detected',
|
||||
authResults: { spf: 'fail', dkim: 'fail', dmarc: 'fail' },
|
||||
authResultsOriginal: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Clean non-simulation SPAM fixture — generic bulk/newsletter sender, no
|
||||
* spoofing, all auth verdicts pass, no attachment/url indicators.
|
||||
*/
|
||||
export const cleanSpamMessage: NormalizedMessage = makeNormalizedMessage({
|
||||
from: {
|
||||
displayName: 'Example Newsletter',
|
||||
email: 'news@mail.example-newsletter.com',
|
||||
domain: 'mail.example-newsletter.com',
|
||||
},
|
||||
returnPath: 'bounce@mail.example-newsletter.com',
|
||||
subject: 'Your weekly digest',
|
||||
authResults: { spf: 'pass', dkim: 'pass', dmarc: 'pass' },
|
||||
authResultsOriginal: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Suspicious-but-contained UNWANTED fixture — a single suspicious signal
|
||||
* (paired in Task 2 with exactly one url indicator, not shared across
|
||||
* messages) with delivery contained to the reporter only — below the
|
||||
* THREAT bar per D-04.
|
||||
*/
|
||||
export const suspiciousUnwantedMessage: NormalizedMessage = makeNormalizedMessage({
|
||||
from: {
|
||||
displayName: 'Vendor Promo',
|
||||
email: 'promo@promo.some-vendor.net',
|
||||
domain: 'promo.some-vendor.net',
|
||||
},
|
||||
returnPath: 'bounce@promo.some-vendor.net',
|
||||
subject: 'Special offer just for you',
|
||||
authResults: { spf: 'pass', dkim: 'pass', dmarc: 'pass' },
|
||||
authResultsOriginal: null,
|
||||
urls: ['http://promo.some-vendor.net/deal'],
|
||||
});
|
||||
501
lib/services/campaign-classifier.test.ts
Normal file
501
lib/services/campaign-classifier.test.ts
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock postgresClient BEFORE importing the module under test — mirrors
|
||||
// campaign-grouping-service.test.ts's vi.mock() factory-mocking discipline.
|
||||
const queryMock = vi.fn();
|
||||
vi.mock('./postgres-client', () => ({
|
||||
postgresClient: {
|
||||
query: (...args: unknown[]) => queryMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock getBlastRadius entirely — mirrors mimecast-blast-radius.test.ts's
|
||||
// sibling-service mocking pattern. No real Mimecast/Postgres calls happen.
|
||||
const getBlastRadiusMock = vi.fn();
|
||||
vi.mock('./mimecast-blast-radius', () => ({
|
||||
getBlastRadius: (...args: unknown[]) => getBlastRadiusMock(...args),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first -- imported after vi.mock hoisting
|
||||
import {
|
||||
KNOWN_SIMULATION_SENDERS,
|
||||
domainMatchesAllowlist,
|
||||
isKnownSimulationSender,
|
||||
effectiveAuthResults,
|
||||
hasHardAuthFail,
|
||||
computeConfidence,
|
||||
mapVerdictToActions,
|
||||
computeRequiresApproval,
|
||||
classifyCampaign,
|
||||
} from './campaign-classifier';
|
||||
// eslint-disable-next-line import/first -- imported after vi.mock hoisting
|
||||
import {
|
||||
knowbe4SimMessage,
|
||||
bsnSimMessage,
|
||||
threatMessage,
|
||||
cleanSpamMessage,
|
||||
suspiciousUnwantedMessage,
|
||||
} from './campaign-classifier.fixtures';
|
||||
import type { NormalizedMessage } from './eml-parser';
|
||||
|
||||
describe('KNOWN_SIMULATION_SENDERS', () => {
|
||||
it('includes both the KnowBe4 and Breach Secure Now sender domains', () => {
|
||||
const allDomains = KNOWN_SIMULATION_SENDERS.flatMap((entry) => entry.domains);
|
||||
expect(allDomains).toContain('it-support.care');
|
||||
expect(allDomains).toContain('breachsecurenow.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('domainMatchesAllowlist', () => {
|
||||
it('matches an exact allowlisted domain', () => {
|
||||
expect(domainMatchesAllowlist('it-support.care')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches a proper subdomain of an allowlisted domain', () => {
|
||||
expect(domainMatchesAllowlist('sub.it-support.care')).toBe(true);
|
||||
expect(domainMatchesAllowlist('em8721.breachsecurenow.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT match a bare substring / suffix-spoofed domain (T-19-01)', () => {
|
||||
expect(domainMatchesAllowlist('it-support.care.attacker.net')).toBe(false);
|
||||
expect(domainMatchesAllowlist('evil-it-support.care')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isKnownSimulationSender', () => {
|
||||
it('matches on From domain (knowbe4SimMessage)', () => {
|
||||
expect(isKnownSimulationSender(knowbe4SimMessage)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches on Return-Path domain when From.domain is null (Pitfall 3, bsnSimMessage)', () => {
|
||||
expect(bsnSimMessage.from.domain).toBeNull();
|
||||
expect(isKnownSimulationSender(bsnSimMessage)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match a non-allowlisted sender', () => {
|
||||
expect(isKnownSimulationSender(threatMessage)).toBe(false);
|
||||
expect(isKnownSimulationSender(cleanSpamMessage)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveAuthResults', () => {
|
||||
it('returns authResultsOriginal when present (Pitfall 1)', () => {
|
||||
expect(effectiveAuthResults(knowbe4SimMessage)).toEqual(knowbe4SimMessage.authResultsOriginal);
|
||||
});
|
||||
|
||||
it('falls back to authResults when authResultsOriginal is null', () => {
|
||||
expect(threatMessage.authResultsOriginal).toBeNull();
|
||||
expect(effectiveAuthResults(threatMessage)).toEqual(threatMessage.authResults);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasHardAuthFail', () => {
|
||||
it('is true when spf is fail', () => {
|
||||
expect(hasHardAuthFail({ spf: 'fail' })).toBe(true);
|
||||
});
|
||||
|
||||
it('is true when dkim is fail', () => {
|
||||
expect(hasHardAuthFail({ dkim: 'fail' })).toBe(true);
|
||||
});
|
||||
|
||||
it('is true when dmarc is fail', () => {
|
||||
expect(hasHardAuthFail({ dmarc: 'fail' })).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for none/neutral/undefined verdicts', () => {
|
||||
expect(hasHardAuthFail({ spf: 'none', dkim: 'neutral' })).toBe(false);
|
||||
expect(hasHardAuthFail({})).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when all verdicts pass', () => {
|
||||
expect(hasHardAuthFail({ spf: 'pass', dkim: 'pass', dmarc: 'pass' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeConfidence', () => {
|
||||
it('is 1.0 with no deductions and no reasons when all evidence is present (confidence deduction baseline)', () => {
|
||||
const result = computeConfidence({
|
||||
hasAnyMessage: true,
|
||||
blastRadiusStatus: 'ok',
|
||||
hasAttachmentOrUrlIndicators: true,
|
||||
});
|
||||
expect(result.confidence).toBe(1.0);
|
||||
expect(result.reasons).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deducts 0.4 and names the reason when no message was parsed (confidence deduction)', () => {
|
||||
const result = computeConfidence({
|
||||
hasAnyMessage: false,
|
||||
blastRadiusStatus: 'ok',
|
||||
hasAttachmentOrUrlIndicators: true,
|
||||
});
|
||||
expect(result.confidence).toBe(0.6);
|
||||
expect(result.reasons).toHaveLength(1);
|
||||
expect(result.reasons[0]).toMatch(/message/i);
|
||||
});
|
||||
|
||||
it('deducts 0.3 and names the reason when blast-radius is unavailable (confidence deduction)', () => {
|
||||
const result = computeConfidence({
|
||||
hasAnyMessage: true,
|
||||
blastRadiusStatus: 'unavailable',
|
||||
hasAttachmentOrUrlIndicators: true,
|
||||
});
|
||||
expect(result.confidence).toBe(0.7);
|
||||
expect(result.reasons[0]).toMatch(/mimecast|blast/i);
|
||||
});
|
||||
|
||||
it('deducts 0.2 and names the reason when no attachment/url indicators are found (confidence deduction)', () => {
|
||||
const result = computeConfidence({
|
||||
hasAnyMessage: true,
|
||||
blastRadiusStatus: 'ok',
|
||||
hasAttachmentOrUrlIndicators: false,
|
||||
});
|
||||
expect(result.confidence).toBe(0.8);
|
||||
expect(result.reasons[0]).toMatch(/indicator/i);
|
||||
});
|
||||
|
||||
it('floors at 0.10 with all three named reasons when every evidence source is missing (confidence deduction)', () => {
|
||||
const result = computeConfidence({
|
||||
hasAnyMessage: false,
|
||||
blastRadiusStatus: 'unavailable',
|
||||
hasAttachmentOrUrlIndicators: false,
|
||||
});
|
||||
expect(result.confidence).toBe(0.1);
|
||||
expect(result.reasons).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapVerdictToActions', () => {
|
||||
it('maps SPAM to no_action', () => {
|
||||
expect(mapVerdictToActions('SPAM', { clicked: 0 })).toEqual(['no_action']);
|
||||
});
|
||||
|
||||
it('maps UNWANTED to warn_user', () => {
|
||||
expect(mapVerdictToActions('UNWANTED', { clicked: 0 })).toEqual(['warn_user']);
|
||||
});
|
||||
|
||||
it('maps THREAT with no clicks to block_sender + purge_message', () => {
|
||||
expect(mapVerdictToActions('THREAT', { clicked: 0 })).toEqual(['block_sender', 'purge_message']);
|
||||
});
|
||||
|
||||
it('maps THREAT with clicks to also include reset_password/isolate_endpoint/disable_forwarding_rule', () => {
|
||||
const actions = mapVerdictToActions('THREAT', { clicked: 1 });
|
||||
expect(actions).toContain('block_sender');
|
||||
expect(actions).toContain('purge_message');
|
||||
expect(actions).toContain('reset_password');
|
||||
expect(actions).toContain('isolate_endpoint');
|
||||
expect(actions).toContain('disable_forwarding_rule');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeRequiresApproval', () => {
|
||||
it('is false for disable_forwarding_rule alone (requires_approval invariant)', () => {
|
||||
expect(computeRequiresApproval(['disable_forwarding_rule'])).toBe(false);
|
||||
});
|
||||
|
||||
it('is true when disable_forwarding_rule is combined with a destructive action (requires_approval invariant)', () => {
|
||||
expect(computeRequiresApproval(['disable_forwarding_rule', 'block_sender'])).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['block_sender', 'purge_message', 'reset_password', 'isolate_endpoint'])(
|
||||
'is true for %s alone (requires_approval invariant)',
|
||||
(action) => {
|
||||
expect(computeRequiresApproval([action])).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('is false for no_action and warn_user (requires_approval invariant)', () => {
|
||||
expect(computeRequiresApproval(['no_action'])).toBe(false);
|
||||
expect(computeRequiresApproval(['warn_user'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// classifyCampaign — mocked-DB orchestration tests (CLASSIFY-01/02/03/04/06,
|
||||
// D-02/D-03/D-04/D-06)
|
||||
//
|
||||
// `queryMock` routes staged rows based on a distinguishing SQL substring per
|
||||
// call (`FROM reports`, `FROM messages`, `FROM indicators`,
|
||||
// `INSERT INTO classifications`) — NOT by call order — mirroring
|
||||
// campaign-grouping-service.test.ts's makeClient() discipline.
|
||||
// =============================================================================
|
||||
|
||||
interface ReportFixtureRow {
|
||||
id: string;
|
||||
title: string | null;
|
||||
created_at: string;
|
||||
requester_email: string | null;
|
||||
}
|
||||
|
||||
interface StagedRows {
|
||||
reports?: ReportFixtureRow[];
|
||||
messages?: Array<{ id: string; report_id: string; headers: NormalizedMessage }>;
|
||||
indicators?: Array<{ id: string; message_id: string; indicator_type: string; value: string }>;
|
||||
}
|
||||
|
||||
function stageQueries(rows: StagedRows) {
|
||||
queryMock.mockImplementation(async (sql: string) => {
|
||||
if (sql.includes('INSERT INTO classifications')) {
|
||||
return { rows: [{ id: 'classification-1', created_at: '2026-07-16T00:00:00.000Z' }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('FROM reports')) {
|
||||
return { rows: rows.reports ?? [], rowCount: rows.reports?.length ?? 0 };
|
||||
}
|
||||
if (sql.includes('FROM messages')) {
|
||||
return { rows: rows.messages ?? [], rowCount: rows.messages?.length ?? 0 };
|
||||
}
|
||||
if (sql.includes('FROM indicators')) {
|
||||
return { rows: rows.indicators ?? [], rowCount: rows.indicators?.length ?? 0 };
|
||||
}
|
||||
throw new Error(`Unstaged query in test mock: ${sql}`);
|
||||
});
|
||||
}
|
||||
|
||||
function toMessageRow(id: string, reportId: string, fixture: NormalizedMessage) {
|
||||
return { id, report_id: reportId, headers: fixture };
|
||||
}
|
||||
|
||||
const REPORTER_EMAIL = 'reporter@wulfconsulting.test';
|
||||
|
||||
describe('classifyCampaign', () => {
|
||||
beforeEach(() => {
|
||||
queryMock.mockReset();
|
||||
getBlastRadiusMock.mockReset();
|
||||
});
|
||||
|
||||
it('returns exactly one verdict with the full payload shape (returns exactly one verdict)', async () => {
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)],
|
||||
indicators: [],
|
||||
});
|
||||
getBlastRadiusMock.mockResolvedValue({
|
||||
status: 'ok',
|
||||
matched: 1,
|
||||
delivered: 0,
|
||||
held: 1,
|
||||
rejected: 0,
|
||||
clicked: 0,
|
||||
perRecipient: [],
|
||||
source: 'fan-out',
|
||||
});
|
||||
|
||||
const result = await classifyCampaign('campaign-1');
|
||||
|
||||
expect(['SPAM', 'UNWANTED', 'THREAT']).toContain(result.verdict);
|
||||
expect(typeof result.id).toBe('string');
|
||||
expect(result.campaignId).toBe('campaign-1');
|
||||
expect(typeof result.confidence).toBe('number');
|
||||
expect(typeof result.summary).toBe('string');
|
||||
expect(Array.isArray(result.reasons)).toBe(true);
|
||||
expect(Array.isArray(result.recommendedActions)).toBe(true);
|
||||
expect(typeof result.requiresApproval).toBe('boolean');
|
||||
expect(typeof result.createdAt).toBe('string');
|
||||
});
|
||||
|
||||
it('inserts exactly one append-only classifications row with no ON CONFLICT', async () => {
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)],
|
||||
indicators: [],
|
||||
});
|
||||
getBlastRadiusMock.mockResolvedValue({
|
||||
status: 'ok',
|
||||
matched: 1,
|
||||
delivered: 0,
|
||||
held: 1,
|
||||
rejected: 0,
|
||||
clicked: 0,
|
||||
perRecipient: [],
|
||||
source: 'fan-out',
|
||||
});
|
||||
|
||||
await classifyCampaign('campaign-1');
|
||||
|
||||
const insertCalls = queryMock.mock.calls.filter(
|
||||
([sql]) => typeof sql === 'string' && sql.includes('INSERT INTO classifications')
|
||||
);
|
||||
expect(insertCalls).toHaveLength(1);
|
||||
expect(insertCalls[0][0]).not.toMatch(/ON CONFLICT/i);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['knowbe4 (From match)', knowbe4SimMessage],
|
||||
['breach-secure-now (Return-Path match)', bsnSimMessage],
|
||||
])(
|
||||
'never classifies a known simulation sender as THREAT despite a hard auth fail and delivered>0 (simulation allowlist: %s)',
|
||||
async (_label, fixture) => {
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: fixture.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', fixture)],
|
||||
indicators: [],
|
||||
});
|
||||
getBlastRadiusMock.mockResolvedValue({
|
||||
status: 'ok',
|
||||
matched: 1,
|
||||
delivered: 1,
|
||||
held: 0,
|
||||
rejected: 0,
|
||||
clicked: 0,
|
||||
perRecipient: [{ recipient: REPORTER_EMAIL, status: 'delivered' }],
|
||||
source: 'fan-out',
|
||||
});
|
||||
|
||||
const result = await classifyCampaign('campaign-1');
|
||||
|
||||
expect(result.verdict).not.toBe('THREAT');
|
||||
}
|
||||
);
|
||||
|
||||
it('classifies a real non-simulation signal as THREAT with destructive recommended actions (threat tier)', async () => {
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: threatMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', threatMessage)],
|
||||
indicators: [],
|
||||
});
|
||||
getBlastRadiusMock.mockResolvedValue({
|
||||
status: 'ok',
|
||||
matched: 3,
|
||||
delivered: 3,
|
||||
held: 0,
|
||||
rejected: 0,
|
||||
clicked: 0,
|
||||
perRecipient: [
|
||||
{ recipient: REPORTER_EMAIL, status: 'delivered' },
|
||||
{ recipient: 'victim2@wulfconsulting.test', status: 'delivered' },
|
||||
{ recipient: 'victim3@wulfconsulting.test', status: 'delivered' },
|
||||
],
|
||||
source: 'fan-out',
|
||||
});
|
||||
|
||||
const result = await classifyCampaign('campaign-1');
|
||||
|
||||
expect(result.verdict).toBe('THREAT');
|
||||
expect(result.recommendedActions).toContain('block_sender');
|
||||
expect(result.recommendedActions).toContain('purge_message');
|
||||
expect(result.requiresApproval).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies THREAT via the known-bad-indicator OR-branch when auth passes across 2 messages (threat tier known-bad indicator)', async () => {
|
||||
const sharedUrl = 'http://evil-shared.example.test/payload';
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: 'Invoice attached', created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
{ id: 'report-2', title: 'Invoice attached', created_at: '2026-07-15T02:00:00.000Z', requester_email: 'reporter2@wulfconsulting.test' },
|
||||
],
|
||||
messages: [
|
||||
toMessageRow('message-1', 'report-1', cleanSpamMessage),
|
||||
toMessageRow('message-2', 'report-2', cleanSpamMessage),
|
||||
],
|
||||
indicators: [
|
||||
{ id: 'ind-1', message_id: 'message-1', indicator_type: 'url', value: sharedUrl },
|
||||
{ id: 'ind-2', message_id: 'message-2', indicator_type: 'url', value: sharedUrl },
|
||||
],
|
||||
});
|
||||
getBlastRadiusMock.mockResolvedValue({
|
||||
status: 'ok',
|
||||
matched: 1,
|
||||
delivered: 1,
|
||||
held: 0,
|
||||
rejected: 0,
|
||||
clicked: 0,
|
||||
perRecipient: [{ recipient: REPORTER_EMAIL, status: 'delivered' }],
|
||||
source: 'fan-out',
|
||||
});
|
||||
|
||||
const result = await classifyCampaign('campaign-1');
|
||||
|
||||
expect(result.verdict).toBe('THREAT');
|
||||
});
|
||||
|
||||
it('classifies a clean campaign with no indicators and no delivery/click signal as SPAM (spam vs unwanted tier)', async () => {
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)],
|
||||
indicators: [],
|
||||
});
|
||||
getBlastRadiusMock.mockResolvedValue({
|
||||
status: 'ok',
|
||||
matched: 1,
|
||||
delivered: 0,
|
||||
held: 1,
|
||||
rejected: 0,
|
||||
clicked: 0,
|
||||
perRecipient: [],
|
||||
source: 'fan-out',
|
||||
});
|
||||
|
||||
const result = await classifyCampaign('campaign-1');
|
||||
|
||||
expect(result.verdict).toBe('SPAM');
|
||||
});
|
||||
|
||||
it('classifies a suspicious-but-contained campaign (one url indicator, delivery contained to reporter) as UNWANTED (spam vs unwanted tier)', async () => {
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: suspiciousUnwantedMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', suspiciousUnwantedMessage)],
|
||||
indicators: [
|
||||
{ id: 'ind-1', message_id: 'message-1', indicator_type: 'url', value: 'http://promo.some-vendor.net/deal' },
|
||||
],
|
||||
});
|
||||
getBlastRadiusMock.mockResolvedValue({
|
||||
status: 'ok',
|
||||
matched: 1,
|
||||
delivered: 1,
|
||||
held: 0,
|
||||
rejected: 0,
|
||||
clicked: 0,
|
||||
perRecipient: [{ recipient: REPORTER_EMAIL, status: 'delivered' }],
|
||||
source: 'fan-out',
|
||||
});
|
||||
|
||||
const result = await classifyCampaign('campaign-1');
|
||||
|
||||
expect(result.verdict).toBe('UNWANTED');
|
||||
});
|
||||
|
||||
it('keeps persisted reasons short and free of raw body text even with many indicators (evidence bounding)', async () => {
|
||||
const manyIndicators = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `ind-${i}`,
|
||||
message_id: 'message-1',
|
||||
indicator_type: 'url',
|
||||
value: `http://spammy-${i}.example.test/x`,
|
||||
}));
|
||||
stageQueries({
|
||||
reports: [
|
||||
{ id: 'report-1', title: cleanSpamMessage.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL },
|
||||
],
|
||||
messages: [toMessageRow('message-1', 'report-1', cleanSpamMessage)],
|
||||
indicators: manyIndicators,
|
||||
});
|
||||
getBlastRadiusMock.mockResolvedValue({
|
||||
status: 'ok',
|
||||
matched: 1,
|
||||
delivered: 0,
|
||||
held: 1,
|
||||
rejected: 0,
|
||||
clicked: 0,
|
||||
perRecipient: [],
|
||||
source: 'fan-out',
|
||||
});
|
||||
|
||||
const result = await classifyCampaign('campaign-1');
|
||||
|
||||
expect(result.reasons.length).toBeLessThanOrEqual(5);
|
||||
for (const reason of result.reasons) {
|
||||
expect(reason.length).toBeLessThan(300);
|
||||
}
|
||||
});
|
||||
});
|
||||
524
lib/services/campaign-classifier.ts
Normal file
524
lib/services/campaign-classifier.ts
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
/**
|
||||
* Campaign Classifier (Phase 19)
|
||||
*
|
||||
* Pure, deterministic SPAM/UNWANTED/THREAT rule engine over bounded,
|
||||
* structured phishing-triage evidence. No LLM/Anthropic/OpenRouter calls
|
||||
* anywhere in this module (D-01) — same evidence-in -> rule-eval ->
|
||||
* verdict-out shape as lib/services/robotic-classifier.ts.
|
||||
*
|
||||
* `classifyCampaign(campaignId)` (Task 2) is the single exported
|
||||
* orchestrator: gather evidence -> apply the D-06 (simulation allowlist) ->
|
||||
* D-03 (THREAT) -> D-04 (SPAM/UNWANTED) rule order -> compute D-05
|
||||
* confidence -> map D-08 recommended actions -> append one classifications
|
||||
* row (D-02, no ON CONFLICT). This file currently implements the pure rule
|
||||
* functions those steps compose (Task 1).
|
||||
*/
|
||||
|
||||
import type { AuthResults } from './eml-parser';
|
||||
import { postgresClient } from './postgres-client';
|
||||
import { getBlastRadius, type BlastRadiusResult } from './mimecast-blast-radius';
|
||||
|
||||
// =============================================================================
|
||||
// D-06/D-07: KnowBe4 / Breach Secure Now simulation sender-domain allowlist
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* NOT exhaustive — see 19-RESEARCH.md "Pitfall 4" and "D-07 Findings" for
|
||||
* provenance and known gaps. Refresh from new ticket evidence or a vendor
|
||||
* domain export as needed. This is a TypeScript constant (not a
|
||||
* live-editable DB table) by design — T-19-02's logic-drift mitigation
|
||||
* relies on every rule, including this allowlist, being unit-tested pure
|
||||
* code rather than a runtime-editable table.
|
||||
*/
|
||||
export const KNOWN_SIMULATION_SENDERS: readonly { vendor: string; domains: readonly string[] }[] = [
|
||||
{
|
||||
vendor: 'knowbe4',
|
||||
// 219 tickets, ~37 impersonated personas — 19-RESEARCH.md D-07 finding #2
|
||||
domains: ['it-support.care'],
|
||||
},
|
||||
{
|
||||
vendor: 'breach-secure-now',
|
||||
// confirmed via ticket #610787/#610770/#650284 — 19-RESEARCH.md D-07 finding #1
|
||||
domains: ['breachsecurenow.com'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Exact-domain-or-proper-subdomain match ONLY — never `.includes()`
|
||||
* substring matching (T-19-01 spoofing guard: a domain like
|
||||
* `it-support.care.attacker.net` must NOT match).
|
||||
*/
|
||||
export function domainMatchesAllowlist(domain: string): boolean {
|
||||
const lower = domain.toLowerCase();
|
||||
return KNOWN_SIMULATION_SENDERS.some((entry) =>
|
||||
entry.domains.some((allowed) => lower === allowed || lower.endsWith(`.${allowed}`))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow shape shared by both the full Phase 16 `NormalizedMessage` (used
|
||||
* directly by unit tests/fixtures) and this module's own bounded
|
||||
* `ParsedMessage` (built from `messages.headers` JSONB in
|
||||
* `gatherCampaignEvidence`) — only the sender-identity fields the allowlist
|
||||
* check needs.
|
||||
*/
|
||||
export interface SenderIdentity {
|
||||
from: { domain: string | null };
|
||||
returnPath: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks BOTH the From domain and the Return-Path domain (Pitfall 3 — From
|
||||
* may lack a visible email address on some real report tickets, e.g. the
|
||||
* Breach Secure Now fixture).
|
||||
*/
|
||||
export function isKnownSimulationSender(message: SenderIdentity): boolean {
|
||||
const fromDomain = message.from.domain;
|
||||
const returnPathDomain = message.returnPath?.split('@')[1] ?? null;
|
||||
return [fromDomain, returnPathDomain]
|
||||
.filter((domain): domain is string => domain !== null)
|
||||
.some(domainMatchesAllowlist);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Auth-verdict precedence (Pitfall 1: forwarding-induced auth-verdict inversion)
|
||||
// =============================================================================
|
||||
|
||||
/** Prefers the pre-forwarding verdict when present (Pitfall 1). */
|
||||
export function effectiveAuthResults(headers: {
|
||||
authResults: AuthResults;
|
||||
authResultsOriginal: AuthResults | null;
|
||||
}): AuthResults {
|
||||
return headers.authResultsOriginal ?? headers.authResults;
|
||||
}
|
||||
|
||||
/** True iff any of spf/dkim/dmarc is a hard 'fail' — never on 'none'/'neutral'/undefined. */
|
||||
export function hasHardAuthFail(auth: AuthResults): boolean {
|
||||
return auth.spf === 'fail' || auth.dkim === 'fail' || auth.dmarc === 'fail';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// D-05: Confidence scoring — additive-from-1.0, each deduction named in reasons
|
||||
// =============================================================================
|
||||
|
||||
export interface ConfidenceResult {
|
||||
confidence: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface ConfidenceEvidenceFlags {
|
||||
hasAnyMessage: boolean;
|
||||
blastRadiusStatus: 'ok' | 'unavailable';
|
||||
hasAttachmentOrUrlIndicators: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weights: message-parse absence (0.4) is heaviest since it starves every
|
||||
* other evidence source (no sender domain, no auth verdicts, no indicators
|
||||
* without a parsed message); Mimecast unavailability (0.3) is next since
|
||||
* D-03's THREAT gate directly depends on it; missing indicators (0.2) is
|
||||
* lightest since a genuinely clean message legitimately has none. The three
|
||||
* sum to 0.9, leaving a natural 0.10 floor when all three evidence sources
|
||||
* are missing — no extra clamping logic needed.
|
||||
*/
|
||||
export function computeConfidence(evidence: ConfidenceEvidenceFlags): ConfidenceResult {
|
||||
let confidence = 1.0;
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (!evidence.hasAnyMessage) {
|
||||
confidence -= 0.4;
|
||||
reasons.push(
|
||||
'No .eml/message evidence parsed for any report in this campaign — sender-domain and auth-verdict signals unavailable'
|
||||
);
|
||||
}
|
||||
if (evidence.blastRadiusStatus !== 'ok') {
|
||||
confidence -= 0.3;
|
||||
reasons.push('Mimecast blast-radius data unavailable — delivery/click evidence could not be confirmed');
|
||||
}
|
||||
if (!evidence.hasAttachmentOrUrlIndicators) {
|
||||
confidence -= 0.2;
|
||||
reasons.push('No attachment-hash or URL indicators found for this campaign');
|
||||
}
|
||||
|
||||
return { confidence: Math.round(confidence * 100) / 100, reasons };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// D-08: Recommended-actions vocabulary + requires_approval invariant
|
||||
// =============================================================================
|
||||
|
||||
export type Verdict = 'SPAM' | 'UNWANTED' | 'THREAT';
|
||||
|
||||
/** Always force requires_approval:true when recommended (CLASSIFY-02). */
|
||||
export const DESTRUCTIVE_ACTIONS = new Set([
|
||||
'block_sender',
|
||||
'purge_message',
|
||||
'reset_password',
|
||||
'isolate_endpoint',
|
||||
]);
|
||||
|
||||
export interface ActionEvidence {
|
||||
clicked: number;
|
||||
}
|
||||
|
||||
export function mapVerdictToActions(verdict: Verdict, evidence: ActionEvidence): string[] {
|
||||
switch (verdict) {
|
||||
case 'SPAM':
|
||||
return ['no_action'];
|
||||
case 'UNWANTED':
|
||||
return ['warn_user'];
|
||||
case 'THREAT': {
|
||||
const actions = ['block_sender', 'purge_message'];
|
||||
// Evidence of actual interaction (not just delivery) raises the bar to
|
||||
// credential/endpoint-compromise-level actions. ASSUMPTION FLAG: this
|
||||
// is a reasoned research proposal (19-RESEARCH.md Open Question #1),
|
||||
// NOT an explicit D-08 decision — CONTEXT.md D-08 only locks the
|
||||
// vocabulary and the OR'd approval invariant, delegating finer
|
||||
// action-mapping to "Claude's Discretion". Worth a quick user
|
||||
// confirmation since it materially affects what Phase 20 gates
|
||||
// approval on; does not contradict any locked decision.
|
||||
if (evidence.clicked > 0) {
|
||||
actions.push('reset_password', 'isolate_endpoint', 'disable_forwarding_rule');
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** OR'd across all recommended actions (CLASSIFY-02) — never per-action. */
|
||||
export function computeRequiresApproval(actions: string[]): boolean {
|
||||
return actions.some((action) => DESTRUCTIVE_ACTIONS.has(action));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Evidence gathering — gatherCampaignEvidence (CLASSIFY-06 / T-19-03 bounded)
|
||||
// =============================================================================
|
||||
|
||||
/** Cap on sample arrays exposed for human-readable evidence — never the full set (T-19-03). */
|
||||
const MAX_SAMPLE_SIZE = 10;
|
||||
|
||||
interface ReportDbRow {
|
||||
id: string;
|
||||
title: string | null;
|
||||
created_at: string;
|
||||
requester_email: string | null;
|
||||
}
|
||||
|
||||
interface MessageDbRow {
|
||||
id: string;
|
||||
report_id: string;
|
||||
headers: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface IndicatorDbRow {
|
||||
id: string;
|
||||
message_id: string;
|
||||
indicator_type: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded, structured per-message fields needed by the rule engine — parsed
|
||||
* out of `messages.headers` JSONB (the Phase 16 `NormalizedMessage` object).
|
||||
* Deliberately narrower than the full `NormalizedMessage` shape: never
|
||||
* carries `bodyPreview`/raw content into the classifier or its output
|
||||
* (CLASSIFY-06).
|
||||
*/
|
||||
export interface ParsedMessage extends SenderIdentity {
|
||||
id: string;
|
||||
reportId: string;
|
||||
from: { domain: string | null; email: string | null };
|
||||
authResults: AuthResults;
|
||||
authResultsOriginal: AuthResults | null;
|
||||
subject: string | null;
|
||||
}
|
||||
|
||||
export interface CampaignIndicator {
|
||||
id: string;
|
||||
messageId: string;
|
||||
indicatorType: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface CampaignReportSummary {
|
||||
id: string;
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
requesterEmail: string | null;
|
||||
}
|
||||
|
||||
export interface CampaignEvidence {
|
||||
campaignId: string;
|
||||
reportCount: number;
|
||||
messageCount: number;
|
||||
indicatorCount: number;
|
||||
/** Capped at MAX_SAMPLE_SIZE — for human-readable evidence only (T-19-03). */
|
||||
reportSample: CampaignReportSummary[];
|
||||
messages: ParsedMessage[];
|
||||
indicators: CampaignIndicator[];
|
||||
blastRadius: BlastRadiusResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers a bounded evidence payload for one campaign: linked reports
|
||||
* (earliest-first), their parsed messages, cross-message indicators, and a
|
||||
* single `getBlastRadius()` lookup keyed off the EARLIEST report (research
|
||||
* A6 — "the original" convention, mirroring campaign-grouping-service.ts's
|
||||
* own `ORDER BY r.created_at ASC`). When the campaign has no linked reports,
|
||||
* synthesizes `unavailable`/`not_configured` without calling Mimecast.
|
||||
*/
|
||||
export async function gatherCampaignEvidence(campaignId: string): Promise<CampaignEvidence> {
|
||||
// Bulk-fetch linked reports (+ join contacts for requester email — mirrors
|
||||
// app/api/phishing/campaigns/[id]/route.ts's existing bulk-fetch shape).
|
||||
const reportsRes = await postgresClient.query<ReportDbRow>(
|
||||
`SELECT r.id::text AS id, r.title, r.created_at::text AS created_at,
|
||||
c.email_address AS requester_email
|
||||
FROM reports r
|
||||
LEFT JOIN contacts c ON c.id = r.requester_contact_id
|
||||
WHERE r.campaign_id = $1
|
||||
ORDER BY r.created_at ASC`,
|
||||
[campaignId]
|
||||
);
|
||||
const reports: CampaignReportSummary[] = reportsRes.rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
createdAt: r.created_at,
|
||||
requesterEmail: r.requester_email,
|
||||
}));
|
||||
const reportIds = reports.map((r) => r.id);
|
||||
|
||||
const messagesRes = reportIds.length
|
||||
? await postgresClient.query<MessageDbRow>(
|
||||
`SELECT id::text AS id, report_id::text AS report_id, headers
|
||||
FROM messages WHERE report_id = ANY($1::uuid[])`,
|
||||
[reportIds]
|
||||
)
|
||||
: { rows: [] as MessageDbRow[] };
|
||||
const messages: ParsedMessage[] = messagesRes.rows.map((m) => {
|
||||
const headers = (m.headers ?? {}) as {
|
||||
from?: { domain?: string | null; email?: string | null };
|
||||
returnPath?: string | null;
|
||||
authResults?: AuthResults;
|
||||
authResultsOriginal?: AuthResults | null;
|
||||
subject?: string | null;
|
||||
};
|
||||
return {
|
||||
id: m.id,
|
||||
reportId: m.report_id,
|
||||
from: { domain: headers.from?.domain ?? null, email: headers.from?.email ?? null },
|
||||
returnPath: headers.returnPath ?? null,
|
||||
authResults: headers.authResults ?? {},
|
||||
authResultsOriginal: headers.authResultsOriginal ?? null,
|
||||
subject: headers.subject ?? null,
|
||||
};
|
||||
});
|
||||
const messageIds = messages.map((m) => m.id);
|
||||
|
||||
const indicatorsRes = messageIds.length
|
||||
? await postgresClient.query<IndicatorDbRow>(
|
||||
`SELECT id::text AS id, message_id::text AS message_id, indicator_type, value
|
||||
FROM indicators WHERE message_id = ANY($1::uuid[])`,
|
||||
[messageIds]
|
||||
)
|
||||
: { rows: [] as IndicatorDbRow[] };
|
||||
const indicators: CampaignIndicator[] = indicatorsRes.rows.map((i) => ({
|
||||
id: i.id,
|
||||
messageId: i.message_id,
|
||||
indicatorType: i.indicator_type,
|
||||
value: i.value,
|
||||
}));
|
||||
|
||||
const primaryReport = reports[0] ?? null;
|
||||
let blastRadius: BlastRadiusResult;
|
||||
if (primaryReport) {
|
||||
const primaryMessage = messages.find((m) => m.reportId === primaryReport.id) ?? null;
|
||||
const senderIndicator = indicators.find(
|
||||
(i) => i.messageId === primaryMessage?.id && i.indicatorType === 'sender'
|
||||
);
|
||||
const createdAt = new Date(primaryReport.createdAt);
|
||||
blastRadius = await getBlastRadius({
|
||||
sender: senderIndicator?.value ?? primaryMessage?.from.email ?? '',
|
||||
recipient: primaryReport.requesterEmail ?? '',
|
||||
subject: primaryMessage?.subject ?? primaryReport.title ?? '',
|
||||
dateWindow: {
|
||||
start: new Date(createdAt.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: new Date(createdAt.getTime() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// No report ever linked to this campaign — nothing to look up (research A6).
|
||||
blastRadius = { status: 'unavailable', reason: 'not_configured' };
|
||||
}
|
||||
|
||||
return {
|
||||
campaignId,
|
||||
reportCount: reports.length,
|
||||
messageCount: messages.length,
|
||||
indicatorCount: indicators.length,
|
||||
reportSample: reports.slice(0, MAX_SAMPLE_SIZE),
|
||||
messages,
|
||||
indicators,
|
||||
blastRadius,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// D-03/D-04: Verdict tier evaluation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Known-bad indicator match (D-03 second signal, research A4): cross-report
|
||||
* correlation only — same attachment_hash/url value spanning >=2 distinct
|
||||
* messages in the campaign. No external reputation lookup.
|
||||
*/
|
||||
function hasKnownBadIndicatorMatch(indicators: CampaignIndicator[]): boolean {
|
||||
const messagesByValue = new Map<string, Set<string>>();
|
||||
for (const indicator of indicators) {
|
||||
if (indicator.indicatorType !== 'attachment_hash' && indicator.indicatorType !== 'url') continue;
|
||||
const key = `${indicator.indicatorType}:${indicator.value}`;
|
||||
const set = messagesByValue.get(key) ?? new Set<string>();
|
||||
set.add(indicator.messageId);
|
||||
messagesByValue.set(key, set);
|
||||
}
|
||||
return Array.from(messagesByValue.values()).some((set) => set.size >= 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* D-03: THREAT requires BOTH (a) evidence the message reached someone
|
||||
* (blast-radius delivered>0 or clicked>0) AND (b) a malicious signal — a
|
||||
* hard SPF/DKIM/DMARC fail (via effectiveAuthResults' authResultsOriginal
|
||||
* precedence) OR a known-bad indicator match. Either signal alone is not
|
||||
* enough — a contained blast radius isn't a realized threat yet.
|
||||
*/
|
||||
function evaluateThreatTier(evidence: CampaignEvidence): boolean {
|
||||
if (evidence.blastRadius.status !== 'ok') return false;
|
||||
const { delivered, clicked } = evidence.blastRadius;
|
||||
if (delivered <= 0 && clicked <= 0) return false;
|
||||
|
||||
const hasAuthFail = evidence.messages.some((message) => hasHardAuthFail(effectiveAuthResults(message)));
|
||||
const hasIndicatorMatch = hasKnownBadIndicatorMatch(evidence.indicators);
|
||||
return hasAuthFail || hasIndicatorMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* D-04: SPAM = no suspicious signal at all. UNWANTED = a suspicious signal
|
||||
* present (an attachment/url indicator match, even a single one, or
|
||||
* delivery contained to the reporter(s) only) but below the THREAT bar.
|
||||
*/
|
||||
function evaluateSpamVsUnwanted(evidence: CampaignEvidence): 'SPAM' | 'UNWANTED' {
|
||||
const hasAnyIndicator = evidence.indicators.some(
|
||||
(indicator) => indicator.indicatorType === 'attachment_hash' || indicator.indicatorType === 'url'
|
||||
);
|
||||
|
||||
const requesterEmails = evidence.reportSample
|
||||
.map((report) => report.requesterEmail)
|
||||
.filter((email): email is string => email !== null);
|
||||
|
||||
const deliveryContainedToReporter =
|
||||
evidence.blastRadius.status === 'ok' &&
|
||||
evidence.blastRadius.delivered > 0 &&
|
||||
evidence.blastRadius.perRecipient
|
||||
.filter((recipient) => recipient.status === 'delivered')
|
||||
.every((recipient) => requesterEmails.includes(recipient.recipient));
|
||||
|
||||
return hasAnyIndicator || deliveryContainedToReporter ? 'UNWANTED' : 'SPAM';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// classifyCampaign — orchestrator (CLASSIFY-01, D-02 append-only INSERT)
|
||||
// =============================================================================
|
||||
|
||||
export interface ClassifyResult {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
verdict: Verdict;
|
||||
confidence: number;
|
||||
summary: string;
|
||||
reasons: string[];
|
||||
recommendedActions: string[];
|
||||
requiresApproval: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single exported orchestrator: gather evidence -> D-06 simulation
|
||||
* short-circuit -> D-03 THREAT tier -> D-04 SPAM/UNWANTED split -> D-05
|
||||
* confidence -> D-08 recommended actions -> append-only INSERT into
|
||||
* `classifications` (D-02, no ON CONFLICT — history is never overwritten).
|
||||
*/
|
||||
export async function classifyCampaign(campaignId: string): Promise<ClassifyResult> {
|
||||
try {
|
||||
const evidence = await gatherCampaignEvidence(campaignId);
|
||||
|
||||
// D-06: allowlist match short-circuits before THREAT tier evaluation,
|
||||
// regardless of other signals — D-03/D-04 still decide which of SPAM/
|
||||
// UNWANTED applies.
|
||||
const isSimulation = evidence.messages.some((message) => isKnownSimulationSender(message));
|
||||
|
||||
let verdict: Verdict;
|
||||
const reasons: string[] = [];
|
||||
if (isSimulation) {
|
||||
verdict = evaluateSpamVsUnwanted(evidence);
|
||||
reasons.push(
|
||||
'Sender domain matches a known phishing-simulation vendor allowlist (KnowBe4/Breach Secure Now) — THREAT tier skipped'
|
||||
);
|
||||
} else if (evaluateThreatTier(evidence)) {
|
||||
verdict = 'THREAT';
|
||||
} else {
|
||||
verdict = evaluateSpamVsUnwanted(evidence);
|
||||
}
|
||||
|
||||
const clicked = evidence.blastRadius.status === 'ok' ? evidence.blastRadius.clicked : 0;
|
||||
const recommendedActions = mapVerdictToActions(verdict, { clicked });
|
||||
const requiresApproval = computeRequiresApproval(recommendedActions);
|
||||
|
||||
const { confidence, reasons: confidenceReasons } = computeConfidence({
|
||||
hasAnyMessage: evidence.messageCount > 0,
|
||||
blastRadiusStatus: evidence.blastRadius.status === 'ok' ? 'ok' : 'unavailable',
|
||||
hasAttachmentOrUrlIndicators: evidence.indicators.some(
|
||||
(indicator) => indicator.indicatorType === 'attachment_hash' || indicator.indicatorType === 'url'
|
||||
),
|
||||
});
|
||||
reasons.push(...confidenceReasons);
|
||||
reasons.push(
|
||||
`Verdict ${verdict} determined from ${evidence.reportCount} linked report(s) and ${evidence.messageCount} parsed message(s)`
|
||||
);
|
||||
|
||||
const summary = `${verdict} (confidence ${confidence}): ${reasons[0]}`;
|
||||
|
||||
// D-02: append-only INSERT — no ON CONFLICT. Each classify call is a new
|
||||
// history row; "current" verdict is the most recent by created_at.
|
||||
const insertResult = await postgresClient.query<{ id: string; created_at: string }>(
|
||||
`INSERT INTO classifications (
|
||||
campaign_id, verdict, confidence, summary, reasons, recommended_actions, requires_approval
|
||||
) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7)
|
||||
RETURNING id::text AS id, created_at::text AS created_at`,
|
||||
[
|
||||
campaignId,
|
||||
verdict,
|
||||
confidence,
|
||||
summary,
|
||||
JSON.stringify(reasons),
|
||||
JSON.stringify(recommendedActions),
|
||||
requiresApproval,
|
||||
]
|
||||
);
|
||||
const row = insertResult.rows[0];
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
campaignId,
|
||||
verdict,
|
||||
confidence,
|
||||
summary,
|
||||
reasons,
|
||||
recommendedActions,
|
||||
requiresApproval,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('[CAMPAIGN-CLASSIFIER] Failed to classify campaign', campaignId, message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue