A dynamic scan runs in phases: reconnaissance, enrichment, network checks, screenshots, active tooling, upload. Each one gets a timeout, because leaving any of them unbounded is obviously wrong.
Then someone asks how long a scan takes, and the honest answer is: up to the sum of all of them. Six phases at ten minutes each is an hour, and the "10 minute timeout" in the config is describing a phase, not a scan.
Worse, the phases are ordered. Reconnaissance runs first and takes what it takes. If a target has forty thousand URLs, recon consumes its full allowance, then enrichment consumes its full allowance, and the active checks — the phase that actually finds vulnerabilities — start fifty minutes into a scan somebody expected to finish in ten.
The failure this produces
The symptom is not usually a timeout. It is a scan that finished, reported nothing interesting, and burned an hour of CI.
- In a pipeline, the job hits the platform's own limit and gets killed mid-phase. Partial results, no report, and a red build that teaches people to make the scan non-blocking.
- Against a large target, crawl and enrichment eat the budget and the vulnerability checks are the ones that get cut — precisely inverting the priority.
- In scheduling, you cannot answer "how many scans fit in this window", because the per-scan duration has no upper bound you can state.
One clock, shared
The fix is a single deadline for the whole scan, from which every phase draws.
budget := 25 * time.Minute
deadline := start.Add(budget)
Each phase then asks how much time remains and takes the smaller of that and its own cap:
func phaseWindow(until time.Time, cap time.Duration) (time.Duration, bool) {
const floor = 15 * time.Second
left := time.Until(until)
if left < floor {
return 0, false // skip the phase entirely
}
if left < cap {
return left, true // give it what is left
}
return cap, true
}
Three properties follow, and each one solves a real complaint:
The scan has a stated maximum. Total duration is the budget, not the sum of the caps. That is a number you can put in a config and a dashboard.
Phases degrade instead of dying. A phase given four minutes of a ten-minute cap does four minutes of useful work and returns what it found. It does not start work it cannot finish.
The floor prevents useless starts. Below fifteen seconds a phase is skipped rather than launched. Starting a headless browser with eight seconds left produces a timeout error where a skip would have produced a clean report.
Choose the budgets from the use case
The per-phase caps stay — they stop one phase monopolising a large budget — but the wall clock is what the user selects, and the modes should map to how the scan is used:
| Mode | Wall clock | For |
|---|---|---|
| Fast | 6 minutes | Pull-request gate |
| Standard | 25 minutes | Nightly build |
| Recon | 45 minutes | Attack-surface mapping, no active checks |
| Deep | 7 hours | Scheduled full assessment |
Fast is set by what a developer will wait for at a pull request, not by what the scanner would like. Deep is set by an overnight window. Neither number is derived from the phases; the phases are fitted to them, which is the correct direction.
The phase you must not bound
One exception, and it is the one that makes the design work in practice: do not put the results upload under the scan deadline.
If the wall clock expires during upload, the scan discards everything it just spent twenty-five minutes finding. The user experiences a scan that ran to completion and reported nothing — which is worse than a scan that took thirty seconds longer than promised, and much harder to diagnose.
Upload is bounded by its own network timeout and retry policy. The scan budget governs discovery; delivering what was discovered is not discovery. If that distinction is not explicit in the code, someone will eventually "fix" the inconsistency by bringing upload under the deadline, and the bug will come back as intermittent empty reports.
Report the budget in the results
Whether the clock ran out is a fact about the scan, and it belongs in the output:
{
"budget_seconds": 1500,
"elapsed_seconds": 1500,
"budget_exhausted": true,
"phases_skipped": ["screenshots"],
"phases_truncated": ["tools"]
}
A scan that hit its deadline covered less than a scan that finished early, and a reader comparing two runs needs to know which they are looking at. Without it, "we found fewer issues this week" is unreadable — it could be a fix, or it could be a target that grew past the budget.
That field also gives you the upgrade prompt that is actually honest: not "buy the deep tier", but this scan stopped with two phases outstanding; a longer budget would have covered them.