mirror of
https://github.com/simstudioai/sim.git
synced 2026-11-05 08:15:55 +08:00
* feat(secrets): record which secrets each run resolves and surface it per secret
Redaction stops a value at a boundary but cannot stop code that never emits it —
a Function block can print a key one character at a time and nothing ever matches
the secret. That is undecidable in general, so this adds the other half of the
posture: attribution.
Every run now records which configured secrets it actually resolved, under whose
identity, through which surface (workflow, Sim agent, MCP). The data already
existed in ResolvedSecretTraceRegistry.addActiveEntry and was persisted only for
paused runs; this persists it for every terminal path.
Execution logs cannot answer this. They store the whole available encrypted
environment rather than what a run referenced, they evidence a secret only where
value-matching redaction happened to fire, and they expire under
logRetentionHours — while "who has touched this key" outlives any single run.
- secret_usage: per-UTC-day rollup keyed by workspace, secret, scope, owner,
source, workflow, actor. A one-minute schedule touching three secrets would
otherwise write thousands of rows a day, which is also why this is not
audit_log. workflow_id/actor_user_id use '' sentinels rather than null so the
unique key works on Postgres 14 without NULLS NOT DISTINCT, and are not FKs:
they are historical facts, and an onDelete would rewrite a key column.
- secret_owner_user_id is part of the key. Two people can hold a personal secret
under one name and a shared personal secret resolves for a caller who does not
own it, so name and scope alone do not identify a secret. It is NOT the actor:
a scheduled run resolves the workflow owner's personal slice under the
workspace's execution actor.
- Direct environment reads are now detected in JS (TypeScript AST), Python
(tokenizer-checked) and shell (quote/heredoc-scanned), so a secret read as
environmentVariables['K'] or $K enters the run's provenance instead of going
unredacted. Each detector prescans for names that are actually configured
secrets before paying for a lex or quote-frame pass.
- Copilot integration tool calls are covered: resolveCopilotEnvReferences
substitutes {{SECRET}} into user-only params, which is a real use.
- See usage lives behind a credential-admin gate, using the same predicate that
reveals the value; members get a disabled chip explaining why.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(audit): register the secret-usage route in the validation baseline
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(secrets): keep rollup metadata with its run, and stop shadowed bindings faking usage
Review round 1.
- record.ts: last_execution_id/last_trigger were assigned unconditionally while
last_used_at was chosen by greatest(), so two runs completing out of order split
one row between them — the newer run's timestamp beside the older run's execution
id, making "View log" open a run the row does not describe. Both are now guarded
on the timestamp actually advancing, so the row's metadata always belongs to the
run that owns its timestamp.
- javascript.ts: a local binding named environmentVariables (declaration, parameter,
destructured binding, or bare reassignment) made reads off the user's own object
look like mounted-secret reads. Any such binding now disables detection for the
file; the AST already had parent pointers, so this is a kind check during the
existing walk.
- python.ts: same class of bug with no parser available, so the rule is an allowlist
— every mention of the binding must be a literal subscript or .get(), otherwise
detection is off for the file. This also subsumes the cross-line attribute case
(other.\n environmentVariables['K']), which the previous space-and-tab look-behind
missed.
Under-reporting is the safe direction here: a trail that claims a use that never
happened is worse than one that misses a use.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(db): format the generated migration snapshot
CI runs lint:check across every workspace; the drizzle-kit output in packages/db
had never been through biome, so the branch was green locally (where lint had
only been run inside apps/sim) and red on CI. Whitespace only — both files are
byte-for-byte identical once parsed, and drizzle-kit still reports no pending
schema diff against the reformatted snapshot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(secrets): detect every rebinding of the environment identifier, not just declarations
Review round 2. A bare `for (environmentVariables of rows)` has no declaration to
key off, so the previous check missed it and reads of the loop value were still
recorded as secret usage.
Rather than extend the hand-rolled node-kind list, this reuses the pair the same
file already applies to reject a placeholder in a write position:
isDeclarationIdentifier covers declarations, parameters, destructured bindings and
imports, and isWriteIdentifier covers every assignment operator, ++/--,
destructuring targets, and for-in / for-of initializers.
That also closes four forms neither the review nor the original check named:
logical (||=) and nullish (??=) assignment, and object and array destructuring
assignment. Six of the eight added cases fail against the previous check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(secrets): apply the rebinding rule to shell, and say when a run's log is gone
Review round 3, plus the docs that were left claiming the old behavior.
- shell.ts: a script that writes a configured name (API_KEY=local, export/local/
readonly, read, for, unset) expands its own value from that point on, not the
mounted secret, so recording it claimed a use that never happened. Every mention
of the name must now be a `$NAME` / `${NAME}` expansion, matching the allowlist
shape the Python detector already uses. Applied per name rather than per file:
JavaScript and Python shadow one object holding every secret, whereas rebinding
one shell variable says nothing about the rest.
- The usage trail deliberately outlives execution logs, so a row routinely names a
run whose log has been pruned. The read now left-joins workflow_execution_logs on
its unique execution_id and reports availability, and the panel renders the chip
disabled with the platform tooltip instead of linking into an empty Logs view.
Three states: no run to link, a run whose log is gone, and a live link.
- Docs said a direct environmentVariables/$KEY read does not activate masking,
which this branch changes. Corrected in credentials.mdx, function.mdx and the
logging FAQ, and the recognition limits are now written down: runtime-built
names, reassigned bindings, and reads that cannot be told apart from text.
Added a "See usage" section covering who can see it and why an empty trail
means "nothing recognized" rather than "never used".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(secrets): writing a name is not reading it, and a bare mention is not a rebinding
Review round 4.
- javascript.ts / python.ts: `environmentVariables.API_KEY = 'x'` and
`delete environmentVariables.API_KEY` touch the name without ever reading the
mounted value, but the detectors matched the member access and recorded a use
that never happened. JavaScript now asks the same isWriteIdentifier the
placeholder rewriter uses (its parameter is widened to ts.Node — the body
already walked generic nodes, so this is a type change, not a behaviour one)
plus a delete check; Python excludes a subscript followed by `=` and a `del`
target.
- shell.ts: requiring every mention of a name to be an expansion also fired on
text that binds nothing — a comment naming the key, or `echo "API_KEY=$API_KEY"`
where the literal is an argument rather than an assignment — and dropping those
cost masking on a genuine read. It now looks for actual writes: an assignment at
command-word position, a binding builtin, `printf -v`, or a `for` target.
The two directions are not symmetric, which is why this errs toward detecting
the read: missing a write records a use of a secret the script only had in its
environment, a misleading audit row and nothing more, since masking still
searches for the real value and will not find it. Over-detecting a write
suppresses masking on a value that does reach the log.
This also makes the code match what the docs already described — skipping after
a rebinding, not after any mention.
13 tests added; 11 fail against the previous code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(secrets): an update reads before it stores, and a del target may be parenthesized
Review round 5. The first of these is a regression from round 4.
- javascript.ts: reusing isWriteIdentifier to answer "is this a read" was wrong.
That predicate answers the rewriter's question — is this a target the
substitution must refuse — so it treats every assignment operator alike, which
is correct there and wrong here: `+=`, `||=`, `??=`, `++` and `--` all load the
current value before storing, so they are genuine reads and were silently
losing their masking. Only a plain `=` stores without reading. Replaced with a
purpose-named predicate, and isWriteIdentifier's parameter is narrowed back to
ts.Identifier now that nothing else needs it widened.
A test committed last round asserted the wrong behaviour for `+=`; it has been
corrected rather than left to pin the bug.
- python.ts: `del (environmentVariables['K'])` slipped past a check that looked
only at the characters immediately before the match. It now isolates the
enclosing logical line and tests whether that is a del statement, which also
covers `del((x))`, `del(x)`, `del a, x`, and a del after a semicolon.
12 tests added or corrected; 10 fail against the previous code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(secrets): stop excluding Python writes, which kept leaking in the unsafe direction
Review round 6. Greptile found that `del environmentVariables[environmentVariables['K']]`
had its inner access — which computes a key, so it is a genuine read — skipped
along with the delete, leaving that value unmasked.
The narrow fix was another textual rule. Instead this removes the write and delete
exclusions from the Python detector entirely, because they were optimizing the
wrong direction.
`resolvedSecretNames` feeds `outputSecretMatcher`, an exact-value matcher over the
output. Naming a secret the code never read costs nothing there: the matcher scans
for a value that does not appear. Failing to name one that was read leaves it
unmasked. The two error directions are therefore not comparable, and the
exclusions bought only audit-trail tidiness while every heuristic they needed has
so far leaked into the dangerous side — first a parenthesized target, now a nested
read. A `del` or an assignment is reported like any other access.
JavaScript keeps its exclusion: a real AST answers the question per node, with no
text to misread, and it has produced no such hole.
Net 30 lines removed from python.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(secrets): report recognized reads instead of proving they are not reads
Review round 7. Greptile flagged both directions at once — false usage from
reporting a write target, and unmasked secrets from the file-wide shadow flag —
so I traced what the signal actually drives before choosing.
The chain: the compiler's names feed outputSecretPlaintextsByName and the
exact-value matcher, NOT context.resolvedSecretNames, which starts empty. After
execution activateOutputSecretProvenance scans the output and adds only names
whose plaintext actually appeared; those become __resolvedSecretNames, which
tools/index.ts turns into recordResolved calls, which is what the usage trail
reads.
So a compile-time false positive produces no usage row on the ordinary path — it
only hands the matcher a value the code never emits. It does produce one on the
!projection.safe fallback, where the system already over-approximates by design.
A false negative, by contrast, keeps the value out of the matcher entirely, so a
genuinely read secret is never masked on any path.
That asymmetry decides it, so every "prove this is not a read" mechanism is gone:
- javascript.ts: the file-wide shadow flag. A helper declaring its own
environmentVariables discarded genuine reads of the mounted binding everywhere
else in the file — Greptile's security finding, and real.
- python.ts: the allowlist requiring every mention to be a subscript or .get().
Same hole: passing the dict to a function suppressed unrelated reads.
- shell.ts: the rebinding check. It had the same hole in a form nobody flagged —
`echo "$API_KEY"; API_KEY=local` dropped the first read, which is of the real
secret.
What stays is the question of whether the text is code at all — strings, comments,
single quotes, quoted heredocs — plus the receiver check that `other.environment
Variables['K']` is a different object, and JavaScript's node-precise write/delete
exclusion, which cannot suppress a read elsewhere.
Net 215 lines removed across the three detectors and their tests. Docs updated:
the rule is now stated as reporting rather than proving, and that See usage may
occasionally list a secret the code had available but did not read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(secrets): drop the last write-vs-read special case
`environmentVariables` is a plain object deserialized from the run payload
(route.ts:206), not a handle on the stored secret. Assigning to it changes
nothing outside the sandbox and is discarded when the run ends, so separating a
write from a read bought almost nothing while leaving JavaScript as the one
language still trying to prove a read is not a read.
Every language now follows the same rule: report a recognized read of a
configured secret name. The only exclusions left are facts rather than
inferences — the text is not executable (string, comment, single quote, quoted
heredoc), the receiver is a different object, or the name is not statically
knowable.
Docs note that assigning to the binding does not edit the secret.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(secrets): ship only the fields the trail actually shows
Five fields crossed the API and reached no reader: usageDate, firstUsedAt,
actorEmail, workflowId and actorUserId. The panel renders the timestamp, the
trigger, what used the secret, the actor's name, the run count and the run link;
everything else was projected, serialized and discarded.
first_used_at is dropped from the table as well. Nothing read it, and inside a
per-day bucket "first used that day" says nothing next to "last used that day" —
so it was a column written on every run for no question anyone asks. The upsert
loses its least() with it. Migration regenerated; the identifier columns behind
the joins stay, they simply are not returned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(secrets): report referenced code secrets, not only ones that surface in output
The Function route activated a secret's provenance — and therefore its usage row
and downstream masking — only when the exact value appeared in the result,
stdout, or error. That gate made the trail miss silent use entirely: a key that
authenticates an API call and is never echoed reported nothing, and so did the
founding scenario of this feature, a key exfiltrated character by character. The
innocent run that echoed a key got a row; the run worth catching did not.
Activation now follows the referenced set the compiler already computes: resolved
{{KEY}} bindings plus recognized direct reads, filtered to configured values —
the same set the unsafe-projection fallback already activated. An extra name only
hands the output matcher a value that never appears; configured-but-unreferenced
values are still never included. The output-scan activation path and its surface
helper are deleted rather than kept alongside.
One old test pinned the gate ("does not activate a referenced secret that does
not cross the Function result"); it now asserts the reverse, with the reasoning
attached. Two new tests pin the char-split exfiltration and the silent API-call
case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(secrets): shell escaping is backslash parity, not adjacency
Review round 8. `\\$API_KEY` is an escaped backslash followed by a LIVE expansion
— bash prints `\` plus the value — while `\$API_KEY` is an escaped dollar and
stays literal. Checking only the character adjacent to `$` read every even run as
escaped, dropping a real read from usage and masking alike; verified against
bash before fixing.
The scanner now counts the run of backslashes before the `$` and skips only odd
runs, the same parity rule logicalLineEndAfterContinuations in this file already
applies to line continuations. Six-case parity table added; the three even-run
cases fail against the previous check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(secrets): recognize destructured environment reads
Review round 9. `const { API_KEY } = environmentVariables` delivers the value by
name with no property- or element-access node in the AST, so the member-access
walk missed it entirely — and a missed read leaves an emitted value unmasked,
the dangerous direction.
The AST walk now also recognizes the declaration form (shorthand, renames,
defaults, string-literal keys), the assignment form ({ KEY } = env), and a
...rest element — which names no key but takes every value, so it reports every
configured name; the alternative left `const { ...all } = env; return all`
entirely unmasked. A computed key stays unrecognized, the same runtime-name
boundary as a computed subscript, and a receiver that is not the bare identifier
is not attributed.
Nine cases added; the six positive ones fail against the previous walk.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(secrets): one receiver rule for destructured reads, parentheses included
Review round 10. Two accurate findings, folded into a generalization instead of
two more special cases:
- A parameter default (function f({ API_KEY } = environmentVariables)) and a
binding-element default are the same by-name delivery as a variable
declaration. The detector now keys on the ObjectBindingPattern itself and
checks its parent's initializer, so every declaration position follows one
rule instead of per-kind arms.
- Parentheses group without changing the receiver, so (environmentVariables) is
unwrapped before the identifier check — in the destructuring arm AND the
member-access arm, which had the same hole unreported.
Declined the for-of-over-array-literal finding: the receiver there is a
container, not the environment object, and following data flow through
containers has no fixed point — the same documented boundary as aliasing and
computed keys. A test pins the boundary so it reads as chosen, not missed.
Eight cases added; the seven receiver-rule cases fail against the previous code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(secrets): a dot in prose is not a qualifier, and a literal computed key is a subscript
Review round 11. Both findings were implementation-narrower-than-rule, fixed by
consulting authorities the detectors already had rather than adding new ones:
- python.ts: the receiver walk crosses whitespace so a parenthesized `other.` on
a previous line is seen — but it landed on a comment's final period
(`# Load the value.`) and discarded the genuine read on the next line. The
landing position is now checked against the same lexer ranges that filter the
candidates, which is also why the receiver check moves after lexing.
- javascript.ts: `const { ['API_KEY']: key } = environmentVariables` is the
element-access rule in pattern position, so a computed key holding a string
literal resolves like a literal subscript; any other computed key keeps the
runtime-name boundary a computed subscript already has.
Eight cases added; the comment-period case and all three literal-computed-key
cases fail against the previous code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1403 lines
51 KiB
TypeScript
1403 lines
51 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { readdir, readFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..')
|
|
const API_DIR = path.join(ROOT, 'apps/sim/app/api')
|
|
const CONTRACTS_DIR = path.join(ROOT, 'apps/sim/lib/api/contracts')
|
|
const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
|
|
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
|
|
|
|
const BASELINE = {
|
|
totalRoutes: 1123,
|
|
zodRoutes: 1123,
|
|
nonZodRoutes: 0,
|
|
} as const
|
|
|
|
const BOUNDARY_POLICY_BASELINE = {
|
|
routeZodImports: 0,
|
|
routeLocalSchemaRoutes: 0,
|
|
routeLocalSchemaConstructors: 0,
|
|
routeZodErrorReferences: 0,
|
|
clientHookZodImports: 0,
|
|
clientHookLocalSchemaFiles: 0,
|
|
clientHookLocalSchemaConstructors: 0,
|
|
clientHookRawFetches: 0,
|
|
clientSameOriginApiFetches: 0,
|
|
doubleCasts: 6,
|
|
rawJsonReads: 5,
|
|
untypedResponses: 0,
|
|
annotationsMissingReason: 0,
|
|
} as const
|
|
|
|
const INDIRECT_ZOD_ROUTES = new Set([
|
|
// Catch-all JSON 404 for unknown /api/v2 paths. It has no contract by
|
|
// construction: it exists precisely for requests that match no operation, so
|
|
// there is no input to validate and its only response is the fixed v2 error
|
|
// envelope.
|
|
'apps/sim/app/api/v2/[[...segments]]/route.ts',
|
|
'apps/sim/app/api/demo-requests/route.ts',
|
|
// Input-less session-bound GET: nothing to validate; response is
|
|
// contract-typed via `satisfies InvitationDetails` in the route.
|
|
// Public updater feed: input-less GET, session-less, returns YAML (not JSON),
|
|
// so it can't be JSON-contract-bound. Wrapped in withRouteHandler.
|
|
'apps/sim/app/api/desktop/update/latest-mac.yml/route.ts',
|
|
// Public updater download redirect: input-less GET, session-less, whose only
|
|
// response is a 302 to a GitHub release asset. Wrapped in withRouteHandler.
|
|
'apps/sim/app/api/desktop/update/download/route.ts',
|
|
'apps/sim/app/api/invitations/route.ts',
|
|
'apps/sim/app/api/logs/export/route.ts',
|
|
'apps/sim/app/api/tools/docusign/route.ts',
|
|
// Better Auth handles its own validation for the catch-all route below.
|
|
'apps/sim/app/api/auth/[...all]/route.ts',
|
|
// Better Auth handles validation for the Stripe webhook handler.
|
|
'apps/sim/app/api/auth/webhook/stripe/route.ts',
|
|
// Routes with no client-supplied input that previously had no-op
|
|
// `z.object({}).strict().parse({})` guards. The boundary contract for
|
|
// these routes is "no input", and they consume validated data only via
|
|
// session/headers handled by `getSession()` / Better Auth.
|
|
'apps/sim/app/api/auth/oauth/connections/route.ts',
|
|
'apps/sim/app/api/auth/providers/route.ts',
|
|
'apps/sim/app/api/auth/socket-token/route.ts',
|
|
'apps/sim/app/api/desktop/auth/handoff/route.ts',
|
|
'apps/sim/app/api/workspaces/invitations/route.ts',
|
|
// Internal cron entry point that authenticates via `Authorization: Bearer
|
|
// CRON_SECRET` and ignores query/body. The boundary contract is "no
|
|
// client-supplied input"; query params from external callers are not
|
|
// consumed.
|
|
'apps/sim/app/api/schedules/execute/route.ts',
|
|
// Routes with no client-supplied input. Auth is handled via session/cron/internal
|
|
// tokens and there are no params, query, or body to validate. Previously had
|
|
// no-op `validateSchema(noInputSchema, {})` guards.
|
|
'apps/sim/app/api/health/route.ts',
|
|
'apps/sim/app/api/settings/allowed-providers/route.ts',
|
|
'apps/sim/app/api/settings/allowed-integrations/route.ts',
|
|
'apps/sim/app/api/settings/allowed-mcp-domains/route.ts',
|
|
'apps/sim/app/api/cron/cleanup-tasks/route.ts',
|
|
'apps/sim/app/api/cron/cleanup-soft-deletes/route.ts',
|
|
'apps/sim/app/api/cron/cleanup-stale-executions/route.ts',
|
|
'apps/sim/app/api/cron/cleanup-sandbox-images/route.ts',
|
|
'apps/sim/app/api/cron/renew-subscriptions/route.ts',
|
|
'apps/sim/app/api/cron/reconcile-billing-seats/route.ts',
|
|
'apps/sim/app/api/cron/reconcile-inbox-entitlement/route.ts',
|
|
'apps/sim/app/api/cron/run-data-drains/route.ts',
|
|
'apps/sim/app/api/logs/cleanup/route.ts',
|
|
'apps/sim/app/api/knowledge/connectors/sync/route.ts',
|
|
'apps/sim/app/api/webhooks/outbox/process/route.ts',
|
|
'apps/sim/app/api/webhooks/cleanup/idempotency/route.ts',
|
|
// Shared Slack app event ingest. The body is an opaque, HMAC-verified Slack
|
|
// event envelope (varies per event type) read via parseWebhookBody; there is
|
|
// no client contract to bind — authenticity is enforced by signature.
|
|
'apps/sim/app/api/webhooks/slack/route.ts',
|
|
'apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts',
|
|
'apps/sim/app/api/resume/poll/route.ts',
|
|
// MCP routes that take only auth context (no client-supplied params/query/body).
|
|
'apps/sim/app/api/mcp/discover/route.ts',
|
|
'apps/sim/app/api/mcp/tools/stored/route.ts',
|
|
// MCP OAuth callback is the provider redirect target — the response is HTML
|
|
// that closes the popup, so the JSON-mode contract framework doesn't fit.
|
|
// Validation is enforced via state lookup + session-vs-row userId match.
|
|
'apps/sim/app/api/mcp/oauth/callback/route.ts',
|
|
// Deprecated Copilot MCP surface: these routes are gated to always return
|
|
// 410 Gone and consume no client-supplied input.
|
|
'apps/sim/app/api/mcp/copilot/route.ts',
|
|
'apps/sim/app/api/mcp/copilot/.well-known/oauth-authorization-server/route.ts',
|
|
'apps/sim/app/api/mcp/copilot/.well-known/oauth-protected-resource/route.ts',
|
|
// Deprecated v1 headless copilot chat API: gated to always return 410 Gone
|
|
// and consumes no client-supplied input.
|
|
'apps/sim/app/api/v1/copilot/chat/route.ts',
|
|
])
|
|
|
|
/**
|
|
* Routes baseline-allowed to use `await request.json()` / `await req.json()`
|
|
* directly (without an inline `// boundary-raw-json:` annotation).
|
|
*
|
|
* These are legitimately partial: tolerant body parses (`.catch(() => ({}))`),
|
|
* JSON-RPC envelopes that need their own dispatch, multi-stage MCP routes that
|
|
* read pre-parsed bodies, and routes whose Zod-backed migration is queued
|
|
* behind a separate contract / schema authoring step. New routes must NOT
|
|
* introduce raw `await request.json()` reads — annotate the call with
|
|
* `// boundary-raw-json: <reason>` instead.
|
|
*/
|
|
const RAW_JSON_BASELINE_ROUTES = new Set([
|
|
'apps/sim/app/api/billing/portal/route.ts',
|
|
'apps/sim/app/api/copilot/api-keys/generate/route.ts',
|
|
'apps/sim/app/api/copilot/api-keys/validate/route.ts',
|
|
'apps/sim/app/api/copilot/chat/abort/route.ts',
|
|
'apps/sim/app/api/folders/[id]/restore/route.ts',
|
|
'apps/sim/app/api/invitations/[id]/accept/route.ts',
|
|
'apps/sim/app/api/invitations/[id]/reject/route.ts',
|
|
'apps/sim/app/api/invitations/[id]/route.ts',
|
|
'apps/sim/app/api/knowledge/[id]/documents/route.ts',
|
|
'apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts',
|
|
'apps/sim/app/api/mcp/serve/[serverId]/route.ts',
|
|
'apps/sim/app/api/mcp/servers/route.ts',
|
|
'apps/sim/app/api/mcp/servers/[id]/route.ts',
|
|
'apps/sim/app/api/mcp/servers/test-connection/route.ts',
|
|
'apps/sim/app/api/mcp/tools/discover/route.ts',
|
|
'apps/sim/app/api/mcp/tools/execute/route.ts',
|
|
'apps/sim/app/api/mcp/workflow-servers/route.ts',
|
|
'apps/sim/app/api/mcp/workflow-servers/[id]/route.ts',
|
|
'apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts',
|
|
'apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts',
|
|
'apps/sim/app/api/organizations/route.ts',
|
|
'apps/sim/app/api/organizations/[id]/invitations/route.ts',
|
|
'apps/sim/app/api/organizations/[id]/members/route.ts',
|
|
'apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts',
|
|
'apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts',
|
|
'apps/sim/app/api/speech/token/route.ts',
|
|
'apps/sim/app/api/table/[tableId]/rows/route.ts',
|
|
'apps/sim/app/api/tools/file/manage/route.ts',
|
|
'apps/sim/app/api/workspaces/invitations/batch/route.ts',
|
|
'apps/sim/app/api/workspaces/[id]/route.ts',
|
|
'apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts',
|
|
])
|
|
|
|
const CONTRACT_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/contracts(?:\/[^'"]*)?['"]/
|
|
const DECLARATIVE_ROUTE_BUILDER_IMPORT_PATTERN =
|
|
/\bimport\s*\{[^}]*(?:\bdefineInternalJsonRoute\b|\bdefineV2JsonRoute\b|\bdefineInternalBinaryRoute\b|\bdefineV2BinaryRoute\b)[^}]*\}\s*from\s*['"]@\/lib\/api\/server\/routes['"]/
|
|
const DECLARATIVE_ROUTE_BUILDER_USAGE_PATTERN =
|
|
/\b(?:defineInternalJsonRoute|defineV2JsonRoute|defineInternalBinaryRoute|defineV2BinaryRoute)\s*\(/
|
|
const SERVER_VALIDATION_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/server(?:\/validation)?['"]/
|
|
const SCHEMA_PARSE_PATTERN = /\b\w+Schema\.(?:safeParse|parse)\(/
|
|
const CONTRACT_SERVER_HELPER_PATTERN = /\bparseToolRequest\(/
|
|
const CANONICAL_HELPER_USAGE_PATTERN =
|
|
/\b(?:isZodError|validationErrorResponse|validationErrorResponseFromError|getValidationErrorMessage)\s*\(/
|
|
const CONTRACT_MAP_PARSE_PATTERN =
|
|
/\b\w+ContractsByPath[\s\S]{0,600}\.(?:body|query|params)!?\.(?:safeParse|parse)\(/
|
|
/**
|
|
* Matches `from 'zod'` and any zod subpath import like `from 'zod/v4'` or
|
|
* `from 'zod/mini'`. The capturing-group-free alternation keeps this safe to
|
|
* use with `.test(...)` and `.replace(...)` callers.
|
|
*/
|
|
const ZOD_IMPORT_PATTERN = /\bfrom\s+['"]zod(?:\/[^'"]+)?['"]/
|
|
const ZOD_REQUIRE_PATTERN = /\brequire\(['"]zod(?:\/[^'"]+)?['"]\)/
|
|
const ZOD_SCHEMA_CONSTRUCTOR_PATTERN =
|
|
/\bz\.(?:object|string|number|boolean|array|enum|nativeEnum|union|discriminatedUnion|record|literal|tuple|preprocess|coerce|date|unknown|any|instanceof|custom|lazy)\s*\(/g
|
|
const ZOD_ERROR_PATTERN = /\bZodError\b|\bz\.ZodError\b/
|
|
const SKIP_DIRS = new Set(['node_modules', '.next', '.turbo', 'coverage'])
|
|
const WIRE_TYPE_DECLARATION_PATTERN =
|
|
/(?:^|\n)\s*(?:export\s+)?(interface|type)\s+([A-Z]\w*(?:Response|Result))\b(?=\s*(?:=|extends|\{))/g
|
|
const CONTRACT_DERIVED_WIRE_TYPE_PATTERN =
|
|
/\b(?:ContractJsonResponse|ContractJsonErrorResponse|z\.(?:input|output|infer))\b/
|
|
|
|
const RAW_FETCH_PATTERN = /\bfetch\(/g
|
|
const RAW_FETCH_HELPER_GUARD_PATTERN = /(?:requestJson|requestRaw|prefetchJson|preFetchJson)$/
|
|
/**
|
|
* Matches `fetch(` (with optional whitespace, including newlines) followed by
|
|
* a string literal — single quote, double quote, or template literal —
|
|
* whose first character is `/api/`. This catches same-origin internal API
|
|
* fetches in any non-test source file under `apps/sim/**` that aren't an
|
|
* `app/api/**\/route.ts` server handler. Template literals with leading
|
|
* interpolations (e.g. `${base}/api/foo`) are intentionally NOT matched
|
|
* because they're rare and could trigger false positives on non-`/api/` URLs.
|
|
*/
|
|
const SAME_ORIGIN_API_FETCH_PATTERN = /\bfetch\(\s*[`'"]\/api\//g
|
|
const DOUBLE_CAST_PATTERN = /\bas unknown as\b/g
|
|
/**
|
|
* Matches `await request.json()` / `await req.json()` and the multi-line
|
|
* `await request.clone().json()` clone-then-read variant. Both forms read
|
|
* the request body without going through `parseRequest(...)` / a contract
|
|
* and count toward the `rawJsonReads` ratchet.
|
|
*
|
|
* `\s` is multi-line (handles common Prettier/Biome formatting where the
|
|
* `.clone()` and `.json()` calls land on separate lines).
|
|
*/
|
|
const RAW_JSON_READ_PATTERN =
|
|
/\bawait\s+(?:request|req)\s*(?:\.\s*clone\s*\(\s*\))?\s*\.\s*json\s*\(\s*\)/g
|
|
/**
|
|
* Matches `schema:` followed directly by a "validates nothing" zod construct.
|
|
* Three forms are treated equivalently:
|
|
* 1. `schema: z.unknown()` — no validation at all.
|
|
* 2. `schema: z.object({}).passthrough()` — validates only that the value
|
|
* is an object; allows any keys/values.
|
|
* 3. `schema: z.record(z.string(), z.unknown())` — validates only that the
|
|
* value is a string-keyed object; values are arbitrary.
|
|
*
|
|
* Anchored on the literal `schema:` token so that nested `z.unknown()` /
|
|
* `z.object({}).passthrough()` uses inside an otherwise-typed object schema
|
|
* are NOT flagged — only the top-level response declaration in
|
|
* `defineRouteContract({ ..., response: { mode: 'json', schema: ... } })`.
|
|
*/
|
|
const UNTYPED_RESPONSE_PATTERN =
|
|
/\bschema\s*:\s*(?:z\.unknown\s*\(\s*\)|z\.object\s*\(\s*\{\s*\}\s*\)\s*\.passthrough\s*\(\s*\)|z\.record\s*\(\s*z\.string\s*\(\s*\)\s*,\s*z\.unknown\s*\(\s*\)\s*\))/g
|
|
const RAW_FETCH_ANNOTATION_PREFIX = '// boundary-raw-fetch:'
|
|
const DOUBLE_CAST_ANNOTATION_PREFIX = '// double-cast-allowed:'
|
|
const RAW_JSON_ANNOTATION_PREFIX = '// boundary-raw-json:'
|
|
const UNTYPED_RESPONSE_ANNOTATION_PREFIX = '// untyped-response:'
|
|
const SOURCE_FILE_EXTENSIONS = /\.(?:ts|tsx)$/
|
|
const TEST_FILE_PATTERN = /(?:\.test|\.spec)\.(?:ts|tsx)$/
|
|
const TEST_HELPER_FILE_PATTERN = /(?:^|\/)test-[^/]+\.ts$/
|
|
const TEST_DIR_SEGMENT_PATTERN = /(?:^|\/)(?:__tests__|testing)(?:\/|$)/
|
|
/**
|
|
* Skips user-uploaded content stored under `apps/sim/uploads/...` (workspace
|
|
* file uploads, etc.). Does NOT match `apps/sim/lib/uploads/...`, which is
|
|
* source code for the uploads subsystem.
|
|
*/
|
|
const USER_UPLOADS_DIR_PATTERN = /(?:^|\/)apps\/sim\/uploads(?:\/|$)/
|
|
const SOURCE_SKIP_DIRS = new Set([
|
|
'node_modules',
|
|
'.next',
|
|
'.turbo',
|
|
'coverage',
|
|
'dist',
|
|
'__tests__',
|
|
'testing',
|
|
])
|
|
|
|
type AnnotationKind = 'raw-fetch' | 'double-cast' | 'raw-json' | 'untyped-response'
|
|
|
|
interface AnnotationResult {
|
|
allowed: boolean
|
|
missingReason: boolean
|
|
}
|
|
|
|
interface RawFetchFinding {
|
|
path: string
|
|
line: number
|
|
preview: string
|
|
}
|
|
|
|
interface SameOriginApiFetchFinding {
|
|
path: string
|
|
line: number
|
|
preview: string
|
|
}
|
|
|
|
interface DoubleCastFinding {
|
|
path: string
|
|
line: number
|
|
preview: string
|
|
}
|
|
|
|
interface RawJsonFinding {
|
|
path: string
|
|
line: number
|
|
preview: string
|
|
}
|
|
|
|
interface UntypedResponseFinding {
|
|
path: string
|
|
line: number
|
|
preview: string
|
|
}
|
|
|
|
interface AnnotationMissingReasonFinding {
|
|
path: string
|
|
line: number
|
|
kind: AnnotationKind
|
|
}
|
|
|
|
interface RouteAudit {
|
|
path: string
|
|
usesZod: boolean
|
|
hasZodImport: boolean
|
|
schemaConstructorCount: number
|
|
hasZodErrorReference: boolean
|
|
hasBodyRead: boolean
|
|
hasQueryRead: boolean
|
|
hasFormDataRead: boolean
|
|
hasParamsContext: boolean
|
|
}
|
|
|
|
interface WireTypeFinding {
|
|
path: string
|
|
name: string
|
|
line: number
|
|
}
|
|
|
|
interface QueryHookAudit {
|
|
path: string
|
|
hasZodImport: boolean
|
|
schemaConstructorCount: number
|
|
adHocWireTypes: WireTypeFinding[]
|
|
}
|
|
|
|
interface FamilyStats {
|
|
total: number
|
|
zod: number
|
|
nonZod: number
|
|
}
|
|
|
|
type BoundaryPolicyKey = keyof typeof BOUNDARY_POLICY_BASELINE
|
|
|
|
interface BoundaryPolicyMetric {
|
|
key: BoundaryPolicyKey
|
|
label: string
|
|
current: number
|
|
}
|
|
|
|
interface PrintOnlyBoundaryPolicyMetric {
|
|
label: string
|
|
current: number
|
|
}
|
|
|
|
async function walk(
|
|
dir: string,
|
|
shouldIncludeFile: (fileName: string) => boolean,
|
|
results: string[] = []
|
|
): Promise<string[]> {
|
|
const entries = await readdir(dir, { withFileTypes: true })
|
|
|
|
for (const entry of entries) {
|
|
if (SKIP_DIRS.has(entry.name)) continue
|
|
|
|
const fullPath = path.join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
await walk(fullPath, shouldIncludeFile, results)
|
|
} else if (shouldIncludeFile(entry.name)) {
|
|
results.push(fullPath)
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
function lineNumberForIndex(content: string, index: number): number {
|
|
let line = 1
|
|
for (let i = 0; i < index; i++) {
|
|
if (content.charCodeAt(i) === 10) line += 1
|
|
}
|
|
return line
|
|
}
|
|
|
|
/**
|
|
* Inspects up to three consecutive non-empty preceding lines for an
|
|
* opt-out annotation matching the given kind. The annotation is allowed
|
|
* when the matching prefix is followed by a non-empty reason. When the
|
|
* prefix is present but the reason is empty, `missingReason` is set so
|
|
* the audit can flag and fail on dangling annotations.
|
|
*/
|
|
function extractAnnotation(
|
|
content: string,
|
|
lineIndex: number,
|
|
kind: AnnotationKind
|
|
): AnnotationResult {
|
|
const prefix =
|
|
kind === 'raw-fetch'
|
|
? RAW_FETCH_ANNOTATION_PREFIX
|
|
: kind === 'double-cast'
|
|
? DOUBLE_CAST_ANNOTATION_PREFIX
|
|
: kind === 'raw-json'
|
|
? RAW_JSON_ANNOTATION_PREFIX
|
|
: UNTYPED_RESPONSE_ANNOTATION_PREFIX
|
|
const lines = content.split('\n')
|
|
let inspected = 0
|
|
|
|
for (let i = lineIndex - 1; i >= 0 && inspected < 3; i -= 1) {
|
|
const trimmed = lines[i]?.trim() ?? ''
|
|
if (trimmed.length === 0) continue
|
|
inspected += 1
|
|
|
|
if (!trimmed.startsWith('//')) {
|
|
return { allowed: false, missingReason: false }
|
|
}
|
|
|
|
const prefixIndex = trimmed.indexOf(prefix)
|
|
if (prefixIndex === -1) continue
|
|
|
|
const reason = trimmed.slice(prefixIndex + prefix.length).trim()
|
|
if (reason.length === 0) {
|
|
return { allowed: false, missingReason: true }
|
|
}
|
|
return { allowed: true, missingReason: false }
|
|
}
|
|
|
|
return { allowed: false, missingReason: false }
|
|
}
|
|
|
|
/**
|
|
* Walks `apps/sim/**` and optionally `packages/**` for `.ts` / `.tsx`
|
|
* source files, excluding tests, build artifacts, and coverage output.
|
|
* Kept separate from `walk(API_DIR)` because the source-wide audit has
|
|
* different exclusion rules than the route audit.
|
|
*/
|
|
async function walkAllSourceFiles(root: string, includePackages: boolean): Promise<string[]> {
|
|
const roots = includePackages
|
|
? [path.join(root, 'apps/sim'), path.join(root, 'packages')]
|
|
: [path.join(root, 'apps/sim')]
|
|
const results: string[] = []
|
|
|
|
for (const start of roots) {
|
|
await walkSourceTree(start, results)
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
async function walkSourceTree(dir: string, results: string[]): Promise<void> {
|
|
let entries
|
|
try {
|
|
entries = await readdir(dir, { withFileTypes: true })
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (SOURCE_SKIP_DIRS.has(entry.name)) continue
|
|
|
|
const fullPath = path.join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
await walkSourceTree(fullPath, results)
|
|
continue
|
|
}
|
|
|
|
if (!SOURCE_FILE_EXTENSIONS.test(entry.name)) continue
|
|
if (TEST_FILE_PATTERN.test(entry.name)) continue
|
|
if (TEST_HELPER_FILE_PATTERN.test(entry.name)) continue
|
|
if (TEST_DIR_SEGMENT_PATTERN.test(fullPath)) continue
|
|
if (USER_UPLOADS_DIR_PATTERN.test(fullPath)) continue
|
|
|
|
results.push(fullPath)
|
|
}
|
|
}
|
|
|
|
function isContractFetchHelperCall(line: string, matchIndex: number): boolean {
|
|
const before = line.slice(0, matchIndex)
|
|
return RAW_FETCH_HELPER_GUARD_PATTERN.test(before)
|
|
}
|
|
|
|
function buildPreview(line: string): string {
|
|
return line.trim().slice(0, 160)
|
|
}
|
|
|
|
function findRawFetchFindings(
|
|
filePath: string,
|
|
content: string
|
|
): {
|
|
findings: RawFetchFinding[]
|
|
exemptions: number
|
|
missingReasons: AnnotationMissingReasonFinding[]
|
|
} {
|
|
const relativePath = path.relative(ROOT, filePath)
|
|
const lines = content.split('\n')
|
|
const findings: RawFetchFinding[] = []
|
|
const missingReasons: AnnotationMissingReasonFinding[] = []
|
|
let exemptions = 0
|
|
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
const line = lines[i] ?? ''
|
|
RAW_FETCH_PATTERN.lastIndex = 0
|
|
let match: RegExpExecArray | null
|
|
while ((match = RAW_FETCH_PATTERN.exec(line)) !== null) {
|
|
if (isContractFetchHelperCall(line, match.index)) continue
|
|
|
|
const annotation = extractAnnotation(content, i, 'raw-fetch')
|
|
if (annotation.missingReason) {
|
|
missingReasons.push({ path: relativePath, line: i + 1, kind: 'raw-fetch' })
|
|
findings.push({ path: relativePath, line: i + 1, preview: buildPreview(line) })
|
|
continue
|
|
}
|
|
if (annotation.allowed) {
|
|
exemptions += 1
|
|
continue
|
|
}
|
|
findings.push({ path: relativePath, line: i + 1, preview: buildPreview(line) })
|
|
}
|
|
}
|
|
|
|
return { findings, exemptions, missingReasons }
|
|
}
|
|
|
|
function findDoubleCastFindings(
|
|
filePath: string,
|
|
content: string
|
|
): {
|
|
findings: DoubleCastFinding[]
|
|
exemptions: number
|
|
missingReasons: AnnotationMissingReasonFinding[]
|
|
} {
|
|
const relativePath = path.relative(ROOT, filePath)
|
|
const lines = content.split('\n')
|
|
const findings: DoubleCastFinding[] = []
|
|
const missingReasons: AnnotationMissingReasonFinding[] = []
|
|
let exemptions = 0
|
|
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
const line = lines[i] ?? ''
|
|
DOUBLE_CAST_PATTERN.lastIndex = 0
|
|
while (DOUBLE_CAST_PATTERN.exec(line) !== null) {
|
|
const annotation = extractAnnotation(content, i, 'double-cast')
|
|
if (annotation.missingReason) {
|
|
missingReasons.push({ path: relativePath, line: i + 1, kind: 'double-cast' })
|
|
findings.push({ path: relativePath, line: i + 1, preview: buildPreview(line) })
|
|
continue
|
|
}
|
|
if (annotation.allowed) {
|
|
exemptions += 1
|
|
continue
|
|
}
|
|
findings.push({ path: relativePath, line: i + 1, preview: buildPreview(line) })
|
|
}
|
|
}
|
|
|
|
return { findings, exemptions, missingReasons }
|
|
}
|
|
|
|
/**
|
|
* Inspect a route file for `await request.json()` / `await req.json()` reads.
|
|
*
|
|
* Returns one finding per unannotated read. Routes in
|
|
* `RAW_JSON_BASELINE_ROUTES` are baseline-allowed: their reads still appear
|
|
* in `findings` so the `rawJsonReads` ratcheted metric counts them, but they
|
|
* are NOT required to carry per-line `// boundary-raw-json: <reason>`
|
|
* annotations. The ratchet's enforcement is by file count
|
|
* (see `buildBoundaryPolicyMetrics`): adding a raw read in a route outside
|
|
* the baseline pushes the unique-file count above `BOUNDARY_POLICY_BASELINE.rawJsonReads`.
|
|
*
|
|
* Annotated reads (`// boundary-raw-json: <reason>` on one of the three
|
|
* preceding non-empty lines) are treated as exemptions and excluded from
|
|
* `findings`. An annotation with the prefix but an empty reason is flagged
|
|
* via `missingReasons` and still counts as a finding.
|
|
*/
|
|
function findRawJsonFindings(
|
|
filePath: string,
|
|
content: string
|
|
): {
|
|
findings: RawJsonFinding[]
|
|
exemptions: number
|
|
missingReasons: AnnotationMissingReasonFinding[]
|
|
} {
|
|
const relativePath = path.relative(ROOT, filePath)
|
|
const lines = content.split('\n')
|
|
const findings: RawJsonFinding[] = []
|
|
const missingReasons: AnnotationMissingReasonFinding[] = []
|
|
let exemptions = 0
|
|
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
const line = lines[i] ?? ''
|
|
RAW_JSON_READ_PATTERN.lastIndex = 0
|
|
while (RAW_JSON_READ_PATTERN.exec(line) !== null) {
|
|
const annotation = extractAnnotation(content, i, 'raw-json')
|
|
if (annotation.missingReason) {
|
|
missingReasons.push({ path: relativePath, line: i + 1, kind: 'raw-json' })
|
|
findings.push({ path: relativePath, line: i + 1, preview: buildPreview(line) })
|
|
continue
|
|
}
|
|
if (annotation.allowed) {
|
|
exemptions += 1
|
|
continue
|
|
}
|
|
findings.push({ path: relativePath, line: i + 1, preview: buildPreview(line) })
|
|
}
|
|
}
|
|
|
|
return { findings, exemptions, missingReasons }
|
|
}
|
|
|
|
/**
|
|
* Inspect a contracts file for "validates nothing" response schema
|
|
* declarations. Three forms are treated equivalently and all count toward
|
|
* the `untypedResponses` ratchet:
|
|
* - `schema: z.unknown()`
|
|
* - `schema: z.object({}).passthrough()`
|
|
* - `schema: z.record(z.string(), z.unknown())`
|
|
*
|
|
* Anchored on `schema:` so nested uses inside an otherwise-typed object
|
|
* (e.g. `output: z.unknown()` inside `z.object({ ... })`) are NOT flagged.
|
|
* Each callsite must carry a `// untyped-response: <reason>` annotation on
|
|
* one of the three preceding non-empty lines. Annotated callsites become
|
|
* exemptions; un-annotated callsites become findings that count toward the
|
|
* `untypedResponses` ratchet. An annotation with the prefix but an empty
|
|
* reason is flagged via `missingReasons` and still counts as a finding.
|
|
*/
|
|
function findUntypedResponseFindings(
|
|
filePath: string,
|
|
content: string
|
|
): {
|
|
findings: UntypedResponseFinding[]
|
|
exemptions: number
|
|
missingReasons: AnnotationMissingReasonFinding[]
|
|
} {
|
|
const relativePath = path.relative(ROOT, filePath)
|
|
const lines = content.split('\n')
|
|
const findings: UntypedResponseFinding[] = []
|
|
const missingReasons: AnnotationMissingReasonFinding[] = []
|
|
let exemptions = 0
|
|
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
const line = lines[i] ?? ''
|
|
UNTYPED_RESPONSE_PATTERN.lastIndex = 0
|
|
while (UNTYPED_RESPONSE_PATTERN.exec(line) !== null) {
|
|
const annotation = extractAnnotation(content, i, 'untyped-response')
|
|
if (annotation.missingReason) {
|
|
missingReasons.push({ path: relativePath, line: i + 1, kind: 'untyped-response' })
|
|
findings.push({ path: relativePath, line: i + 1, preview: buildPreview(line) })
|
|
continue
|
|
}
|
|
if (annotation.allowed) {
|
|
exemptions += 1
|
|
continue
|
|
}
|
|
findings.push({ path: relativePath, line: i + 1, preview: buildPreview(line) })
|
|
}
|
|
}
|
|
|
|
return { findings, exemptions, missingReasons }
|
|
}
|
|
|
|
function isClientHookFile(filePath: string): boolean {
|
|
const normalized = filePath.replace(/\\/g, '/')
|
|
return (
|
|
normalized.startsWith(`${QUERY_HOOKS_DIR}/`) || normalized.startsWith(`${SELECTOR_HOOKS_DIR}/`)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Identifies `apps/sim/app/api/**\/route.ts` API route handlers. Same-origin
|
|
* `/api/` fetch scanning skips these — server-side fetches from inside a
|
|
* route handler are a different concern and are not what this ratchet is
|
|
* trying to catch.
|
|
*/
|
|
function isApiRouteHandler(filePath: string): boolean {
|
|
const normalized = filePath.replace(/\\/g, '/')
|
|
if (!normalized.startsWith(`${API_DIR}/`)) return false
|
|
return normalized.endsWith('/route.ts')
|
|
}
|
|
|
|
/**
|
|
* Inspect a non-API-route source file under `apps/sim/**` for raw
|
|
* `fetch('/api/...')`, `fetch("/api/...")`, or ``fetch(`/api/...`)`` calls.
|
|
*
|
|
* Each callsite is either an exemption (annotated with
|
|
* `// boundary-raw-fetch: <reason>` on one of the three preceding non-empty
|
|
* lines) or a finding. The ratchet's enforcement is by unique-file count:
|
|
* introducing a same-origin `/api/` fetch without an annotation pushes the
|
|
* unique-file count above `BOUNDARY_POLICY_BASELINE.clientSameOriginApiFetches`
|
|
* and fails the audit.
|
|
*
|
|
* Scanning runs over the entire file content (not line-by-line) so that
|
|
* multi-line constructs like `fetch(\n \`/api/...\`)` are still caught.
|
|
* An annotation with the prefix but an empty reason is flagged via
|
|
* `missingReasons` and still counts as a finding.
|
|
*/
|
|
function findSameOriginApiFetchFindings(
|
|
filePath: string,
|
|
content: string
|
|
): {
|
|
findings: SameOriginApiFetchFinding[]
|
|
exemptions: number
|
|
missingReasons: AnnotationMissingReasonFinding[]
|
|
} {
|
|
const relativePath = path.relative(ROOT, filePath)
|
|
const lines = content.split('\n')
|
|
const findings: SameOriginApiFetchFinding[] = []
|
|
const missingReasons: AnnotationMissingReasonFinding[] = []
|
|
let exemptions = 0
|
|
|
|
SAME_ORIGIN_API_FETCH_PATTERN.lastIndex = 0
|
|
let match: RegExpExecArray | null
|
|
while ((match = SAME_ORIGIN_API_FETCH_PATTERN.exec(content)) !== null) {
|
|
const lineNumber = lineNumberForIndex(content, match.index)
|
|
const lineIndex = lineNumber - 1
|
|
const line = lines[lineIndex] ?? ''
|
|
|
|
const annotation = extractAnnotation(content, lineIndex, 'raw-fetch')
|
|
if (annotation.missingReason) {
|
|
missingReasons.push({ path: relativePath, line: lineNumber, kind: 'raw-fetch' })
|
|
findings.push({ path: relativePath, line: lineNumber, preview: buildPreview(line) })
|
|
continue
|
|
}
|
|
if (annotation.allowed) {
|
|
exemptions += 1
|
|
continue
|
|
}
|
|
findings.push({ path: relativePath, line: lineNumber, preview: buildPreview(line) })
|
|
}
|
|
|
|
return { findings, exemptions, missingReasons }
|
|
}
|
|
|
|
function routeFamily(routePath: string): string {
|
|
const relative = routePath.replace(/^apps\/sim\/app\/api\//, '')
|
|
const [first, second] = relative.split('/')
|
|
|
|
if (first === 'tools') return `tools/${second ?? 'unknown'}`
|
|
if (first === 'v1') return second ? `v1/${second}` : 'v1'
|
|
return first ?? 'unknown'
|
|
}
|
|
|
|
function hasZodUsage(relativePath: string, content: string): boolean {
|
|
if (ZOD_IMPORT_PATTERN.test(content) || ZOD_REQUIRE_PATTERN.test(content)) {
|
|
return true
|
|
}
|
|
if (
|
|
/\bparseRequest\(/.test(content) &&
|
|
/\bfrom\s+['"]@\/lib\/api\/server['"]/.test(content) &&
|
|
CONTRACT_IMPORT_PATTERN.test(content)
|
|
) {
|
|
return true
|
|
}
|
|
if (
|
|
CONTRACT_IMPORT_PATTERN.test(content) &&
|
|
DECLARATIVE_ROUTE_BUILDER_IMPORT_PATTERN.test(content) &&
|
|
DECLARATIVE_ROUTE_BUILDER_USAGE_PATTERN.test(content)
|
|
) {
|
|
return true
|
|
}
|
|
if (
|
|
CONTRACT_IMPORT_PATTERN.test(content) &&
|
|
(SCHEMA_PARSE_PATTERN.test(content) || CONTRACT_MAP_PARSE_PATTERN.test(content))
|
|
) {
|
|
return true
|
|
}
|
|
if (CONTRACT_IMPORT_PATTERN.test(content) && CONTRACT_SERVER_HELPER_PATTERN.test(content)) {
|
|
return true
|
|
}
|
|
if (
|
|
CONTRACT_IMPORT_PATTERN.test(content) &&
|
|
SERVER_VALIDATION_IMPORT_PATTERN.test(content) &&
|
|
CANONICAL_HELPER_USAGE_PATTERN.test(content)
|
|
) {
|
|
return true
|
|
}
|
|
if (
|
|
SERVER_VALIDATION_IMPORT_PATTERN.test(content) &&
|
|
/\b(?:isZodError|validationErrorResponseFromError)\b/.test(content) &&
|
|
SCHEMA_PARSE_PATTERN.test(content)
|
|
) {
|
|
return true
|
|
}
|
|
|
|
return INDIRECT_ZOD_ROUTES.has(relativePath)
|
|
}
|
|
|
|
function auditRoute(filePath: string, content: string): RouteAudit {
|
|
const relativePath = path.relative(ROOT, filePath)
|
|
const schemaConstructorCount = [...content.matchAll(ZOD_SCHEMA_CONSTRUCTOR_PATTERN)].length
|
|
|
|
return {
|
|
path: relativePath,
|
|
usesZod: hasZodUsage(relativePath, content),
|
|
hasZodImport: ZOD_IMPORT_PATTERN.test(content) || ZOD_REQUIRE_PATTERN.test(content),
|
|
schemaConstructorCount,
|
|
hasZodErrorReference: ZOD_ERROR_PATTERN.test(content),
|
|
hasBodyRead: /\brequest\.json\(\)|\breq\.json\(\)/.test(content),
|
|
hasQueryRead: /\.searchParams\b|new URL\([^)]*\)\.searchParams/.test(content),
|
|
hasFormDataRead: /\.formData\(\)/.test(content),
|
|
hasParamsContext: /\bparams\b/.test(content) && /\bPromise<\{|\bRouteContext\b/.test(content),
|
|
}
|
|
}
|
|
|
|
function findAdHocWireTypes(filePath: string, content: string): WireTypeFinding[] {
|
|
const findings: WireTypeFinding[] = []
|
|
const relativePath = path.relative(ROOT, filePath)
|
|
|
|
for (const match of content.matchAll(WIRE_TYPE_DECLARATION_PATTERN)) {
|
|
const kind = match[1]
|
|
const name = match[2]
|
|
const declarationStart = match.index ?? 0
|
|
const declarationPreview = content.slice(declarationStart, declarationStart + 800)
|
|
|
|
if (name.endsWith('QueryResult')) continue
|
|
if (CONTRACT_DERIVED_WIRE_TYPE_PATTERN.test(declarationPreview)) continue
|
|
if (kind === 'type') {
|
|
const equalsIndex = declarationPreview.indexOf('=')
|
|
const typeBody = declarationPreview.slice(equalsIndex + 1).trimStart()
|
|
if (!typeBody.startsWith('{') && !/^[A-Z]\w*\s*&\s*\{/.test(typeBody)) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
findings.push({
|
|
path: relativePath,
|
|
name,
|
|
line: lineNumberForIndex(content, declarationStart),
|
|
})
|
|
}
|
|
|
|
return findings
|
|
}
|
|
|
|
function auditQueryHook(filePath: string, content: string): QueryHookAudit {
|
|
const relativePath = path.relative(ROOT, filePath)
|
|
const schemaConstructorCount = [...content.matchAll(ZOD_SCHEMA_CONSTRUCTOR_PATTERN)].length
|
|
|
|
return {
|
|
path: relativePath,
|
|
hasZodImport: ZOD_IMPORT_PATTERN.test(content) || ZOD_REQUIRE_PATTERN.test(content),
|
|
schemaConstructorCount,
|
|
adHocWireTypes: findAdHocWireTypes(filePath, content),
|
|
}
|
|
}
|
|
|
|
function printFamilyStats(audits: RouteAudit[]) {
|
|
const families = new Map<string, FamilyStats>()
|
|
|
|
for (const audit of audits) {
|
|
const family = routeFamily(audit.path)
|
|
const stats = families.get(family) ?? { total: 0, zod: 0, nonZod: 0 }
|
|
stats.total += 1
|
|
if (audit.usesZod) {
|
|
stats.zod += 1
|
|
} else {
|
|
stats.nonZod += 1
|
|
}
|
|
families.set(family, stats)
|
|
}
|
|
|
|
console.log('\nFamily breakdown:')
|
|
for (const [family, stats] of [...families.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
console.log(
|
|
` ${family.padEnd(32)} total=${String(stats.total).padStart(3)} zod=${String(stats.zod).padStart(3)} nonZod=${String(stats.nonZod).padStart(3)}`
|
|
)
|
|
}
|
|
}
|
|
|
|
function printRiskyNonZodRoutes(audits: RouteAudit[]) {
|
|
const risky = audits.filter(
|
|
(audit) =>
|
|
!audit.usesZod &&
|
|
(audit.hasBodyRead || audit.hasQueryRead || audit.hasFormDataRead || audit.hasParamsContext)
|
|
)
|
|
|
|
console.log(`\nNon-Zod routes with parsed inputs: ${risky.length}`)
|
|
for (const audit of risky.slice(0, 50)) {
|
|
const markers = [
|
|
audit.hasBodyRead ? 'json' : null,
|
|
audit.hasFormDataRead ? 'formData' : null,
|
|
audit.hasQueryRead ? 'query' : null,
|
|
audit.hasParamsContext ? 'params' : null,
|
|
].filter(Boolean)
|
|
console.log(` ${audit.path} (${markers.join(', ')})`)
|
|
}
|
|
|
|
if (risky.length > 50) {
|
|
console.log(` ... ${risky.length - 50} more`)
|
|
}
|
|
}
|
|
|
|
function printAllNonZodRoutes(audits: RouteAudit[]) {
|
|
if (!process.argv.includes('--list-non-zod')) return
|
|
|
|
const nonZod = audits.filter((audit) => !audit.usesZod)
|
|
|
|
console.log(`\nAll non-Zod routes: ${nonZod.length}`)
|
|
for (const audit of nonZod) {
|
|
const markers = [
|
|
audit.hasBodyRead ? 'json' : null,
|
|
audit.hasFormDataRead ? 'formData' : null,
|
|
audit.hasQueryRead ? 'query' : null,
|
|
audit.hasParamsContext ? 'params' : null,
|
|
].filter(Boolean)
|
|
console.log(` ${audit.path}${markers.length > 0 ? ` (${markers.join(', ')})` : ''}`)
|
|
}
|
|
}
|
|
|
|
function buildBoundaryPolicyMetrics(
|
|
routeAudits: RouteAudit[],
|
|
queryHookAudits: QueryHookAudit[],
|
|
rawFetchSummary: { findings: RawFetchFinding[]; exemptions: number },
|
|
sameOriginApiFetchSummary: {
|
|
findings: SameOriginApiFetchFinding[]
|
|
exemptions: number
|
|
},
|
|
doubleCastSummary: { findings: DoubleCastFinding[]; exemptions: number },
|
|
rawJsonSummary: { findings: RawJsonFinding[]; exemptions: number },
|
|
untypedResponseSummary: { findings: UntypedResponseFinding[]; exemptions: number },
|
|
annotationsMissingReason: AnnotationMissingReasonFinding[]
|
|
): {
|
|
ratchetedMetrics: BoundaryPolicyMetric[]
|
|
printOnlyMetrics: PrintOnlyBoundaryPolicyMetric[]
|
|
} {
|
|
const routeZodImports = routeAudits.filter((audit) => audit.hasZodImport)
|
|
const routeLocalSchemaRoutes = routeAudits.filter((audit) => audit.schemaConstructorCount > 0)
|
|
const routeLocalSchemaConstructors = routeLocalSchemaRoutes.reduce(
|
|
(total, audit) => total + audit.schemaConstructorCount,
|
|
0
|
|
)
|
|
const routeZodErrorReferences = routeAudits.filter((audit) => audit.hasZodErrorReference)
|
|
const queryHookZodImports = queryHookAudits.filter((audit) => audit.hasZodImport)
|
|
const queryHookLocalSchemaFiles = queryHookAudits.filter(
|
|
(audit) => audit.schemaConstructorCount > 0
|
|
)
|
|
const queryHookLocalSchemaConstructors = queryHookLocalSchemaFiles.reduce(
|
|
(total, audit) => total + audit.schemaConstructorCount,
|
|
0
|
|
)
|
|
const queryHookAdHocWireTypes = queryHookAudits.flatMap((audit) => audit.adHocWireTypes)
|
|
|
|
return {
|
|
ratchetedMetrics: [
|
|
{
|
|
key: 'routeZodImports',
|
|
label: 'route files importing zod',
|
|
current: routeZodImports.length,
|
|
},
|
|
{
|
|
key: 'routeLocalSchemaRoutes',
|
|
label: 'route files with local schema constructors',
|
|
current: routeLocalSchemaRoutes.length,
|
|
},
|
|
{
|
|
key: 'routeLocalSchemaConstructors',
|
|
label: 'route local schema constructor calls',
|
|
current: routeLocalSchemaConstructors,
|
|
},
|
|
{
|
|
key: 'routeZodErrorReferences',
|
|
label: 'route files referencing ZodError',
|
|
current: routeZodErrorReferences.length,
|
|
},
|
|
{
|
|
key: 'clientHookZodImports',
|
|
label: 'client hook files importing zod',
|
|
current: queryHookZodImports.length,
|
|
},
|
|
{
|
|
key: 'clientHookLocalSchemaFiles',
|
|
label: 'client hook files with local schema constructors',
|
|
current: queryHookLocalSchemaFiles.length,
|
|
},
|
|
{
|
|
key: 'clientHookLocalSchemaConstructors',
|
|
label: 'client hook local schema constructor calls',
|
|
current: queryHookLocalSchemaConstructors,
|
|
},
|
|
{
|
|
key: 'clientHookRawFetches',
|
|
label: 'client hook raw fetch() calls',
|
|
current: rawFetchSummary.findings.length,
|
|
},
|
|
{
|
|
key: 'clientSameOriginApiFetches',
|
|
label: 'apps/sim files with raw same-origin /api/ fetch() calls',
|
|
current: new Set(sameOriginApiFetchSummary.findings.map((finding) => finding.path)).size,
|
|
},
|
|
{
|
|
key: 'doubleCasts',
|
|
label: 'as unknown as double-casts (non-test)',
|
|
current: doubleCastSummary.findings.length,
|
|
},
|
|
{
|
|
key: 'rawJsonReads',
|
|
label: 'route files with raw await request.json() reads',
|
|
current: new Set(rawJsonSummary.findings.map((finding) => finding.path)).size,
|
|
},
|
|
{
|
|
key: 'untypedResponses',
|
|
label:
|
|
'contract untyped response schemas (z.unknown / z.object({}).passthrough / z.record)',
|
|
current: untypedResponseSummary.findings.length,
|
|
},
|
|
{
|
|
key: 'annotationsMissingReason',
|
|
label: 'audit annotations missing reason',
|
|
current: annotationsMissingReason.length,
|
|
},
|
|
],
|
|
printOnlyMetrics: [
|
|
{
|
|
label: 'client hook ad-hoc wire Response/Result types',
|
|
current: queryHookAdHocWireTypes.length,
|
|
},
|
|
{
|
|
label: 'client hook raw fetch() exemptions (annotated)',
|
|
current: rawFetchSummary.exemptions,
|
|
},
|
|
{
|
|
label: 'apps/sim raw same-origin /api/ fetch() callsites',
|
|
current: sameOriginApiFetchSummary.findings.length,
|
|
},
|
|
{
|
|
label: 'apps/sim raw same-origin /api/ fetch() exemptions (annotated)',
|
|
current: sameOriginApiFetchSummary.exemptions,
|
|
},
|
|
{
|
|
label: 'as unknown as double-cast exemptions (annotated)',
|
|
current: doubleCastSummary.exemptions,
|
|
},
|
|
{
|
|
label: 'route raw await request.json() annotated exemptions',
|
|
current: rawJsonSummary.exemptions,
|
|
},
|
|
{
|
|
label: 'contract untyped response annotated exemptions',
|
|
current: untypedResponseSummary.exemptions,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
function printBoundaryPolicyMetric(metric: BoundaryPolicyMetric) {
|
|
const baseline = BOUNDARY_POLICY_BASELINE[metric.key]
|
|
const delta = metric.current - baseline
|
|
const deltaText = delta === 0 ? 'at baseline' : `${delta > 0 ? '+' : ''}${delta} vs baseline`
|
|
console.log(` ${metric.label}: ${metric.current} (${deltaText})`)
|
|
}
|
|
|
|
function printRawFetchAndDoubleCastMetrics(
|
|
rawFetchFindings: RawFetchFinding[],
|
|
sameOriginApiFetchFindings: SameOriginApiFetchFinding[],
|
|
doubleCastFindings: DoubleCastFinding[],
|
|
rawJsonFindings: RawJsonFinding[],
|
|
untypedResponseFindings: UntypedResponseFinding[],
|
|
annotationsMissingReason: AnnotationMissingReasonFinding[],
|
|
rawFetchExemptions: number,
|
|
sameOriginApiFetchExemptions: number,
|
|
doubleCastExemptions: number,
|
|
rawJsonExemptions: number,
|
|
untypedResponseExemptions: number
|
|
) {
|
|
console.log('\nRaw fetch and double-cast metrics:')
|
|
console.log(` client hook raw fetch() calls: ${rawFetchFindings.length}`)
|
|
console.log(` client hook raw fetch() exemptions (annotated): ${rawFetchExemptions}`)
|
|
const sameOriginFiles = new Set(sameOriginApiFetchFindings.map((finding) => finding.path)).size
|
|
console.log(
|
|
` apps/sim files with raw same-origin /api/ fetch() calls: ${sameOriginFiles} (baseline ${BOUNDARY_POLICY_BASELINE.clientSameOriginApiFetches})`
|
|
)
|
|
console.log(
|
|
` apps/sim raw same-origin /api/ fetch() callsites: ${sameOriginApiFetchFindings.length}`
|
|
)
|
|
console.log(
|
|
` apps/sim raw same-origin /api/ fetch() exemptions (annotated): ${sameOriginApiFetchExemptions}`
|
|
)
|
|
console.log(` as unknown as double-casts (non-test): ${doubleCastFindings.length}`)
|
|
console.log(` as unknown as double-cast exemptions (annotated): ${doubleCastExemptions}`)
|
|
const rawJsonFiles = new Set(rawJsonFindings.map((finding) => finding.path)).size
|
|
console.log(
|
|
` route files with raw await request.json() reads: ${rawJsonFiles} (baseline ${BOUNDARY_POLICY_BASELINE.rawJsonReads})`
|
|
)
|
|
console.log(` route raw await request.json() reads (callsites): ${rawJsonFindings.length}`)
|
|
console.log(` route raw await request.json() annotated exemptions: ${rawJsonExemptions}`)
|
|
console.log(
|
|
` contract untyped response schemas (z.unknown / z.object({}).passthrough / z.record): ${untypedResponseFindings.length}`
|
|
)
|
|
console.log(` contract untyped response annotated exemptions: ${untypedResponseExemptions}`)
|
|
console.log(` audit annotations missing reason: ${annotationsMissingReason.length}`)
|
|
|
|
console.log(' raw fetch examples:')
|
|
for (const finding of rawFetchFindings.slice(0, 25)) {
|
|
console.log(` ${finding.path}:${finding.line} ${finding.preview}`)
|
|
}
|
|
if (rawFetchFindings.length > 25) {
|
|
console.log(` ... ${rawFetchFindings.length - 25} more`)
|
|
}
|
|
|
|
console.log(' same-origin /api/ fetch examples:')
|
|
for (const finding of sameOriginApiFetchFindings.slice(0, 25)) {
|
|
console.log(` ${finding.path}:${finding.line} ${finding.preview}`)
|
|
}
|
|
if (sameOriginApiFetchFindings.length > 25) {
|
|
console.log(` ... ${sameOriginApiFetchFindings.length - 25} more`)
|
|
}
|
|
|
|
console.log(' double-cast examples:')
|
|
for (const finding of doubleCastFindings.slice(0, 25)) {
|
|
console.log(` ${finding.path}:${finding.line} ${finding.preview}`)
|
|
}
|
|
if (doubleCastFindings.length > 25) {
|
|
console.log(` ... ${doubleCastFindings.length - 25} more`)
|
|
}
|
|
|
|
console.log(' raw await request.json() examples:')
|
|
for (const finding of rawJsonFindings.slice(0, 25)) {
|
|
console.log(` ${finding.path}:${finding.line} ${finding.preview}`)
|
|
}
|
|
if (rawJsonFindings.length > 25) {
|
|
console.log(` ... ${rawJsonFindings.length - 25} more`)
|
|
}
|
|
|
|
console.log(' untyped response schema examples:')
|
|
for (const finding of untypedResponseFindings.slice(0, 25)) {
|
|
console.log(` ${finding.path}:${finding.line} ${finding.preview}`)
|
|
}
|
|
if (untypedResponseFindings.length > 25) {
|
|
console.log(` ... ${untypedResponseFindings.length - 25} more`)
|
|
}
|
|
|
|
console.log(' annotations missing reason (must be 0 for --enforce-boundary-baseline):')
|
|
for (const finding of annotationsMissingReason.slice(0, 25)) {
|
|
console.log(` ${finding.path}:${finding.line} (${finding.kind})`)
|
|
}
|
|
if (annotationsMissingReason.length > 25) {
|
|
console.log(` ... ${annotationsMissingReason.length - 25} more`)
|
|
}
|
|
|
|
console.log(
|
|
' annotation forms: `// boundary-raw-fetch: <reason>` (raw fetch in client hook OR same-origin /api/ fetch outside an API route handler), `// double-cast-allowed: <reason>` (double-cast), `// boundary-raw-json: <reason>` (raw request.json read), `// untyped-response: <reason>` (z.unknown() / z.object({}).passthrough() / z.record(z.string(), z.unknown()) response schema)'
|
|
)
|
|
}
|
|
|
|
function printBoundaryContractDrift(
|
|
routeAudits: RouteAudit[],
|
|
queryHookAudits: QueryHookAudit[],
|
|
sameOriginApiFetchFindings: SameOriginApiFetchFinding[],
|
|
untypedResponseFindings: UntypedResponseFinding[],
|
|
ratchetedMetrics: BoundaryPolicyMetric[],
|
|
printOnlyMetrics: PrintOnlyBoundaryPolicyMetric[]
|
|
) {
|
|
const zodImportRoutes = routeAudits.filter((audit) => audit.hasZodImport)
|
|
const localSchemaRoutes = routeAudits.filter((audit) => audit.schemaConstructorCount > 0)
|
|
const zodErrorRoutes = routeAudits.filter((audit) => audit.hasZodErrorReference)
|
|
const zodImportQueryHooks = queryHookAudits.filter((audit) => audit.hasZodImport)
|
|
const localSchemaQueryHooks = queryHookAudits.filter((audit) => audit.schemaConstructorCount > 0)
|
|
const adHocWireTypes = queryHookAudits.flatMap((audit) => audit.adHocWireTypes)
|
|
const sameOriginApiFetchFiles = [
|
|
...new Set(sameOriginApiFetchFindings.map((finding) => finding.path)),
|
|
].sort()
|
|
|
|
console.log('\nBoundary policy drift:')
|
|
console.log(' ratcheted metrics:')
|
|
for (const metric of ratchetedMetrics) {
|
|
printBoundaryPolicyMetric(metric)
|
|
}
|
|
console.log(' print-only heuristics:')
|
|
for (const metric of printOnlyMetrics) {
|
|
console.log(` ${metric.label}: ${metric.current}`)
|
|
}
|
|
console.log(
|
|
' ratchet enforcement: pass --enforce-boundary-baseline to fail on ratcheted metric increases'
|
|
)
|
|
console.log(' ratchet update: lower BOUNDARY_POLICY_BASELINE after reducing a ratcheted count')
|
|
|
|
console.log('\nBoundary policy examples:')
|
|
console.log(' route zod import examples:')
|
|
for (const audit of zodImportRoutes.slice(0, 25)) {
|
|
console.log(` ${audit.path}`)
|
|
}
|
|
|
|
if (zodImportRoutes.length > 25) {
|
|
console.log(` ... ${zodImportRoutes.length - 25} more`)
|
|
}
|
|
|
|
console.log(' route local schema constructor examples:')
|
|
for (const audit of localSchemaRoutes.slice(0, 25)) {
|
|
console.log(` ${audit.path} (${audit.schemaConstructorCount})`)
|
|
}
|
|
|
|
if (localSchemaRoutes.length > 25) {
|
|
console.log(` ... ${localSchemaRoutes.length - 25} more`)
|
|
}
|
|
|
|
console.log(' route ZodError reference examples:')
|
|
for (const audit of zodErrorRoutes.slice(0, 25)) {
|
|
console.log(` ${audit.path}`)
|
|
}
|
|
|
|
if (zodErrorRoutes.length > 25) {
|
|
console.log(` ... ${zodErrorRoutes.length - 25} more`)
|
|
}
|
|
|
|
console.log(' client hook zod import examples:')
|
|
for (const audit of zodImportQueryHooks.slice(0, 25)) {
|
|
console.log(` ${audit.path}`)
|
|
}
|
|
|
|
if (zodImportQueryHooks.length > 25) {
|
|
console.log(` ... ${zodImportQueryHooks.length - 25} more`)
|
|
}
|
|
|
|
console.log(' query-hook local schema constructor examples:')
|
|
for (const audit of localSchemaQueryHooks.slice(0, 25)) {
|
|
console.log(` ${audit.path} (${audit.schemaConstructorCount})`)
|
|
}
|
|
|
|
if (localSchemaQueryHooks.length > 25) {
|
|
console.log(` ... ${localSchemaQueryHooks.length - 25} more`)
|
|
}
|
|
|
|
console.log(' query-hook ad-hoc wire type examples:')
|
|
for (const finding of adHocWireTypes.slice(0, 25)) {
|
|
console.log(` ${finding.path}:${finding.line} ${finding.name}`)
|
|
}
|
|
|
|
if (adHocWireTypes.length > 25) {
|
|
console.log(` ... ${adHocWireTypes.length - 25} more`)
|
|
}
|
|
|
|
console.log(' apps/sim same-origin /api/ fetch file examples:')
|
|
for (const filePath of sameOriginApiFetchFiles.slice(0, 25)) {
|
|
console.log(` ${filePath}`)
|
|
}
|
|
|
|
if (sameOriginApiFetchFiles.length > 25) {
|
|
console.log(` ... ${sameOriginApiFetchFiles.length - 25} more`)
|
|
}
|
|
|
|
console.log(' contract untyped response schema examples:')
|
|
for (const finding of untypedResponseFindings.slice(0, 25)) {
|
|
console.log(` ${finding.path}:${finding.line} ${finding.preview}`)
|
|
}
|
|
|
|
if (untypedResponseFindings.length > 25) {
|
|
console.log(` ... ${untypedResponseFindings.length - 25} more`)
|
|
}
|
|
}
|
|
|
|
function boundaryPolicyFailures(metrics: BoundaryPolicyMetric[]): string[] {
|
|
return metrics
|
|
.filter((metric) => metric.current > BOUNDARY_POLICY_BASELINE[metric.key])
|
|
.map(
|
|
(metric) =>
|
|
`${metric.label} increased from ${BOUNDARY_POLICY_BASELINE[metric.key]} to ${metric.current}`
|
|
)
|
|
}
|
|
|
|
async function auditQueryHooks(): Promise<QueryHookAudit[]> {
|
|
const queryHookFiles = await walk(
|
|
QUERY_HOOKS_DIR,
|
|
(fileName) => /\.(ts|tsx)$/.test(fileName) && !/\.test\.(ts|tsx)$/.test(fileName)
|
|
)
|
|
const audits: QueryHookAudit[] = []
|
|
|
|
for (const filePath of queryHookFiles) {
|
|
const content = await readFile(filePath, 'utf8')
|
|
audits.push(auditQueryHook(filePath, content))
|
|
}
|
|
|
|
return audits
|
|
}
|
|
|
|
async function main() {
|
|
const checkOnly = process.argv.includes('--check')
|
|
const enforceBoundaryBaseline = process.argv.includes('--enforce-boundary-baseline')
|
|
const routeFiles = await walk(API_DIR, (fileName) => fileName === 'route.ts')
|
|
const audits: RouteAudit[] = []
|
|
const rawJsonFindings: RawJsonFinding[] = []
|
|
const annotationsMissingReason: AnnotationMissingReasonFinding[] = []
|
|
let rawJsonExemptions = 0
|
|
|
|
for (const filePath of routeFiles) {
|
|
const content = await readFile(filePath, 'utf8')
|
|
audits.push(auditRoute(filePath, content))
|
|
|
|
const rawJson = findRawJsonFindings(filePath, content)
|
|
rawJsonFindings.push(...rawJson.findings)
|
|
rawJsonExemptions += rawJson.exemptions
|
|
annotationsMissingReason.push(...rawJson.missingReasons)
|
|
}
|
|
const queryHookAudits = await auditQueryHooks()
|
|
|
|
const sourceFiles = await walkAllSourceFiles(ROOT, true)
|
|
const rawFetchFindings: RawFetchFinding[] = []
|
|
const sameOriginApiFetchFindings: SameOriginApiFetchFinding[] = []
|
|
const doubleCastFindings: DoubleCastFinding[] = []
|
|
let rawFetchExemptions = 0
|
|
let sameOriginApiFetchExemptions = 0
|
|
let doubleCastExemptions = 0
|
|
|
|
const appsSimRoot = path.join(ROOT, 'apps/sim')
|
|
|
|
for (const filePath of sourceFiles) {
|
|
const content = await readFile(filePath, 'utf8')
|
|
const normalized = filePath.replace(/\\/g, '/')
|
|
|
|
if (isClientHookFile(filePath)) {
|
|
const rawFetch = findRawFetchFindings(filePath, content)
|
|
rawFetchFindings.push(...rawFetch.findings)
|
|
rawFetchExemptions += rawFetch.exemptions
|
|
annotationsMissingReason.push(...rawFetch.missingReasons)
|
|
}
|
|
|
|
if (
|
|
normalized.startsWith(`${appsSimRoot}/`) &&
|
|
!isApiRouteHandler(filePath) &&
|
|
filePath !== path.join(ROOT, 'scripts', 'check-api-validation-contracts.ts')
|
|
) {
|
|
const sameOrigin = findSameOriginApiFetchFindings(filePath, content)
|
|
sameOriginApiFetchFindings.push(...sameOrigin.findings)
|
|
sameOriginApiFetchExemptions += sameOrigin.exemptions
|
|
annotationsMissingReason.push(...sameOrigin.missingReasons)
|
|
}
|
|
|
|
const doubleCast = findDoubleCastFindings(filePath, content)
|
|
doubleCastFindings.push(...doubleCast.findings)
|
|
doubleCastExemptions += doubleCast.exemptions
|
|
annotationsMissingReason.push(...doubleCast.missingReasons)
|
|
}
|
|
|
|
const contractFiles = await walk(CONTRACTS_DIR, (fileName) => /\.ts$/.test(fileName))
|
|
const untypedResponseFindings: UntypedResponseFinding[] = []
|
|
let untypedResponseExemptions = 0
|
|
|
|
for (const filePath of contractFiles) {
|
|
const content = await readFile(filePath, 'utf8')
|
|
const untyped = findUntypedResponseFindings(filePath, content)
|
|
untypedResponseFindings.push(...untyped.findings)
|
|
untypedResponseExemptions += untyped.exemptions
|
|
annotationsMissingReason.push(...untyped.missingReasons)
|
|
}
|
|
|
|
const { ratchetedMetrics, printOnlyMetrics } = buildBoundaryPolicyMetrics(
|
|
audits,
|
|
queryHookAudits,
|
|
{ findings: rawFetchFindings, exemptions: rawFetchExemptions },
|
|
{
|
|
findings: sameOriginApiFetchFindings,
|
|
exemptions: sameOriginApiFetchExemptions,
|
|
},
|
|
{ findings: doubleCastFindings, exemptions: doubleCastExemptions },
|
|
{ findings: rawJsonFindings, exemptions: rawJsonExemptions },
|
|
{ findings: untypedResponseFindings, exemptions: untypedResponseExemptions },
|
|
annotationsMissingReason
|
|
)
|
|
|
|
const totalRoutes = audits.length
|
|
const zodRoutes = audits.filter((audit) => audit.usesZod).length
|
|
const nonZodRoutes = totalRoutes - zodRoutes
|
|
|
|
console.log('API validation route audit')
|
|
console.log(` total routes: ${totalRoutes}`)
|
|
console.log(` Zod-backed routes: ${zodRoutes}`)
|
|
console.log(` non-Zod routes: ${nonZodRoutes}`)
|
|
console.log(
|
|
` baseline: total=${BASELINE.totalRoutes} zod=${BASELINE.zodRoutes} nonZod=${BASELINE.nonZodRoutes}`
|
|
)
|
|
|
|
printFamilyStats(audits)
|
|
printRiskyNonZodRoutes(audits)
|
|
printAllNonZodRoutes(audits)
|
|
printBoundaryContractDrift(
|
|
audits,
|
|
queryHookAudits,
|
|
sameOriginApiFetchFindings,
|
|
untypedResponseFindings,
|
|
ratchetedMetrics,
|
|
printOnlyMetrics
|
|
)
|
|
printRawFetchAndDoubleCastMetrics(
|
|
rawFetchFindings,
|
|
sameOriginApiFetchFindings,
|
|
doubleCastFindings,
|
|
rawJsonFindings,
|
|
untypedResponseFindings,
|
|
annotationsMissingReason,
|
|
rawFetchExemptions,
|
|
sameOriginApiFetchExemptions,
|
|
doubleCastExemptions,
|
|
rawJsonExemptions,
|
|
untypedResponseExemptions
|
|
)
|
|
|
|
if (!checkOnly) return
|
|
|
|
const failures: string[] = []
|
|
if (totalRoutes > BASELINE.totalRoutes) {
|
|
failures.push(`route count increased from ${BASELINE.totalRoutes} to ${totalRoutes}`)
|
|
}
|
|
if (nonZodRoutes > BASELINE.nonZodRoutes) {
|
|
failures.push(
|
|
`non-Zod routes increased from ${BASELINE.nonZodRoutes} to ${nonZodRoutes} (${zodRoutes} Zod-backed routes)`
|
|
)
|
|
}
|
|
if (enforceBoundaryBaseline) {
|
|
failures.push(...boundaryPolicyFailures(ratchetedMetrics))
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
console.error('\nAPI validation audit failed:')
|
|
for (const failure of failures) {
|
|
console.error(` - ${failure}`)
|
|
}
|
|
process.exit(1)
|
|
}
|
|
|
|
console.log('\nAPI validation audit passed.')
|
|
}
|
|
|
|
void main().catch((error) => {
|
|
console.error('API validation audit failed:', error)
|
|
process.exit(1)
|
|
})
|