Authorization testing for REST APIs and MCP servers — one problem class, two surfaces.
Authorization is the check a callee makes before it acts: may this caller perform
this action on this object? When that check is missing, weak or inconsistent, the
request is still well-formed and the response is still a 200 — the system simply
hands one tenant's data to another. overstep makes the intended answer explicit as
a file, turns it into positive and negative requests against a running target, and
reports every request that should have been refused and wasn't.
authorization matrix ──► positive + negative tests ──► run ──► findings
(subjects × resources) (self / other, per role) (REST + MCP)
Contents
Why it is hard to automate · Scope · Install · Usage · Non-goals · Roadmap · Contributing
Most vulnerability classes have a syntactic tell. An injection payload, a traversal sequence, a missing security header: a scanner recognises the shape of the problem without knowing anything about the application. Authorization has no such shape.
- It is a logic flaw, not a syntax flaw.
GET /invoices/8842is a valid request whether or not invoice 8842 belongs to the caller. Nothing in the request, the response, or the specification distinguishes the legitimate read from the cross-tenant one. The two are byte-identical apart from a number. - Detection requires the intended policy, and the intended policy is not in the
code. To call a
200a finding you must already know that this subject was not supposed to reach this object. That knowledge lives in a product decision, not in an OpenAPI document, a type signature or a route table — which is why a scanner with no policy input can only report what crashed, never what leaked. - Ownership is invisible to a spec. An OpenAPI document describes
/invoices/{id}as taking a string. It does not say which ids belong to whom, so nothing tells a generic scanner which id to substitute in order to make the request wrong. Without at least two identities holding genuinely different objects, there is no cross-owner probe to send. - The failure is silent by construction. A missing check produces no error, no log line and no anomaly. It looks exactly like a feature working.
The consequence is that authorization testing cannot be discovery-driven. It has to be declarative: someone writes down the intended policy, and the tool holds the target to it. That is what the authorization matrix is, and it is why overstep asks for one before it sends anything.
Authorization is the problem class. REST and MCP are two surfaces on which it shows up — transports and layers, not two separate tools sharing a repository. The detection logic is the same on both: resolve who the caller is, resolve which object the request names, ask whether the callee refused, and compare that against the declared policy.
The vocabulary is the standard one, and it is transport-independent:
| Term | The question | Where it applies |
|---|---|---|
| Object-level authorization (BOLA) | may this subject reach this object? | REST, MCP |
| Function-level authorization (BFLA) | may this subject invoke this operation? | REST, MCP |
| Property-level authorization (BOPLA) | may this subject see this field of an allowed response? | REST, MCP |
| Privilege escalation | does a lower-privileged role reach what a higher one is reserved for? | REST, MCP |
| Multi-tenancy isolation | does the tenant boundary hold when the caller is authenticated but foreign? | REST, MCP |
| Credential audience binding | does the callee check who the credential was issued for? | MCP |
| Session binding | can a connection identifier stand in for a credential? | MCP |
| Authorization drift | did any of the above answers change since the last release? | REST, MCP |
Everything except delivery is common to both modules and lives in one place: the
matrix model, the planner that expands it into cases, the ownership resolution,
the classifier, the confidence grading, the drift baseline, the waivers and every
report format. The architecture is module-based: each surface is a package
under overstep/modules/, and reaches the core only through registries — how a
case is delivered, how a finding is rendered, how a setup step runs, where a
credential is discovered, and which finding classes it can report. Neither module
sits at the package root, neither may import the other, and the core may not
import either; an import-graph test measures all three.
A resource does not name its module — it is read off the body the resource declares — so adding a third surface means a package, an executor and a body shape, not a string in a config file.
The matrix file has the same shape. Its top level is the shared core — subjects,
resources, policy, credentials, fixtures — and each module's own configuration
sits under its name in modules:. Neither surface is
the unmarked default, so adding a third would add a block rather than rearrange
the two that exist.
A single matrix can span both. The dispatcher groups planned cases by the module each resource belongs to and routes each group to its executor, so a REST API and the MCP server in front of it are one run, one baseline and one report.
MCP belongs under the same umbrella as REST, not above it — but it is where the class is currently hardest to reason about, for four reasons:
- The caller is an agent, not a person. The identity that reaches the server is acting on behalf of a user. Authorization therefore depends on a delegation chain (user → agent → server → downstream service) rather than on a single authenticated principal, and every hop is somewhere the caller's authority can fail to be attenuated — that is, fail to shrink to the subset of privileges the delegation was meant to convey. This is multi-hop delegation, and it is the structural difference from a REST endpoint that answers one authenticated user directly.
- There is no
403. A refusal arrives in-band, as a JSON-RPC error or anisErrorresult, or on the HTTP leg as a401whose body is not JSON-RPC at all. A checker that reads only one of those calls a secure server wide open, or a broken one clean. - The surface is wider than the tool list. The same objects are reachable
through
resources/readby URI. A server can enforce ownership perfectly on every tool and hand the data out through the other door. - The protocol carries authorization requirements of its own. A server must refuse a credential that was not issued for it, and — on the revisions that have sessions — must not let a session identifier stand in for one. Neither is a question about your policy, and neither surfaces in any test of your tools.
overstep tests the server's own enforcement on this surface. It does not drive the agent, and it does not evaluate what the agent was persuaded to ask for; see non-goals.
Conservative markers: implemented means exercised by the test suite; partial means it works within a stated limit; planned means not built.
The bundled demos are a subset of that — they cover object- and function-level probes, resource reads, session binding and tool enumeration, and are what the numbers further down are measured from. Property-level checks, policy conditions, cross-method probing, credential audience and OAuth discovery are covered by tests rather than by a demo matrix, so read those rows as "there is a test for it", not as "you can watch it run in two minutes".
| Capability | REST | MCP |
|---|---|---|
| Object-level (BOLA) probes across owners | implemented | implemented (tool arguments and resource URIs) |
| Function-level (BFLA) and privilege-escalation probes | implemented | implemented |
| Property-level (BOPLA) forbidden response fields | implemented | implemented |
| Multi-tenancy isolation via policy conditions | implemented | implemented |
| Ownership injection points | implemented (path, query, header, cookie, form, JSON body, GraphQL variables) | implemented (tool argument, resource URI placeholder) |
Cross-method probing (PUT/DELETE against another owner's object) |
implemented | n/a — MCP has no verb |
| Credential audience probe | n/a | implemented (Streamable HTTP only) |
| Session-binding probe | n/a | implemented (Streamable HTTP, stateful revisions; not applicable on 2026-07-28) |
| Tool-enumeration probe | n/a | implemented, opt-in |
| Transport | implemented (HTTP/HTTPS) | implemented (Streamable HTTP and stdio) |
| Matrix scaffolding | partial — OpenAPI drafts a full matrix with --with-policy; a HAR capture drafts resources only, with no policy |
implemented — a live server or a saved listing drafts a full matrix |
| Credential acquisition | implemented (arbitrary login request, OAuth 2.1 password and client-credentials grants) | partial — the same, plus RFC 9728/8414/8707 discovery; the interactive authorization-code flow is not supported |
| Delegation-chain and scope-attenuation testing | planned | planned |
pip install overstep
overstep versionPython 3.10+. Or run it without installing anything:
docker run --rm -v "$PWD:/work" -w /work ghcr.io/kabiri-labs/overstep \
run matrix.yaml --out outTo work on overstep itself, or to run the bundled demos, clone the repository and install it editable — see CONTRIBUTING.md:
git clone https://github.com/kabiri-labs/overstep && cd overstep
pip install -e ".[dev]"The path from nothing to a result you can trust is the same for both modules:
overstep scaffold |
draft a matrix from a live MCP server, an OpenAPI document or a HAR capture |
overstep validate |
lint it — and with --live, check every credential still works |
overstep plan |
print what it would send, touching the network zero times |
overstep run |
send it and report |
overstep snapshot |
record today's decisions as a drift baseline |
overstep coverage |
report what the matrix does and does not reach |
Each command names the next on stderr; set OVERSTEP_NO_HINTS=1 to switch that
off.
Three parts — subjects (who), resources (what) and policy (the
allow-list). Everything not explicitly allowed is denied. roles: is ordered
least- to most-privileged, which is what separates BFLA from vertical privilege
escalation.
The file has two levels. Everything a run needs regardless of how a request is
delivered — subjects, resources, policy, credentials, fixtures — is declared once
at the top. Everything that only means something to one surface lives under that
surface's name in modules:.
A resource never names its module. It declares an HTTP request, or an MCP
call (a tool) or read (a resource URI), and that body is what places it — so
a resource cannot contradict itself about what it sends.
roles: [anonymous, user, admin]
modules:
rest:
base_url: http://127.0.0.1:8000
subjects:
- { name: alice, role: user, token: "${ALICE_TOKEN}", marker: "alice@corp", attributes: { user_id: u1 } }
- { name: bob, role: user, token: "${BOB_TOKEN}", marker: "bob@corp", attributes: { user_id: u2 } }
- { name: root, role: admin, token: "${ADMIN_TOKEN}", attributes: { user_id: u9 } }
- { name: anon, role: anonymous, token: null }
resources:
- name: get_user
request: { method: GET, path: "/users/{id}" }
type: object # object-level -> BOLA surface
owner: id # {id} must match the caller's user_id
owner_attr: user_id
- name: admin_list_users
request: { method: GET, path: "/admin/users" }
type: function # function-level -> BFLA surface
policy:
get_user:
allow:
- { role: user, scope: own } # a user may read only their own profile
- { role: admin, scope: any } # admins may read anyone's
admin_list_users:
allow:
- { role: admin } # admin-onlyEvery deny case the planner derives is a probe: if the target answers it
successfully, that is a finding. Every allow case is a control: if the target
refuses it, either the matrix or the target is wrong, and overstep reports
unexpected-deny.
A deliberately broken HTTP API ships with the repository.
python -m uvicorn examples.rest_api.server:app --port 8000
overstep plan examples/rest_api/matrix.yamlplan sends nothing. It prints the eighteen cases the matrix above (plus a
delete_user resource) expands into — every subject against every resource:
┏━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓
┃ Expected ┃ Class ┃ Request ┃ Subject ┃ Variant ┃
┡━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━┩
│ allow │ object │ GET /users/u1 │ alice (user) │ self │
│ deny │ object │ GET /users/u2 │ alice (user) │ other │
│ allow │ object │ GET /users/u2 │ bob (user) │ self │
│ deny │ object │ GET /users/u1 │ bob (user) │ other │
│ allow │ object │ GET /users/u9 │ root (admin) │ self │
│ allow │ object │ GET /users/u1 │ root (admin) │ other │
│ deny │ object │ GET /users/u1 │ anon (anonymous) │ other │
│ deny │ object │ DELETE /users/u2 │ alice (user) │ other │
│ … │ │ │ │ │
│ deny │ function │ GET /admin/users │ alice (user) │ na │
│ allow │ function │ GET /admin/users │ root (admin) │ na │
└──────────┴──────────┴──────────────────┴──────────────────┴─────────┘
The other rows are the object-level probes. They exist only because alice and
bob hold genuinely different objects; give two subjects the same id and the
cross-owner probe disappears, which is why validate warns about it.
overstep run examples/rest_api/matrix.yaml --out out Tests run 18
Positive / negative 7 / 11
Vulnerabilities 8 (3 defects)
BOLA 2
privilege-escalation 6
Object resources probed 2/2
Eight probes got through, tracing back to three distinct defects — one row per thing to fix, with the subjects that reached it as evidence of blast radius.
The MCP demo is a deliberately broken Streamable HTTP server.
python -m uvicorn examples.mcp_api.server:app --port 9000
overstep run examples/mcp_api/matrix.yaml --out out Tests run 27
Positive / negative 8 / 15 (+4 listing)
Vulnerabilities 16 (7 defects)
BOLA 4
privilege-escalation 5
session-hijack 3
tool-enumeration 4
Object resources probed 2/2
Its matrix has the same shape — the same core at the top, a different module block, and resources whose bodies place them:
modules:
mcp:
servers:
- name: docs
url: http://127.0.0.1:9000/mcp # Streamable HTTP (JSON-RPC)
resources:
- name: read_document
call: { server: docs, tool: read_document }
type: object # object-level -> BOLA surface on the tool argument
owner: doc_id
owner_attr: doc_id
- name: read_doc_resource
read: { server: docs, uri: "doc://acme/{doc_id}" }
type: object # object-level -> BOLA surface on the resource URI
owner: doc_id # the {placeholder} carrying the object id
owner_attr: doc_id
- name: reset_tenant
call: { server: docs, tool: reset_tenant, mutating: true } # skipped under --read-only
type: functionTwo of the four BOLA findings come through tools/call and two through
resources/read, against the same two documents, because the server has the same
missing ownership check on both doors — the case a tools-only checker reports
clean.
The other two classes are about the connection rather than a declared operation,
and have no equivalent in a REST scanner. The server hands out an Mcp-Session-Id
at initialize and then accepts it in place of a credential, and it advertises
list_all_users and reset_tenant to plain users who cannot invoke them. Those
rows appear in plan alongside the declared ones:
│ deny │ function │ tools/list docs │ alice (user) │ session │
│ allow │ function │ tools/list docs │ alice (user) │ enumerate │
Reports land in out/:
| File | For |
|---|---|
report.html |
humans — findings with evidence and repro |
findings.json |
scripts and dashboards (CWE + OWASP tagged) |
overstep.sarif |
GitHub code scanning |
junit.xml |
CI test reporters |
overstep run exits non-zero on findings, so it fails a pipeline out of the box.
A stdio variant of the same demo lives at examples/mcp_api/matrix_stdio.yaml.
overstep scaffold http://127.0.0.1:9000/mcp --fmt mcp --server-name docs \
--token "$TOKEN" > matrix.yaml # MCP: a full matrix
overstep scaffold openapi.yaml --with-policy > matrix.yaml # REST: a full matrix
overstep scaffold traffic.har --fmt har > resources.yaml # REST: resources onlyFor MCP, overstep reads both tools/list and resources/templates/list, so
the second door is in the draft from the start, and it records which
protocol revision the server speaks. For OpenAPI,
--with-policy reads the document's own security declarations; a document that
describes no authorization at all yields a deny-by-default guess behind an
explicit warning, because reading silence as "everything is public" would produce
a matrix with zero negative tests.
The scaffold leaves PASTE_..._TOKEN and REPLACE_ME_1 / REPLACE_ME_2 where it
cannot know the answer. Two things to get right: give the two peer subjects
genuinely different objects, and keep secrets out of the file (${ALICE_TOKEN}
from the environment or --env-file). Then review the starter policy — it is a
guess, and it is the one part of the file only you can write.
overstep validate matrix.yaml # structural errors and leftover placeholders
overstep validate matrix.yaml --live # one expected-allow request per subject
overstep plan matrix.yaml # read every request before sending one
overstep run matrix.yaml --out out --read-only--live proves each credential is still accepted, which matters because an
expired token turns every negative test into a pass for the wrong reason. It is
side-effect free: probes go out read-only and non-mutating operations are
preferred.
The allow/deny signal is the one thing a tool cannot infer, so it is declared.
Each module declares it under the same key — access: — and the schema is the
module's own. A resource may override it with the same key; which matcher parses
it follows from the resource's body, so a REST resource cannot be handed an MCP
matcher by accident.
REST. By default 2xx means granted and anything else means denied. For APIs
that redirect on success, return 200 with an error body, or mask 403 as 404:
modules:
rest:
access:
allow_status: ["2xx"] # exact codes, ranges ("200-299") or classes ("2xx")
deny_body_regex: "access denied|not authorized" # a 200 with this body -> deny
treat_redirect_as: deny # deny | allow | statusEvaluation order: deny_body_regex (wins, fails safe) → allow_body_regex →
redirect handling → allow_status.
MCP. There is no 403, so the deny signal is spelled out on both legs:
modules:
mcp:
access:
is_error_is_deny: true # a result with isError: true -> denied
jsonrpc_error_is_deny: true # a JSON-RPC error -> denied
deny_status: ["4xx", "5xx"] # an HTTP-leg refusal (401/403) -> denied
# deny_content_regex: "permission denied"deny_status is the half that is easy to miss. The MCP authorization spec has an
unauthorized request answered with 401 and a WWW-Authenticate header, and
nothing requires the body to be a JSON-RPC message — an empty body, or a
framework's own {"detail": "Not authenticated"}, is what many servers send. Such
a response carries no in-band deny signal at all, so without reading the status,
the servers that reject before dispatching — the ones doing it right — are
exactly the ones a run would report wide open. It does not apply to stdio, which
has no HTTP leg.
The identifier of the object a subject reaches for is the object-level surface,
and it does not always sit in a path parameter or a tool argument.
ownership.injections says where to write it; overstep fills each location with
the caller's own object (SELF) or a victim's (OTHER).
resources:
- name: get_order
request: { method: GET, path: /orders } # the id is NOT in the path
type: object
objects: { alice: order-a1, bob: order-b1 }
ownership:
injections:
- location: query # -> GET /orders?order_id=order-b1
selector: order_idlocation is one of path, query, header, cookie, form, json,
graphql_variables, mcp_argument or mcp_resource_uri. selector is read per
location: a path parameter, a query/header/cookie/form key, a JSONPath into the
body, a GraphQL variable name, a tool-argument key, or a {placeholder} in an MCP
resource URI template. owner: is the shorthand for exactly one of these, in
whichever place the resource naturally carries an id — a path parameter for a
request, a tool argument for a call, the URI placeholder for a read — so it
never has to restate what the body already says. Set owner_attr on an individual
injection to source it from a different subject attribute — the tenant, say,
rather than the object id.
A full example lives in
examples/injections/matrix.yaml.
Cross-method probing (REST only). A GET-only resource can hide a missing check
on other verbs. probe_methods: [PUT, DELETE] fires each verb at another
subject's object as a negative test.
A tool that cries wolf gets switched off. These decide whether a finding is real.
Confidence. A successful result on an object-level probe is not proof that
data leaked — the call may have returned an empty list. Give each subject a
marker (a string that uniquely identifies its data) and overstep looks for the
victim's marker before it trusts the outcome: confirmed (the victim's data
appeared), suspected (access granted, no victim data — downgraded to medium),
unverified (no marker configured). This matters more on MCP, where there is
often no status code to lean on.
One defect, not one finding per identity. A missing check is reported once per
identity that reaches it, so one bug can arrive as a dozen findings. Every report
therefore carries a defect roll-up — one row per thing to fix, with the subjects
as evidence — in the summary, in findings.json under defects, and as the
leading table of the HTML report. Nothing is filtered; gating still counts
findings.
A repro that runs. Each finding carries a runnable command and a structured
request record, with the credential replaced by a shell variable named after the
subject it belongs to (OVERSTEP_TOKEN_<SUBJECT>), so the line is safe to paste
into a ticket and still works once the variable is set. stdio findings get an
stdio repro; a session-binding finding gets the two-step form its defect requires.
Property-level checks. List the JSON keys an allowed response must never contain. Matching is key-based, so a name appearing in free text does not false-positive:
forbidden_fields: [password_hash, is_admin]How many victims each subject probes. By default every subject sends one
cross-owner probe, which catches a check that is missing outright. Set
probe_victims: all (matrix-wide or per resource) to send one probe per distinct
object instead — for a check that holds for some owners and not others, such as a
tenant whose ACL rows were never backfilled.
Custom conditions. For tenant isolation and attribute matching, an allow rule
can carry a boolean condition over subject and target attributes:
policy:
read_document:
allow:
- role: user
condition: "subject.tenant == target.tenant"Conditions run through a restricted AST evaluator — comparisons, boolean logic and attribute/index access only. No function calls, no arbitrary names.
Custom headers. By default each subject authenticates with Authorization: Bearer <token>. Headers can be set on the resource (or, for MCP, the server) and
on the subject; subject headers override what they inherit. An Authorization
header set on the subject is a deliberate choice of scheme and is never
overwritten by the token; one set on the resource or server belongs to no identity
in particular, so a subject's own token replaces it — otherwise a matrix written to
tell callers apart would be testing a single caller under several names.
Credentials. Any ${VAR} in the matrix is replaced from the environment at
load time (${VAR:-default} for a fallback); a missing variable fails the run
loudly. Pass a dotenv file with --env-file. A subject can instead obtain its
token by logging in before the run — type: http posts an arbitrary login request
and reads the token out of the response, oauth2_password and
oauth2_client_credentials build the standard token-endpoint form. {{var}}
placeholders are filled per subject from auth.vars, so one provider serves many
identities and secrets never touch the file.
Real objects. Meaningful object-level testing needs a real owned object.
objects: maps each subject to the id it owns; setup: steps run once before the
suite as a chosen subject and extract values into a capture context that fills
{{name}} placeholders, including in objects:; teardown: steps clean the
fixtures up best-effort afterwards.
setup:
- { as: alice, call: { server: docs, tool: create_document, arguments: { body: "notes" } }, extract: { ALICE_DOC: "$.id" } }
- { as: bob, call: { server: docs, tool: create_document, arguments: { body: "plans" } }, extract: { BOB_DOC: "$.id" } }
resources:
- name: read_document
# …
objects: { alice: "{{ALICE_DOC}}", bob: "{{BOB_DOC}}" }Three checks run against every Streamable HTTP server beyond what the matrix declares, because they ask about the credential and the connection rather than about any one operation. None is available on stdio, where identity is an environment variable the server itself named rather than anything travelling on a connection.
| Probe | Default | Asks |
|---|---|---|
| Credential audience | on | does the server check who the credential was issued for? |
| Session binding | on | can a session id stand in for a credential? |
| Tool enumeration | off | is the privileged half of the catalogue advertised to everyone? |
Credential audience. The MCP authorization spec is unambiguous: a server must not accept a token that was not issued for it. A server that skips the check is a confused deputy, and the blast radius is not one object but every service trusting the same issuer. Declare what a subject's token is bound to and overstep replays that credential at every server the audience does not identify:
subjects:
- { name: alice, role: user, token: "${ALICE_DOCS_TOKEN}", token_audience: docs }The audience is inferred when a subject authenticates through a provider that
discovers its token endpoint from a server or sends an explicit resource — such a
token is audience-bound. With no audience known, no probe is generated: overstep
does not guess which credential belongs where. The probe is tools/list, which
requires authorization, takes no arguments and changes nothing, so it isolates the
single question. The policy is deliberately not consulted — an admin's token bound
to server A must still be refused by server B. Set
modules.mcp.probes.token_audience: false if one credential is legitimately
valid at several of your declared servers.
Session binding. Streamable HTTP hands out an Mcp-Session-Id at initialize,
and the spec is explicit that it must not authenticate. The probe opens a session
as the subject, then sends the same anonymous request twice — once carrying the
session id, once without. The second request is the control: a server whose listing
is simply public answers both, and calling that a hijack would be a finding about
nothing. Only the difference counts. A server that issues no session id has
nothing to hijack, and the probe is recorded as skipped rather than passed.
Tool enumeration. Opt-in via modules.mcp.probes.tool_enumeration: true, because listing
everything and enforcing at call time is a common and defensible design. It calls
tools/list as each subject and compares the result against the policy already
written; a paginated listing is followed to the end. A tool the matrix does not
declare is undescribed rather than disallowed — that is
coverage's gap to report — and a subject
that cannot list at all has nothing to disclose. Enumeration probes are never
counted as positive controls, since a public listing answers with no credential at
all.
Through 2025-11-25 a connection opens with an initialize handshake and may
carry an Mcp-Session-Id. From 2026-07-28 the core is stateless: the
handshake and the session header are gone, and every request carries its own
protocol version and client capabilities in params._meta, plus Mcp-Method and
Mcp-Name headers.
overstep drives both. The default stays on the stateful wire, because which revision to speak is a fact about the target, not a preference:
modules:
mcp:
servers:
- name: docs
url: https://mcp.example.com/mcp
protocol_version: "2026-07-28" # default: 2025-06-18You do not have to know which one to write — scaffold asks the server
(server/discover first, then a negotiated initialize) and records the answer.
Two consequences: session binding is reported as skipped, not applicable on
2026-07-28, because the defect was removed from the protocol rather than fixed in
your server; and a version mismatch ends the run as
inconclusive rather than scoring it,
because a refusal to speak is not a denial.
For a remote server behind OAuth 2.1, a provider can discover where to authenticate rather than hardcoding a token endpoint: overstep reads the server's Protected Resource Metadata (RFC 9728), then the Authorization Server Metadata (RFC 8414), obtains a token with a machine grant, and sends the resource indicator (RFC 8707) so the token is audience-bound.
auth:
providers:
- name: mcp_oauth
type: oauth2_client_credentials
discover_from: docs # the MCP server name (or a URL)
issuer: https://login.example.com # where these credentials are registered
client_id: "{{client_id}}"
client_secret: "${CLIENT_SECRET}"Discovery starts at the server under test, which means the least trusted host in
the picture names where your client_secret is posted. Four checks are automatic:
the authorization server and token endpoint must be HTTPS (loopback and
--insecure excepted); the metadata's issuer must be identical to the identifier
the well-known URL was built from (RFC 8414 §3.3); the resource metadata's
resource must be identical to the server URL your matrix declared (RFC 9728
§3.3); and a metadata request must not be redirected to another origin. None of
those catches a target that names an authorization server it simply owns and
describes honestly — issuer: is what refuses that, and validate warns when a
provider sends a secret without one.
The interactive authorization-code flow needs a browser and is not supported.
For a server that runs as a local process, declare a command instead of a url.
overstep launches one process per subject and speaks JSON-RPC over stdin/stdout.
There is no HTTP header for identity, so the subject's token goes into the process
environment:
modules:
mcp:
servers:
- name: docs
command: ["python", "server.py"]
token_env: MCP_TOKEN # each subject's token -> this env varEverything else is identical — object and function resources, ownership, markers,
--read-only, reports. The three protocol probes do not apply.
Finding today's holes is the first run. What compounds is the run after it, because an authorization surface usually breaks by changing: a refactor drops a scope check, a new operation ships without an owner check, a role gains a permission in a config nobody reviewed as security. None of that looks anomalous on its own. It is only wrong relative to what was agreed last month.
overstep snapshot matrix.yaml --out baseline.json # once, after triage
overstep run matrix.yaml --baseline baseline.json --fail-on vuln-or-drift # every PRA cell that flipped deny → allow is a newly opened hole; allow → deny is a new restriction, often intended and occasionally an outage.
Gate on vuln-or-drift, not drift. A diff can only speak about cells that
existed on both sides, so a newly added operation has nothing to differ from: the
new tool shipped without an owner check is reported as a vulnerability, not as
drift, and a drift-only gate goes green on exactly the case that motivates the
baseline.
--fail-on |
Exits non-zero when… |
|---|---|
vuln (default) |
there is an active, non-waived vulnerability |
drift |
a decision changed versus --baseline, ignoring pre-existing findings |
vuln-or-drift |
either is present |
any |
any active finding exists, including unexpected-deny |
never |
always exits zero (report-only) |
The cost of vuln-or-drift is that pre-existing findings fail the build too.
Waivers are the mechanism for that — each accepted finding named by its stable
test_id, with a mandatory reason and an optional expiry, reviewed like any other
code. Waived findings leave the gating set but stay visible in the reports, and an
expired waiver stops suppressing and prints a warning.
# waivers.yaml
waivers:
- id: read_document::alice::other
vuln_class: BOLA
reason: "Tracked in SEC-1234; fix scheduled next release."
expires: 2026-12-31Keep matrix.yaml, baseline.json and waivers.yaml in version control and
authorization gets reviewed like any other code.
Safety and pipeline flags. --read-only skips every mutating operation
(POST/PUT/PATCH/DELETE over HTTP, any tool marked mutating over MCP).
--max-retries N (default 2) retries 429/503, honouring Retry-After.
--concurrency N bounds in-flight requests. Ready-made artifacts:
examples/ci/github-actions.yml,
examples/ci/gitlab-ci.yml, the
ghcr.io/kabiri-labs/overstep image, and an overstep-validate pre-commit hook.
An absence of findings is worth something only if the run could have seen them. Two checks enforce that.
Inconclusive runs. If the requests never arrived or the credentials were never
accepted, every negative test "passes" for the wrong reason and a naive summary
reads Vulnerabilities 0. overstep calls that run inconclusive and exits 3.
A run is inconclusive when, for any one target, at least half its requests failed
at the transport layer, or nothing proves its credentials work — every
expected-allow test skipped, or none of the ones that ran allowed. The judgement is
per target, so a busy healthy one cannot outvote a small one that answered nothing.
Exit code 3 is distinct from 1 (findings) and 2 (bad input), so CI can tell "your
server has a hole" from "the scan never ran", and snapshot refuses to write a
baseline against a dead target. --allow-inconclusive reports anyway.
The credential half of that check needs expected-allow tests to work, on the target you want it to speak for. An allowed request is the only thing that proves a credential is still accepted, so a target with no expected-allow case has no such proof to lose — and overstep does not condemn it for that, because an intentionally all-negative suite is a legitimate thing to write.
The consequence is worth stating plainly, and it is sharper than it first looks because the judgement is per target. A matrix spanning a REST API and an MCP server, with its only positive control on the REST side, says nothing about the MCP server's credentials: if that server rejects every one of them, its every expected-deny case is denied, and the run is reported as conclusive and clean. The healthy target does not vouch for the silent one — the same isolation that stops a busy target outvoting a small one also stops it covering for one.
Give every target at least one expected-allow case if you want expired credentials to fail the build. Unreachability is still caught either way; it is only the credential check that has nothing to stand on.
Coverage. overstep coverage measures two gaps and sends nothing:
overstep coverage matrix.yaml --spec http://127.0.0.1:9000/mcp --fmt mcp --token "$TOKEN"The outer gap is what the matrix never declared — measured against an
independent description of the surface (--spec takes an MCP server or
tools.json with --fmt mcp, an OpenAPI document, or a HAR capture). The inner
gap is what the run could not ask about: a cross-owner probe is the only thing that
tests object-level authorization, and the planner generates one only when two
subjects resolve to genuinely different objects. Where they do not, it drops the
probe rather than replaying a subject's own request under another label — so the
run reports a resource nobody probed instead of counting it as clean.
--fail-under N gates on either percentage.
| Command | What it does |
|---|---|
overstep scaffold SPEC |
draft a matrix from a live MCP server, tools.json, OpenAPI or HAR |
overstep validate MATRIX |
lint for structural problems and unfilled placeholders (--live probes the target; --strict fails on warnings) |
overstep plan MATRIX |
print the generated test cases (no network) |
overstep coverage MATRIX |
report what the matrix covers, vs. --spec and vs. its own object surface (no network) |
overstep run MATRIX |
generate, execute and report; non-zero exit on findings |
overstep snapshot MATRIX |
record current decisions as a drift baseline |
overstep version |
print the installed version |
| Flag | run |
snapshot |
Meaning |
|---|---|---|---|
--base URL |
✅ | ✅ | override the matrix base_url |
--out PATH |
✅ | ✅ | report directory / baseline file |
--fail-on VALUE |
✅ | — | gating |
--baseline FILE |
✅ | — | compare against a snapshot for drift |
--waivers FILE |
✅ | — | accepted findings |
--read-only |
✅ | ✅ | skip mutating verbs and tools |
--concurrency N |
✅ | ✅ | bound in-flight requests |
--max-retries N |
✅ | ✅ | retry 429/503 with backoff |
--env-file FILE |
✅ | ✅ | dotenv for ${VAR} values |
--allow-inconclusive |
✅ | ✅ | report/write anyway, keeping the old exit code |
--insecure |
✅ | ✅ | disable TLS verification |
Exit codes: 0 clean · 1 findings (per --fail-on) · 2 bad input or
setup failure · 3 inconclusive run.
run and snapshot share one pipeline — authenticate → setup → plan → dispatch →
teardown — so every transport behaves identically and setup fixtures are cleaned up
even if a run is interrupted.
Every class maps to its CWE and OWASP API Security Top 10 entry, carried in the
SARIF rules (with a security-severity score) and on every JSON finding.
| Class | CWE | OWASP API Top 10 | Surface |
|---|---|---|---|
| BOLA | CWE-639 | API1:2023 | REST, MCP |
| BOPLA | CWE-213 | API3:2023 | REST, MCP |
| BFLA | CWE-285 | API5:2023 | REST, MCP |
| privilege-escalation | CWE-269 | API5:2023 | REST, MCP |
| token-audience | CWE-863 | API2:2023 | MCP |
| session-hijack | CWE-287 | API2:2023 | MCP |
| tool-enumeration | CWE-200 | API5:2023 | MCP |
Knowing the edges is part of deciding whether this fits.
- It does not discover your surface. You declare it.
scaffolddrafts a matrix from a live MCP server, an OpenAPI document or a HAR capture, but you review it. - It does not invent your policy. The matrix is the specification. A wrong
matrix produces wrong results;
validate, the plan table and the coverage and inconclusive checks exist to catch the common mistakes, not to guess intent. - It does not test authentication. Login strength, token forgery and session fixation are out of scope: overstep tests what an already authenticated identity is permitted to do. Two deliberate exceptions, both because the MCP spec makes them requirements of the server: credential audience validation and session binding.
- It does not test the delegation chain. overstep authenticates as a subject and tests the enforcement of the callee it talks to. It does not verify that authority was correctly attenuated across hops, that an on-behalf-of token carries the right actor claims, or that a downstream service re-checks what an upstream one already allowed. Those are real parts of the problem class and they are roadmap, not features.
- It is not an AI or LLM security tool. It does not drive an agent with
natural-language prompts, and it has no opinion about tool descriptions. Prompt
injection, tool poisoning and confused-deputy attacks against the agent are a
separate, non-deterministic concern. overstep tests the server's enforcement, and
the two are complements: a description scanner will not tell you the server hands
doc://acme/anyoneto whoever asks, and overstep will not tell you a tool description is lying to your agent. - It does not fuzz. Every request is one the matrix asked for, which is what makes results deterministic and diffable.
- It is a test, not a control. It runs in CI against a target you nominate; it enforces nothing at runtime.
- It sends real requests. Use
--read-onlyagainst anything you care about.
Short and honest. Nothing here is scheduled.
| Status | |
|---|---|
| REST and MCP modules as described above | implemented |
| Policy inference when scaffolding from a HAR capture (today: resources only) | planned |
| Interactive OAuth authorization-code flow for subjects that cannot use a machine grant | planned |
| Delegation-chain testing: on-behalf-of token shape, actor claims, re-checking at each hop | planned |
| Scope-attenuation checks: proving a delegated credential carries strictly fewer privileges than the one it was derived from | planned |
| Further surfaces | planned, unscheduled — rest and mcp are the two modules that exist. A third is a package under overstep/modules/, an executor, a body shape and a config block; the registries carry the rest |
The matrix format, the test_id shape, the finding classes and their wire values,
the exit codes and the report documents are the surface this version freezes.
They are pinned by golden files that fail on any change, so a release that moves
one has to say so.
It does not mean the roadmap above is finished, or that the tool covers every authorization question — the non-goals are as true at 1.0.0 as they were before it. It means the parts you would build a pipeline against have stopped moving underneath you, and that breaking one is now a decision with a version number rather than an ordinary release.
Issues and pull requests are welcome. See CONTRIBUTING.md for
the development setup, and SECURITY.md for reporting a
vulnerability in overstep itself. The test suite runs with pytest -q.
Only test targets you are authorized to test.