error field: a
human-readable message. A client parses one shape.
code: a short, stable,
machine-readable token from a fixed vocabulary (unauthorized, forbidden,
not_found, conflict, validation_failed, unprocessable, rate_limited,
internal_error) that a client can branch on without matching the message
text, which is free to change:
code is not yet on every business-rule-specific rejection — an
endpoint that names exactly which rule was broken ("image reference must not contain credentials", above) may still answer with only error. Never branch
client logic on the absence of code; treat it as present-when-useful, not as
a discriminator between response shapes.
Status codes
401 tells you nothing about why
Missing, malformed, unknown, wrong secret, revoked and expired credentials all produce the same401 with the same body. Distinguishing them would tell an
attacker which part of a forged credential to fix next, which is a hint worth
more to them than it is to you.
403 from a scope check carries two extra fields
required_scope is what the endpoint needed. principal is user or
api_key, which distinguishes “this key was issued too narrowly” — fix by
issuing a new key — from “this person’s role does not permit it” — fix by
changing a role. Two different problems with two different owners.
404 covers two different situations, on purpose
A resource that does not exist and a resource belonging to another organisation return byte-identical responses. This is the one place where the honest-looking answer is the wrong one. A403
for “exists, but not yours” would confirm that the identifier names something
real. That turns a UUID guess into reconnaissance: an attacker who can tell a
real identifier from a fabricated one learns which resources exist and, by
extension, something about who else is a customer.
The same rule holds inside a bulk response, where a finding belonging to
another organisation is reported as not_found rather than as a permission
error. A bulk endpoint that distinguished them would be a fast way to enumerate
which identifiers are real, 500 at a time.
GET /assets/{id}/schedule follows it too: an asset with no schedule returns
404, not 200 with a null. An asset with no schedule and an asset that is
not yours must look the same.
Rate limiting
The limiter exists to protect capacity, not to meter usage. Nothing here prices a plan or produces an invoice. It counts per credential, never per organisation — an API key by its own identifier, a browser session by user identifier. Counting per organisation would make every credential inside a company share one bucket, and the first thing that happens is a misbehaving CI job locking its colleagues out of the dashboard. Counting per IP address is no better: a customer’s whole CI fleet can sit behind one NAT address while an attacker with a/64 is effectively unlimited, and an IP is not something TRUSTIVAN has
authenticated.
The default is 600 requests per minute, configurable per deployment.
Generous on purpose: a pipeline polling a scan every two seconds for ten
minutes makes three hundred requests. A limiter that interrupts legitimate work
teaches customers to retry harder, which is the opposite of what it is for.
Headers
Every response carries the current state, so a well-behaved client can slow down before it is refused:Retry-After is rounded up to at least one second — a value of 0 would tell
a client to retry immediately, into the same refusal.
The body says what to do and nothing else. No algorithm name, no cache state,
no shard identifier. An error body is read by whoever is probing the service,
and infrastructure detail in it is a map for them.
Authentication endpoints are limited separately
POST /auth/signup, /auth/login, /auth/refresh and /auth/exchange are
limited per source address at 10 requests per minute.
They have to be. The per-credential limiter runs after authentication has
produced a credential to count, and on a login attempt there is not one yet.
Without a separate control, password brute force, credential stuffing and user
enumeration would be bounded only by network bandwidth.
The failure mode, stated plainly
The limiter keeps its counters in Redis, and it fails open. When Redis is unreachable, requests are allowed. That is a deliberate trade, not an oversight. Every request this limiter sees has already presented a valid credential. Failing closed would turn a Redis blip into a total API outage for every customer — a far larger incident than the one being prevented. A control whose failure mode is worse than the threat is not a control worth having. The degradation is logged rather than swallowed: a limiter that has been failing open for a week without anyone noticing is a limiter that is not there. A deployment that has decided otherwise can configure it to fail closed. A deployment without Redis has no per-credential rate limiting at all. It runs unlimited rather than refusing to start. If you are operating TRUSTIVAN yourself and you have not deployed Redis, that limit does not exist for you, whatever this page’s default says.Limits
Two of these deserve a note.
The bulk ceiling is on work, not on convenience. Without one, a single
request makes the server hold a transaction over an unbounded number of rows,
which is a denial of service the caller does not even have to intend.
The concurrency limit never drops work. Requesting a sixth scan while five
are running on Pro returns normally and the scan sits in
queued until capacity
frees. A client polls the same way it always does; the only difference is that
the terminal state arrives later. This is the one limit that does not produce
an error, because an error would push retry logic into every caller for a
condition that resolves itself.
Errors specific to a request body
Some endpoints reject input at the boundary with a message that names the rule that was broken.POST /scans treats the image reference as untrusted input. Private and
link-local registries, embedded credentials, URL schemes and path traversal
segments are all refused:
PATCH /findings/{id} refuses status: "resolved". Resolution means a scan
that would have found the vulnerability did not — evidence, not opinion. The
way to close something you decided not to fix is to suppress it; the way to
close something you fixed is to rescan. See
Finding lifecycle.
PUT /assets/{id}/schedule refuses a digest-pinned asset. There is no tag to
re-resolve, so a “rescan” would either re-read identical bytes forever or
silently scan :latest — a different image from the one you are watching.
Bulk responses are not errors
POST /findings/bulk returns 200 whenever the request itself was valid, even
when every individual item was rejected.
207 Multi-Status is a WebDAV
code most HTTP clients treat as an error, and a caller that got one would have
to decide whether “partial success” meant retry.
Per-item outcomes are applied, already_applied, not_found,
invalid_transition and conflict. already_applied is not an error — a
retried request, a re-run pipeline and an operator repeating themselves all
produce it.
A request-level problem is still a request-level failure. A bulk suppression
with no reason is a single 400, not 500 identical per-item rejections
dressed up as partial success — because nothing in that request could ever have
succeeded.
Verdicts are never errors either
POST /scans/{id}/verdict returns 201 when it records a decision and 200
when it replays one. A failing verdict is a successful request.
A pipeline that treated a policy failure as an HTTP error could not distinguish
“the build should stop” from “TRUSTIVAN is down”, and those demand opposite
reactions. Branch on blocking in the body.
The one genuine error case is 409: no policy is enabled for this
organisation, so there is nothing to evaluate against.
Operational probes
GET /health is a liveness probe and deliberately checks nothing. A liveness
probe that consulted the database would restart every instance during a
failover, turning a recoverable dependency outage into a total one.
GET /ready checks dependencies and returns 503 while any is unavailable, so
an orchestrator stops routing to a process that cannot work.
Neither takes a credential, and neither lives under /api/v1.
