Skip to content

feat(api): auto-retry failed pentest scans and clean up failure messages#3398

Merged
tofikwest merged 9 commits into
mainfrom
feat/pentest-auto-retry-failed-scans
Jul 14, 2026
Merged

feat(api): auto-retry failed pentest scans and clean up failure messages#3398
tofikwest merged 9 commits into
mainfrom
feat/pentest-auto-retry-failed-scans

Conversation

@tofikwest

@tofikwest tofikwest commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

What & why

When a penetration-test scan fails, two things were wrong for customers:

  1. Raw internal errors leaked — the failure box showed verbatim provider/infrastructure error text (internal engineering conditions, vendor names) that isn't actionable and looks alarming for a security product.
  2. No retry — most of these failures are transient infrastructure blips that succeed on a plain re-run (done manually today, one customer complaint at a time).

This makes failed scans retry automatically with the same parameters (up to 2 retries). Customers never see the intermediate failures — the scan simply appears to keep running, and only surfaces as failed once every attempt is exhausted, with a clean message. API-only — no frontend changes.

How

  • Retry lineage — new columns on SecurityPenetrationTestRun (rootRunId, attemptNumber, retryOfProviderRunId, retryTriggeredAt, scanParams) group a scan and its retries. Customers only ever see the stable root id.
  • Auto-retry — on pentest.failed (never cancelled — a cancel is a deliberate stop and stays refunded), after the existing refund, spawn the next attempt with the same params. Guarded by an atomic retryTriggeredAt claim (redelivery-safe) and best-effort so it can never break the webhook. Capped at 3 attempts.
  • Masking — reads resolve the requested id to the active (highest) attempt and collapse status: a failed non-final attempt is reported as in-progress (with a grace-window safety valve so a retry that never materializes can't hang forever). Only an exhausted lineage reports failed.
  • Clean messages — raw provider error strings are mapped to white-labeled, actionable text that notes the credit was refunded; the raw string never leaves the server.
  • Billing nets to a single charge (debit per attempt, refund per failure), scoped strictly to the lineage.

Testing

npx jest src/security-penetration-tests11 suites, 140 tests green, including 32 new tests covering the retry decision logic (spawn / cap / idempotency / orphan / best-effort), lineage collapse, status masking, and error-message mapping. Typecheck clean for all touched files; prettier clean.

Follow-up (not in this PR)

FailedDetail.tsx still renders a static "Common causes" list (VPN/WAF/auth/runtime) that now reads inconsistently beside the clean message. Recommend removing/softening it in a small separate frontend change.


Summary by cubic

Automatically retries failed pentest scans (up to 2 retries) and replaces raw provider errors with clean, neutral messages. The failure view now renders the message as prose and drops the “Common causes” list.

  • New Features

    • Auto‑retry on pentest.failed only (never on cancelled); idempotent via existence of a retry child (retryOfProviderRunId), capped at 3 attempts, and lineage‑wide blocked on cancel via retryBlockedAt. If spawning a retry fails, the error is rethrown so the webhook redelivers; if recording the cancel block fails, that error is also rethrown to redeliver until stored.
    • Track retry lineage with rootRunId, attemptNumber, retryOfProviderRunId, retryTriggeredAt, and persisted scanParams; retries reuse full params (including pipelineTesting and customer additionalContext); API id is the lineage root.
    • Collapse reads and progress to the active attempt and mask fresh failed non‑final attempts as in‑progress with a grace window (revealed exactly at the boundary). Show white‑labeled failures only once a final failure is confirmed, using neutral “couldn’t be completed” wording and “you won’t be charged” (never echoing vendor names).
  • Migration

    • Adds lineage columns and index, plus retryBlockedAt; backfills root_run_id = provider_run_id in packages/db/prisma. Column remains nullable to avoid deploy hazards; reads coalesce rootRunId ?? providerRunId. No manual steps required.

Written for commit d24c083. Summary will update on new commits.

Review in cubic

Failed penetration-test runs now retry automatically with the same
parameters (up to 2 retries), so transient infrastructure failures are
invisible to customers — the scan appears to keep running and only
surfaces as failed once every attempt is exhausted.

- Track a retry lineage per scan (root run id + attempt number), refund
  each failed attempt (existing behavior), and spawn the next attempt from
  the failure webhook. Idempotent via an atomic claim and best-effort so it
  can never break the webhook. Cancellations are never retried.
- Reads resolve to the active attempt and collapse status, masking a failed
  non-final attempt as in-progress with a grace-window safety valve.
- Replace raw provider error strings with clean, white-labeled messages
  that note the credit was refunded.

Adds unit coverage for the retry decision logic, lineage collapse, status
masking, and error mapping.
@vercel

vercel Bot commented Jul 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
comp-framework-editor Ready Ready Preview, Comment Jul 14, 2026 1:48am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
app Skipped Skipped Jul 14, 2026 1:48am
portal Skipped Skipped Jul 14, 2026 1:48am

Request Review

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 9 files

Confidence score: 5/5

  • Safe to merge after the addressed issues were fixed.

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread apps/api/src/security-penetration-tests/pentest-lineage.util.ts Outdated
Remove the hardcoded "Common causes" list (VPN/WAF/auth/runtime) — it
contradicted the now-clean, self-explanatory failure message and only ever
applied to one class of failure. Render the message as prose instead of
monospace since it's no longer a raw error string.
- Block auto-retry once a run is cancelled, so a late or duplicate
  pentest.failed webhook can't restart a deliberately stopped scan.
- Persist and restore pipelineTesting and the caller's additionalContext
  so retries are true same-parameter reruns.
- Reword the failure message to "you won't be charged" instead of asserting
  the refund is already complete (the refund and the read can race).
- Enforce root_run_id NOT NULL (backfilled) to guarantee every run has a
  stable lineage root.
- Reveal a failure exactly at the grace-window boundary (>=), matching the
  documented behavior.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 8 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Enforcing root_run_id NOT NULL in a separate migration risked a failed
`migrate deploy` if a pre-feature write inserted a NULL between the backfill
and the constraint. Revert to a nullable column and rely on the existing
`rootRunId ?? providerRunId` coalesce in reads, which keeps lineage queries
correct without any constraint or deploy ordering requirement.
The phrase was inaccurate when a failure is revealed after the grace window
because a retry never materialized (best-effort spawn failed). Use a neutral
"couldn't be completed" that holds whether or not retries ran; billing
wording already says "you won't be charged".
@tofikwest

Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai review it

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review it

@tofikwest I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 10 files

Confidence score: 5/5

  • Safe to merge after the addressed issues were fixed.

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread apps/api/src/security-penetration-tests/security-penetration-tests.service.ts Outdated
Comment thread apps/api/src/security-penetration-tests/pentest-run-error.util.ts Outdated
Comment thread apps/api/src/security-penetration-tests/security-penetration-tests.service.ts Outdated
Comment thread packages/db/prisma/schema/security-penetration-test-run.prisma
- Release the retry claim and rethrow when spawning the replacement run
  fails, so the webhook 5xx's and Maced redelivers (refund is idempotent) —
  a transient spawn failure no longer permanently consumes the slot and
  drops the retry.
- Block auto-retry across the whole lineage on cancellation, so a
  failed-before-cancelled race can't spawn a retry of a stopped scan.
- Apply the failed-attempt masking to the progress response too, so polling
  during the retry window stays consistent with the collapsed run status.
- Pluralize humanizeMinutes correctly ("1 minute").
- Share attempt resolution via getReportResolved so report/PDF downloads
  don't resolve the lineage twice.
- Fix the scanParams schema doc to match what's actually stored.
@tofikwest

Copy link
Copy Markdown
Contributor Author

Addressed the latest review batch in 814262add:

  • P1 — retry claim consumed on spawn failure: the claim is now released and the error rethrown when spawning the replacement run fails, so the handler 5xx's and Maced redelivers. The refund is idempotent (creditRefundedAt), so redelivery re-attempts only the retry instead of permanently dropping it.
  • P2 — failed-before-cancelled race: cancellation now blocks the retry slot across the whole lineage (by rootRunId), so no pentest.failed for any lineage member can spawn a retry of a stopped scan, regardless of arrival order. (A retry already in flight isn't force-cancelled at the provider — bounded: it's refunded and only wastes compute.)
  • P2 — progress exposes failed during the mask window: getReportProgress now masks a failed non-final attempt as provisioning, consistent with getReport.
  • P3 — humanizeMinutes(1): now pluralizes correctly ("1 minute").
  • P3 — double attempt resolution: extracted getReportResolved; report/PDF downloads no longer resolve the lineage twice.
  • P3 — scanParams doc: updated to match the implementation (stores the user's original briefing; per-finding notes are re-resolved on retry).

Tests: 145/145 green (serial), typecheck + prettier clean. Added coverage for the release-and-rethrow path, lineage-wide cancel block, and progress masking.

@tofikwest

Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai review it

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review it

@tofikwest I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 10 files

Confidence score: 3/5

  • In apps/api/src/security-penetration-tests/security-penetration-tests.service.ts, retry flows drop the originally resolved webhookUrl, so completion callbacks can be sent to the default Comp webhook instead of the caller’s custom endpoint; merging as-is risks incorrect notifications and broken downstream integrations — persist and restore the resolved callback URL in the stored scan parameters before merging.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/api/src/security-penetration-tests/security-penetration-tests.service.ts">

<violation number="1" location="apps/api/src/security-penetration-tests/security-penetration-tests.service.ts:169">
P2: A custom callback receives only the original attempt; retry completion is sent to the default Comp webhook because `webhookUrl` is discarded from persisted scan parameters. Persist and restore the original resolved callback URL if external callbacks remain supported.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread apps/api/src/security-penetration-tests/security-penetration-tests.service.ts Outdated
* the ownership row so an auto-retry reconstructs the request from our own DB.
* `additionalContext` here is the caller's original free-text briefing only —
* on retry it is passed back through `resolveAdditionalContext`, which re-adds
* the target's finding-context notes. `webhookUrl` is excluded (re-resolved to

@cubic-dev-ai cubic-dev-ai Bot Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A custom callback receives only the original attempt; retry completion is sent to the default Comp webhook because webhookUrl is discarded from persisted scan parameters. Persist and restore the original resolved callback URL if external callbacks remain supported.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/security-penetration-tests/security-penetration-tests.service.ts, line 169:

<comment>A custom callback receives only the original attempt; retry completion is sent to the default Comp webhook because `webhookUrl` is discarded from persisted scan parameters. Persist and restore the original resolved callback URL if external callbacks remain supported.</comment>

<file context>
@@ -135,6 +141,51 @@ type CreatePentestBodyWithScanProfile = CreatePentestBody & {
+ * the ownership row so an auto-retry reconstructs the request from our own DB.
+ * `additionalContext` here is the caller's original free-text briefing only —
+ * on retry it is passed back through `resolveAdditionalContext`, which re-adds
+ * the target's finding-context notes. `webhookUrl` is excluded (re-resolved to
+ * our endpoint).
+ */
</file context>
Fix with cubic

Comment thread apps/api/src/security-penetration-tests/security-penetration-tests.service.ts Outdated
Comment thread apps/api/src/security-penetration-tests/security-penetration-tests.service.ts Outdated
… marker

Consolidates the retry-claim logic so it stops recurring edge cases:

- Idempotency/durability now key off whether a retry child actually exists
  (retryOfProviderRunId), not a mutable claim. A transient spawn/DB failure
  no longer strands the retry — the webhook 5xx's, Maced redelivers, and the
  next delivery sees no child and re-attempts. Removes the fragile
  claim-release step entirely.
- Cancellation uses a distinct retryBlockedAt marker (set lineage-wide), so a
  late pentest.failed can never be confused with a spawn claim and retry a
  stopped scan, regardless of arrival order.
- fromScanParams keeps the valid subset of checks instead of dropping all when
  a single entry is stale.
- getReportProgress reports the same grace-based collapsed status as getReport
  (via the shared resolver), so the two endpoints can't contradict.
@tofikwest

Copy link
Copy Markdown
Contributor Author

Addressed the 3rd review batch in 047e0e2e7. Consolidated the retry logic so the same area stops generating edge cases:

  • P1 (claim released erases cancel block) + P1 (release failure strands retry): both stemmed from overloading one column for "claimed" and "blocked". Reworked so idempotency/durability key off whether a retry child actually exists (retryOfProviderRunId) — no mutable claim, no fragile release. A transient spawn/DB failure now can't strand the retry (webhook 5xx's → Maced redelivers → next delivery sees no child → re-attempts). Cancellation uses a distinct retryBlockedAt marker set lineage-wide, so a late pentest.failed can never be mistaken for a claim.
  • P2 (progress lacks grace window): getReportProgress now derives its status from the same shared resolver as getReport, so both endpoints report the identical grace-based collapsed status — no contradictory window.
  • P2 (checks all-or-nothing): fromScanParams keeps the valid subset of checks instead of dropping the whole selection when one entry is stale.
  • P2 (webhookUrl dropped on retry): not an issue — webhookUrl isn't a customer callback. normalizeWebhookPath forces it to Comp's own webhook route, and events are signed with Comp's secret and handled by Comp's handleWebhook. It's Comp's per-env callback endpoint, so re-resolving it on retry (to the current env's default) is correct, not a lost custom callback.

Tests: 146 green in isolation and serially, typecheck + prettier clean. Added coverage for cancelled-lineage block, child-existence idempotency, and progress/report status consistency.

Note: one bounded, deliberate limitation remains — a retry already in flight when a cancel arrives isn't force-cancelled at the provider (it's refunded, just wastes compute). Also flagging a pre-existing test-infra flake unrelated to this change: the spec files mutate the global fetch, so a full parallel run can occasionally fail one create test; every file passes in isolation.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/db/prisma/schema/security-penetration-test-run.prisma">

<violation number="1" location="packages/db/prisma/schema/security-penetration-test-run.prisma:40">
P1: A cancellation can still spawn a replacement scan when its webhook lands after `maybeAutoRetry` reads `retryBlockedAt` but before `createReport` executes. Coordinate cancellation and retry creation through a serialized/conditional lineage claim so a cancelled scan cannot be recreated after the cancellation is accepted.

(Based on your team's feedback about preserving terminal cancellation state.) [FEEDBACK_USED]</violation>

<violation number="2" location="packages/db/prisma/schema/security-penetration-test-run.prisma:40">
P1: A transient database failure while recording cancellation is acknowledged as success, leaving no `retryBlockedAt`; a later `pentest.failed` then recreates a deliberately cancelled scan. Propagate this failure (or durably enqueue it) so Maced redelivers the cancellation event until the lineage block is stored.

(Based on your team's feedback about preserving terminal cancellation state.) [FEEDBACK_USED]</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

/// from retryTriggeredAt) so a late `pentest.failed` can never spawn a
/// retry of a deliberately stopped scan, regardless of webhook arrival
/// order. Checked by the auto-retry path before spawning.
retryBlockedAt DateTime? @map("retry_blocked_at")

@cubic-dev-ai cubic-dev-ai Bot Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A cancellation can still spawn a replacement scan when its webhook lands after maybeAutoRetry reads retryBlockedAt but before createReport executes. Coordinate cancellation and retry creation through a serialized/conditional lineage claim so a cancelled scan cannot be recreated after the cancellation is accepted.

(Based on your team's feedback about preserving terminal cancellation state.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/db/prisma/schema/security-penetration-test-run.prisma, line 40:

<comment>A cancellation can still spawn a replacement scan when its webhook lands after `maybeAutoRetry` reads `retryBlockedAt` but before `createReport` executes. Coordinate cancellation and retry creation through a serialized/conditional lineage claim so a cancelled scan cannot be recreated after the cancellation is accepted.

(Based on your team's feedback about preserving terminal cancellation state.) </comment>

<file context>
@@ -27,14 +27,18 @@ model SecurityPenetrationTestRun {
+  /// from retryTriggeredAt) so a late `pentest.failed` can never spawn a
+  /// retry of a deliberately stopped scan, regardless of webhook arrival
+  /// order. Checked by the auto-retry path before spawning.
+  retryBlockedAt DateTime? @map("retry_blocked_at")
+
   /// The scan parameters needed to faithfully re-run this scan
</file context>
Fix with cubic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged as a bounded, deliberate limitation — not fixing. This is a check-then-call-external-API race (read retryBlockedAt, then createReport hits Maced). Fully eliminating it would need either a distributed lock held across the external provider call, or provider-side cancellation of the in-flight descendant run — real complexity for a scenario that requires (a) the provider emitting BOTH failed and cancelled for one run (a single run has one terminal state in normal operation) AND (b) the cancel landing in the sub-second window during createReport. Impact is bounded: the spawned retry is refunded and only wastes compute — the same documented limitation as an already-in-flight retry not being force-cancelled. Recording this as an accepted trade-off rather than over-engineering for an anomaly-dependent case.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point — this is a bounded, accepted trade-off here, not something to force into a lock/cancellation redesign. The parent comment is too broad for this PR.

This comment was influenced by this learning. Open the link to edit it, or reply here to edit or delete it.

/// from retryTriggeredAt) so a late `pentest.failed` can never spawn a
/// retry of a deliberately stopped scan, regardless of webhook arrival
/// order. Checked by the auto-retry path before spawning.
retryBlockedAt DateTime? @map("retry_blocked_at")

@cubic-dev-ai cubic-dev-ai Bot Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A transient database failure while recording cancellation is acknowledged as success, leaving no retryBlockedAt; a later pentest.failed then recreates a deliberately cancelled scan. Propagate this failure (or durably enqueue it) so Maced redelivers the cancellation event until the lineage block is stored.

(Based on your team's feedback about preserving terminal cancellation state.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/db/prisma/schema/security-penetration-test-run.prisma, line 40:

<comment>A transient database failure while recording cancellation is acknowledged as success, leaving no `retryBlockedAt`; a later `pentest.failed` then recreates a deliberately cancelled scan. Propagate this failure (or durably enqueue it) so Maced redelivers the cancellation event until the lineage block is stored.

(Based on your team's feedback about preserving terminal cancellation state.) </comment>

<file context>
@@ -27,14 +27,18 @@ model SecurityPenetrationTestRun {
+  /// from retryTriggeredAt) so a late `pentest.failed` can never spawn a
+  /// retry of a deliberately stopped scan, regardless of webhook arrival
+  /// order. Checked by the auto-retry path before spawning.
+  retryBlockedAt DateTime? @map("retry_blocked_at")
+
   /// The scan parameters needed to faithfully re-run this scan
</file context>
Fix with cubic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d24c08357. blockAutoRetry no longer swallows the error — if recording the cancellation block fails, it propagates so the handler returns non-2xx and Maced redelivers the cancellation until the block is durably stored. The refund runs first and is idempotent (creditRefundedAt), so redelivery is safe. This matches the refund and retry durability patterns.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it — the cancellation block now fails closed and Maced redelivers until it’s durably stored, with the refund remaining safe and idempotent.

blockAutoRetry no longer swallows DB errors — if recording the lineage
cancellation block fails, it propagates so the handler 5xx's and Maced
redelivers the cancellation until the block is stored (the refund runs first
and is idempotent). Consistent with the refund and retry durability paths;
prevents a lost block from letting a later pentest.failed retry a cancelled
scan.
@vercel vercel Bot temporarily deployed to Preview – portal July 14, 2026 01:47 Inactive
@vercel vercel Bot temporarily deployed to Preview – app July 14, 2026 01:47 Inactive
@tofikwest tofikwest merged commit b9aee8b into main Jul 14, 2026
11 checks passed
@tofikwest tofikwest deleted the feat/pentest-auto-retry-failed-scans branch July 14, 2026 01:49
@claudfuen

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 3.101.2 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants