Load testing & security scanning
Put a path under load with k6 and scan it with ZAP: starting a run, reading p95 and error rate, threshold verdicts, findings, and the honest limits.
Your tests answer "does it work". These two answer "does it hold up" and "what is exposed" — and neither is a browser test, which is why they live on their own tab with their own engines.
Load runs go through k6. Security scans go through OWASP ZAP. They sit side by side under Quality → Load & security on a project, and they are deliberately never merged into a single number. A latency percentile and a missing security header are not the same kind of claim, and one score covering both would hide whichever one you needed.
Before the tab appears
Three things have to be true, and they fail in different places:
- The surface is switched on.
nonFunctionalis a platform rollout flag, off by default, flipped in /admin → Runtime. It is flagged because showing this tab shows a customer a button that spends real money against their own infrastructure. - Your plan includes runs. Free and Pro include zero of both. See the table at the bottom.
- The project has a base URL. Everything here is measured against it.
And one more that fails later rather than at the door: a worker with k6 and ZAP
actually installed has to be running. If no execution queue is up at all, a start is
refused immediately with "no non-functional worker". If a worker claims the job but
does not have the engine, the run finishes with status error and says so rather
than pretending.
Starting a load run
New opens a dialog with two ways to describe what to hit.
Compose
Type target paths, one per line. Every one becomes a GET, between 1 and 100 of
them.
| Field | Range | Default |
|---|---|---|
| Virtual users | 1–1000 | 10 |
| Ramp-up | 0–3600 seconds | 5 |
| Duration | 1–3600 seconds | 30 |
The load profile is a ramp to the requested virtual users, then that many held for the duration.
Derive
Build the scenario from something you already have: a HAR file, or your project's approved contract rules. Contract derivation uses approved rules only — a proposed rule is not the spec yet.
Derive makes you Preview before Start unlocks, and the preview reports two
things: the requests it produced, and how many it threw away, split into assets,
third-party hosts, other origins, and non-GET requests with no body. A 200-entry
recording distilled to four requests looks like a broken importer unless it tells
you why it dropped the other 196.
Thresholds
Three, all optional — blank means do not check this, not "check it against zero".
- p95 in milliseconds
- Error rate as a percentage
- Minimum RPS
Set the ones you would actually act on. A threshold you would not treat as a failure is noise in every future result.
What you get back
Start queues the run and returns immediately. If it is refused, the reason renders in the dialog rather than as a disappearing toast: no base URL, no non-functional worker, or the plan limit with a link to plans.
Reading a result
Opening a run polls every four seconds while it is queued or running, and stops as soon as it is terminal.
The metrics are p50 / p95 / p99, RPS, error rate, requests, duration, and the profile that produced them (virtual users, ramp).
A dash is not a zero. Any number k6 did not report renders as
—rather than being coerced. p99 is usually one of them: k6's summary export carries the median and p95, so a blank p99 means k6 did not produce one, not that nothing was slow.
Each configured threshold gets a row showing its budget beside the measured value, and a verdict:
| Verdict | Meaning |
|---|---|
pass | Measured, and inside the budget. |
fail | Measured, and outside it. |
unmeasured | The number the threshold needed never arrived. Never counted as a pass. |
The run's own status follows from those: failed if any threshold failed, error if
nothing was measured at all or a threshold went unmeasured, otherwise passed. If
you configured no thresholds, the panel says so instead of showing a met state — a
run with nothing to check did not check anything.
The download
The download button gives you the scenario you submitted plus the parsed result,
as JSON. It is named result, not summary, because it is not k6's summary
document: the adapter reads the handful of numbers it needs and deletes its temporary
directory, so the raw summary never reaches the row. The panel says so above the
button too. If you need k6's own output today, run k6 yourself.
Stopping a load run
Stop is real. The k6 process gets SIGTERM, and SIGKILL five seconds later if it
has not exited — it is not a row that flips to "cancelled" while k6 keeps hammering
your service for the rest of the half hour.
A cancelled run gets no threshold verdict at all. Not pass, not fail — none.
Its metrics are blank, its request count is zero, and it carries a note saying load
was applied up to the point it stopped, so nothing in it is a verdict about your
service. A late-arriving result from the engine cannot overwrite it either: every
terminal write from the worker is conditional on the row still being unfinished.
Security scanning
The security tab runs a ZAP baseline scan: a quick crawl that reports what it can see from outside. It is not a penetration test and it does not exploit anything.
Authorization comes first
You attest, per host, that you are authorized to scan it — a host, a confirmation checkbox, and your name on the record. Attestations are listed, and revocable. Scanning a host with no live attestation is refused.
Scans are capped on purpose: 10 requests per second, 500 URLs, one hour. This is a tool pointed at your own production-shaped infrastructure, and a scanner that can take a service down is a scanner nobody is allowed to run.
A scan can run authenticated by naming an auth role whose session has been captured; the scan replays those cookies. A role with no captured session is refused up front rather than quietly scanning as an anonymous visitor.
Starting a scan is an API call today. The tab attests, lists scans and findings, and cancels — there is no "New scan" button in it yet. Use
POST /api/projects/:projectId/security/scanswith{ "targetUrl": "…" }(and optionallyauthRoleId) in the meantime.
Findings
Findings are grouped by kind, which is a claim type rather than a severity:
| Kind | What it asserts |
|---|---|
confirmed-vulnerability | Observed to be exploitable. |
possible-vulnerability | A scanner thinks so. Nothing was confirmed. |
missing-security-header | A header that should be there is not. |
functional-authorization-failure | A user reached something they should not have. |
dependency-cve | A dependency with a published advisory. |
Everything ZAP reports lands as possible-vulnerability — a baseline scan
detects, it does not confirm — and ZAP's own severity string ("High", "Medium") is
shown unchanged, attributed to ZAP, rather than being translated into a Vera
severity it did not mean. Each finding also carries how it is known: observed,
inspected or reported.
One finding is raised per alert type, not per URL, with the occurrence count folded into its title. Twenty-five pages missing the same header is one problem to fix, not twenty-five.
There is no total and no score, deliberately. Counting a missing header and a confirmed vulnerability into one number produces something that goes down when you fix the easy one.
You can also import findings from scanners you already run — Semgrep, Snyk, Trivy,
CodeQL — as SARIF, which land as reported.
Stopping a scan
ZAP is a shell script that starts a JVM, so the whole process group is signalled; killing the script alone would leave Java running.
A stopped scan saves no findings at all, including ones it had already collected. A partial report would read as a clean bill of health for every page it never reached, which is a worse outcome than no report.
What your plan includes
| Plan | Load runs / month | DAST scans / month |
|---|---|---|
| Free | 0 | 0 |
| Pro | 0 | 0 |
| Team | 4 | 2 |
| Enterprise | 20 | 10 |
Enterprise is a real ceiling rather than "unlimited". A limit nobody reaches still says the product has one.
A plan with zero disables the New button and explains why; running out of a non-zero monthly allowance is refused when you press Start, inside the dialog.
What these numbers do not prove
Worth being blunt about, because both surfaces are easy to over-read:
- A load run is HTTP, not browsers. k6 issues requests; it does not execute your JavaScript, render, or hydrate. It measures your backend under concurrency, not what a user's page felt like.
- It is one origin point. Traffic comes from wherever the worker runs. That is not the geography or the network your users have.
- A baseline scan is a smoke alarm. Passing it means an automated crawler found nothing obvious in an hour at ten requests a second. It is not a security review, and it says nothing at all about the paths it never reached.
See also
- Analytics & flaky-test detection — the functional side of "is it healthy"
- Scheduled runs — the cadence half of the same question
- What Vera stores, and where it goes — where run records and findings live
- The API — the versioned surface for scripting