From b9aee8b88ad6a38db3bf25dcc5de62c46b4c6c55 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:49:11 -0400 Subject: [PATCH 1/3] Merge pull request #3398 from trycompai/feat/pentest-auto-retry-failed-scans feat(api): auto-retry failed pentest scans and clean up failure messages --- .../pentest-lineage.util.spec.ts | 113 ++++ .../pentest-lineage.util.ts | 66 +++ .../pentest-run-error.util.spec.ts | 95 ++++ .../pentest-run-error.util.ts | 89 ++++ ...security-penetration-tests.service.spec.ts | 432 ++++++++++++++- .../security-penetration-tests.service.ts | 503 ++++++++++++++++-- .../_components/FailedDetail.tsx | 24 +- .../migration.sql | 9 + .../migration.sql | 7 + .../migration.sql | 2 + .../security-penetration-test-run.prisma | 44 ++ 11 files changed, 1323 insertions(+), 61 deletions(-) create mode 100644 apps/api/src/security-penetration-tests/pentest-lineage.util.spec.ts create mode 100644 apps/api/src/security-penetration-tests/pentest-lineage.util.ts create mode 100644 apps/api/src/security-penetration-tests/pentest-run-error.util.spec.ts create mode 100644 apps/api/src/security-penetration-tests/pentest-run-error.util.ts create mode 100644 packages/db/prisma/migrations/20260714001700_pentest_auto_retry_lineage/migration.sql create mode 100644 packages/db/prisma/migrations/20260714001815_backfill_pentest_lineage_root/migration.sql create mode 100644 packages/db/prisma/migrations/20260714013723_pentest_retry_blocked_marker/migration.sql diff --git a/apps/api/src/security-penetration-tests/pentest-lineage.util.spec.ts b/apps/api/src/security-penetration-tests/pentest-lineage.util.spec.ts new file mode 100644 index 0000000000..c29e1ead18 --- /dev/null +++ b/apps/api/src/security-penetration-tests/pentest-lineage.util.spec.ts @@ -0,0 +1,113 @@ +import { + collapsedStatus, + MAX_ATTEMPTS, + RETRY_GRACE_MS, +} from './pentest-lineage.util'; + +describe('collapsedStatus', () => { + const now = 1_000_000_000_000; + + it('passes through completed', () => { + expect( + collapsedStatus({ + activeStatus: 'completed', + attemptNumber: 2, + failedAtMs: null, + nowMs: now, + }), + ).toBe('completed'); + }); + + it.each(['provisioning', 'cloning', 'running'] as const)( + 'passes through in-progress status %s', + (status) => { + expect( + collapsedStatus({ + activeStatus: status, + attemptNumber: 1, + failedAtMs: null, + nowMs: now, + }), + ).toBe(status); + }, + ); + + it('never masks cancelled (staff cancel is terminal, never retried)', () => { + expect( + collapsedStatus({ + activeStatus: 'cancelled', + attemptNumber: 1, + failedAtMs: now - 1000, + nowMs: now, + }), + ).toBe('cancelled'); + }); + + describe('failed', () => { + it('masks a fresh failed non-final attempt as provisioning', () => { + expect( + collapsedStatus({ + activeStatus: 'failed', + attemptNumber: 1, + failedAtMs: now - 5_000, // well within grace + nowMs: now, + }), + ).toBe('provisioning'); + }); + + it('masks the second attempt too', () => { + expect( + collapsedStatus({ + activeStatus: 'failed', + attemptNumber: 2, + failedAtMs: now - 5_000, + nowMs: now, + }), + ).toBe('provisioning'); + }); + + it('reveals failed once the lineage is exhausted (final attempt)', () => { + expect( + collapsedStatus({ + activeStatus: 'failed', + attemptNumber: MAX_ATTEMPTS, + failedAtMs: now - 5_000, + nowMs: now, + }), + ).toBe('failed'); + }); + + it('reveals failed after the grace window elapses without a retry', () => { + expect( + collapsedStatus({ + activeStatus: 'failed', + attemptNumber: 1, + failedAtMs: now - (RETRY_GRACE_MS + 1), + nowMs: now, + }), + ).toBe('failed'); + }); + + it('reveals failed exactly at the grace-window boundary', () => { + expect( + collapsedStatus({ + activeStatus: 'failed', + attemptNumber: 1, + failedAtMs: now - RETRY_GRACE_MS, + nowMs: now, + }), + ).toBe('failed'); + }); + + it('reveals failed when the failure time is unknown (cannot bound the mask)', () => { + expect( + collapsedStatus({ + activeStatus: 'failed', + attemptNumber: 1, + failedAtMs: null, + nowMs: now, + }), + ).toBe('failed'); + }); + }); +}); diff --git a/apps/api/src/security-penetration-tests/pentest-lineage.util.ts b/apps/api/src/security-penetration-tests/pentest-lineage.util.ts new file mode 100644 index 0000000000..92134dc0bd --- /dev/null +++ b/apps/api/src/security-penetration-tests/pentest-lineage.util.ts @@ -0,0 +1,66 @@ +/** + * Pure helpers for the pentest retry-lineage feature. + * + * A scan is a "lineage": the original run plus up to `MAX_ATTEMPTS - 1` + * automatic retries, all sharing one root run id. Customers only ever see the + * root; our API resolves it to the active (highest-numbered) attempt and only + * reports a terminal failure once the whole lineage is exhausted. + */ + +export type PentestRunStatus = + | 'provisioning' + | 'cloning' + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +/** Original attempt + 2 automatic retries. */ +export const MAX_ATTEMPTS = 3; + +/** + * How long a failed non-final attempt is masked as in-progress while its + * retry is spawned. Comfortably covers provider webhook-delivery latency; if a + * retry never materializes within this window the real failure is revealed so + * the run can't hang on a fake "running" state forever. + */ +export const RETRY_GRACE_MS = 10 * 60 * 1000; + +/** + * Computes the customer-facing status for a lineage from its active (highest) + * attempt. The active attempt is authoritative because we only ever retry + * `failed` runs — so no earlier attempt can be `completed`/`running`. + * + * - `completed` / in-progress statuses pass through unchanged. + * - `cancelled` is terminal and never retried → always revealed. + * - `failed`: + * - lineage exhausted (`attemptNumber >= maxAttempts`) → revealed. + * - otherwise masked as `provisioning` (a retry is expected) while inside + * the grace window; revealed once the window elapses without a retry + * appearing, or when the failure time is unknown (can't bound the mask). + */ +export function collapsedStatus(params: { + activeStatus: PentestRunStatus; + attemptNumber: number; + failedAtMs: number | null; + nowMs: number; + maxAttempts?: number; + graceMs?: number; +}): PentestRunStatus { + const { activeStatus, attemptNumber, failedAtMs, nowMs } = params; + const maxAttempts = params.maxAttempts ?? MAX_ATTEMPTS; + const graceMs = params.graceMs ?? RETRY_GRACE_MS; + + // Only `failed` triggers an auto-retry, so only `failed` is ever masked. + if (activeStatus !== 'failed') { + return activeStatus; + } + + if (attemptNumber >= maxAttempts) { + return 'failed'; + } + if (failedAtMs === null || nowMs - failedAtMs >= graceMs) { + return 'failed'; + } + return 'provisioning'; +} diff --git a/apps/api/src/security-penetration-tests/pentest-run-error.util.spec.ts b/apps/api/src/security-penetration-tests/pentest-run-error.util.spec.ts new file mode 100644 index 0000000000..63fa802dd8 --- /dev/null +++ b/apps/api/src/security-penetration-tests/pentest-run-error.util.spec.ts @@ -0,0 +1,95 @@ +import { toCustomerFacingError } from './pentest-run-error.util'; + +describe('toCustomerFacingError', () => { + const infraMessagePrefix = + 'The scan was interrupted by a temporary infrastructure issue'; + + describe('infra / transient failures', () => { + const infraErrors = [ + 'Sandbox container deleted by Daytona infrastructure (age 35m). Desired-state=started but container gone — likely Daytona backup/restore race condition.', + 'Run timed out — sandbox became unresponsive', + 'Timed out with no sandbox after 9m', + 'Hard timeout: 4m (cap 120m); Daytona unreachable', + ]; + + it.each(infraErrors)( + 'maps infra error to the transient message: %s', + (raw) => { + expect(toCustomerFacingError(raw)).toContain(infraMessagePrefix); + }, + ); + + it('never echoes the raw provider text', () => { + const raw = + 'Sandbox container deleted by Daytona infrastructure (age 35m). backup/restore race condition.'; + const message = toCustomerFacingError(raw); + expect(message.toLowerCase()).not.toContain('daytona'); + expect(message.toLowerCase()).not.toContain('sandbox'); + expect(message.toLowerCase()).not.toContain('container'); + }); + }); + + describe('genuine runtime cap', () => { + it('humanizes a 720m cap as 12 hours', () => { + const message = toCustomerFacingError( + 'Hard timeout: workflow ran 722m (cap 720m)', + ); + expect(message).toContain('maximum runtime of 12 hours'); + expect(message).toContain("won't be charged"); + }); + + it('humanizes a 120m cap as 2 hours', () => { + const message = toCustomerFacingError( + 'Hard timeout: workflow ran 130m (cap 120m)', + ); + expect(message).toContain('maximum runtime of 2 hours'); + }); + + it('renders a single-hour cap without pluralizing', () => { + const message = toCustomerFacingError( + 'Hard timeout: workflow ran 61m (cap 60m)', + ); + expect(message).toContain('maximum runtime of 1 hour'); + expect(message).not.toContain('1 hours'); + }); + + it('falls back to minutes for a non-hour cap', () => { + const message = toCustomerFacingError( + 'Hard timeout: workflow ran 95m (cap 90m)', + ); + expect(message).toContain('maximum runtime of 90 minutes'); + }); + + it('renders a single-minute cap without pluralizing', () => { + const message = toCustomerFacingError( + 'Hard timeout: workflow ran 2m (cap 1m)', + ); + expect(message).toContain('maximum runtime of 1 minute'); + expect(message).not.toContain('1 minutes'); + }); + + it('classifies the "; Daytona unreachable" variant as infra, not runtime cap', () => { + const message = toCustomerFacingError( + 'Hard timeout: 4m (cap 120m); Daytona unreachable', + ); + expect(message).toContain(infraMessagePrefix); + expect(message).not.toContain('maximum runtime'); + }); + }); + + describe('unknown / empty', () => { + const genericPrefix = "The scan couldn't be completed"; + + it('returns the generic message for an unrecognized error', () => { + expect( + toCustomerFacingError('some brand new error we have not seen'), + ).toContain(genericPrefix); + }); + + it('returns the generic message for null / undefined / empty', () => { + expect(toCustomerFacingError(null)).toContain(genericPrefix); + expect(toCustomerFacingError(undefined)).toContain(genericPrefix); + expect(toCustomerFacingError('')).toContain(genericPrefix); + }); + }); +}); diff --git a/apps/api/src/security-penetration-tests/pentest-run-error.util.ts b/apps/api/src/security-penetration-tests/pentest-run-error.util.ts new file mode 100644 index 0000000000..5662e7c046 --- /dev/null +++ b/apps/api/src/security-penetration-tests/pentest-run-error.util.ts @@ -0,0 +1,89 @@ +/** + * Maps a raw provider run-error string into a clean, customer-facing message. + * + * Penetration tests are executed by an external provider whose backend runs + * each scan inside a disposable sandbox. When a run fails, the provider returns + * a freeform `error` string that can name our infrastructure vendor, describe + * internal engineering conditions ("backup/restore race condition"), or be + * arbitrary text — none of which is useful or safe to surface to a customer of + * a security product. + * + * This helper is only applied once a scan's entire retry lineage is exhausted + * (every attempt failed), so every message it returns describes a genuine, + * final failure. It says the customer "won't be charged" rather than asserting + * the refund is already complete — a failed run is always refunded, but the + * refund and this read can race, so the phrasing stays true regardless of + * timing (net effect: a failed scan is never charged). + * + * The raw string is never echoed back; unrecognized errors fall through to a + * safe generic message. + */ + +const INFRA_TRANSIENT_MESSAGE = + 'The scan was interrupted by a temporary infrastructure issue and ' + + "couldn't be completed. You won't be charged for this scan — please try " + + 'running it again, and contact support if this keeps happening.'; + +const GENERIC_MESSAGE = + "The scan couldn't be completed. You won't be charged for this scan — " + + 'please try running it again, or contact support if this keeps happening.'; + +/** + * True for transient infrastructure failures that a re-run typically clears: + * the sandbox was deleted, never provisioned, went unresponsive, or the + * provisioning path could not reach the sandbox host. The "Daytona + * unreachable" hard-timeout variant belongs here (an infra problem during + * provisioning), NOT with the genuine runtime cap below — so it must be + * checked first. + */ +function isInfraTransient(raw: string): boolean { + return ( + /sandbox container deleted/i.test(raw) || + /backup\/restore race/i.test(raw) || + /sandbox became unresponsive/i.test(raw) || + /timed out with no sandbox/i.test(raw) || + /daytona unreachable/i.test(raw) + ); +} + +/** + * Renders a whole number of minutes as a friendly duration: exact hours become + * "12 hours" / "1 hour", anything else stays in minutes. Guards against junk + * input by falling back to a neutral phrase. + */ +function humanizeMinutes(minutes: number): string { + if (!Number.isFinite(minutes) || minutes <= 0) { + return 'its time limit'; + } + if (minutes % 60 === 0) { + const hours = minutes / 60; + return `${hours} hour${hours === 1 ? '' : 's'}`; + } + return `${minutes} minute${minutes === 1 ? '' : 's'}`; +} + +export function toCustomerFacingError( + rawError: string | null | undefined, +): string { + const raw = typeof rawError === 'string' ? rawError : ''; + + if (isInfraTransient(raw)) { + return INFRA_TRANSIENT_MESSAGE; + } + + // Genuine runtime cap: the scan used its full time budget without finishing + // (e.g. "Hard timeout: workflow ran 722m (cap 720m)"). The infra check above + // has already claimed the "; Daytona unreachable" variant. + const capMatch = + /hard timeout:\s*workflow ran\s+\d+m\s*\(cap\s+(\d+)m\)/i.exec(raw); + if (capMatch) { + const cap = humanizeMinutes(Number(capMatch[1])); + return ( + `Your scan reached its maximum runtime of ${cap} before completing. ` + + "You won't be charged for this scan. Large targets may need a lighter " + + 'scan depth — contact support if this keeps happening.' + ); + } + + return GENERIC_MESSAGE; +} diff --git a/apps/api/src/security-penetration-tests/security-penetration-tests.service.spec.ts b/apps/api/src/security-penetration-tests/security-penetration-tests.service.spec.ts index 575bb50ad8..a062f465f4 100644 --- a/apps/api/src/security-penetration-tests/security-penetration-tests.service.spec.ts +++ b/apps/api/src/security-penetration-tests/security-penetration-tests.service.spec.ts @@ -44,7 +44,9 @@ jest.mock('@db', () => ({ securityPenetrationTestRun: { upsert: jest.fn(), findUnique: jest.fn(), + findFirst: jest.fn(), findMany: jest.fn(), + updateMany: jest.fn(), }, securityPenetrationTestFindingContext: { findMany: jest.fn(), @@ -67,7 +69,9 @@ type MockDb = { securityPenetrationTestRun: { upsert: jest.Mock; findUnique: jest.Mock; + findFirst: jest.Mock; findMany: jest.Mock; + updateMany: jest.Mock; }; securityPenetrationTestFindingContext: { findMany: jest.Mock; @@ -143,9 +147,35 @@ describe('SecurityPenetrationTestsService', () => { mockedDb.securityPenetrationTestRun.findUnique.mockResolvedValue({ organizationId: 'org_123', }); + // findFirst is used three ways. Defaults: not cancelled, no retry child, + // and the active attempt resolves to the run itself (reflect the root). + mockedDb.securityPenetrationTestRun.findFirst.mockImplementation( + (args?: { + where?: { + rootRunId?: string; + retryBlockedAt?: unknown; + retryOfProviderRunId?: string; + }; + }) => { + const where = args?.where ?? {}; + if ('retryBlockedAt' in where) return Promise.resolve(null); // not cancelled + if ('retryOfProviderRunId' in where) return Promise.resolve(null); // no child + if (where.rootRunId) { + return Promise.resolve({ + providerRunId: where.rootRunId, + attemptNumber: 1, + }); + } + return Promise.resolve(null); + }, + ); mockedDb.securityPenetrationTestRun.findMany.mockResolvedValue([ - { providerRunId: 'run_123' }, + { providerRunId: 'run_123', rootRunId: 'run_123', attemptNumber: 1 }, ]); + // Retry idempotency claim succeeds by default. + mockedDb.securityPenetrationTestRun.updateMany.mockResolvedValue({ + count: 1, + }); // Default: no stored finding-context notes for the target. mockedDb.securityPenetrationTestFindingContext.findMany.mockResolvedValue( [], @@ -1033,4 +1063,404 @@ describe('SecurityPenetrationTestsService', () => { HttpException, ); }); + + describe('auto-retry lineage', () => { + it('persists self-root lineage for an original run', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ id: 'run_orig', status: 'provisioning' }), + { status: 200 }, + ), + ); + + await service.createReport('org_123', { + targetUrl: 'https://app.example.com', + scanDepth: 'deep', + checks: ['xss'], + }); + + expect(mockedDb.securityPenetrationTestRun.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + providerRunId: 'run_orig', + rootRunId: 'run_orig', + attemptNumber: 1, + retryOfProviderRunId: null, + scanParams: expect.objectContaining({ + targetUrl: 'https://app.example.com', + scanDepth: 'deep', + checks: ['xss'], + }), + }), + }), + ); + }); + + it('persists inherited lineage for a retry run', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ id: 'run_retry', status: 'provisioning' }), + { status: 200 }, + ), + ); + + await service.createReport( + 'org_123', + { targetUrl: 'https://app.example.com' }, + { + attemptNumber: 2, + rootRunId: 'run_orig', + retryOfProviderRunId: 'run_orig', + }, + ); + + expect(mockedDb.securityPenetrationTestRun.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + providerRunId: 'run_retry', + rootRunId: 'run_orig', + attemptNumber: 2, + retryOfProviderRunId: 'run_orig', + }), + }), + ); + }); + + it('spawns a retry with the same params (incl. pipeline + context) and incremented attempt', async () => { + mockedDb.securityPenetrationTestRun.findUnique.mockResolvedValueOnce({ + organizationId: 'org_123', + attemptNumber: 1, + rootRunId: 'run_orig', + scanParams: { + targetUrl: 'https://app.example.com', + scanDepth: 'deep', + checks: ['xss'], + pipelineTesting: true, + additionalContext: 'prior briefing', + }, + }); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ id: 'run_retry', status: 'provisioning' }), + { status: 200 }, + ), + ); + + await service['maybeAutoRetry']('run_orig'); + + const createBody = await getRequestBody(); + expect(createBody).toEqual( + expect.objectContaining({ + targetUrl: 'https://app.example.com', + scanDepth: 'deep', + checks: ['xss'], + pipelineTesting: true, + }), + ); + // The user's briefing survives the round-trip (re-persisted for any + // further retry). + expect(mockedDb.securityPenetrationTestRun.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + providerRunId: 'run_retry', + rootRunId: 'run_orig', + attemptNumber: 2, + retryOfProviderRunId: 'run_orig', + scanParams: expect.objectContaining({ + pipelineTesting: true, + additionalContext: 'prior briefing', + }), + }), + }), + ); + }); + + it('blocks auto-retry across the whole lineage when a run is cancelled', async () => { + mockedDb.securityPenetrationTestRun.findUnique.mockResolvedValueOnce({ + rootRunId: 'run_orig', + }); + + await service['blockAutoRetry']('run_cancelled'); + + // Sets the distinct cancellation marker on every attempt sharing the + // lineage root, so a late `failed` for any member can't restart it. + expect( + mockedDb.securityPenetrationTestRun.updateMany, + ).toHaveBeenCalledWith( + expect.objectContaining({ + where: { rootRunId: 'run_orig', retryBlockedAt: null }, + data: expect.objectContaining({ retryBlockedAt: expect.any(Date) }), + }), + ); + }); + + it('propagates a DB failure while blocking so the cancellation redelivers', async () => { + mockedDb.securityPenetrationTestRun.updateMany.mockRejectedValueOnce( + new Error('db unavailable'), + ); + + // Rethrows (not swallowed) so the webhook 5xx's and Maced redelivers the + // cancellation until the block is durably stored. + await expect( + service['blockAutoRetry']('run_cancelled'), + ).rejects.toThrow(); + }); + + it('does not retry a cancelled lineage even if a late failed arrives', async () => { + mockedDb.securityPenetrationTestRun.findUnique.mockResolvedValueOnce({ + organizationId: 'org_123', + attemptNumber: 1, + rootRunId: 'run_orig', + scanParams: { targetUrl: 'https://app.example.com' }, + }); + // Block check (first findFirst) reports the lineage is cancelled. + mockedDb.securityPenetrationTestRun.findFirst.mockResolvedValueOnce({ + providerRunId: 'run_orig', + }); + + await service['maybeAutoRetry']('run_orig'); + + expect(mockedDb.securityPenetrationTestRun.upsert).not.toHaveBeenCalled(); + }); + + it('does not retry when a retry child already exists (idempotent)', async () => { + mockedDb.securityPenetrationTestRun.findUnique.mockResolvedValueOnce({ + organizationId: 'org_123', + attemptNumber: 1, + rootRunId: 'run_orig', + scanParams: { targetUrl: 'https://app.example.com' }, + }); + // Block check → not cancelled; child check → a child already exists. + mockedDb.securityPenetrationTestRun.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ providerRunId: 'run_retry' }); + + await service['maybeAutoRetry']('run_orig'); + + expect(mockedDb.securityPenetrationTestRun.upsert).not.toHaveBeenCalled(); + }); + + it('does not retry once the lineage is exhausted', async () => { + mockedDb.securityPenetrationTestRun.findUnique.mockResolvedValueOnce({ + organizationId: 'org_123', + attemptNumber: 3, + rootRunId: 'run_orig', + scanParams: { targetUrl: 'https://app.example.com' }, + }); + + await service['maybeAutoRetry']('run_orig'); + + expect( + mockedDb.securityPenetrationTestRun.updateMany, + ).not.toHaveBeenCalled(); + expect(mockedDb.securityPenetrationTestRun.upsert).not.toHaveBeenCalled(); + }); + + it('does not retry an orphan run with no ownership row', async () => { + mockedDb.securityPenetrationTestRun.findUnique.mockResolvedValueOnce( + null, + ); + + await service['maybeAutoRetry']('run_ghost'); + + expect( + mockedDb.securityPenetrationTestRun.updateMany, + ).not.toHaveBeenCalled(); + expect(mockedDb.securityPenetrationTestRun.upsert).not.toHaveBeenCalled(); + }); + + it('rethrows when the retry spawn fails so the webhook redelivers (durable via child-existence)', async () => { + mockedDb.securityPenetrationTestRun.findUnique.mockResolvedValueOnce({ + organizationId: 'org_123', + attemptNumber: 1, + rootRunId: 'run_orig', + scanParams: { targetUrl: 'https://app.example.com' }, + }); + fetchMock.mockResolvedValueOnce( + new Response('{"error":"boom"}', { status: 500 }), + ); + + // No child is created, so it rethrows → webhook 5xx → Maced redelivers → + // next delivery sees no child and re-attempts. Nothing to release. + await expect(service['maybeAutoRetry']('run_orig')).rejects.toThrow(); + }); + + it('collapses a retry lineage to a single active-attempt entry', async () => { + mockedDb.securityPenetrationTestRun.findMany.mockResolvedValueOnce([ + { providerRunId: 'run_orig', rootRunId: 'run_orig', attemptNumber: 1 }, + { providerRunId: 'run_retry', rootRunId: 'run_orig', attemptNumber: 2 }, + ]); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify([ + { + id: 'run_orig', + status: 'failed', + targetUrl: 'https://a.com', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + error: 'Sandbox container deleted by Daytona', + }, + { + id: 'run_retry', + status: 'running', + targetUrl: 'https://a.com', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:05:00.000Z', + }, + ]), + { status: 200 }, + ), + ); + + const result = await service.listReports('org_123'); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual( + expect.objectContaining({ id: 'run_orig', status: 'running' }), + ); + }); + + it('masks a fresh failed non-final attempt as in-progress', async () => { + mockedDb.securityPenetrationTestRun.findUnique.mockResolvedValue({ + organizationId: 'org_123', + rootRunId: 'run_orig', + }); + mockedDb.securityPenetrationTestRun.findFirst.mockResolvedValueOnce({ + providerRunId: 'run_orig', + attemptNumber: 1, + }); + const recent = new Date().toISOString(); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'run_orig', + status: 'failed', + targetUrl: 'https://a.com', + createdAt: recent, + updatedAt: recent, + error: 'Sandbox container deleted by Daytona', + }), + { status: 200 }, + ), + ); + + const report = await service.getReport('org_123', 'run_orig'); + + expect(report.status).toBe('provisioning'); + expect(report.error).toBeNull(); + expect(report.failedReason).toBeNull(); + }); + + it('reveals a clean, white-labeled failure once the lineage is exhausted', async () => { + mockedDb.securityPenetrationTestRun.findUnique.mockResolvedValue({ + organizationId: 'org_123', + rootRunId: 'run_orig', + }); + mockedDb.securityPenetrationTestRun.findFirst.mockResolvedValueOnce({ + providerRunId: 'run_retry2', + attemptNumber: 3, + }); + const recent = new Date().toISOString(); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'run_retry2', + status: 'failed', + targetUrl: 'https://a.com', + createdAt: recent, + updatedAt: recent, + error: + 'Sandbox container deleted by Daytona infrastructure — backup/restore race condition', + }), + { status: 200 }, + ), + ); + + const report = await service.getReport('org_123', 'run_orig'); + + expect(report.status).toBe('failed'); + expect(report.id).toBe('run_orig'); + expect(report.failedReason).toContain('temporary infrastructure issue'); + expect(report.failedReason?.toLowerCase()).not.toContain('daytona'); + expect(report.error?.toLowerCase()).not.toContain('daytona'); + }); + + it('masks progress status as in-progress for a failed non-final attempt', async () => { + mockedDb.securityPenetrationTestRun.findUnique.mockResolvedValue({ + organizationId: 'org_123', + rootRunId: 'run_orig', + }); + const recent = new Date().toISOString(); + // getReportResolved.get() then progress() — two provider calls. + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'run_orig', + status: 'failed', + targetUrl: 'https://a.com', + createdAt: recent, + updatedAt: recent, + }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + status: 'failed', + completedAgents: 2, + totalAgents: 5, + elapsedMs: 1000, + }), + { status: 200 }, + ), + ); + + const progress = await service.getReportProgress('org_123', 'run_orig'); + + expect(progress.status).toBe('provisioning'); + }); + + it('exposes failed progress once the lineage is exhausted', async () => { + mockedDb.securityPenetrationTestRun.findUnique.mockResolvedValue({ + organizationId: 'org_123', + rootRunId: 'run_orig', + }); + mockedDb.securityPenetrationTestRun.findFirst.mockResolvedValueOnce({ + providerRunId: 'run_retry2', + attemptNumber: 3, + }); + const recent = new Date().toISOString(); + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'run_retry2', + status: 'failed', + targetUrl: 'https://a.com', + createdAt: recent, + updatedAt: recent, + }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + status: 'failed', + completedAgents: 2, + totalAgents: 5, + elapsedMs: 1000, + }), + { status: 200 }, + ), + ); + + const progress = await service.getReportProgress('org_123', 'run_orig'); + + expect(progress.status).toBe('failed'); + }); + }); }); diff --git a/apps/api/src/security-penetration-tests/security-penetration-tests.service.ts b/apps/api/src/security-penetration-tests/security-penetration-tests.service.ts index a6afcb2b04..b5faac7bb6 100644 --- a/apps/api/src/security-penetration-tests/security-penetration-tests.service.ts +++ b/apps/api/src/security-penetration-tests/security-penetration-tests.service.ts @@ -6,7 +6,7 @@ import { Injectable, Logger, } from '@nestjs/common'; -import { db } from '@db'; +import { db, Prisma } from '@db'; import { createMacedClient, MacedApiError, @@ -43,6 +43,12 @@ import { type ReportContextNote, } from './report-appendix.util'; import { PentestCreditsService } from './pentest-credits.service'; +import { toCustomerFacingError } from './pentest-run-error.util'; +import { + collapsedStatus, + MAX_ATTEMPTS, + type PentestRunStatus, +} from './pentest-lineage.util'; /** * Drops events that mention our infrastructure provider in any string @@ -135,6 +141,51 @@ type CreatePentestBodyWithScanProfile = CreatePentestBody & { checks?: PentestCheck[]; }; +/** + * Where a run sits in its retry lineage. Originals use the default + * (`attemptNumber: 1`, no parent, `rootRunId: null` → resolved to the run's + * own providerRunId once Maced assigns it). Auto-retries pass the inherited + * root and incremented attempt number. + */ +interface RunLineage { + attemptNumber: number; + rootRunId: string | null; + retryOfProviderRunId: string | null; +} + +/** Lineage fields persisted on the ownership row (rootRunId resolved). */ +interface OwnershipLineage { + rootRunId: string; + attemptNumber: number; + retryOfProviderRunId: string | null; + scanParams: RetryScanParams; +} + +/** + * The subset of a create request needed to faithfully re-run a scan. Stored on + * 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). + */ +interface RetryScanParams { + targetUrl: string; + repoUrl?: string; + pipelineTesting?: boolean; + testMode?: boolean; + scanDepth?: ScanDepth; + evidenceLevel?: EvidenceLevel; + checks?: PentestCheck[]; + additionalContext?: string; +} + +const ORIGINAL_RUN_LINEAGE: RunLineage = { + attemptNumber: 1, + rootRunId: null, + retryOfProviderRunId: null, +}; + @Injectable() export class SecurityPenetrationTestsService { private readonly logger = new Logger(SecurityPenetrationTestsService.name); @@ -232,24 +283,59 @@ export class SecurityPenetrationTestsService { async listReports( organizationId: string, ): Promise { - const ownedRunIds = await this.listOwnedRunIds(organizationId); - if (ownedRunIds.size === 0) { + const rows = await db.securityPenetrationTestRun.findMany({ + where: { organizationId }, + select: { providerRunId: true, rootRunId: true, attemptNumber: true }, + }); + if (rows.length === 0) { return []; } + // Collapse each lineage to its active (highest-numbered) attempt so the + // customer sees one entry per scan — retries never appear as separate rows. + const activeByRoot = new Map< + string, + { providerRunId: string; attemptNumber: number } + >(); + for (const row of rows) { + const root = row.rootRunId ?? row.providerRunId; + const current = activeByRoot.get(root); + if (!current || row.attemptNumber > current.attemptNumber) { + activeByRoot.set(root, { + providerRunId: row.providerRunId, + attemptNumber: row.attemptNumber, + }); + } + } + const reports = await this.callMaced( () => this.macedClient.pentests.list(), 'listing penetration tests', ); - - return reports - .filter((report) => ownedRunIds.has(report.id)) - .map((report) => this.mapMacedRunToSecurityPenetrationTest(report)); + const reportById = new Map(reports.map((report) => [report.id, report])); + + const result: SecurityPenetrationTest[] = []; + for (const [rootRunId, active] of activeByRoot) { + const report = reportById.get(active.providerRunId); + // The provider may not know a just-created run yet, or may have pruned + // it — skip rather than surface a half-populated row. + if (!report) continue; + result.push( + this.collapseRun(report, { + rootRunId, + attemptNumber: active.attemptNumber, + }), + ); + } + return result; } async createReport( organizationId: string, payload: CreatePenetrationTestDto, + // Internal: set by the auto-retry path to link a new run into an existing + // lineage. User-initiated creates use the original-run default. + lineage: RunLineage = ORIGINAL_RUN_LINEAGE, ): Promise { const resolvedWebhookUrl = this.resolveWebhookUrl(payload.webhookUrl); // Resolved before the billing reservation so a DB failure here can't @@ -400,6 +486,13 @@ export class SecurityPenetrationTestsService { organizationId, providerRunId, consumedSubscriptionAllowance ? billingUsageSourceId : null, + { + // An original run is its own lineage root; a retry inherits it. + rootRunId: lineage.rootRunId ?? providerRunId, + attemptNumber: lineage.attemptNumber, + retryOfProviderRunId: lineage.retryOfProviderRunId, + scanParams: this.toScanParams(payload), + }, ); if (!ownershipPersisted) { // We debited and Maced created the run, but our DB rejected the @@ -482,34 +575,71 @@ export class SecurityPenetrationTestsService { }); } - async getReport( + /** + * Resolves ownership + the active attempt, fetches the run, and returns both + * the collapsed customer-facing run and the active provider run id. Callers + * that also need the active id (report/PDF downloads) use this to avoid + * re-resolving the lineage a second time. + */ + private async getReportResolved( organizationId: string, id: string, - ): Promise { + ): Promise<{ run: SecurityPenetrationTest; activeProviderRunId: string }> { await this.assertRunOwnership(organizationId, id); + const { rootRunId, activeProviderRunId, attemptNumber } = + await this.resolveActiveAttempt(organizationId, id); const report = await this.callMaced( - () => this.macedClient.pentests.get(id), - `fetching penetration test ${id}`, + () => this.macedClient.pentests.get(activeProviderRunId), + `fetching penetration test ${activeProviderRunId}`, ); - return this.mapMacedRunToSecurityPenetrationTest(report); + return { + run: this.collapseRun(report, { rootRunId, attemptNumber }), + activeProviderRunId, + }; + } + + async getReport( + organizationId: string, + id: string, + ): Promise { + const { run } = await this.getReportResolved(organizationId, id); + return run; } async getReportProgress( organizationId: string, id: string, ): Promise { - await this.assertRunOwnership(organizationId, id); - return this.callMaced( - () => this.macedClient.pentests.progress(id), - `fetching penetration test progress ${id}`, + // Resolve via the shared helper so progress reports the SAME collapsed, + // grace-based status as getReport — the two endpoints can never contradict + // each other for the same run (a failed non-final attempt reads as + // in-progress within the grace window, and as failed once it elapses). + const { run, activeProviderRunId } = await this.getReportResolved( + organizationId, + id, + ); + const progress = await this.callMaced( + () => this.macedClient.pentests.progress(activeProviderRunId), + `fetching penetration test progress ${activeProviderRunId}`, ); + // The only divergence from progress's own status is the failed↔provisioning + // masking that getReport applies (grace-based). Override just those so the + // two endpoints agree; every other state already matches. + if (run.status === 'failed' || run.status === 'provisioning') { + return { ...progress, status: run.status }; + } + return progress; } async getReportIssues(organizationId: string, id: string): Promise { await this.assertRunOwnership(organizationId, id); + const { activeProviderRunId } = await this.resolveActiveAttempt( + organizationId, + id, + ); return this.callMaced( - () => this.macedClient.pentests.issues(id), - `fetching penetration test issues ${id}`, + () => this.macedClient.pentests.issues(activeProviderRunId), + `fetching penetration test issues ${activeProviderRunId}`, ); } @@ -518,9 +648,13 @@ export class SecurityPenetrationTestsService { id: string, ): Promise { await this.assertRunOwnership(organizationId, id); + const { activeProviderRunId } = await this.resolveActiveAttempt( + organizationId, + id, + ); const events = await this.callMaced( - () => this.macedClient.pentests.events(id), - `fetching penetration test events ${id}`, + () => this.macedClient.pentests.events(activeProviderRunId), + `fetching penetration test events ${activeProviderRunId}`, ); // Filter at the API layer (defense in depth) — a UI-only filter // would leave Maced-internal tool names (`mcp__maced-helper__*`) @@ -535,11 +669,14 @@ export class SecurityPenetrationTestsService { organizationId: string, id: string, ): Promise { - const run = await this.getReport(organizationId, id); + const { run, activeProviderRunId } = await this.getReportResolved( + organizationId, + id, + ); const report = await this.callMaced( - () => this.macedClient.pentests.report(id), - `fetching penetration test report ${id}`, + () => this.macedClient.pentests.report(activeProviderRunId), + `fetching penetration test report ${activeProviderRunId}`, ); const notes = await this.findContextNotesQuietly( @@ -562,11 +699,14 @@ export class SecurityPenetrationTestsService { organizationId: string, id: string, ): Promise { - const run = await this.getReport(organizationId, id); + const { run, activeProviderRunId } = await this.getReportResolved( + organizationId, + id, + ); const blob = await this.callMaced( - () => this.macedClient.pentests.reportPdf(id), - `fetching penetration test PDF ${id}`, + () => this.macedClient.pentests.reportPdf(activeProviderRunId), + `fetching penetration test PDF ${activeProviderRunId}`, ); const original = Buffer.from(await blob.arrayBuffer()); @@ -690,6 +830,26 @@ export class SecurityPenetrationTestsService { await this.refundOnTerminalFailure(event.data.pentestId, event.type); } + // Auto-retry transient failures so customers never see intermediate + // failures. Only `pentest.failed` — a `pentest.cancelled` is a deliberate + // stop (staff cancels a run and it's refunded) and must never be re-run. + // If spawning the retry fails, `maybeAutoRetry` releases its claim and + // rethrows so this handler returns non-2xx and Maced redelivers — the + // refund above is idempotent (`creditRefundedAt`), so redelivery safely + // re-attempts only the retry rather than dropping it. + if (event.type === 'pentest.failed') { + await this.maybeAutoRetry(event.data.pentestId); + } + + // A cancellation is terminal. Record a lineage-wide block so a late or + // duplicate `pentest.failed` for the same run (arriving after the cancel) + // can never spawn a retry of a scan that was deliberately stopped. If the + // block can't be stored, this throws so Maced redelivers the cancellation + // until it sticks (the refund above already ran and is idempotent). + if (event.type === 'pentest.cancelled') { + await this.blockAutoRetry(event.data.pentestId); + } + // Successful completion deserves its own audit-log row so the // run's lifecycle is durably recorded ("scan completed for X with N // findings"). Without this the audit log shows the create but not @@ -831,6 +991,146 @@ export class SecurityPenetrationTestsService { }); } + /** + * Automatically re-runs a failed scan with the same parameters, up to + * `MAX_ATTEMPTS - 1` times, so transient provider/infra failures are invisible + * to the customer. Runs after the failure has already been refunded. + * + * Idempotency and durability are governed by whether a retry child actually + * exists (a row whose `retryOfProviderRunId` is this run), NOT by a mutable + * claim: + * - If a child already exists, the retry succeeded on an earlier delivery → + * skip (idempotent under redelivery). + * - If the spawn fails, no child is created, so the error is rethrown; the + * handler returns non-2xx, Maced redelivers, and the next delivery sees no + * child and re-attempts. The refund is idempotent (`creditRefundedAt`), so + * redelivery re-attempts only the retry. A transient spawn/DB failure can + * never permanently strand the retry. + * + * Cancellation is a DISTINCT marker (`retryBlockedAt`, set lineage-wide), so a + * late `pentest.failed` for a cancelled scan is always blocked here regardless + * of arrival order — it can't be confused with a spawn claim. + * + * Returns (without rethrowing) when a retry legitimately shouldn't happen: + * orphan run, exhausted lineage, cancelled lineage, an existing child, or + * unusable stored params. + */ + private async maybeAutoRetry(failedProviderRunId: string): Promise { + const row = await db.securityPenetrationTestRun.findUnique({ + where: { providerRunId: failedProviderRunId }, + select: { + organizationId: true, + attemptNumber: true, + rootRunId: true, + scanParams: true, + }, + }); + if (!row) { + this.logger.log( + `[Retry] skip run=${failedProviderRunId} (no ownership row — orphan)`, + ); + return; + } + if (row.attemptNumber >= MAX_ATTEMPTS) { + this.logger.log( + `[Retry] skip run=${failedProviderRunId} (lineage exhausted, attempt ${row.attemptNumber}/${MAX_ATTEMPTS})`, + ); + return; + } + + const rootRunId = row.rootRunId ?? failedProviderRunId; + + // Cancelled lineage → never retry (distinct marker, order-independent). + const blocked = await db.securityPenetrationTestRun.findFirst({ + where: { rootRunId, retryBlockedAt: { not: null } }, + select: { providerRunId: true }, + }); + if (blocked) { + this.logger.log( + `[Retry] skip run=${failedProviderRunId} (lineage cancelled)`, + ); + return; + } + + // Idempotency: if a retry child already exists, an earlier delivery already + // spawned it — nothing to do. + const existingChild = await db.securityPenetrationTestRun.findFirst({ + where: { retryOfProviderRunId: failedProviderRunId }, + select: { providerRunId: true }, + }); + if (existingChild) { + this.logger.log( + `[Retry] skip run=${failedProviderRunId} (retry child ${existingChild.providerRunId} already exists)`, + ); + return; + } + + const payload = this.fromScanParams(row.scanParams); + if (!payload) { + this.logger.warn( + `[Retry] skip run=${failedProviderRunId} (missing/invalid scanParams)`, + ); + return; + } + + // Record when the retry was initiated (informational only). + await db.securityPenetrationTestRun + .updateMany({ + where: { providerRunId: failedProviderRunId }, + data: { retryTriggeredAt: new Date() }, + }) + .catch(() => undefined); + + const nextAttempt = row.attemptNumber + 1; + try { + const retried = await this.createReport(row.organizationId, payload, { + attemptNumber: nextAttempt, + rootRunId, + retryOfProviderRunId: failedProviderRunId, + }); + this.logger.log( + `[Retry] spawned run=${retried.id} attempt=${nextAttempt}/${MAX_ATTEMPTS} root=${rootRunId} from=${failedProviderRunId}`, + ); + } catch (error) { + // No child was created, so rethrow: the handler 5xx's, Maced redelivers, + // and the next delivery sees no child and re-attempts. Nothing to release. + this.logger.error( + `[Retry] spawn failed for run=${failedProviderRunId}; will retry on redelivery: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + throw error; + } + } + + /** + * Marks a whole lineage as cancelled (via the distinct `retryBlockedAt` + * marker) so no `pentest.failed` — for the cancelled run or any sibling — can + * spawn a retry of a deliberately stopped scan, regardless of webhook arrival + * order. Best-effort. + * + * Stops NEW retries; a retry already in flight when the cancel arrives is not + * force-cancelled at the provider (bounded — it is refunded and only wastes + * compute). + * + * Errors are NOT swallowed: if recording the block fails, it propagates so the + * handler returns non-2xx and Maced redelivers the cancellation until the + * block is durably stored (mirrors the refund/retry durability pattern). The + * refund runs first and is idempotent, so redelivery is safe. Without this, a + * lost block could let a later `pentest.failed` retry a cancelled scan. + */ + private async blockAutoRetry(providerRunId: string): Promise { + const row = await db.securityPenetrationTestRun.findUnique({ + where: { providerRunId }, + select: { rootRunId: true }, + }); + const rootRunId = row?.rootRunId ?? providerRunId; + await db.securityPenetrationTestRun.updateMany({ + where: { rootRunId, retryBlockedAt: null }, + data: { retryBlockedAt: new Date() }, + }); + } + private formatDurationMs(ms: number): string { const totalMin = Math.max(Math.round(ms / 60_000), 0); const hours = Math.floor(totalMin / 60); @@ -889,6 +1189,41 @@ export class SecurityPenetrationTestsService { }; } + /** + * Maps a provider run into a customer-facing run for a lineage: applies the + * collapsed status (masking a failed non-final attempt as in-progress), + * pins the id to the stable lineage root, and — only when a genuine, final + * failure is revealed — replaces the raw provider error with a clean, + * white-labeled message. The active (highest) attempt is authoritative + * because we only ever retry `failed` runs. + */ + private collapseRun( + report: Pentest | PentestWithProgress, + ctx: { rootRunId: string; attemptNumber: number }, + ): SecurityPenetrationTest { + const mapped = this.mapMacedRunToSecurityPenetrationTest(report); + const activeStatus: PentestRunStatus = mapped.status; + const parsedFailedAt = + activeStatus === 'failed' && mapped.updatedAt + ? Date.parse(mapped.updatedAt) + : NaN; + const status = collapsedStatus({ + activeStatus, + attemptNumber: ctx.attemptNumber, + failedAtMs: Number.isNaN(parsedFailedAt) ? null : parsedFailedAt, + nowMs: Date.now(), + }); + const customerError = + status === 'failed' ? toCustomerFacingError(mapped.error) : null; + return { + ...mapped, + id: ctx.rootRunId, + status, + error: customerError, + failedReason: customerError, + }; + } + private getScanProfileFields( report: Pentest | PentestWithProgress | PentestCreated, ): Pick { @@ -920,6 +1255,67 @@ export class SecurityPenetrationTestsService { return fields; } + /** + * Extracts the re-runnable scan parameters from a create request, to persist + * on the ownership row for a future auto-retry. Only defined fields are + * included so the stored JSON stays clean. + */ + private toScanParams(payload: CreatePenetrationTestDto): RetryScanParams { + const params: RetryScanParams = { targetUrl: payload.targetUrl }; + if (payload.repoUrl !== undefined) params.repoUrl = payload.repoUrl; + if (payload.pipelineTesting !== undefined) { + params.pipelineTesting = payload.pipelineTesting; + } + if (payload.testMode !== undefined) params.testMode = payload.testMode; + if (payload.scanDepth !== undefined) params.scanDepth = payload.scanDepth; + if (payload.evidenceLevel !== undefined) { + params.evidenceLevel = payload.evidenceLevel; + } + if (payload.checks !== undefined) params.checks = payload.checks; + if (payload.additionalContext !== undefined) { + params.additionalContext = payload.additionalContext; + } + return params; + } + + /** + * Rebuilds a create DTO from persisted scanParams for an auto-retry, reusing + * the same type guards as the provider-response mapping. Returns null when + * the stored value is missing or malformed (retry is then skipped). + */ + private fromScanParams( + raw: Prisma.JsonValue | null | undefined, + ): CreatePenetrationTestDto | null { + if (!this.isRecord(raw)) return null; + const targetUrl = raw.targetUrl; + if (typeof targetUrl !== 'string') return null; + + const dto: CreatePenetrationTestDto = { targetUrl }; + if (typeof raw.repoUrl === 'string') dto.repoUrl = raw.repoUrl; + if (typeof raw.pipelineTesting === 'boolean') { + dto.pipelineTesting = raw.pipelineTesting; + } + if (typeof raw.testMode === 'boolean') dto.testMode = raw.testMode; + if (this.isScanDepth(raw.scanDepth)) dto.scanDepth = raw.scanDepth; + if (this.isEvidenceLevel(raw.evidenceLevel)) { + dto.evidenceLevel = raw.evidenceLevel; + } + if (Array.isArray(raw.checks)) { + const checks = raw.checks.filter((check): check is PentestCheck => + this.isPentestCheck(check), + ); + // Keep whatever survived validation rather than dropping the whole + // selection if a single entry is stale (e.g. a check enum value removed + // between deploys) — a retry with the valid subset beats silently + // running the full default check set. + if (checks.length > 0) dto.checks = checks; + } + if (typeof raw.additionalContext === 'string') { + dto.additionalContext = raw.additionalContext; + } + return dto; + } + private isScanDepth(value: unknown): value is ScanDepth { return ( typeof value === 'string' && @@ -1136,6 +1532,7 @@ export class SecurityPenetrationTestsService { organizationId: string, reportId: string, billingUsageSourceId: string | null, + lineage: OwnershipLineage, ): Promise { // Defensive: if a row already exists for this providerRunId, do NOT // overwrite its organizationId. Maced generates unique providerRunIds @@ -1153,6 +1550,12 @@ export class SecurityPenetrationTestsService { organizationId, providerRunId: reportId, billingUsageSourceId, + rootRunId: lineage.rootRunId, + attemptNumber: lineage.attemptNumber, + retryOfProviderRunId: lineage.retryOfProviderRunId, + // Cast at the DB boundary: RetryScanParams is a flat, JSON-safe object, + // but its optional fields aren't assignable to InputJsonValue directly. + scanParams: lineage.scanParams as unknown as Prisma.InputJsonValue, }, update: {}, }); @@ -1162,6 +1565,7 @@ export class SecurityPenetrationTestsService { organizationId: string, reportId: string, billingUsageSourceId: string | null, + lineage: OwnershipLineage, ): Promise { for (let attempt = 1; attempt <= 3; attempt += 1) { try { @@ -1169,6 +1573,7 @@ export class SecurityPenetrationTestsService { organizationId, reportId, billingUsageSourceId, + lineage, ); return true; } catch (error) { @@ -1182,6 +1587,38 @@ export class SecurityPenetrationTestsService { return false; } + /** + * Resolves any attempt id in a lineage to the currently active (highest + * attemptNumber) attempt, so reads always follow retries. Callers use the + * returned `activeProviderRunId` for provider calls and `rootRunId` as the + * stable customer-facing id. Coalesces a null `rootRunId` (legacy row) to the + * requested id so pre-feature runs resolve to themselves. + */ + private async resolveActiveAttempt( + organizationId: string, + requestedId: string, + ): Promise<{ + rootRunId: string; + activeProviderRunId: string; + attemptNumber: number; + }> { + const row = await db.securityPenetrationTestRun.findUnique({ + where: { providerRunId: requestedId }, + select: { rootRunId: true }, + }); + const rootRunId = row?.rootRunId ?? requestedId; + const active = await db.securityPenetrationTestRun.findFirst({ + where: { organizationId, rootRunId }, + orderBy: { attemptNumber: 'desc' }, + select: { providerRunId: true, attemptNumber: true }, + }); + return { + rootRunId, + activeProviderRunId: active?.providerRunId ?? requestedId, + attemptNumber: active?.attemptNumber ?? 1, + }; + } + private async assertRunOwnership( organizationId: string, reportId: string, @@ -1221,20 +1658,6 @@ export class SecurityPenetrationTestsService { return marker.organizationId; } - private async listOwnedRunIds(organizationId: string): Promise> { - const markers = - (await db.securityPenetrationTestRun.findMany({ - where: { - organizationId, - }, - select: { - providerRunId: true, - }, - })) ?? []; - - return new Set(markers.map(({ providerRunId }) => providerRunId)); - } - private isCompWebhookUrl(value: string): boolean { try { const parsed = new URL(value); diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FailedDetail.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FailedDetail.tsx index 8664ed61ad..339e43e867 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FailedDetail.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FailedDetail.tsx @@ -1,8 +1,8 @@ 'use client'; +import type { PentestRun } from '@/lib/security/penetration-tests-client'; import { Button } from '@trycompai/design-system'; import { Renew, Warning } from '@trycompai/design-system/icons'; -import type { PentestRun } from '@/lib/security/penetration-tests-client'; import { formatReportDate } from '../lib'; import { StatusPill } from './StatusPill'; @@ -20,13 +20,9 @@ export function FailedDetail({ run, onRetry }: FailedDetailProps) {
- - {run.id} - + {run.id}
-

- {run.targetUrl} -

+

{run.targetUrl}

Started {formatReportDate(run.createdAt)} Failed {formatReportDate(run.updatedAt)} @@ -40,23 +36,11 @@ export function FailedDetail({ run, onRetry }: FailedDetailProps) {
Run error
-

{reason}

+

{reason}

-
-
- Common causes -
-
    -
  • Target resolves to a non-routable IP (VPN not connected)
  • -
  • Your IP wasn't allowlisted on the target's WAF / CDN
  • -
  • Authentication flow requires credentials the scanner wasn't given
  • -
  • Workflow exceeded the runtime cap (typically ~12 hours)
  • -
-
- {onRetry ? (