Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,13 @@ jobs:
go-version: "1.23.3"
cache: true

- name: Set up Just
uses: extractions/setup-just@v2
with:
just-version: "1.39.0"
- name: Set up Just (crates.io — casey/just's GitHub releases
are currently unresolvable by setup-just; revert to the
extractions/setup-just action when upstream heals)
uses: dtolnay/rust-toolchain@stable

- name: Install Just
run: cargo install just --version 1.39.0 --locked

- name: Run the canonical gate
run: just ci
14 changes: 9 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,18 @@ jobs:
go-version: "1.23.3"
cache: true

- name: Run the CI gate (never release a broken build)
uses: extractions/setup-just@v2
with:
just-version: "1.39.0"
# casey/just's GitHub releases are currently unresolvable by
# setup-just; crates.io install instead. Revert when upstream heals.
- name: Set up Rust (for just)
uses: dtolnay/rust-toolchain@stable

- name: Install Just
run: cargo install just --version 1.39.0 --locked

- name: just ci
- name: Run the CI gate (never release a broken build)
run: just ci


- name: Install syft (SBOM tool — GoReleaser's `sboms` pipe shells out to it)
run: curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin

Expand Down
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,49 @@ All notable changes to **stunt** are documented here. The format is based on

## [Unreleased]

## [0.45.0] — 2026-08-17

### Testing

- **Fuzz testing for the engine and every adapter.** Go-native fuzz
targets plus a deterministic all-adapter safety sweep now guard the one
invariant a mock must uphold: **client input never produces a 5xx**
(Starlark has no try/except, so any builtin raise on attacker-shaped
input is an unhandled 500; real APIs answer bad input with 4xx).
- `TestAdapterInputSafety` drives every reference adapter's routes
with adversarial-but-deterministic requests — garbage path params
(negative/huge/unicode), JSON-null and malformed bodies, batch
arrays, bracket-form bodies, garbage auth, and ~30 cursor/limit
query param names (case-sensitive variants included) poisoned at
once. Note the sweep runs with garbage auth, so for auth-gated
adapters it proves the gate itself never 5xxs; deep post-auth paths
get their coverage from the curated fuzz target.
- `FuzzMatchRoute` / `FuzzParseFormBody` (router + bracket-form
parser), `FuzzParseMultipart` (the multipart decoder's total-
contract), `FuzzValidateHeader` (webhook header injection), and
`FuzzAdapterRequests` (coverage-guided full-dispatch fuzzing —
method, path, query, and body — over a curated adapter set: stripe,
cloudflare-D1, salesforce-SOQL, powerplatform-OData, emailoctopus,
eth-jsonrpc, shopify).
- Fuzz seed corpora run in plain `go test` forever; `just fuzz` runs
coverage-guided rounds locally (found inputs land in
`testdata/fuzz/` — commit them).
- **First-run findings, all fixed:** `paginate` raised on invalid
cursors (a tampered token 500'd — now returns `(None, None)` and
every cursor-exposing adapter, ~50 in total, answers its provider's
400 shape); `query_select`/`paginate` raised on out-of-int64 limits
and could overflow `start+limit` into a panicking negative slice
bound with a valid cursor (clamped against the remaining items — a
huge limit means no effective limit); `crypto.base64_decode`/
`base64url_decode` raised on malformed input (now total, returning
`None`); JSON-RPC batch elements that weren't objects, or a
non-string `method`, crashed eth-jsonrpc/erc4337 (now per-element
`-32600` Invalid Request per the spec); anaplan built blob names
from raw path params (now validates identifiers, 400); printify/jira
parsed unbounded ints from client ids/params (now int64-bounded);
the router captured an empty param name for a `{}` manifest typo
(now never matches).

## [0.44.0] — 2026-08-17

### Adapters
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,10 @@ stunt catalog search stripe # browse the adapter registry

**Reference adapters in this repo** — 95 of them (Stripe, Salesforce, Discord, Twilio,
Square, Adyen, AWS S3, Google/Microsoft/Apple families, blockchain RPCs, …; all unofficial,
synthetic-data-only, with a DISCLAIMER). Browse them with `stunt catalog search`. Highlights:
synthetic-data-only, with a DISCLAIMER). Browse them with `stunt catalog search`. Every one
passes an adversarial input-safety sweep (garbage params, null/malformed bodies, ~30 tampered
cursor/limit param names — never a 5xx) plus coverage-guided fuzzing of the engine's parsers
and dispatch (`just fuzz` for longer rounds). Highlights:

| Adapter | Simulates | Backing |
|---|---|---|
Expand Down
16 changes: 14 additions & 2 deletions adapters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,11 +188,23 @@ page, next_cursor = paginate(docs, limit, cursor)
| Argument | Type | Default | Notes |
|----------|------|---------|-------|
| `items` | iterable | required | The full result list (typically `store_collection(...).list()`), filtered first |
| `limit` | int | `None` | Page size. `None` or `<= 0` **disables paging** (returns the whole list, `next_cursor = None`) — so unmodified handlers keep their prior behavior |
| `limit` | int | `None` | Page size. `None` or `<= 0` **disables paging** (returns the whole list, `next_cursor = None`) — so unmodified handlers keep their prior behavior. An out-of-int64 value is clamped (a client-sent huge limit means "no limit") |
| `cursor` | str | `None` | Opaque offset token returned by a prior call; `None`/`""` for the first page |

`next_cursor` is the opaque token for the next page, or `None` when no items remain.

A **syntactically invalid cursor returns `(None, None)` instead of raising** — cursors
are client input, handlers have no try/except, and a raise would be an unhandled 500.
Guard the page and answer with the provider's own 400 (`Invalid pageToken`,
`invalid_cursor`, `InvalidQueryParameterValue`, …) — every cursor-exposing reference
adapter does:

```python
page, next_cursor = _list_page(req, docs)
if page == None:
return _g_err(400, "Invalid pageToken", "INVALID_ARGUMENT")
```

The builtin does the universal slicing; **the adapter owns the provider envelope and cursor mapping**.
Stripe, for example, has no cursor field — clients set `starting_after` to the last returned id — so a
thin wrapper translates the id to an offset and reports `has_more`:
Expand Down Expand Up @@ -282,7 +294,7 @@ A receiver that verifies a webhook signature (Stripe `Stripe-Signature`, GitHub

| Module | Functions | Notes |
|--------|-----------|-------|
| `crypto` | `hmac_sha256(key, data, encoding="hex")`, `hmac_sha1(...)`, `sha256(data, encoding="hex")`, `base64_encode(data)`, `base64_decode(s)`, `base64url_encode(data)`, `base64url_decode(s)`, `ecdsa_sign_p256(private_key_pem, data, encoding="hex")`, `ecdsa_verify_p256(public_key_pem, data, signature, encoding="hex")`, `rsa_sign(private_key_pem, data, encoding="hex")`, `rsa_verify(public_key_pem, data, signature, encoding="hex")`, `rsa_public_jwk(public_key_pem)→{kty,n,e}`, `ec_public_jwk(public_key_pem)→{kty,crv,x,y}`, `ed25519_sign(private_key_pem, data, encoding="hex")`, `ed25519_verify(public_key_pem, data, signature, encoding="hex")` | `encoding` is `"hex"` (default), `"base64"`, or `"base64url"`. MAC, hash, and asymmetric signature (ECDSA P-256 raw r‖s; RSA-SHA256 PKCS#1 v1.5; Ed25519 over the raw message). Keys arrive as PEM strings the adapter supplies (ship a fixed keypair for determinism). `rsa_public_jwk`/`ec_public_jwk` return the public key's JWK params (base64url) for serving JWKS — RS256 issuers (Entra ID, Cognito) and ES256 issuers (Sign in with Apple, APNs) respectively. `base64url_decode` accepts padded input (JWT segments are unpadded). No encryption/KDF/key-gen |
| `crypto` | `hmac_sha256(key, data, encoding="hex")`, `hmac_sha1(...)`, `sha256(data, encoding="hex")`, `base64_encode(data)`, `base64_decode(s)`, `base64url_encode(data)`, `base64url_decode(s)`, `ecdsa_sign_p256(private_key_pem, data, encoding="hex")`, `ecdsa_verify_p256(public_key_pem, data, signature, encoding="hex")`, `rsa_sign(private_key_pem, data, encoding="hex")`, `rsa_verify(public_key_pem, data, signature, encoding="hex")`, `rsa_public_jwk(public_key_pem)→{kty,n,e}`, `ec_public_jwk(public_key_pem)→{kty,crv,x,y}`, `ed25519_sign(private_key_pem, data, encoding="hex")`, `ed25519_verify(public_key_pem, data, signature, encoding="hex")` | `encoding` is `"hex"` (default), `"base64"`, or `"base64url"`. MAC, hash, and asymmetric signature (ECDSA P-256 raw r‖s; RSA-SHA256 PKCS#1 v1.5; Ed25519 over the raw message). Keys arrive as PEM strings the adapter supplies (ship a fixed keypair for determinism). `rsa_public_jwk`/`ec_public_jwk` return the public key's JWK params (base64url) for serving JWKS — RS256 issuers (Entra ID, Cognito) and ES256 issuers (Sign in with Apple, APNs) respectively. `base64url_decode` accepts padded input (JWT segments are unpadded). `base64_decode`/`base64url_decode` are **total** — malformed input returns `None` instead of raising (the argument is usually client input: auth material, cursors, ids). No encryption/KDF/key-gen |
| `clock` | `now_unix()`, `now_rfc3339()` | Wall clock from the engine's injectable `clock.Clock` — real today; the virtual mode is the seam for future record/replay |

**Rule:** MAC the `events_body(...)` bytes verbatim — never a re-marshalled copy — so the signer and verifier agree on the exact bytes.
Expand Down
2 changes: 2 additions & 0 deletions adapters/adyen-style/scripts/payments.star
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ def on_list_payments(req):

# Apply cursor pagination (pageSize + cursor) after building the list.
page, next_cursor = _list_page(req, items)
if page == None:
return _adyen_err(400, "400", "Invalid cursor parameter.", "validation")
body = {
"paymentData": page,
}
Expand Down
2 changes: 2 additions & 0 deletions adapters/anaplan-style/scripts/catalog.star
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ def _list_catalog(req, ws, mid, name):
})

page, next_cursor = _list_page(req, items)
if page == None:
return respond(400, {"status": "FAILURE", "statusMessage": "Invalid offset parameter."})
paging = {
"currentPageSize": len(page),
"offset": _to_int(req.get("query", {}).get("offset", "")),
Expand Down
9 changes: 9 additions & 0 deletions adapters/anaplan-style/scripts/files.star
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ def on_upload_file(req):
ws = req["params"]["workspaceId"]
mid = req["params"]["modelId"]
fid = req["params"]["fileId"]
if not _id_ok(ws) or not _id_ok(mid) or not _id_ok(fid):
return respond(400, {
"status": "FAILURE",
"statusMessage": "Invalid identifier.",
})
key = _file_key(ws, mid, fid)
bkey = _blob_key(ws, mid, fid)

Expand Down Expand Up @@ -145,6 +150,8 @@ def on_list_files(req):
})

page, next_cursor = _list_page(req, items)
if page == None:
return respond(400, {"status": "FAILURE", "statusMessage": "Invalid offset parameter."})
paging = {
"currentPageSize": len(page),
"offset": _to_int(req.get("query", {}).get("offset", "")),
Expand Down Expand Up @@ -224,6 +231,8 @@ def on_list_chunks(req):
})

page, next_cursor = _list_page(req, items)
if page == None:
return respond(400, {"status": "FAILURE", "statusMessage": "Invalid offset parameter."})
paging = {
"currentPageSize": len(page),
"offset": _to_int(req.get("query", {}).get("offset", "")),
Expand Down
14 changes: 14 additions & 0 deletions adapters/anaplan-style/scripts/lib.star
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,17 @@ def _seed():
"active": True,
"size": (2*1024*1024),
})

# _id_ok guards the blob-store name charset: path ids are client input and
# the store rejects names outside [A-Za-z0-9][A-Za-z0-9._-]* (a raise there
# is an unhandled 500). Real Anaplan ids are alphanumeric.
def _id_ok(s):
if s == None or s == "":
return False
for i in range(len(s)):
c = s[i]
ok = (c >= "a" and c <= "z") or (c >= "A" and c <= "Z") or (c >= "0" and c <= "9") or c == "_" or c == "-" or c == "."
if not ok:
return False
f = s[0]
return (f >= "a" and f <= "z") or (f >= "A" and f <= "Z") or (f >= "0" and f <= "9")
4 changes: 4 additions & 0 deletions adapters/anaplan-style/scripts/models.star
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ def on_list_models(req):
models = []

page, next_cursor = _list_page(req, models)
if page == None:
return respond(400, {"status": "FAILURE", "statusMessage": "Invalid offset parameter."})
paging = {
"currentPageSize": len(page),
"offset": _to_int(req.get("query", {}).get("offset", "")),
Expand Down Expand Up @@ -75,6 +77,8 @@ def on_list_modules(req):
]

page, next_cursor = _list_page(req, modules)
if page == None:
return respond(400, {"status": "FAILURE", "statusMessage": "Invalid offset parameter."})
paging = {
"currentPageSize": len(page),
"offset": _to_int(req.get("query", {}).get("offset", "")),
Expand Down
2 changes: 2 additions & 0 deletions adapters/anaplan-style/scripts/tasks.star
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ def on_list_exports(req):
})

page, next_cursor = _list_page(req, exports)
if page == None:
return respond(400, {"status": "FAILURE", "statusMessage": "Invalid offset parameter."})
paging = {
"currentPageSize": len(page),
"offset": _to_int(req.get("query", {}).get("offset", "")),
Expand Down
2 changes: 2 additions & 0 deletions adapters/anaplan-style/scripts/workspaces.star
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ def on_list_workspaces(req):
})

page, next_cursor = _list_page(req, items)
if page == None:
return respond(400, {"status": "FAILURE", "statusMessage": "Invalid offset parameter."})
paging = {
"currentPageSize": len(page),
"offset": _to_int(req.get("query", {}).get("offset", "")),
Expand Down
2 changes: 2 additions & 0 deletions adapters/apps-script-style/scripts/projects.star
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ def on_list_projects(req):
items.append(_project_resource(p))

page, next_token = _list_page(req, items)
if page == None:
return _g_err(400, "Invalid pageToken", "INVALID_ARGUMENT")
result = {"projects": page}
if next_token != None:
result["nextPageToken"] = next_token
Expand Down
2 changes: 2 additions & 0 deletions adapters/avalara-style/scripts/companies.star
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ def on_list_companies(req):

# Apply OData $top/$skip paging.
page, next_link = _list_page(req, value, "/v2/companies")
if page == None:
return _avalara_err(400, "InvalidCursor", "The cursor parameter is invalid.")

resp = {
"@recordsetCount": len(value),
Expand Down
4 changes: 4 additions & 0 deletions adapters/avalara-style/scripts/definitions.star
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ def on_list_nexuses(req):

# Apply OData $top/$skip paging.
page, next_link = _list_page(req, value, "/v2/definitions/nexuses")
if page == None:
return _avalara_err(400, "InvalidCursor", "The cursor parameter is invalid.")

resp = {
"@recordsetCount": len(value),
Expand Down Expand Up @@ -72,6 +74,8 @@ def on_list_taxcodes(req):

# Apply OData $top/$skip paging.
page, next_link = _list_page(req, value, "/v2/definitions/taxcodes")
if page == None:
return _avalara_err(400, "InvalidCursor", "The cursor parameter is invalid.")

resp = {
"@recordsetCount": len(value),
Expand Down
2 changes: 2 additions & 0 deletions adapters/avalara-style/scripts/transactions.star
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ def on_list_transactions(req):

# Apply OData $top/$skip paging after filtering.
page, next_link = _list_page(req, value, "/v2/transactions")
if page == None:
return _avalara_err(400, "InvalidCursor", "The cursor parameter is invalid.")

resp = {
"@recordsetCount": len(value),
Expand Down
2 changes: 2 additions & 0 deletions adapters/aws-s3-style/scripts/objects.star
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,8 @@ def _list_objects_v2(bucket, req):

# Apply S3 ListObjectsV2 pagination (max-keys + continuation-token).
page, next_cursor = _list_page(req, entries)
if page == None:
return _invalid_argument("continuation-token", "invalid", "The continuation token is not valid.")
truncated = next_cursor != ""

# Effective MaxKeys to echo (requested value, or S3 default).
Expand Down
4 changes: 4 additions & 0 deletions adapters/azure-devops-style/scripts/git.star
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ def on_list_repos(req):

# Apply OData $top/$skip paging (after filtering by project).
page, continuation = _list_page(req, items)
if page == None:
return respond(400, {"message": "Invalid continuation token."})

resp = {"value": page, "count": len(page)}
if continuation != None:
Expand Down Expand Up @@ -139,6 +141,8 @@ def on_list_commits(req):
})

page, continuation = _list_page(req, items)
if page == None:
return respond(400, {"message": "Invalid continuation token."})
resp = {"value": page, "count": len(page)}
if continuation != None:
resp["continuationToken"] = continuation
Expand Down
4 changes: 4 additions & 0 deletions adapters/azure-devops-style/scripts/pipelines.star
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ def on_list_pipelines(req):
items.append(_pipeline_resource(p))

page, continuation = _list_page(req, items)
if page == None:
return respond(400, {"message": "Invalid continuation token."})
resp = {"value": page, "count": len(page)}
if continuation != None:
resp["continuationToken"] = continuation
Expand Down Expand Up @@ -67,6 +69,8 @@ def on_list_runs(req):
items.append(_run_resource(_advance_run(r)))

page, continuation = _list_page(req, items)
if page == None:
return respond(400, {"message": "Invalid continuation token."})
resp = {"value": page, "count": len(page)}
if continuation != None:
resp["continuationToken"] = continuation
Expand Down
2 changes: 2 additions & 0 deletions adapters/azure-devops-style/scripts/projects.star
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ def on_list_projects(req):

# Apply OData $top/$skip paging.
page, continuation = _list_page(req, items)
if page == None:
return respond(400, {"message": "Invalid continuation token."})

resp = {"value": page, "count": len(page)}
if continuation != None:
Expand Down
2 changes: 2 additions & 0 deletions adapters/azure-devops-style/scripts/work.star
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ def on_iterations(req):

# Apply OData $top/$skip paging.
page, continuation = _list_page(req, items)
if page == None:
return respond(400, {"message": "Invalid continuation token."})

resp = {"value": page, "count": len(page)}
if continuation != None:
Expand Down
2 changes: 2 additions & 0 deletions adapters/azure-storage-style/scripts/blobs.star
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ def _list_blobs(req):

# Apply paging (maxresults + marker) after prefix filtering.
matching, next_marker = _list_page(req, matching)
if matching == None:
return _az_error(400, "InvalidQueryParameterValue", "Value for one of the query parameters specified in the request URI is invalid.")

xml = '<?xml version="1.0" encoding="utf-8"?>\n'
xml = xml + '<EnumerationResults ServiceEndpoint="http://stunt.local/" ContainerName="' + _xml_escape(container) + '">\n'
Expand Down
2 changes: 2 additions & 0 deletions adapters/azure-storage-style/scripts/containers.star
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ def on_list_containers(req):

# Apply paging (maxresults + marker) after collecting the full list.
containers, next_marker = _list_page(req, containers)
if containers == None:
return _az_error(400, "InvalidQueryParameterValue", "Value for one of the query parameters specified in the request URI is invalid.")

xml = '<?xml version="1.0" encoding="utf-8"?>\n'
xml = xml + '<EnumerationResults ServiceEndpoint="http://stunt.local/">\n'
Expand Down
2 changes: 2 additions & 0 deletions adapters/braze-style/scripts/segments.star
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ def on_list_segments(req):
return err

page, next_cursor = _list_page(req, _SEGMENTS)
if page == None:
return respond(400, {"errors": [{"message": "Invalid cursor parameter."}]})
body = {
"message": "success",
"segments": page,
Expand Down
4 changes: 4 additions & 0 deletions adapters/chainlink-style/scripts/automation.star
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ def on_list_upkeeps(req):
upkeeps.append(_upkeep_view(doc))

page, next_cursor = _list_page(req, upkeeps)
if page == None:
return _cl_err(400, "invalid_cursor", "Invalid cursor token")
body = {"data": page}
if next_cursor != None:
body["nextCursor"] = next_cursor
Expand Down Expand Up @@ -323,6 +325,8 @@ def on_list_performs(req):
rev.append(entries[i])

page, next_cursor = _list_page(req, rev)
if page == None:
return _cl_err(400, "invalid_cursor", "Invalid cursor token")
body = {"data": page, "count": len(rev)}
if next_cursor != None:
body["nextCursor"] = next_cursor
Expand Down
Loading
Loading