Skip to main content

API reports

Once routes are checked against their API contracts, three questions get an answer that no single request gives: which endpoints exist that nobody documented, which documented ones nobody calls any more, and how the traffic departs from what the contract says. A fourth is answered from configuration alone: which credential each endpoint actually asks for.

The four come out of one report, for a workspace in Threat Studio (the API page of a workspace), or for any set of routes from the admin API.

The inventory​

The contract plugin counts what it sees, with inventory on (the default): each operation of the contract with its calls and the statuses its backend answered, and each path outside the contract, its identifiers folded into {id} (/legacy/42/export is /legacy/{id}/export). Each node counts in memory and publishes what changed every half minute to a hash of its own in the shared store, kept six months; a report merges every node's. The number of records is bounded per node (security.api.inventory.max-records, 20 000), and paths outside the contract to 200 per route — past that, a scanner walking random paths fills one other paths bucket rather than the store.

Shadow endpoints​

A path outside the contract that the backend answered with a 2xx is an endpoint that exists and that nobody documented: undocumented, unreviewed, usually unsecured. One answered 404 is mostly a probe. In monitor mode the request reaches the backend, which is what tells the two apart; in enforce mode it is refused, and counted as such.

Zombie operations​

An operation of the contract nobody called for zombie_days (90 by default) is a zombie: deprecated in practice, still reachable. An operation can only be called a zombie on a route observed for at least that long; before that it is unused.

Drift​

With drift_sampling_seconds (60), a payload of each operation is compared with its declared shape once in that many seconds on each node — a request body, and each response status:

DriftWhat
undeclared_fieldA field the schema does not declare, where it declares its properties: response 200 $.items[].internalCost
type_mismatchA value of another type than declared: a string where an integer is declared
undeclared_statusA status the operation declares neither exactly, nor by class, nor as default
undeclared_media_typeA media type the response does not declare for that status

Compositions are read generously — the properties of every member of allOf, oneOf and anyOf count as declared — so a payload is never reported for matching one branch rather than another. A field whose name is one a response should think twice about carrying (password, ssn, card_number, apiKey, refreshToken…) is flagged sensitive: the day a backend quietly starts returning one is a documentation defect and a disclosure incident at once. It pairs with the sensitive data guard, which masks what it recognises by value.

Credentials​

The auth posture reads each route's plugin chain: the plugins that refuse a request without a credential (api keys, authentication modules, JWT and OIDC verifiers, biscuits, client certificates, HMAC and HTTP signatures, basic auth), and the paths they are scoped to with include and exclude. An api key plugin that is not mandatory checks nothing. Each operation of the contract is then compared with the security requirements the contract declares for it:

SeverityFindingWhen
highunauthenticatedThe contract asks for a credential, nothing on the route checks one for this endpoint
mediumscheme_mismatchSomething checks a credential, never one of the kinds the contract asks for: an api key where it declares OpenID Connect
lowno_credentialNothing checks a credential, and the contract says nothing about it — or there is no contract
infopublicNothing checks a credential, as the contract says (security: [])
infoundeclared_authSomething checks a credential on an endpoint the contract says is public

It is a configuration audit, not a runtime control: a credential checked by a global plugin is not seen.

From a CI​

The same report is served by the admin API, for an admin api key:

curl -s -u "$CLIENT_ID:$CLIENT_SECRET" -H 'Host: otoroshi-api.oto.tools' \
'https://otoroshi.example.com/api/extensions/cloud-apim/waf/api/_report?route_ids=route_orders'

route_ids is a comma-separated list, every route when absent; zombie_days sets the threshold. A pipeline failing on any unauthenticated endpoint is one jq away:

… | jq -e '.summary.auth.high == 0'