Commit graph

964 commits

Author SHA1 Message Date
7ac57bd2d4 chore: merge executor worktree (worktree-agent-af5d279be12eefb03) — plan 19-01 2026-07-16 08:22:47 -04:00
63f173ea44 docs(19-01): complete classification engine pure functions + orchestrator plan
- 19-01-SUMMARY.md: task commits, decisions, deviations, self-check
- deferred-items.md: logs 2 pre-existing unrelated itglue-search.test.ts
  failures discovered during full `npm test` run (out of scope, not fixed)
2026-07-16 08:22:14 -04:00
38c1ae4daf feat(19-01): implement classifyCampaign orchestrator + evidence gathering (GREEN)
- gatherCampaignEvidence: bulk-fetches reports (earliest-first, joined to
  contacts for requester email) -> messages (report_id = ANY) -> indicators
  (message_id = ANY), parses messages.headers JSONB into bounded
  ParsedMessage fields, and runs one getBlastRadius() lookup keyed off the
  earliest report's sender/subject/±24h window (research A6); synthesizes
  unavailable/not_configured with no Mimecast call when no report is linked
- evaluateThreatTier (D-03): blastRadius.status==='ok' AND
  (delivered>0 OR clicked>0) AND (hasHardAuthFail via effectiveAuthResults
  OR hasKnownBadIndicatorMatch — same attachment_hash/url value spanning
  >=2 distinct messages, cross-report correlation only, no external
  reputation lookup per research A4)
- evaluateSpamVsUnwanted (D-04): UNWANTED when any attachment/url indicator
  matches or delivery is contained to the reporter(s) only; SPAM otherwise
- classifyCampaign: D-06 simulation short-circuit -> D-03 -> D-04 ->
  computeConfidence -> mapVerdictToActions -> computeRequiresApproval ->
  append-only INSERT into classifications (D-02, no ON CONFLICT), wrapped
  in try/catch logging [CAMPAIGN-CLASSIFIER] + err.message and rethrowing
- isKnownSimulationSender relaxed to a narrower SenderIdentity shape so both
  the full NormalizedMessage fixtures and the bounded ParsedMessage type
  can share it
- All 39 tests green; tsc clean; full `npm test` suite green except 2
  pre-existing, unrelated itglue-search.test.ts failures (see
  deferred-items.md)
2026-07-16 08:20:58 -04:00
f6c954aa23 test(19-01): add classifyCampaign orchestrator tests (RED)
- Mock ./postgres-client (query-only) and ./mimecast-blast-radius
  (getBlastRadius), routing staged rows by SQL substring per call
- Named tests for CLASSIFY-01 (returns exactly one verdict), D-02
  (append-only INSERT, no ON CONFLICT), D-06 simulation allowlist
  (KnowBe4 From-match + BSN Return-Path-match), positive-path D-03 THREAT,
  D-03 known-bad-indicator OR-branch (auth pass, shared indicator across 2
  messages), D-04 SPAM/UNWANTED split, and CLASSIFY-06 evidence bounding
2026-07-16 08:20:49 -04:00
3ea6c95e38 feat(19-01): implement classifier pure rule functions (D-05/D-06/D-08)
- KNOWN_SIMULATION_SENDERS allowlist (it-support.care, breachsecurenow.com)
  with domainMatchesAllowlist (exact-or-proper-subdomain, no substring match
  — T-19-01) and isKnownSimulationSender (checks From + Return-Path domain
  — Pitfall 3)
- effectiveAuthResults (authResultsOriginal precedence — Pitfall 1) and
  hasHardAuthFail (spf/dkim/dmarc hard-fail only)
- computeConfidence: additive-from-1.0 with 0.4/0.3/0.2 named deductions,
  floors at 0.10 (D-05)
- mapVerdictToActions + DESTRUCTIVE_ACTIONS + computeRequiresApproval
  (OR'd across actions, D-08/CLASSIFY-02)
- All 30 pure-function tests green; tsc clean
2026-07-16 08:14:57 -04:00
f4e6baf505 test(19-01): add failing tests + synthetic fixtures for classifier pure rule functions
- campaign-classifier.test.ts: describe blocks for domainMatchesAllowlist,
  isKnownSimulationSender, effectiveAuthResults, hasHardAuthFail,
  computeConfidence, mapVerdictToActions, computeRequiresApproval
- campaign-classifier.fixtures.ts: synthetic KnowBe4/BSN simulation fixtures
  plus non-simulation threat/clean-spam/suspicious-unwanted fixtures
- Covers CLASSIFY-01/02/03/04/06 pure-function behavior (T-19-01, Pitfall 1/3)
2026-07-16 08:14:51 -04:00
f2b3602ca1 docs(19): add pattern map 2026-07-16 08:05:15 -04:00
0be109d4cd docs(19): create phase plan 2026-07-16 08:04:57 -04:00
6981dcc628 docs(19): revise plans for D-03/D-04 verdict coverage + decision citations 2026-07-16 08:04:39 -04:00
ef55e6dfd3 docs(19): create phase plan 2026-07-16 07:49:00 -04:00
8d4618cf0f docs(phase-19): add validation strategy 2026-07-16 07:40:15 -04:00
e1c7eff088 docs(19): research classification engine phase domain 2026-07-16 07:38:52 -04:00
b09b00a08a docs(state): record phase 19 context session 2026-07-16 07:15:01 -04:00
32cdf55506 docs(19): capture phase context 2026-07-16 07:14:54 -04:00
b305dd5108 docs(18): close out HUMAN-UAT.md — all items resolved after gap closure 2026-07-16 07:00:03 -04:00
7d34b33273 docs(phase-18): final verification report — status passed 2026-07-16 06:59:24 -04:00
cd9dfc3954 docs(18): update code review after CR-03/CR-02 gap closure 2026-07-16 06:56:03 -04:00
b59d9c2071 docs(18-05): finalize summary after Task 3 live verification (approved) 2026-07-16 06:49:38 -04:00
c3ad292139 chore: merge executor worktree (worktree-agent-a153b3afb38c39100) 2026-07-16 06:40:43 -04:00
4a34ba8ed2 docs(18-05): add partial SUMMARY for Tasks 1-2 (Task 3 checkpoint pending)
Documents the CR-03 own-campaign revalidation guard and CR-02 origin
decrement fix (Tasks 1-2, committed and green), ahead of the blocking
Task 3 human live-database-verification checkpoint.
2026-07-16 00:11:58 -04:00
6945591f76 fix(18-05): decrement origin campaign report_count on migration + diverged create-new (CR-02)
Cross-campaign migration (a report moves from campaign A to a
different existing campaign B on re-analyze) never decremented A's
report_count, leaving it permanently stale. The same abandonment
happens on the signal-diverged create-new fall-through added for
CR-03 (Task 1) — landing on a brand-new campaign instead of an
existing sibling's, but the same class of staleness.

Add a shared decrementOriginCampaign() helper and call it in both
locations, guarded so it only fires when there is a real origin
(ownReport.campaign_id non-null) and the match doesn't resolve back to
the report's own campaign (the existing same-campaign no-op guard is
unchanged). D-08 sibling upgrades (never-grouped report -> sibling
campaign) increment the destination only, with no origin to
decrement.

- Test E: cross-campaign migration decrements origin, increments destination
- Test F: D-08 upgrade with no prior campaign increments destination only
- Test G (renamed from existing 18-04 test): same-campaign re-match remains zero mutations
- Test H: signal-diverged create-new also decrements the abandoned origin (plan-checker-flagged case)

Tests E and H failed against the pre-fix code (confirmed during RED).
2026-07-16 00:10:45 -04:00
633b48c3b2 fix(18-05): add own-campaign revalidation guard before create-new (CR-03)
Re-analyzing a single-report campaign (no sibling report exists yet)
fell through to "create new campaign" because every tier query
self-excludes the report's own row, so matchCampaignId stayed null and
the report's still-valid campaign was abandoned in favor of a
duplicate campaigns row sharing the same campaign_key.

Add a guard that runs only when no sibling matched and the report
already has campaign_id: recompute the report's current tier keys
once, look up its own campaign row, and reuse it (created:false) when
the stored campaign_key still matches. Falls through to create-new
unchanged when the campaign row is gone or the signal has genuinely
diverged. D-08 sibling upgrades are untouched (guard only runs when no
sibling matched).

- Tests A/B reproduce the exact CR-03 duplicate-campaign scenario and
  failed against the pre-fix code (confirmed during RED)
- Tests C/D guard the create-new no-regression and signal-diverged
  fall-through paths
2026-07-16 00:08:36 -04:00
24aa571245 docs(18): record planning completion for 18-05 2026-07-16 00:03:45 -04:00
b7a5273848 docs(18): extend 18-05 gap-closure plan to decrement origin on signal-diverged create-new too 2026-07-16 00:02:53 -04:00
9c69b9033c docs(18): document CR-03 duplicate-campaign gap found via live verification 2026-07-15 23:44:57 -04:00
a48c0583bd test(18): persist human verification items as UAT 2026-07-15 22:44:19 -04:00
cc2544c7ee docs(18): update code review report after gap-closure re-review 2026-07-15 22:36:03 -04:00
650f9b8100 fix(18): decouple campaigns count-query params from list-query placeholder numbering
The count query reused statusFilter (built with $3 against the list query's
3-element params array) but only passed a 1-element params array, causing a
Postgres bind-parameter mismatch (500) on any `?status=` filtered request.
Pre-existing since 18-03; surfaced by the 18-04 gap-closure code re-review.
Gives the count query its own independent param array/placeholder numbering.
2026-07-15 22:35:47 -04:00
d3373ab708 docs(phase-18): update tracking after gap-closure wave 2026-07-15 22:28:22 -04:00
55ab2c2e05 chore: merge executor worktree (worktree-agent-af09d50545da22d95) 2026-07-15 22:27:01 -04:00
eb660354ef docs(18-04): record self-check results in plan summary 2026-07-15 22:26:37 -04:00
21fde9eb3d docs(18-04): complete gap-closure plan summary
- CAMP-02 report_count double-increment fix and WR-02 limit-clamp fix, with
  a passing regression test and clean type check
2026-07-15 22:26:21 -04:00
abe3d4b900 fix(18-04): clamp campaigns list limit param to [0, 200] (WR-02)
- Parse limit once with Number.isFinite instead of `|| 50`, so an explicit
  limit=0 is honored instead of silently replaced by the default
- Math.max/min clamps to [0, 200], preventing a negative limit from reaching
  the SQL LIMIT clause and raising an unhandled 500
2026-07-15 22:25:29 -04:00
9ca2ccf1c7 fix(18-04): short-circuit own-campaign re-match to stop report_count double-increment
- OwnReportRow now selects campaign_id::text; match branch no-ops (created:
  false, no UPDATE) when the tiered match resolves to the report's own
  current campaign_id via a sibling row
- Closes CAMP-02 gap / CR-01: /analyze can be re-run indefinitely without
  inflating campaigns.report_count
2026-07-15 22:25:04 -04:00
afd7af70c8 test(18-04): add failing regression test for campaign report_count double-increment
- Sibling report already in campaign-1 re-matches via Tier 3; asserts zero
  UPDATE campaigns / INSERT campaigns / UPDATE reports calls (CAMP-02/CR-01)
2026-07-15 22:24:17 -04:00
20f3e1bbb5 docs(18): create gap-closure plan 18-04 2026-07-15 22:22:17 -04:00
ea71579fb0 fix(phase-18): revert premature roadmap completion marker after gaps_found verification 2026-07-15 19:49:37 -04:00
7f51d49839 docs(phase-18): add verification report (gaps found) 2026-07-15 19:49:01 -04:00
bd90cdcb30 docs(18): add code review report 2026-07-15 19:43:12 -04:00
95859660d6 docs(phase-18): update tracking after wave 2 2026-07-15 19:34:50 -04:00
701fea04cc chore: merge executor worktree (worktree-agent-a985b30606e37e1d0) 2026-07-15 19:33:32 -04:00
c0967b5a9c chore: merge executor worktree (worktree-agent-a9f362ad9e34b1a2a) 2026-07-15 19:33:32 -04:00
a544024e75 docs(18-02): complete on-demand analyze route + automatic grouping wiring plan
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 19:33:00 -04:00
86f5166a86 docs(18-03): complete campaign read API plan
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 19:32:31 -04:00
959907d63b feat(18-03): add GET /api/phishing/campaigns/[id] nested detail
- requirePermission('phishing','read') gate (ACCESS-01)
- UUID-validated id (400 on malformed), 404 when campaign absent
- bulk-fetch reports/messages/indicators via ANY($1::uuid[]) keyed by parent id array (device-link-conflicts pattern)
- requesterEmail derived via reports.requester_contact_id -> contacts join (campaigns has no recipients column)
- messages.subject pulled from headers->>'subject' JSONB (no subject column)
- classifications included in shape (Phase 19 stub, expected empty)
2026-07-15 19:31:35 -04:00
c852cfee13 feat(18-03): add GET /api/phishing/campaigns paginated list
- requirePermission('phishing','read') gate (ACCESS-01)
- limit/offset clamped, optional status filter via parameterized $n placeholder (never string-interpolated)
- camelCase response { items, total, limit, offset }
2026-07-15 19:31:19 -04:00
19b8b4b415 feat(18-02): wire groupReportIntoCampaign into webhook + cron sweep paths
- webhook-service.ts: triggerPhishingDetection calls groupReportIntoCampaign
  with skipIfAlreadyGrouped:true after a flagged detection (D-01, D-08)
- phishing-sweep-service.ts: per-ticket sweep loop calls the same, inside the
  existing try/catch so a grouping failure counts against result.errors
  without aborting the sweep
2026-07-15 19:31:18 -04:00
de013e6ec1 feat(18-02): add POST /api/phishing/tickets/[ticket_id]/analyze route
- Orchestrates detectPhishingTicket -> parseAndStoreMessage -> groupReportIntoCampaign
- requirePermission('phishing','analyze') gate first-line (D-06, 401/403)
- Validates ticket_id numeric (400), missing ticket (404), non-phishing ticket (400)
- No skipIfAlreadyGrouped (D-08) — always re-runs grouping on demand
2026-07-15 19:30:46 -04:00
c77b7edae7 docs(phase-18): update tracking after wave 1 2026-07-15 19:27:40 -04:00
19f05dd564 chore: merge executor worktree (worktree-agent-a7caddbc3f0fc1d30) 2026-07-15 19:25:52 -04:00