Skip to content

Commit b9aee8b

Browse files
authored
Merge pull request #3398 from trycompai/feat/pentest-auto-retry-failed-scans
feat(api): auto-retry failed pentest scans and clean up failure messages
1 parent 32ea555 commit b9aee8b

11 files changed

Lines changed: 1323 additions & 61 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import {
2+
collapsedStatus,
3+
MAX_ATTEMPTS,
4+
RETRY_GRACE_MS,
5+
} from './pentest-lineage.util';
6+
7+
describe('collapsedStatus', () => {
8+
const now = 1_000_000_000_000;
9+
10+
it('passes through completed', () => {
11+
expect(
12+
collapsedStatus({
13+
activeStatus: 'completed',
14+
attemptNumber: 2,
15+
failedAtMs: null,
16+
nowMs: now,
17+
}),
18+
).toBe('completed');
19+
});
20+
21+
it.each(['provisioning', 'cloning', 'running'] as const)(
22+
'passes through in-progress status %s',
23+
(status) => {
24+
expect(
25+
collapsedStatus({
26+
activeStatus: status,
27+
attemptNumber: 1,
28+
failedAtMs: null,
29+
nowMs: now,
30+
}),
31+
).toBe(status);
32+
},
33+
);
34+
35+
it('never masks cancelled (staff cancel is terminal, never retried)', () => {
36+
expect(
37+
collapsedStatus({
38+
activeStatus: 'cancelled',
39+
attemptNumber: 1,
40+
failedAtMs: now - 1000,
41+
nowMs: now,
42+
}),
43+
).toBe('cancelled');
44+
});
45+
46+
describe('failed', () => {
47+
it('masks a fresh failed non-final attempt as provisioning', () => {
48+
expect(
49+
collapsedStatus({
50+
activeStatus: 'failed',
51+
attemptNumber: 1,
52+
failedAtMs: now - 5_000, // well within grace
53+
nowMs: now,
54+
}),
55+
).toBe('provisioning');
56+
});
57+
58+
it('masks the second attempt too', () => {
59+
expect(
60+
collapsedStatus({
61+
activeStatus: 'failed',
62+
attemptNumber: 2,
63+
failedAtMs: now - 5_000,
64+
nowMs: now,
65+
}),
66+
).toBe('provisioning');
67+
});
68+
69+
it('reveals failed once the lineage is exhausted (final attempt)', () => {
70+
expect(
71+
collapsedStatus({
72+
activeStatus: 'failed',
73+
attemptNumber: MAX_ATTEMPTS,
74+
failedAtMs: now - 5_000,
75+
nowMs: now,
76+
}),
77+
).toBe('failed');
78+
});
79+
80+
it('reveals failed after the grace window elapses without a retry', () => {
81+
expect(
82+
collapsedStatus({
83+
activeStatus: 'failed',
84+
attemptNumber: 1,
85+
failedAtMs: now - (RETRY_GRACE_MS + 1),
86+
nowMs: now,
87+
}),
88+
).toBe('failed');
89+
});
90+
91+
it('reveals failed exactly at the grace-window boundary', () => {
92+
expect(
93+
collapsedStatus({
94+
activeStatus: 'failed',
95+
attemptNumber: 1,
96+
failedAtMs: now - RETRY_GRACE_MS,
97+
nowMs: now,
98+
}),
99+
).toBe('failed');
100+
});
101+
102+
it('reveals failed when the failure time is unknown (cannot bound the mask)', () => {
103+
expect(
104+
collapsedStatus({
105+
activeStatus: 'failed',
106+
attemptNumber: 1,
107+
failedAtMs: null,
108+
nowMs: now,
109+
}),
110+
).toBe('failed');
111+
});
112+
});
113+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Pure helpers for the pentest retry-lineage feature.
3+
*
4+
* A scan is a "lineage": the original run plus up to `MAX_ATTEMPTS - 1`
5+
* automatic retries, all sharing one root run id. Customers only ever see the
6+
* root; our API resolves it to the active (highest-numbered) attempt and only
7+
* reports a terminal failure once the whole lineage is exhausted.
8+
*/
9+
10+
export type PentestRunStatus =
11+
| 'provisioning'
12+
| 'cloning'
13+
| 'running'
14+
| 'completed'
15+
| 'failed'
16+
| 'cancelled';
17+
18+
/** Original attempt + 2 automatic retries. */
19+
export const MAX_ATTEMPTS = 3;
20+
21+
/**
22+
* How long a failed non-final attempt is masked as in-progress while its
23+
* retry is spawned. Comfortably covers provider webhook-delivery latency; if a
24+
* retry never materializes within this window the real failure is revealed so
25+
* the run can't hang on a fake "running" state forever.
26+
*/
27+
export const RETRY_GRACE_MS = 10 * 60 * 1000;
28+
29+
/**
30+
* Computes the customer-facing status for a lineage from its active (highest)
31+
* attempt. The active attempt is authoritative because we only ever retry
32+
* `failed` runs — so no earlier attempt can be `completed`/`running`.
33+
*
34+
* - `completed` / in-progress statuses pass through unchanged.
35+
* - `cancelled` is terminal and never retried → always revealed.
36+
* - `failed`:
37+
* - lineage exhausted (`attemptNumber >= maxAttempts`) → revealed.
38+
* - otherwise masked as `provisioning` (a retry is expected) while inside
39+
* the grace window; revealed once the window elapses without a retry
40+
* appearing, or when the failure time is unknown (can't bound the mask).
41+
*/
42+
export function collapsedStatus(params: {
43+
activeStatus: PentestRunStatus;
44+
attemptNumber: number;
45+
failedAtMs: number | null;
46+
nowMs: number;
47+
maxAttempts?: number;
48+
graceMs?: number;
49+
}): PentestRunStatus {
50+
const { activeStatus, attemptNumber, failedAtMs, nowMs } = params;
51+
const maxAttempts = params.maxAttempts ?? MAX_ATTEMPTS;
52+
const graceMs = params.graceMs ?? RETRY_GRACE_MS;
53+
54+
// Only `failed` triggers an auto-retry, so only `failed` is ever masked.
55+
if (activeStatus !== 'failed') {
56+
return activeStatus;
57+
}
58+
59+
if (attemptNumber >= maxAttempts) {
60+
return 'failed';
61+
}
62+
if (failedAtMs === null || nowMs - failedAtMs >= graceMs) {
63+
return 'failed';
64+
}
65+
return 'provisioning';
66+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { toCustomerFacingError } from './pentest-run-error.util';
2+
3+
describe('toCustomerFacingError', () => {
4+
const infraMessagePrefix =
5+
'The scan was interrupted by a temporary infrastructure issue';
6+
7+
describe('infra / transient failures', () => {
8+
const infraErrors = [
9+
'Sandbox container deleted by Daytona infrastructure (age 35m). Desired-state=started but container gone — likely Daytona backup/restore race condition.',
10+
'Run timed out — sandbox became unresponsive',
11+
'Timed out with no sandbox after 9m',
12+
'Hard timeout: 4m (cap 120m); Daytona unreachable',
13+
];
14+
15+
it.each(infraErrors)(
16+
'maps infra error to the transient message: %s',
17+
(raw) => {
18+
expect(toCustomerFacingError(raw)).toContain(infraMessagePrefix);
19+
},
20+
);
21+
22+
it('never echoes the raw provider text', () => {
23+
const raw =
24+
'Sandbox container deleted by Daytona infrastructure (age 35m). backup/restore race condition.';
25+
const message = toCustomerFacingError(raw);
26+
expect(message.toLowerCase()).not.toContain('daytona');
27+
expect(message.toLowerCase()).not.toContain('sandbox');
28+
expect(message.toLowerCase()).not.toContain('container');
29+
});
30+
});
31+
32+
describe('genuine runtime cap', () => {
33+
it('humanizes a 720m cap as 12 hours', () => {
34+
const message = toCustomerFacingError(
35+
'Hard timeout: workflow ran 722m (cap 720m)',
36+
);
37+
expect(message).toContain('maximum runtime of 12 hours');
38+
expect(message).toContain("won't be charged");
39+
});
40+
41+
it('humanizes a 120m cap as 2 hours', () => {
42+
const message = toCustomerFacingError(
43+
'Hard timeout: workflow ran 130m (cap 120m)',
44+
);
45+
expect(message).toContain('maximum runtime of 2 hours');
46+
});
47+
48+
it('renders a single-hour cap without pluralizing', () => {
49+
const message = toCustomerFacingError(
50+
'Hard timeout: workflow ran 61m (cap 60m)',
51+
);
52+
expect(message).toContain('maximum runtime of 1 hour');
53+
expect(message).not.toContain('1 hours');
54+
});
55+
56+
it('falls back to minutes for a non-hour cap', () => {
57+
const message = toCustomerFacingError(
58+
'Hard timeout: workflow ran 95m (cap 90m)',
59+
);
60+
expect(message).toContain('maximum runtime of 90 minutes');
61+
});
62+
63+
it('renders a single-minute cap without pluralizing', () => {
64+
const message = toCustomerFacingError(
65+
'Hard timeout: workflow ran 2m (cap 1m)',
66+
);
67+
expect(message).toContain('maximum runtime of 1 minute');
68+
expect(message).not.toContain('1 minutes');
69+
});
70+
71+
it('classifies the "; Daytona unreachable" variant as infra, not runtime cap', () => {
72+
const message = toCustomerFacingError(
73+
'Hard timeout: 4m (cap 120m); Daytona unreachable',
74+
);
75+
expect(message).toContain(infraMessagePrefix);
76+
expect(message).not.toContain('maximum runtime');
77+
});
78+
});
79+
80+
describe('unknown / empty', () => {
81+
const genericPrefix = "The scan couldn't be completed";
82+
83+
it('returns the generic message for an unrecognized error', () => {
84+
expect(
85+
toCustomerFacingError('some brand new error we have not seen'),
86+
).toContain(genericPrefix);
87+
});
88+
89+
it('returns the generic message for null / undefined / empty', () => {
90+
expect(toCustomerFacingError(null)).toContain(genericPrefix);
91+
expect(toCustomerFacingError(undefined)).toContain(genericPrefix);
92+
expect(toCustomerFacingError('')).toContain(genericPrefix);
93+
});
94+
});
95+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Maps a raw provider run-error string into a clean, customer-facing message.
3+
*
4+
* Penetration tests are executed by an external provider whose backend runs
5+
* each scan inside a disposable sandbox. When a run fails, the provider returns
6+
* a freeform `error` string that can name our infrastructure vendor, describe
7+
* internal engineering conditions ("backup/restore race condition"), or be
8+
* arbitrary text — none of which is useful or safe to surface to a customer of
9+
* a security product.
10+
*
11+
* This helper is only applied once a scan's entire retry lineage is exhausted
12+
* (every attempt failed), so every message it returns describes a genuine,
13+
* final failure. It says the customer "won't be charged" rather than asserting
14+
* the refund is already complete — a failed run is always refunded, but the
15+
* refund and this read can race, so the phrasing stays true regardless of
16+
* timing (net effect: a failed scan is never charged).
17+
*
18+
* The raw string is never echoed back; unrecognized errors fall through to a
19+
* safe generic message.
20+
*/
21+
22+
const INFRA_TRANSIENT_MESSAGE =
23+
'The scan was interrupted by a temporary infrastructure issue and ' +
24+
"couldn't be completed. You won't be charged for this scan — please try " +
25+
'running it again, and contact support if this keeps happening.';
26+
27+
const GENERIC_MESSAGE =
28+
"The scan couldn't be completed. You won't be charged for this scan — " +
29+
'please try running it again, or contact support if this keeps happening.';
30+
31+
/**
32+
* True for transient infrastructure failures that a re-run typically clears:
33+
* the sandbox was deleted, never provisioned, went unresponsive, or the
34+
* provisioning path could not reach the sandbox host. The "Daytona
35+
* unreachable" hard-timeout variant belongs here (an infra problem during
36+
* provisioning), NOT with the genuine runtime cap below — so it must be
37+
* checked first.
38+
*/
39+
function isInfraTransient(raw: string): boolean {
40+
return (
41+
/sandbox container deleted/i.test(raw) ||
42+
/backup\/restore race/i.test(raw) ||
43+
/sandbox became unresponsive/i.test(raw) ||
44+
/timed out with no sandbox/i.test(raw) ||
45+
/daytona unreachable/i.test(raw)
46+
);
47+
}
48+
49+
/**
50+
* Renders a whole number of minutes as a friendly duration: exact hours become
51+
* "12 hours" / "1 hour", anything else stays in minutes. Guards against junk
52+
* input by falling back to a neutral phrase.
53+
*/
54+
function humanizeMinutes(minutes: number): string {
55+
if (!Number.isFinite(minutes) || minutes <= 0) {
56+
return 'its time limit';
57+
}
58+
if (minutes % 60 === 0) {
59+
const hours = minutes / 60;
60+
return `${hours} hour${hours === 1 ? '' : 's'}`;
61+
}
62+
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
63+
}
64+
65+
export function toCustomerFacingError(
66+
rawError: string | null | undefined,
67+
): string {
68+
const raw = typeof rawError === 'string' ? rawError : '';
69+
70+
if (isInfraTransient(raw)) {
71+
return INFRA_TRANSIENT_MESSAGE;
72+
}
73+
74+
// Genuine runtime cap: the scan used its full time budget without finishing
75+
// (e.g. "Hard timeout: workflow ran 722m (cap 720m)"). The infra check above
76+
// has already claimed the "; Daytona unreachable" variant.
77+
const capMatch =
78+
/hard timeout:\s*workflow ran\s+\d+m\s*\(cap\s+(\d+)m\)/i.exec(raw);
79+
if (capMatch) {
80+
const cap = humanizeMinutes(Number(capMatch[1]));
81+
return (
82+
`Your scan reached its maximum runtime of ${cap} before completing. ` +
83+
"You won't be charged for this scan. Large targets may need a lighter " +
84+
'scan depth — contact support if this keeps happening.'
85+
);
86+
}
87+
88+
return GENERIC_MESSAGE;
89+
}

0 commit comments

Comments
 (0)