audit: production-readiness campaign (issues #120–#154) #4039
15 changed files+223−37
Modified.github/workflows/security.yml+13−5View fileUnifiedSplit
@@ -31,18 +31,26 @@ jobs:
3131 - name: Install dependencies
3232 run: bun install --frozen-lockfile
3333
34 # `bun audit` against bun.lock. The previous step ran
35 # `npx audit-ci ... || true`, which could never work here — audit-ci
36 # requires an npm/yarn/pnpm lockfile and this is a Bun workspace, so
37 # every run died on "Cannot establish package-manager type" and the
38 # `|| true` reported success. The job was green while auditing nothing.
39 # The gate fails on UNREVIEWED criticals only; see the script for why
40 # high/moderate are reported rather than blocking.
3441 - name: Run dependency audit
35 run: |
36 # Bun does not have a built-in audit command yet.
37 # Use npm audit as a fallback against the lockfile.
38 npx audit-ci --config audit-ci.json || true
42 run: bun run scripts/check-dependency-audit.ts | tee -a "$GITHUB_STEP_SUMMARY"
3943
4044 - name: Check for known vulnerabilities with OSV-Scanner
4145 run: |
4246 curl -sfL -o osv-scanner \
4347 "https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64"
4448 chmod +x osv-scanner
45 ./osv-scanner --lockfile=bun.lock --recursive . || true
49 # Non-blocking by design (bun audit above is the gate), but the
50 # output now reaches the job summary instead of being discarded —
51 # `|| true` with no visible result is indistinguishable from a
52 # scanner that never ran.
53 ./osv-scanner --lockfile=bun.lock --recursive . | tee -a "$GITHUB_STEP_SUMMARY" || true
4654
4755 codeql:
4856 name: CodeQL Analysis
ModifiedCLAUDE.md+8−2View fileUnifiedSplit
@@ -572,6 +572,9 @@ All planned feature tiers are **code-complete**: Tiers 1–8 (36 core + 20 expan
572572| 158 | **The outbound virus scanner reported `clean: true` for every file it never scanned, and it is live in the send path.** `services/security/src/virus-scanner.ts` returned `clean: true, detections: 0` on all four non-scan paths (no API key, file >32MB, API error, poll timeout), and `messages.ts` gates sends on it. `VIRUSTOTAL_API_KEY` is set nowhere (not in the env template, `lib/env.ts`, or preflight), so in production the scanner scans nothing and passes everything while looking like a real control — the fabricated-clean-verdict class of #141. The test suite *pinned the fail-open as correct*, the #152 mock-blindness shape again. **Two corrections to the record:** #141's note that a real scanner "needs a new dependency" is wrong (this is a complete VirusTotal client over plain `fetch`), and #146 should not list `services/security` as unwired — it has a live production importer. | HIGH | 2026-08-05 | **FIXED 2026-08-05** — the four non-scan paths now return `clean: false` with their truthful `status` (`skipped`/`error`/`pending`); a clean verdict requires an actual scan (`status === "clean"`). `isSafe()` still fails **open** (blocks only on detected threats — failing closed would refuse every attachment with no key configured), but its contract is now documented as "block-or-not, NOT a clean verdict", and the send path is unchanged. Tests rewritten to assert unscanned ≠ clean while the send still proceeds. **Still Craig's call (Boss Rules #8/#10):** `uploadAndPoll` sends customer attachments to VirusTotal, whose free tier shares samples — a confidentiality/GDPR decision, and whether to configure the key at all. |
573573| 159 | **Warm-up adaptive safety is inert across the board — the ramp advances blind and two of three send paths ignore the hard pause.** Three related gaps found by importer-mapping `services/reputation`: (a) `warmup/monitor.ts`'s `runHealthCheckCycle()` (its own comment says "call periodically") has **zero callers** — it is the only writer of `bounceRate24h`/`complaintRate24h`/`totalBounced`, so the >0.1% complaint and >10% bounce auto-pause gates can never fire and `maybeAdvanceAutoStep` gates on a bounce rate permanently pinned at 0: **the ramp always steps up on schedule regardless of a domain bouncing 40%.** This contradicts #82/#138's "the pause machinery is real and drives the schedule." (b) The reputation hard-pause (`warmupSessions.status = "paused"`) is read only on `routes/messages.ts`'s path; `lib/agent-send.ts` and the MTA's own `warmup-gate.ts` don't consult it, so a hard-paused domain keeps sending via the other two producers. (c) The API-side counter is a stale-read lost-update race (check and increment several awaits apart, no atomicity — unlike the MTA gate's documented INCR-then-compare) and counts *messages* not *recipients*, so one `POST` with 500 addresses consumes 1 of a day-1 cap of ~20. | HIGH | 2026-08-05 | **FIXED 2026-08-05 (all three).** **(a)** `runHealthCheckCycle()` now runs hourly from `server.ts`, the cadence its own doc comment specifies and the one its 24h windows assume — so `adjustSchedule` actually writes bounce/complaint rates and the auto-pause gates can fire. It reads what the DB already has (delivery results + FBL rows), so it works today and gets sharper when #82 c/d credentials land; it is not blocked on them. **(b)** The warm-up + hard-pause check moved out of `routes/messages.ts` into `runPreSendGate`, so **every** producer inherits it — the #151 shape, one path later. Placed **last** in the gate deliberately: it is the only check with a side effect (it enrols a domain), so a message refused by any earlier control must never reach it; a test pins that a quota refusal creates no session, and another pins the 429 body verbatim so no API contract moved. `agent-send.ts` now also **records** against the ramp, since a check with no matching record would let agent volume go unseen. **(c)** `recordSend(domainId, count)` takes RECIPIENTS, not messages (both call sites pass the real recipient count — ISP caps are per-recipient, and one API call can carry hundreds), and the increment moved into SQL (`sentToday + n`) so concurrent sends compose instead of clobbering each other's read-then-write. 6 tests, incl. one asserting the payload is a SQL expression and not a pre-computed number — the property a tidy-looking "simplification" would undo. |
574574| 160 | **DKIM private keys are stored in plaintext under two comments claiming they are encrypted.** `packages/db/src/schema/domains.ts` (`// Encrypted at rest`) and `services/dns/src/auto-config.ts` both claim encryption; `auto-config.ts` inserts raw PEM and `services/mta/src/worker.ts` reads and signs with it directly. Production Postgres is local with no TDE and no app-level crypto on this column. A backup or disk leak hands out signing keys for **every customer domain** — an attacker can then send DKIM-valid, DMARC-passing mail as any customer. OAuth tokens got AES-256-GCM in #80; the strictly more dangerous secret in the same database did not. | HIGH | 2026-08-05 | **FIXED 2026-08-05** — new `packages/crypto/src/secret-box.ts` is the **single** implementation of at-rest secret encryption; `services/dns` seals the key on write (both the create and the rotate path) and `services/mta` opens it in the signer. It lives in `@alecrae/crypto` rather than `apps/api` because neither service can import from the API — and `apps/api/src/lib/token-crypto.ts` (#80's OAuth tokens) is now a thin re-export of it rather than a second copy, since a duplicated crypto boundary is exactly how #124's header fix ended up applied to one of two builders. **Failure direction is the load-bearing choice:** the worker uses `openSecretSafe`, which returns null rather than throwing, so a key that cannot be decrypted is treated as a key we do not have — routing into #144's **hold, don't send unsigned** branch instead of failing the job into the DLQ. Legacy plaintext rows pass through unchanged and self-heal on next write, so **no migration** (Boss Rule #7). Both misleading "Encrypted at rest" comments replaced with what the code does plus a write-only-through-sealSecret instruction. 11 tests covering the compatibility contract and both failure directions. **Rotation caveat, documented in the module:** rotating `JWT_SECRET` makes stored keys unopenable — affected domains then hold mail until keys are regenerated and DNS republished (nothing leaks; mail stops). Same class as #115c's OAuth warning. |
575| 161 | **The "Dependency Audit" CI job has never audited anything, and four SOC 2 documents attest that it does.** `.github/workflows/security.yml` ran `npx audit-ci --config audit-ci.json \|\| true`. audit-ci resolves its package manager from `package-lock.json`/`yarn.lock`/`pnpm-lock.yaml` — this is a Bun workspace with `bun.lock` and none of those exist, so **every run died on "Cannot establish package-manager type"** and `\|\| true` turned the error into a pass. The OSV-Scanner step beside it also ended in `\|\| true` with its output discarded, which is indistinguishable from a scanner that never ran. The step's own comment ("Bun does not have a built-in audit command yet") was stale — `bun audit` works, and running it revealed **155 advisories: 3 critical, 72 high, 68 moderate, 12 low**, none of which anything had ever reported. Worse than a missing control, because `docs/compliance/soc2/controls-matrix.md` recorded it **Implemented** with a "moderate severity threshold", and three more SOC 2 policy documents repeated the claim — an attestation to auditors about a control that audited nothing. Same class as #150 (preflight that could not run) and #143 (e2e suite that executes nowhere). | HIGH | 2026-08-05 | **FIXED 2026-08-05** — new `scripts/check-dependency-audit.ts` runs `bun audit` against the real lockfile and **fails the build on any unreviewed CRITICAL**. The three current criticals (`tar`, `shell-quote`, `vitest`) are allowlisted **individually with a written reason each**, verified against `bun audit`'s own dependency paths: all three are build/dev-chain only (expo, electron-builder, pulumi, drizzle-kit, react-native, the test runner) and no `apps/api` or `services/mta` request path reaches them; the vitest advisory additionally requires a UI server neither CI nor production starts. Removing a package from that list is how it becomes blocking again, so an accepted risk must be re-stated deliberately rather than inherited. **High/moderate/low are reported, not blocking, and that is deliberate:** there are ~140, nearly all transitive dev tooling, and a permanently-red gate is one nobody reads — it would bury the criticals this exists to surface. Counts print on every run so the number cannot quietly grow. OSV-Scanner output now goes to the job summary instead of being discarded. `audit-ci.json` deleted. **All four SOC 2 documents corrected**, with the controls matrix stating plainly that any prior evidence period should be treated as unscanned. |
576| 162 | **Quoted-printable attachments were corrupted on every MBOX/EML import, and three package manifests pointed `start` at a `dist` their build never produces.** (a) `packages/email-parser/src/parser.ts`'s `decodeTransferEncoding` ran the QP branch through `new TextEncoder().encode(decodeQuotedPrintable(body))` — a double encode. `decodeQuotedPrintable` returns one code unit per byte, so re-encoding it as UTF-8 turns **every byte >= 0x80 into two**: a 12-byte PDF fragment came out 15 bytes and structurally broken, silently, with no error. Verified by running both paths side by side. This is the attachment half of issue #113(a) — the correct helper, `quotedPrintableToBytes`, was written for that very fix and left unused 30 lines below the broken call. The 7bit/8bit branch had the same defect. (b) `apps/api`, `services/collab` and `services/jmap` paired an `echo` build script with `start: bun run dist/...`, so `bun run start` fails outright, and `services/sentinel`'s `main` pointed at a `dist` nothing emits — the #153 defect class, still present in the API itself. | MEDIUM | 2026-08-05 | **FIXED 2026-08-05** — (a) both branches now produce faithful bytes (`quotedPrintableToBytes` for QP, latin1 for 7bit/8bit, since those carry raw octets and TextEncoder would corrupt 8bit bodies the same way). Regression test asserts the exact 12 bytes of a QP PDF header including `=FF`, a byte no UTF-8 encoding can produce — proven to distinguish the old path, which yielded 15. (b) All four manifests now run from source under Bun, matching what production's systemd units actually execute (`bun run .../src/index.ts`) and the pattern #153 established for `services/inbound`. |
577| 163 | **Batch categorisation invented a category from the character sum of the email id and stored it as Claude output.** `POST /v1/ai-categorization/categorize/batch`, for any id it had no record of, computed `hash % PRIMARY_CATEGORIES.length` and persisted the result with `aiModel: "haiku"` and `confidence: 0.5` — a category with no relationship to any message, or to any model, attributed to one. Those rows were then averaged into the categorisation-accuracy figure the stats tab reports, so the accuracy metric was partly measuring a hash of id strings. Same fabricated-output class as #141 (invented virus verdicts) and #137 (placeholder text presented as AI). It also quietly swallowed a cross-tenant probe: another account's email id came back with a confident-looking category instead of being ignored. | MEDIUM | 2026-08-05 | **FIXED 2026-08-05** — unknown ids are **skipped and returned in a new `skipped` array** rather than invented, so a caller that asked for 50 and got 40 knows which ten and why (silently dropping them would trade one dishonesty for another). Nothing is persisted for an email the account does not own. |
575578| 55 | MTA + inbound services not running; mail DNS incomplete | HIGH | 2026-06-19 | SUPERSEDED — full current state + phased fix in `docs/infra/multi-platform-mail-plan.md` |
576579| 58 | `services/imap` TCP listener not bootable | HIGH | 2026-07-01 | DECIDED 2026-07-01 (Craig): OAuth-only for launch — not blocking |
577580| 59 | `.env.production`/`.env.test` old contents still in git history (untracked 2026-07-01 but not scrubbed) — rotate any values that were ever real | HIGH | 2026-07-01 | OPEN (rotation unconfirmed) |
@@ -642,7 +645,7 @@ All planned feature tiers are **code-complete**: Tiers 1–8 (36 core + 20 expan
642645
643646## 🗓️ NEXT ACTIONS — IN ORDER
644647
645### ⏸️ RESUME HERE — session paused 2026-08-05 05:40 UTC
648### ⏸️ RESUME HERE — session paused 2026-08-06 06:20 UTC
646649
647650**Branch `audit/production-readiness-2026-07-28`, working tree clean, all four gates green. Draft PR #92 is open against main to run the four required CI checks against the whole branch for the first time (checks only run on PRs, and none existed before — so 90 audit commits had never been CI-validated; all four required checks pass, only the pre-existing Default-Setup CodeQL conflict fails, which is already item 12 on Craig's list).**
648651
@@ -654,6 +657,9 @@ All planned feature tiers are **code-complete**: Tiers 1–8 (36 core + 20 expan
654657- **#158 (HIGH, fixed): the outbound virus scanner reported `clean: true` for files it never scanned** (no key configured in prod), the #141 fabricated-verdict class, live in the send path. Unscanned is now honestly not-clean; the send still fails open by policy but no longer lies about it.
655658- **#159 (HIGH, fixed): warm-up adaptive safety was inert in three ways.** The bounce/complaint health cycle had zero callers (so the auto-pause could never fire and the ramp advanced on a bounce rate pinned at 0), the reputation hard-pause was consulted on only one of three send paths, and the counter both raced and counted messages instead of recipients. All three closed: hourly health cycle, warm-up moved into the shared gate so every producer inherits it, and recipient-accurate SQL-atomic counting.
656659- **#160 (HIGH, fixed): DKIM private keys were plaintext under comments claiming encryption.** Now sealed with AES-256-GCM through a single shared `secret-box` in `@alecrae/crypto` (which `token-crypto.ts` also delegates to, rather than keeping a second copy). Undecryptable keys route into #144's hold-don't-send-unsigned branch rather than throwing; legacy rows self-heal, so no migration.
660- **#161 (HIGH, fixed): the "Dependency Audit" CI job had never audited anything** — `audit-ci` cannot run against a Bun lockfile, so it errored every time and `|| true` reported success, while **155 advisories including 3 critical** went unreported and four SOC 2 documents attested the control was implemented. Replaced with a real `bun audit` gate that blocks on unreviewed criticals; the three current ones are allowlisted individually with verified reasons; all four SOC 2 docs corrected.
661- **#162 (MEDIUM, fixed): quoted-printable attachments were corrupted on every MBOX/EML import** — a double-encode turned every byte ≥ 0x80 into two, so a 12-byte PDF fragment arrived as 15 broken ones. The correct helper had been sitting unused 30 lines below since #113(a). Also fixed four manifests whose `start` pointed at a `dist` their build never produces (the #153 class, still present in the API itself).
662- **#163 (MEDIUM, fixed): batch categorisation invented a category from the character sum of the email id** and stored it as Claude output, then averaged it into the reported accuracy figure. Unknown ids are now skipped and reported.
657663- **Smaller fixes shipped:** the `mta: ok` health probe was meaningless (queue-readable ≠ worker-consuming — the exact #149 silent-vanish mode; now reports degraded with 3 tests); the attachments page still claimed "virus scanned"; nine `(legal)` pages including the **CCPA-required California-notice and Do-Not-Sell** bounced logged-out visitors to `/login`; dead footer links to nonexistent status/changelog/docs; the undefined `accent`/`text-text-*` colour utilities (~109 uses rendering transparent/blue); `box-deploy.sh` would have **restarted the emergency-stopped MTA** as a deploy side effect (used `is-enabled` not `is-active`); the service-drift checker was blind to IPv6 `[::]` wildcard binds (a repeat open-relay on v6 would pass it forever); route-coverage.md regenerated (was 3+ weeks stale while marked authoritative).
658664
659665**PRODUCTION IS 115 COMMITS STALE — this is now the single most important operational fact.** Live runs commit `51c763e` (2026-07-19). **None** of the post-07-20 security fixes are deployed: the open relay closure, the `/t/click` open redirect, header injection, E2EE, the send-path 422 blocker, chat IDOR, cross-tenant passkey deletion — all sit un-merged on this branch. The box's git checkout was updated once after that (to `4260525`) without a rebuild/restart, so even `/v1/health`'s `deployDrift` (which compares checkout HEAD to origin, not the *running* binary to its checkout) under-reports. The running commit vs. the box checkout is a drift axis nothing currently measures.
@@ -847,7 +853,7 @@ mail DNS (SPF/PTR) still references it pending the mail-box decision in
847853- **Mail services** (`alecrae-mta` outbound worker, `services/inbound` MX
848854 listener) are **not running anywhere yet** — phased bring-up is the mail plan.
849855
850**Last updated:** 2026-08-05 05:40 UTC
856**Last updated:** 2026-08-06 06:20 UTC
851857**Shipped 2026-08-05 — full production-readiness audit (Craig: "a serious audit, no code or webpage unchecked", website-in-sync-with-code):** Five parallel deep audits — doc-vs-code verification, frontend↔backend contract drift, the never-audited surfaces (desktop/mobile/SDK/five orphan services/CI/DB), the live public website, and a ground-truth deploy check. The doc claims held up far better than history (9/10 recent "FIXED" rows verified with wiring + tests), but the surface sweep found six new HIGH/CRITICAL defects, two of which detonate on the next deploy. Fixed everything bounded, with tests: **#155** (CRITICAL — migration journal out of order, next `db:migrate` silently skips 0008/0009/0010, reverting the dedup index, dropping `provider_message_id`, breaking Stripe webhooks; no gate saw it); **#156** (CRITICAL — RCE via `POST /v1/scripts/:id/test`'s `new Function()` fake-sandbox, reachable by any signup since `register` grants owner; endpoint 501'd, executor disconnected + structurally guarded); **#157** (HIGH — the marketing send path was still unsatisfiable after #152 because the compliance gate hardcoded unsubscribe/address to false; now read from the real body); **#158** (HIGH — outbound virus scanner reported `clean:true` for files it never scanned, the #141 class, live in the send path; unscanned is now honestly not-clean). Logged in full and deliberately **not** half-fixed: **#159** (warm-up bounce/complaint auto-pause has zero callers — the ramp advances blind and two of three send paths ignore the hard pause) and **#160** (DKIM private keys stored plaintext under comments claiming encryption). Smaller fixes: the meaningless `mta:ok` health probe (now verifies a worker consumes the queue — the #149 silent-vanish mode); the attachments "virus scanned" header; **nine legal pages including the CCPA-required California-notice and Do-Not-Sell that bounced logged-out visitors to /login**; dead footer links; the undefined `accent`/`text-text-*` colour utilities (~109 uses); `box-deploy.sh` restarting the emergency-stopped MTA as a side effect; the service-drift checker blind to IPv6 wildcard binds; route-coverage.md regenerated. Opened **draft PR #92** so the four required CI checks validate the branch for the first time — all pass. **Standout operational fact surfaced: production is 115 commits stale (runs 07-19 code), so the entire post-07-20 security campaign — open relay, open redirect, header injection, E2EE, the send-path 422 blocker — is invisible in the running build; a deploy is now the highest-leverage single action.** The live-site audit also found ~12 false/inoperable public claims and legal pages naming an unverifiable US corporation with a 99.99% SLA nothing supports — all Boss Rule #9/#10, logged for Craig, none changed. 12 new tests. typecheck 36/36, lint 0 errors, apps/api 444, monorepo green.
852858**Shipped 2026-08-04 — business email as the driving priority:** Craig has many platforms needing an organisation each with mail on its own domain, which reframes the critical path as send **and receive** on customer domains. Traced that path in code rather than from the docs. Two corrections to the received wisdom came out of it: `services/inbound` is a **complete, bootable receive pipeline** (the "unfinished placeholder" warning everyone repeats is about `services/mta`'s duplicate receiver, a different file), and the real blocker was simply that **no UI anywhere created a mailbox** — provisioning `info@customer.com` required a hand-written curl. Built `/mailboxes` (create, edit, pause, delete, gated on verified domains as the API requires) plus `PATCH /v1/mailboxes/:id`. Then closed **#151**, extracting the pre-send stack into one shared gate — which surfaced the session's worst finding: **#152, `POST /v1/messages` returned 422 for every non-transactional email**, because everything not explicitly transactional was classified as a marketing campaign and the real compliance engine produces ten critical violations for one. It had been invisible because the test suite mocks that engine. Also **#153** (received spam filed into the inbox), the removal of an agent hook that put **every AI-drafted reply on the real send queue with no user approval**, and **#154** (the mailto unsubscribe never sent anything and reported success). 19 new tests, deliberately including ones that run the real compliance engine rather than a stub. apps/api 441/441, monorepo 48/48, typecheck 36/36, lint 0 errors.
853859**Shipped 2026-07-29 (iterations 5-7):** Closed the E2EE fabrication (issue #129 — the server minted its own keypair while the browser kept an unrelated one, so nothing encrypted to a user could ever have been decrypted; plus a no-KDF passphrase-as-raw-AES-key and a settings page claiming mail was "encrypted automatically" when nothing encrypts anything). Then the last two broken journeys: Security Center Overview (two endpoints simply did not exist, so the default tab 404'd for every user — now built over real threat/phishing rows with **no invented score**; the Trust Settings toggles were removed rather than backed, because they controlled nothing) and Integrations (API-key generation 422'd every time; Connected Apps removed rather than rebuilt, since the connector API behind it has no dispatcher per #103). **All seven journeys the 2026-07-22 audit listed as broken-today are now closed, and the fabricated count is 0.** 20 new tests. apps/api 298/298, monorepo 48/48, typecheck 36/36.
Modifiedapps/api/package.json+2−2View fileUnifiedSplit
@@ -7,8 +7,8 @@
77 "types": "src/index.ts",
88 "scripts": {
99 "dev": "bun run --hot src/index.ts",
10 "build": "echo 'API server runs via bun directly — not part of Vercel web deployment'",
11 "start": "bun run dist/index.js",
10 "build": "echo 'Service runs from source under Bun — no build step; see start'",
11 "start": "bun run src/index.ts",
1212 "test": "vitest run --exclude 'tests/e2e/**'",
1313 "test:e2e": "vitest run tests/e2e/",
1414 "typecheck": "tsc --noEmit",
Modifiedapps/api/src/routes/ai-categorization.ts+16−8View fileUnifiedSplit
@@ -326,8 +326,17 @@ aiCategorizationRouter.post(
326326
327327 const emailMap = new Map(emailRecords.map((e) => [e.id, e]));
328328
329 // Process each email — call Claude for those we have records for,
330 // fall back to a deterministic placeholder for unknown IDs
329 // Categorise the emails this account actually owns. An id we have no
330 // record for is SKIPPED and reported back, never invented: the previous
331 // fallback derived a category from the character sum of the id itself
332 // (`hash % PRIMARY_CATEGORIES.length`) and persisted it with
333 // `aiModel: "haiku"`, so a row whose category had no relationship to any
334 // message — or to any model — was stored and later averaged into the
335 // reported categorisation accuracy. Same fabricated-verdict class as
336 // issues #141 and #137. Skipping also closes a quiet cross-tenant edge:
337 // another account's id used to come back with a confident-looking
338 // category rather than being ignored.
339 const skipped: string[] = [];
331340 const results: {
332341 id: string;
333342 accountId: string;
@@ -365,12 +374,8 @@ aiCategorizationRouter.post(
365374 confidence = 0.5;
366375 }
367376 } else {
368 // Deterministic fallback for unknown email IDs
369 const hash = emailId.split("").reduce((acc, ch) => acc + ch.charCodeAt(0), 0);
370 const idx = hash % PRIMARY_CATEGORIES.length;
371 primaryCategory = PRIMARY_CATEGORIES[idx] ?? "important";
372 secondaryCategories = [];
373 confidence = 0.5;
377 skipped.push(emailId);
378 continue;
374379 }
375380
376381 results.push({
@@ -403,6 +408,9 @@ aiCategorizationRouter.post(
403408 categorizedAt: r.categorizedAt.toISOString(),
404409 })),
405410 total: results.length,
411 // Reported rather than silently dropped: a caller that asked for 50 and
412 // got 40 back needs to know which ten, and why.
413 skipped,
406414 });
407415 },
408416);
Deletedaudit-ci.json+0−4View fileUnifiedSplit
@@ -1,4 +0,0 @@
1{
2 "moderate": true,
3 "allowlist": []
4}
Modifieddocs/compliance/soc2/controls-matrix.md+1−1View fileUnifiedSplit
@@ -112,7 +112,7 @@ Evidence paths are relative to the repo root unless otherwise noted.
112112|---|---|---|
113113| CI pipeline enforces quality gates | **Implemented** | `.github/workflows/ci.yml` — lint, typecheck, test, build all gate PRs to main |
114114| Weekly automated security scan | **Implemented** | `.github/workflows/security.yml` — runs every Monday at 06:00 UTC; also runs on every PR to main |
115| Dependency audit (OSV-Scanner + audit-ci) | **Implemented** | `.github/workflows/security.yml` — `dependency-audit` job; `audit-ci.json` configured with `moderate` severity threshold |
115| Dependency audit (`bun audit` + OSV-Scanner) | **Implemented** | `.github/workflows/security.yml` — `dependency-audit` job runs `scripts/check-dependency-audit.ts`, which **fails the build on any unreviewed CRITICAL advisory**; accepted criticals are enumerated in that script with a written reason each. High/moderate/low are reported to the job summary, not blocking — see the script header for the reasoning. **Corrected 2026-08-05:** this row previously claimed `audit-ci` with a `moderate` threshold. That step could never have run — audit-ci requires an npm/yarn/pnpm lockfile and this is a Bun workspace, so it errored on every invocation and `\|\| true` reported success. The control existed on paper and audited nothing; treat any prior evidence period as unscanned. |
116116| CodeQL SAST with security-extended queries | **Implemented** | `.github/workflows/security.yml` — `codeql` job; scans JavaScript/TypeScript |
117117| Secret scanning (Gitleaks on every PR) | **Implemented** | `.github/workflows/security.yml` — `secret-scanning` job; full history checkout (`fetch-depth: 0`) |
118118
Modifieddocs/compliance/soc2/policy-templates/change-management-policy.md+4−1View fileUnifiedSplit
@@ -158,7 +158,10 @@ Schema migrations are irreversible in production. Special rules apply:
158158
159159## 8. Dependency Updates
160160
161- Dependencies are monitored by OSV-Scanner and audit-ci (`.github/workflows/security.yml`)
161- Dependencies are monitored by `bun audit` and OSV-Scanner (`.github/workflows/security.yml`).
162 Unreviewed critical advisories fail the build; accepted ones carry a written
163 reason in `scripts/check-dependency-audit.ts`. (Before 2026-08-05 this said
164 "audit-ci", which could not run against a Bun lockfile and audited nothing.)
162165- Routine patch-level updates (e.g., `1.2.3 → 1.2.4`) follow the standard change process
163166- Major version upgrades follow the major change process if they alter behavior
164167- New dependencies require Craig's authorization (CLAUDE.md Boss Rule #2)
Modifieddocs/compliance/soc2/policy-templates/incident-response-plan.md+1−1View fileUnifiedSplit
@@ -63,7 +63,7 @@ This plan covers all security incidents affecting:
6363| Automated security scans | GitHub Actions security.yml — weekly + every PR | GitHub Actions tab → security workflow |
6464| CodeQL findings | GitHub Security tab → Code scanning alerts | `github.com/[ORG]/AlecRae.com/security/code-scanning` |
6565| Secret scanning | Gitleaks in security.yml + GitHub native secret scanning | GitHub Security tab |
66| Dependency vulnerabilities | OSV-Scanner + audit-ci | security.yml run logs |
66| Dependency vulnerabilities | `bun audit` (gate) + OSV-Scanner (report) | security.yml run logs + job summary |
6767| Threat detection alerts | `GET /v1/security-intelligence/threats` | Security Intelligence dashboard |
6868| Rate limit abuse | `X-RateLimit-*` headers + Redis metrics | Upstash dashboard + Grafana |
6969| Failed login spikes | Authentication logs in Neon (auth table, audit_logs) | Query `audit_logs` for `action = 'login_failed'` |
Modifieddocs/compliance/soc2/policy-templates/information-security-policy.md+3−2View fileUnifiedSplit
@@ -123,8 +123,9 @@ embedded in the platform (per CLAUDE.md Forbidden List items 6–7).
123123
124124## 8. Vulnerability Management
125125
126- Automated dependency scanning runs every Monday (OSV-Scanner + audit-ci)
127 and on every pull request to `main` (`.github/workflows/security.yml`)
126- Automated dependency scanning runs every Monday (`bun audit` + OSV-Scanner)
127 and on every pull request to `main` (`.github/workflows/security.yml`).
128 Unreviewed critical advisories fail the build.
128129- CodeQL SAST analysis with `security-extended` query pack runs on every PR
129130- Gitleaks secret scanning runs on every PR with full commit history
130131- Third-party penetration tests are conducted [INSERT FREQUENCY — recommend annually]
Modifiedpackages/email-parser/src/parser.ts+12−2View fileUnifiedSplit
@@ -449,9 +449,19 @@ function decodeTransferEncoding(
449449 return decodeBase64(body);
450450 }
451451 if (enc === "quoted-printable") {
452 return new TextEncoder().encode(decodeQuotedPrintable(body));
452 // `quotedPrintableToBytes`, NOT TextEncoder.encode(decodeQuotedPrintable()).
453 // The latter double-encodes: decodeQuotedPrintable yields one code unit
454 // per byte, and re-encoding that as UTF-8 turns every byte >= 0x80 into
455 // two — silently corrupting every QP-encoded attachment (a PDF, a .docx)
456 // on the MBOX/EML import path. This is the attachment half of issue
457 // #113(a); the correct helper was written for that fix and left unused
458 // 30 lines below.
459 return quotedPrintableToBytes(body);
453460 }
454 return new TextEncoder().encode(body);
461 // 7bit/8bit/binary carry raw octets, so latin1 (byte-per-code-unit) is the
462 // faithful reading. TextEncoder would re-encode 8bit bodies as UTF-8 and
463 // corrupt them the same way.
464 return latin1ToBytes(body);
455465}
456466
457467function decodeBase64(input: string): Uint8Array {
Modifiedpackages/email-parser/tests/parser.test.ts+33−0View fileUnifiedSplit
@@ -125,6 +125,39 @@ describe("parseEmail — attachment handling", () => {
125125 expect(content).toBe("Hello World");
126126 });
127127
128 it("should decode a quoted-printable attachment byte-for-byte", () => {
129 // Issue #113(a), attachment half: the QP branch ran the decoded string
130 // back through TextEncoder, which re-encodes every byte >= 0x80 as two
131 // UTF-8 bytes. A PDF (which begins %PDF and is full of high bytes) came
132 // out of an MBOX/EML import corrupted and larger than it went in, with
133 // no error anywhere. These are the real first bytes of a PDF header
134 // followed by a high byte, which is exactly where it broke.
135 const qpRaw = [
136 "From: sender@example.com",
137 "To: recipient@example.com",
138 "Subject: QP attachment",
139 `Content-Type: multipart/mixed; boundary="${boundary}"`,
140 "",
141 `--${boundary}`,
142 'Content-Type: application/pdf; name="q.pdf"',
143 'Content-Disposition: attachment; filename="q.pdf"',
144 "Content-Transfer-Encoding: quoted-printable",
145 "",
146 "%PDF-1.4=0A=C3=A9=FF",
147 `--${boundary}--`,
148 ].join("\r\n");
149
150 const email = parseEmail(qpRaw);
151 const bytes = email.attachments[0]!.content;
152 expect(Array.from(bytes)).toEqual([
153 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34, // %PDF-1.4
154 0x0a, // =0A
155 0xc3, 0xa9, // =C3=A9 — two bytes, NOT four
156 0xff, // =FF — a byte no UTF-8 encoding can produce
157 ]);
158 expect(bytes.byteLength).toBe(12);
159 });
160
128161 it("should set the correct content type on the attachment", () => {
129162 const email = parseEmail(raw);
130163 expect(email.attachments[0]!.contentType).toBe("application/pdf");
Addedscripts/check-dependency-audit.ts+120−0View fileUnifiedSplit
@@ -0,0 +1,120 @@
1/**
2 * Dependency audit gate.
3 *
4 * Replaces the `npx audit-ci --config audit-ci.json || true` step, which
5 * could never have worked: audit-ci resolves a package manager from
6 * package-lock.json / yarn.lock / pnpm-lock.yaml, and this repo has none of
7 * them (it is a Bun workspace with bun.lock). Every run errored out on
8 * "Cannot establish package-manager type" and `|| true` swallowed it, so the
9 * "Dependency Audit" job reported green while auditing nothing — the same
10 * control-that-does-nothing class as the preflight script (issue #150) and
11 * the e2e suite that runs nowhere (issue #143). The step's own comment
12 * ("Bun does not have a built-in audit command yet") was stale: `bun audit`
13 * exists and works against bun.lock.
14 *
15 * Policy, and why it is not simply "fail on anything":
16 *
17 * - CRITICAL advisories fail the build, EXCEPT ones listed in
18 * `KNOWN_CRITICAL` with a stated reason. A new critical is therefore
19 * loud, which is the property that was missing entirely.
20 * - high / moderate / low are REPORTED, not blocking. There are currently
21 * ~140 of them, nearly all transitive dev-tooling. A gate nobody can
22 * pass is a gate nobody reads, and turning CI permanently red would
23 * bury the criticals this exists to surface. The counts are printed on
24 * every run so the number cannot quietly grow unnoticed.
25 *
26 * Run: bun run scripts/check-dependency-audit.ts
27 */
28
29interface Advisory {
30 id: number;
31 url: string;
32 title: string;
33 severity: "critical" | "high" | "moderate" | "low" | "info";
34}
35
36/**
37 * Criticals accepted for now, each with the reason it is not blocking.
38 * Removing a package from this list is how it becomes blocking again —
39 * so an accepted risk has to be re-stated deliberately, not inherited.
40 */
41const KNOWN_CRITICAL: Record<string, string> = {
42 // Verified 2026-08-05 against `bun audit`: pulled in only by
43 // @alecrae/mobile › expo, @alecrae/desktop › electron-builder and
44 // @alecrae/infrastructure › @pulumi/pulumi. Neither apps/api nor
45 // services/mta reaches it, so no request path parses attacker-supplied
46 // tar input.
47 tar: "build/packaging chain only (expo, electron-builder, pulumi); no runtime request path reaches it",
48 "shell-quote":
49 "dev tooling only — @pulumi/eks, drizzle-kit, react-native; never invoked by the API or MTA at runtime",
50 vitest:
51 "test runner; the advisory requires the Vitest UI server to be listening, which CI and production never start",
52};
53
54async function main(): Promise<void> {
55 const proc = Bun.spawn(["bun", "audit", "--json"], {
56 stdout: "pipe",
57 stderr: "pipe",
58 });
59 const raw = await new Response(proc.stdout).text();
60 await proc.exited;
61
62 const jsonStart = raw.indexOf("{");
63 if (jsonStart === -1) {
64 console.error("[dep-audit] `bun audit --json` produced no JSON. Output:\n" + raw.slice(0, 500));
65 process.exit(1);
66 }
67
68 let parsed: Record<string, Advisory[]>;
69 try {
70 parsed = JSON.parse(raw.slice(jsonStart)) as Record<string, Advisory[]>;
71 } catch (err) {
72 console.error("[dep-audit] Could not parse audit output:", err);
73 process.exit(1);
74 }
75
76 const counts = { critical: 0, high: 0, moderate: 0, low: 0, info: 0 };
77 const blocking: { pkg: string; advisory: Advisory }[] = [];
78 const accepted: { pkg: string; advisory: Advisory }[] = [];
79
80 for (const [pkg, advisories] of Object.entries(parsed)) {
81 for (const advisory of advisories) {
82 const severity = advisory.severity ?? "info";
83 if (severity in counts) counts[severity as keyof typeof counts] += 1;
84 if (severity !== "critical") continue;
85 if (pkg in KNOWN_CRITICAL) accepted.push({ pkg, advisory });
86 else blocking.push({ pkg, advisory });
87 }
88 }
89
90 console.log(
91 `[dep-audit] ${counts.critical} critical, ${counts.high} high, ` +
92 `${counts.moderate} moderate, ${counts.low} low`,
93 );
94
95 if (accepted.length > 0) {
96 console.log(`\n[dep-audit] Accepted criticals (${accepted.length}) — reason recorded:`);
97 for (const { pkg, advisory } of accepted) {
98 console.log(` • ${pkg}: ${advisory.title}`);
99 console.log(` accepted because: ${KNOWN_CRITICAL[pkg]}`);
100 console.log(` ${advisory.url}`);
101 }
102 }
103
104 if (blocking.length > 0) {
105 console.error(`\n[dep-audit] BLOCKING — ${blocking.length} new critical advisory(ies):`);
106 for (const { pkg, advisory } of blocking) {
107 console.error(` ✖ ${pkg}: ${advisory.title}`);
108 console.error(` ${advisory.url}`);
109 }
110 console.error(
111 "\nFix the dependency, or — if it is genuinely not reachable — add it to " +
112 "KNOWN_CRITICAL in scripts/check-dependency-audit.ts WITH the reason.",
113 );
114 process.exit(1);
115 }
116
117 console.log("\n[dep-audit] No unreviewed critical advisories.");
118}
119
120await main();
Modifiedservices/collab/package.json+2−2View fileUnifiedSplit
@@ -7,8 +7,8 @@
77 "types": "src/server.ts",
88 "scripts": {
99 "dev": "bun run --hot src/server.ts",
10 "build": "echo 'Service builds independently \u2014 not part of Vercel web deployment'",
11 "start": "bun run dist/server.js",
10 "build": "echo 'Service runs from source under Bun — no build step; see start'",
11 "start": "bun run src/server.ts",
1212 "test": "vitest run --passWithNoTests",
1313 "typecheck": "tsc --noEmit",
1414 "lint": "eslint src/",
Modifiedservices/jmap/package.json+2−2View fileUnifiedSplit
@@ -7,8 +7,8 @@
77 "types": "src/index.ts",
88 "scripts": {
99 "dev": "bun run --hot src/index.ts",
10 "build": "echo 'Service builds independently \u2014 not part of Vercel web deployment'",
11 "start": "bun run dist/index.js",
10 "build": "echo 'Service runs from source under Bun — no build step; see start'",
11 "start": "bun run src/index.ts",
1212 "test": "vitest run --passWithNoTests",
1313 "typecheck": "tsc --noEmit",
1414 "lint": "eslint src/",
Modifiedservices/sentinel/package.json+6−5View fileUnifiedSplit
@@ -2,17 +2,18 @@
22 "name": "@alecrae/sentinel",
33 "version": "0.1.0",
44 "private": true,
5 "description": "AI-powered zero-latency validation pipeline \u2014 the immune system of AlecRae",
5 "description": "AI-powered zero-latency validation pipeline — the immune system of AlecRae",
66 "type": "module",
7 "main": "dist/index.js",
8 "types": "dist/index.d.ts",
7 "main": "src/index.ts",
8 "types": "src/index.ts",
99 "scripts": {
1010 "dev": "bun --watch src/index.ts",
11 "build": "echo 'Service builds independently \u2014 not part of Vercel web deployment'",
11 "build": "echo 'Service runs from source under Bun — no build step; see start'",
1212 "test": "bun test",
1313 "typecheck": "tsc --noEmit",
1414 "lint": "eslint src/",
15 "clean": "rm -rf dist"
15 "clean": "rm -rf dist",
16 "start": "bun run src/index.ts"
1617 },
1718 "dependencies": {
1819 "@alecrae/shared": "workspace:*"
1920
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts