Commit graph

118 commits

Author SHA1 Message Date
efbc437e2e feat(17-01): build mimecast-blast-radius.ts fan-out orchestration
- Add getBlastRadius(): never-throwing orchestration that fans out to
  searchDeliveredMessages + getHeldMessages + getThreatEvents (D-01,
  unconditional fan-out) and merges into normalized matched/delivered/
  held/rejected/clicked counts + perRecipient status array
- Config gate (BLAST-02): returns status:'unavailable' reason:'not_configured'
  synchronously when Mimecast is unconfigured, never constructs the client
- Redis-backed 5-min cache (D-04) via redis-client.ts, short-circuits before
  any MimecastClient call on hit
- clicked derived best-effort from getThreatEvents() analysis[] (D-02);
  documents the /api/ttp/url/get-logs limitation in code
- Documents D-05 known limitation: single global getMimecastClient() only,
  not per-company mimecast_tenants
- Unrecognized delivered-message status strings treated conservatively as
  non-rejected (A3 unconfirmed enum), raw values logged at debug level
- Add lib/services/mimecast-blast-radius.test.ts covering config gate,
  cache-hit short-circuit, fan-out merge, never-throw-on-error, and
  unknown-recipient classification
- Log pre-existing unrelated itglue-search.test.ts failures to
  deferred-items.md (out of scope for this plan)
2026-07-15 14:30:00 -04:00
8b032c3890 feat(17-01): add isMimecastConfigured() config gate + test seam
- Add isMimecastConfigured() to lib/services/mimecast-client.ts mirroring
  the pax8-factory.ts is<Name>Configured() convention
- Add _resetMimecastClient() test seam so tests can isolate env-var state
- Add lib/services/mimecast-client.test.ts covering config gate + throw/cache
  behavior of getMimecastClient()
2026-07-15 14:27:47 -04:00
ce16e6747c test(16-03): phishing-eml-service.test.ts orchestration coverage (mocked I/O)
- mocks postgresClient, getAutotaskClient, and B2 client (isB2Configured/presignUpload); no real Postgres/Autotask/B2 credentials or network
- happy path: one messages insert with D-06 auth verdicts in headers, indicators inserted
- no-eml no-op: NO_EML_ATTACHMENTS -> stored:false, no messages insert
- B2 unconfigured: raw_ref null, no presignUpload/PUT
- B2 configured: raw_ref = phishing/{reportId}/{attachmentId}.eml, exactly one PUT
- no-network invariant: fetch never called with a message-body URL from the fixture
- attachment_hash indicator metadata carries filename/contentType/size/related
- all fixtures reused from eml-parser.fixtures.ts (synthetic only)
2026-07-15 10:39:59 -04:00
5088d8d511 feat(16-03): phishing-eml-service.ts orchestration (list->select->fetch->B2->parse->persist)
- parseAndStoreMessage lists ticket attachments, selects the original message via selectOriginalMessage
- fetches full content via getAttachmentContent, size-guards the decoded buffer against MAX_EML_BYTES
- uploads raw bytes to B2 under phishing/{reportId}/{attachmentId}.eml when isB2Configured(), gracefully degrading rawRef=null on failure/absence
- parses via parseEml and persists one messages row (headers incl. D-06 auth verdicts, urls, attachments, body_preview, raw_ref) plus indicators rows (attachment_hash/url/sender) carrying D-07 metadata JSONB
- never fetches any URL extracted from the message; the only outbound calls are the Autotask attachment GET and the B2 presigned PUT
2026-07-15 10:37:35 -04:00
f3ace33f86 chore: merge executor worktree (worktree-agent-a2b7d8cafac0f4270) 2026-07-15 10:32:42 -04:00
654e624505 feat(16-01): implement parseEml, parseAuthResults, extractUrls, buildBodyPreview
GREEN: parseEml normalizes headers (From/Reply-To/Return-Path/To/Cc/
Subject/Date/Message-ID), builds the ordered Received chain from
mail.headerLines, and maps mailparser attachments to AttachmentMeta
(name/content-type/size/sha256 checksum, related flag preserved for
inline/CID parts per Pitfall 5).

parseAuthResults hand-rolls RFC 8601 Authentication-Results parsing
(spf/dkim/dmarc verdicts) rather than using mailauth, which performs
live DNS/HTTP verification (SC#3 violation). Both Authentication-Results
and Authentication-Results-Original are read via mail.headerLines
(Pitfall 4 — headers Map only exposes one occurrence of a repeated
header) and parsed into distinct authResults/authResultsOriginal fields.

extractUrls uses linkify-it with fuzzyLink enabled (scheme-less www.
URLs) scanning both text and html parts, deduped, never dereferenced.

buildBodyPreview prefers mail.text, falling back to a small hand-rolled
HTML-to-text stripper (not the undeclared transitive html-to-text
dependency — see SUMMARY deviations) when only HTML exists; truncated
to 500 chars.

MAX_EML_BYTES (10 MB, below B2's 25 MB cap) is enforced before
simpleParser is ever called (T-16-01 DoS guard).

26/26 tests pass; tsc clean for eml-parser files; full npm test run
confirms 2 pre-existing itglue-search.test.ts failures are unrelated
(logged to deferred-items.md).
2026-07-15 10:30:18 -04:00
4df4816b21 test(16-01): add failing tests for parseEml/parseAuthResults/extractUrls/buildBodyPreview
RED: covers EVID-03 (normalized headers, structured auth verdicts,
Received chain, URLs, attachment metadata incl. related flag),
EVID-04 (no-network spy, truncated body preview), and the DoS size
guard (oversized buffer rejected before simpleParser). New synthetic
fixtures: rich multipart, auth-results-original, inline/CID attachment,
long-body, fuzzy-URL, and an oversized-buffer generator. None of these
exports exist on eml-parser.ts yet.
2026-07-15 10:28:22 -04:00
8630fd5151 feat(16-02): add EML_OBJECT_KEY_REGEX + parameterize B2 key validation (D-05)
- New EML_OBJECT_KEY_REGEX enforces phishing/<id>/<id>.eml, rejects traversal
- presignDownload/presignUpload/downloadToBuffer take optional keyRegex,
  defaulting to OBJECT_KEY_REGEX so existing LogLift call sites are unchanged
- OBJECT_KEY_REGEX itself left untouched (skill-doc rule)
2026-07-15 10:23:19 -04:00
e4718ae71a feat(16-01): implement selectOriginalMessage three-tier attachment selection
GREEN: rfc.eml exact match -> single non-OriginatingEmail message/rfc822
candidate (covers KnowBe4 versioned filenames) -> OriginatingEmail.eml
fallback -> null. Case-insensitive on both content-type and filename
(checked via title/fullPath basename). All 8 selectOriginalMessage tests
pass; tsc clean for eml-parser files.
2026-07-15 10:23:00 -04:00
6de92a507b test(16-02): add failing tests for EML_OBJECT_KEY_REGEX + parameterized B2 key validation
- EML_OBJECT_KEY_REGEX must match phishing/<id>/<id>.eml and reject traversal/wrong-ext/LogLift shapes
- presignUpload must accept an optional keyRegex arg, defaulting to OBJECT_KEY_REGEX
2026-07-15 10:22:46 -04:00
9b65de72dc feat(16-02): add AutotaskClient.getAttachmentContent()
- Fetches Tickets/{id}/Attachments/{attachmentId}, reads response.items?.[0]
- Confirmed live: per-attachment-ID GET is list-shaped, not {item:...}
2026-07-15 10:22:03 -04:00
8be10db6e0 test(16-02): add failing test for AutotaskClient.getAttachmentContent
- Asserts items[0] convention for per-attachment-ID GET
- Asserts {item:...}-shaped response yields null (guards against regression)
2026-07-15 10:22:01 -04:00
2fde1156da test(16-01): add failing tests for selectOriginalMessage (EVID-02)
RED: covers all three selection tiers plus ambiguous/no-eml/empty edge
cases. lib/services/eml-parser.ts does not exist yet — tests fail to
resolve the module import.
2026-07-15 10:21:58 -04:00
9c4584d428 fix(15): exclude soft-deleted notes/time entries from phishing evidence
ticket_notes and time_entries both carry an is_deleted soft-delete flag
(per CLAUDE.md audit-column convention); gatherTicketEvidence was reading
both without filtering it, so retracted notes and reversed time entries
showed up as evidence for every phishing report. Found during code-review
re-verification of the Phase 15 CR-01/WR-01/WR-02 fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wWroM6FXkQJiH3JgYcony
2026-07-15 08:17:07 -04:00
7c63c5f76d fix(15): WR-02 refresh evidence snapshot even when content_hash unchanged 2026-07-15 08:08:20 -04:00
c875081275 fix(15): WR-01 use shared getAutotaskClient factory in phishing-detector 2026-07-15 08:07:45 -04:00
ecc34b4bad fix(15): CR-01 fix webhook-triggered phishing detection reading from unpopulated payload.entity 2026-07-15 08:05:30 -04:00
b199d9991c feat(15-03): register phishing-sweep schedule + migration 098
- extend sync_type union with 'phishing-sweep'
- add defaultSchedules entry (disabled by default, daily 5am cron)
- dispatch branch dynamically imports and calls sweepPhishingTickets
- migrations/098_phishing_sweep_schedule.sql seeds the row for existing installs
2026-07-15 07:49:01 -04:00
194b58b196 feat(15-03): fire-and-forget phishing detection on ticket.created webhook
- triggerPhishingDetection() mirrors triggerWorkflowEngine's payload.entity-first shape
- reads createdByContactID (Autotask field) into created_by_contact_id, per entity-mapper.ts:211
- called alongside the existing workflow-engine trigger, not awaited in the request path
2026-07-15 07:48:17 -04:00
dbd2ebe63c feat(15-03): add bounded phishing sweep service
- sweepPhishingTickets() queries recently-modified tickets (7d window, LIMIT 500)
- delegates each ticket to shared detectPhishingTicket (no duplicated match/hash logic)
- per-row try/catch increments errors without aborting the loop
2026-07-15 07:47:08 -04:00
15d0caa20d feat(15-02): add evidence capture + detectPhishingTicket orchestration
- gatherTicketEvidence: company_name, ticket_notes, time_entries (all
  parameterized $1 queries), and Autotask attachment metadata only
  (fullPath/title/contentType, never base64 data); Autotask call wrapped in
  try/catch so a failure degrades to an empty attachments array
- detectPhishingTicket: matches, hashes, checks D-04 idempotency guard
  (skips re-gathering/writing when content_hash is unchanged), then upserts
  one reports row via ON CONFLICT (ticket_id) DO UPDATE ... RETURNING id
- requester_contact_id binds from ticket.contact_id, created_by_contact_id
  from ticket.created_by_contact_id per interfaces contract
2026-07-15 07:42:59 -04:00
aabf5322e9 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
2026-07-15 07:42:14 -04:00
0e7daf9a6a test(15-02): add failing tests for phishing pattern matcher + content hash
- Covers all 8 locked DETECT-01 patterns individually, negative case,
  case-insensitivity, and content-hash stability/change/null-normalization
2026-07-15 07:41:14 -04:00
4d7a58b46c docs(14-02): drop literal candidate_company_ids mention from resolver docstring
Satisfies the plan's grep-based acceptance check for "no reference to
candidate_company_ids in the resolver source" — code already had no
membership check, this only reworded the explanatory comment.
2026-07-11 14:29:43 -04:00
0ce51a0167 feat(14-02): implement resolvePax8CompanyMatch resolver
- Two-table transactional write: pax8_companies.match_method='manual' AND
  pax8_company_match_review.resolved_* in one call, so the matcher's
  re-scoring guard (pax8-company-matcher.ts ~216-231) never re-flags a
  manually resolved company
- Guards not_found / already_resolved via FOR UPDATE select
- Validates target company existence + active state (substitute for
  candidate-membership check — D-05/D-09 allow non-candidate ids)
- All five vitest behavior cases green; tsc clean
2026-07-11 14:28:48 -04:00
afdcf1412d test(14-02): add failing test for resolvePax8CompanyMatch
- Five behavior cases: success (both writes), not_found, already_resolved,
  company_not_found, and non-candidate companyId still resolves (D-05/D-09)
- Hand-rolled mock tx asserts SQL + bound params per query call
2026-07-11 14:28:44 -04:00
db36c1afe6 chore: merge executor worktree (worktree-agent-a77fd74cb36676519) 2026-07-11 09:48:39 -04:00
d07c0b7826 feat(13-01): dispatch pax8-daily to fullSync with dual guard
- Extend ScheduleConfig.sync_type union with 'pax8-daily'
- Add dual-guarded branch in executeScheduledSync: skips with a
  distinct log when PAX8 is not configured (isPax8Configured()) or
  when integration_settings.key='pax8' is disabled, otherwise calls
  getPax8SyncService().fullSync('scheduled')
- PAX8-only inline check per D-01 — no shared helper, no changes to
  getDbDisabledKeys()/applyDisableOverlay() or other switch branches
2026-07-11 09:46:48 -04:00
3c114ae209 feat(13-02): register PAX8 as a toggleable integration-health row
- Add checkConfigOnly('pax8', 'PAX8', 'finance', [PAX8_CLIENT_ID, PAX8_CLIENT_SECRET]) call site
- Flows through existing applyDisableOverlay automatically for /admin/integrations
2026-07-11 09:46:06 -04:00
b168d44585 feat(appgate): add AppGate SDP integration health check and sync service
Registers AppGate as a checkConfigOnly integration-health row and public
sync route, matching the existing factory + is<Name>Configured() pattern.
Committed now so Phase 13's worktree-isolated executors fork from a HEAD
that includes this integration-health.ts entry, since Plan 13-02 inserts
the PAX8 row immediately after it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHRgZqkzBHBbAbc3KHneuR
2026-07-11 09:43:48 -04:00
be6f07b8c9 fix(12): WR-03 follow-up - fix TS type narrowing on token return 2026-07-11 07:24:15 -04:00
1bb2b8b0a2 fix(12): WR-03 validate PAX8 token response shape in Pax8Client.getToken() 2026-07-11 07:22:59 -04:00
76a652ddfd fix(12): WR-02 rotate matchPax8Companies eligibility ordering and raise the limit to prevent starvation 2026-07-11 07:22:25 -04:00
cb8ae85737 fix(12): WR-01 isolate per-row failures in pax8 sync loops so one bad record can't abort the batch 2026-07-11 07:20:54 -04:00
bddf612c7b fix(12): CR-02 stop folding company-match review counts into sync_history.records_deleted 2026-07-11 07:18:27 -04:00
59294cee5e test(12-04): add pax8-sync-service.test.ts for syncOrders + syncCompanyMatches
- First unit tests for Pax8SyncService, mocking postgresClient.query and
  pax8-company-matcher's matchPax8Companies (following
  pax8-company-matcher.test.ts's mocking discipline)
- Asserts INSERT INTO pax8_orders / pax8_order_items with companyId bound
  into pax8_company_id and amountDue into line_total
- Asserts resolveCostColumns' default (CONFIRM) mapping for both observed
  item types (subscription, one-time) per 12-02-SUMMARY.md
- Asserts child-then-parent tombstone ordering (pax8_order_items before
  pax8_orders)
- Asserts fullSync's entities include 'orders' and 'company_matches'

No TDD gate — plan is autonomous without a plan-level `type: tdd`
frontmatter; the implementation already existed from Tasks 1-2, so this
follows the same single-commit test-addition pattern established by
12-02-SUMMARY.md's Task 2.
2026-07-10 22:54:18 -04:00
5f960601e3 feat(12-04): wire syncOrders + syncCompanyMatches into fullSync
- syncCompanyMatches() delegates to matchPax8Companies() (Plan 03),
  shaping its result into the standard Pax8EntitySyncResult
- fullSync() now pushes ordersResult then matchResult after products,
  so pax8_companies is fully populated before matching runs
- Both steps roll up into the existing success/status/totals reducer
  and sync_history record unchanged
2026-07-10 22:52:30 -04:00
a06c1d314b feat(12-04): add syncOrders nested invoice->item upsert + tombstone
- Adds Pax8SyncService.syncOrders(): pages all invoice headers, then
  per-header pages its items (12-RESEARCH.md Pattern 1 nested fetch)
- resolveCostColumns() branches on item.type per 12-02-SUMMARY.md's
  live spot-check verdicts (all types CONFIRM -> single default branch,
  kept as a named seam for future divergence)
- pax8_orders.pax8_company_id stays NULL (Pitfall 1); per-company data
  lives on pax8_order_items.pax8_company_id
- Tombstones child (pax8_order_items) before parent (pax8_orders) to
  respect the FK, using the existing id <> ALL($1::uuid[]) pattern
2026-07-10 22:52:03 -04:00
fe7860760b chore: merge executor worktree (worktree-agent-a0da7e5b2254b56f8) 2026-07-10 22:48:53 -04:00
ae44669b9e test(12-03): add pax8-company-matcher unit tests
- Mocks postgresClient.query (default export) per pax8-client.test.ts
  discipline, adapted from fetch mocking
- Covers all five decision branches: auto-link, review (below
  threshold), review (near-tie, D-02), empty candidates (D-03), and
  the idempotency guard (D-05/SC#4)
- Covers dryRun: asserts zero UPDATE/INSERT/DELETE calls issued
- npx vitest run lib/services/pax8-company-matcher.test.ts: 6/6 passed
2026-07-10 22:47:43 -04:00
691bb47a91 feat(12-03): create pax8-company-matcher.ts
- Ports device-link-reconciler.ts's findBy*/applyLink/recordConflict/
  pickBestCandidate shape to a single pg_trgm similarity() score
- AUTO_LINK_THRESHOLD=0.90 (D-01), TIE_MARGIN=0.05 (D-02),
  CANDIDATE_FLOOR=0.3, exported and tunable
- decide() implements D-01..D-04: auto-link only on unambiguous
  high-confidence match, review with top-3 candidates (or empty array
  when none clear the floor)
- applyLink()/recordConflict() guard resolved_at IS NOT NULL and
  match_method IS DISTINCT FROM 'manual' (D-05/SC#4 idempotency)
- matchPax8Companies() scans the re-scoring-eligible subset of
  pax8_companies and reports scanned/autoLinked/flaggedAmbiguous/
  flaggedNoCandidate/durationMs
2026-07-10 22:47:33 -04:00
5cfbd13bcb test(12-02): add pagination + GET-only tests for listAllInvoices/listAllInvoiceItems
- listAllInvoices() concatenates pages in order, size=200 on each request
- listAllInvoiceItems(invoiceId) requests the nested /invoices/{id}/items
  path and concatenates its pages
- Extends the existing GET-only / Authorization-header assertion to both
  new methods (PAX8-08)
2026-07-10 22:46:12 -04:00
0920ef8e43 feat(12-02): add listAllInvoices and listAllInvoiceItems to Pax8Client
- listAllInvoices() pages the flat /invoices header list via paginateAll
- listAllInvoiceItems(invoiceId) pages the nested per-invoice
  /invoices/{id}/items child resource
- Both GET-only, reusing the existing paginateAll helper (PAX8-08)
- Note: no /orders call added (12-RESEARCH.md Pitfall 3 — unreliable/504s)
- Log pre-existing unrelated sync-scheduler.ts TS2307 errors to
  deferred-items.md (appgate-factory/appgate-sync-service not in this
  worktree's git history)
2026-07-10 22:45:40 -04:00
cf3ae61dcb feat(11-02): add Pax8SyncService (companies/subscriptions/referenced catalog)
- fullSync() orchestrates companies -> subscriptions -> referenced-only
  products, each with independent try/catch returning Pax8EntitySyncResult
- Referenced product catalog resolved in a single pass: listAllProducts()
  fetched once, filtered in-memory to subscription-referenced ids (D-01/D-02)
- Each entity's UUID-array tombstone soft-deletes rows PAX8 no longer
  returns (is_deleted=true, deleted_at set), skipped when zero ids seen
- sync_history row (entity_type='pax8', sync_type='full') tracks
  started/completed/failed with base columns only
- No PAX8 writes: only Pax8Client's read methods are called
2026-07-10 19:46:11 -04:00
c3a0432869 feat(11-01): implement PAX8 client read-only pagination helpers
- listAllCompanies/listAllSubscriptions/listAllProducts each loop pages via
  the existing fetchJson (inherits 429/Retry-After backoff), size=200
- Shared private paginateAll() helper stops once page.number >= totalPages-1
- Existing listCompanies(page, size) signature unchanged
- GET-only, no mutating method ever set (PAX8-08)
2026-07-10 19:39:26 -04:00
19fe788778 test(11-01): add failing tests for PAX8 client pagination helpers
- listAllCompanies/listAllSubscriptions/listAllProducts multi-page concat
- size=200 param assertion, single-page no-infinite-loop case
- Assert every request is GET with Authorization: Bearer header
2026-07-10 19:39:00 -04:00
532ca96dd0 feat(10-01): implement pax8-factory config check + singleton
- isPax8Configured(): both PAX8_CLIENT_ID and PAX8_CLIENT_SECRET required
- getPax8Client(): throws exact error naming both env vars when missing;
  caches singleton Pax8Client instance
- _resetPax8Client(): test seam to clear the cached singleton
- follows appgate-factory.ts / 10-RESEARCH.md Pattern 2 verbatim
2026-07-10 17:33:56 -04:00
a07fe4574a test(10-01): add failing tests for pax8-factory config check + singleton 2026-07-10 17:33:52 -04:00
1da08093eb feat(10-01): implement Pax8Client token exchange + auth-proof call
- getToken() JSON-body OAuth2 client-credentials exchange with audience field
  (deviates from msgraph-client.ts's form-encoded body per 10-RESEARCH.md Pitfall 3)
- 60s expiry-buffer token cache, reused across calls
- fetchJson<T>() with 429/Retry-After retry copied from msgraph-client.ts
- listCompanies() auth-proof call parsing the {content,page} envelope
- secret never interpolated into any throw/console call
2026-07-10 17:32:25 -04:00
ed485d8bde test(10-01): add failing tests for Pax8Client token exchange + auth-proof call 2026-07-10 17:32:20 -04:00