Defect-discovery audit + fail-closed remediation across money, auth, and compliance paths - #33
devin-ai-integration[bot] wants to merge 5 commits into
Conversation
Co-Authored-By: Patrick Munis <pmunis@gmail.com>
Co-Authored-By: Patrick Munis <pmunis@gmail.com>
Original prompt from Patrick
|
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
⚙️ Control Options:
|
| const updateData: Record<string, unknown> = { | ||
| status: input.transferState, | ||
| webhookPayload: input, | ||
| }; | ||
| if (input.transferState === "COMMITTED") { | ||
| updateData.fulfilment = input.fulfilment ?? null; | ||
| updateData.committedAt = input.completedTimestamp ? new Date(input.completedTimestamp) : new Date(); | ||
| await logAuditEvent({ | ||
| entityType: "payment", | ||
| entityId: tx.id, | ||
| action: "mojaloop_webhook_committed", | ||
| actorId: tx.initiatedBy, | ||
| actorType: "system", | ||
| newState: { transferId: input.transferId, status: "COMMITTED" }, | ||
| }); | ||
| } | ||
| if (input.transferState === "ABORTED") { | ||
| updateData.abortedAt = new Date(); | ||
| updateData.failureReason = input.errorInformation?.errorDescription ?? "Transfer aborted"; | ||
| await logAuditEvent({ | ||
| entityType: "payment", | ||
| entityId: tx.id, | ||
| action: "mojaloop_webhook_aborted", | ||
| actorId: tx.initiatedBy, | ||
| actorType: "system", | ||
| newState: { transferId: input.transferId, status: "ABORTED", error: input.errorInformation }, | ||
| }); | ||
| } | ||
| await updateMojaloopTransaction(input.transferId, updateData as never); |
There was a problem hiding this comment.
🔴 Mojaloop transfer marked settled before its ledger entry exists
The COMMITTED branch persists status: "COMMITTED" and logs a committed audit event before the TigerBeetle bridge is checked and the ledger entry is created. When the bridge is down the handler returns 503, but the transaction row already reads COMMITTED, so getPaymentStatus reports isSettled true with no ledger entry (mojaloop.ts). If redelivery stops, the settlement is permanent and unbacked.
Prompt for agents
In registerMojaloopWebhookRoute (server/webhooks/mojaloop.ts), the COMMITTED transfer state is persisted to mojaloop_transactions (updateMojaloopTransaction) and a 'mojaloop_webhook_committed' audit event is logged before the TigerBeetle ledger settlement is attempted. Because getPaymentStatus derives isSettled from the persisted status, a transfer reports as settled even when the ledger posting later fails and the handler returns 503. Reorder so that for COMMITTED events the TigerBeetle bridge settlement (and createLedgerEntry) succeeds FIRST, and only then persist status=COMMITTED / committedAt and log the committed audit event. On ledger failure, leave the status unchanged (or a non-terminal state) and return 503 so redelivery can retry without exposing a phantom-settled transfer.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Correct, and this was the same class of lie the PR is meant to remove — getPaymentStatus derives isSettled from the persisted status, so a 503 for redelivery still left a transfer reading settled with nothing posted. Fixed in 403cfc8: for COMMITTED, the bridge transfer and the ledger entry must succeed first; only then are status/committedAt/fulfilment persisted and the committed audit event logged. On settlement failure the transaction keeps its prior state and the handler returns 503. RECEIVED/RESERVED/ABORTED are unchanged.
| if (input.transferState === "COMMITTED") { | ||
| try { | ||
| if (!(await tbBridgeAvailable())) { | ||
| throw new Error("TigerBeetle bridge is unavailable"); | ||
| } | ||
| const debitAccountId = await getOrProvisionTraderAccount(tx.initiatedBy, tx.currency); | ||
| const bridgeTransfer = await tbFetch<{ id: string }>("/api/ledger/transfers", { | ||
| method: "POST", | ||
| body: JSON.stringify({ | ||
| debitAccountId, | ||
| creditAccountId: SYSTEM_ACCOUNTS.NCS_REVENUE, | ||
| amount: String(tx.amount), | ||
| currency: tx.currency, | ||
| ledger: 1, | ||
| reference: `DUTY-${tx.declarationId ?? "N/A"}`, | ||
| description: `Duty payment settled via Mojaloop webhook (${input.transferId})`, | ||
| metadata: { mojaloopTransferId: input.transferId }, | ||
| }), | ||
| }); | ||
| await createLedgerEntry({ | ||
| tbTransferId: bridgeTransfer.id, | ||
| debitAccountId, | ||
| creditAccountId: SYSTEM_ACCOUNTS.NCS_REVENUE, | ||
| amountMinorUnits: Math.round(Number(tx.amount) * 100), | ||
| currency: tx.currency, | ||
| ledger: 1, | ||
| entryType: "duty_payment", | ||
| status: "posted", | ||
| declarationId: tx.declarationId ?? undefined, | ||
| mojaloopTransferId: input.transferId, | ||
| reference: `DUTY-${tx.declarationId ?? "N/A"}`, | ||
| description: `Duty payment settled via Mojaloop webhook (${input.transferId})`, | ||
| postedAt: new Date(), | ||
| }); |
There was a problem hiding this comment.
🔴 Duplicate revenue postings on webhook redelivery
The COMMITTED block posts a bridge transfer and inserts a ledger entry with no check on the transfer's current state. Webhook delivery is at-least-once, so a redelivered COMMITTED callback for the same transferId posts the ledger transfer again, double-crediting customs revenue.
Prompt for agents
In registerMojaloopWebhookRoute (server/webhooks/mojaloop.ts), the COMMITTED handling unconditionally posts a TigerBeetle transfer and creates a ledger entry. Mojaloop switches redeliver webhooks (at-least-once), and there is no guard against reprocessing a transfer that is already COMMITTED with an existing ledger entry. Add an idempotency guard: before posting to the bridge, check whether the transaction is already in a terminal COMMITTED state with a recorded settlement (e.g. existing ledger entry / fulfilment) and short-circuit to a success response if so, or use a durable idempotency key on the settlement so redelivery cannot double-post revenue.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Agreed — and the 503 the other fix introduces actively invites redelivery, so this had to land with it. Fixed in 403cfc8 with two layers: a lookup of the existing ledger entry by mojaloopTransferId short-circuits a redelivery to success without re-posting, and an atomic claim on the existing payment_idempotency_keys table (INSERT ... ON CONFLICT DO NOTHING) keyed on the transfer serialises concurrent redeliveries so only one can post. The claim is released when the bridge did not accept the transfer, and retained once it did, so a failure after acceptance can't produce a second bridge transfer. server/mojaloop.webhook.test.ts covers the bridge-unavailable, sequential-duplicate, and concurrent-duplicate cases.
Co-Authored-By: Patrick Munis <pmunis@gmail.com>
Co-Authored-By: Patrick Munis <pmunis@gmail.com>
|
Runtime test report — fail-closed remediation ( Tested against a local dev instance (Postgres + Redis up; TigerBeetle bridge, Temporal, Mojaloop switch, Fluvio, Kafka, Wazuh, ASEAN/CEN deliberately absent). Mojaloop settlement fails closed and cannot double-post (the money path)Real Three consecutive deliveries each retried cleanly (no Session/auth regressions found during testing and fixed in
|
Co-Authored-By: Patrick Munis <pmunis@gmail.com>
Summary
Codebase-wide defect audit (methodology from the attached defect-discovery prompt) plus remediation of the confirmed findings. The audit report is committed at
docs/security/defect-discovery-audit.mdwithfile:lineevidence, the Phase 0 maps, composition chains, negative results, and the residual register for findings that need policy/product decisions rather than code.The dominant defect class was fabricated success on dependency failure: when TigerBeetle, Temporal, Mojaloop, Redis, Permify, the risk scorers, Wazuh, or the DB were unavailable, the system did not fail — it manufactured a plausible answer and told the trader, the officer, and the audit trail that money had moved or that nothing was wrong. Every such path is now fail-closed.
Representative shape of the change:
Money integrity
payments.confirmno longer confirms a payment when Temporal is unreachable or returns non-2xx;payments.initiateno longer returns a created payment when the queue insert fails (the orphanpaymentsrow is removed and the error propagates).paymentWorkerno longer decides transfer outcomes withMath.random()when the Mojaloop switch is down; unavailable items stayqueuedand are retried by the existing back-off (previously they were parked infailed, which the poller never claims again).ledger.*no longer writes Postgres rows withstatus: "posted"when the TigerBeetle bridge is unavailable (duty transfers, bond deposit/release, penalties, transit guarantees) — the bridge is the ledger of record, so those paths now throw.paymentRiskno longer defaults toLOW / APPROVE / 0.10when the scorer is offline.mojaloop.getPaymentStatuswas a query that settled payments: after 5s it flippedPENDING → PROCESSING, after 15s it fabricated an ILP fulfilment, wrote apostedledger entry and an audit event. It is now read-only and ownership-scoped.mojaloop.initiatePaymentderives the amount fromdecl.totalDueinstead of trusting the client, checks declaration ownership, and rejects switch failures instead of falling back to simulation. The availability check moved ahead of the row insert and the failure path cleans up, so an outage no longer leaves an orphanPENDINGtransfer per attempt, and the initiation audit event is written only once the switch has accepted."dev-webhook-secret") toserver/webhooks/mojaloop.ts: Express raw-body HMAC-SHA256 over the exact bytes,timingSafeEqual, and a503(so the switch redelivers) when TigerBeetle cannot accept the settlement transfer. Because delivery is at-least-once and that 503 invites redelivery, settlement is ordered ledger-first and guarded:status: "COMMITTED"is persisted only after the bridge transfer and ledger entry succeed (otherwise the transfer would read settled viaisSettledwith nothing posted), and a redelivery is short-circuited by the existing ledger entry for thatmojaloopTransferId, with concurrent redeliveries serialised by an atomic claim on the existingpayment_idempotency_keystable so revenue cannot be double-credited.Authorization and identity
onboarding.selectRolelet any authenticated user assign themselvescustoms_officer,oga_officer,inspectororfinance— and downstream gates trust the DB role. Self-selection is now limited touser.auditEngine.*was entirelypublicProcedure(unauthenticated post-clearance audit creation, findings, closure, appeals). Now authenticated, role-gated, and trader-scoped.fund-flowidempotency (return false // fail open) and its local Permify helper (return true // fail open — Permify unavailable) both failed open; the helper was also dead code. Now fail-closed viaassertCan, with officer/admin gates on the control-plane procedures (settlement, reconciliation, reversals, penalties, drawback approval, audit recovery, account provisioning).SEVEN_DAYS_MS. A present-but-invalidBearertoken now returnsUNAUTHORIZEDinstead of silently falling through to the session cookie — the rejection lives insdk.authenticateRequest, where the fallback actually happened, so a cookie can no longer rescue a failed Bearer.getRedis()tripped_redisFailed = truepermanently on the first connection error, which (harmless when the only consumer degraded to an in-memory rate limiter) would have turned any transient Redis blip into a permanent lockout of every user until the process restarted, andlazyConnectmade the first request after each boot fail against a healthy Redis. The latch is gone, ioredis reconnects on its own, and commands await readiness on a 500ms budget — so the outage lasts exactly as long as Redis is down, and a partition can't hold request handlers open waiting for an answer that is a foregone rejection. The rate limiter still degrades open to in-memory; only revocation fails closed. The tradeoff that remains is deliberate and recorded in the audit report's residual register: a total Redis outage signs everyone out for its duration, which is the price of a revoked session not surviving one.DEMO_MODEcan no longer bypass Permify or mount the demo login route in production.tradeFinancetookapplicantId/traderIdfrom client input; ordinary users are now bound toctx.user.id, with overrides only for officer/admin roles.batchPaymentsoperational reads are admin-gated.Secrets, webhooks, crons
validateWebhookSecrets()existed but had no call site; it now runs at startup. Committed dev-default secrets (tradegateway-oga-webhook-secret-dev,tradegateway-cep-webhook-secret-dev,dev-webhook-secret) are gone, and a missing secret now rejects requests instead of disabling verification — the sanctions webhook'sif (WEBHOOK_SECRET && provided !== WEBHOOK_SECRET)accepted everything when the env var was empty. Keycloak webhook verification is mandatory, timing-safe, and returns503if the durable audit write fails instead of{received: true}.JWT_SECRETis required in production, and missing production secrets throw instead of logging a warning./api/scheduled/*handlers are behind cron authentication (matching the existing tenant-domain poller pattern) and return503 {ok:false}on DB outage instead of a success shape.Compliance and operational honesty
(hsHash % 40) + 10score — which could never produce a red lane — butriskScore: nulland the red/manual-inspection lane.valuationthrows instead of returningflagged: falsewhen the DB is down.score: 78, andstatus: "completed"playbook runs that took no action. Clients now render unavailable rather than clean-and-empty, so an integration outage no longer looks like a healthy security posture.rulesOfOriginreview is restricted tosubmitted/under_review, so terminal certificates cannot be re-decided.Tests
No test was weakened to make the branch pass. 80 tests asserted the removed fabrications (simulated ASEAN/CEN responses, fail-open Permify/idempotency, DB-fallback ledger postings, synthetic Wazuh events) — those were rewritten to assert the fail-closed behaviour.
tsc --noEmit(72 errors) andvitest(142 failures, all pre-existing onmain) are byte-for-byte identical to themainbaseline: no new type errors, no new failing tests.Link to Devin session: https://app.devin.ai/sessions/e68f0a7bf0e04fb8a3ba32ddb8e1fa23
Requested by: @munisp