Static-analysis CLI that scans a TS/JS repo for one specific bug class: a client-controlled value (query param, form field, request body/header) flowing into an entitlement/quota/tier/plan/role decision, without a matching Stripe-webhook-verified (or otherwise trusted server-side) source gating it.
This is a real bug class: a client-supplied tier/plan/role query
param or form field gets trusted straight into a storage write or an
entitlement decision, handing out paid-tier access for free.
entitlement-guard exists to catch that class of bug before it reaches
production.
It's a variant of broken access control / IDOR (OWASP A01) and mass assignment — but the specific shape (client-controlled value reaching an entitlement decision with no server-side verification in between) is one that pattern-based scanners like CodeQL and Semgrep consistently miss, since it requires tracing a value's provenance rather than matching a comparison pattern.
Detection only. No GitHub Action wiring into a workflow, no billing code, no dashboard, no deployment — the scope is deliberately this narrow: one rule, proven against real ground truth, before growing.
Longer writeup on why this bug class survives code review: Mass assignment (CWE-915) in Node/TypeScript APIs.
We shipped this exact bug in one of our own products: a client-supplied
tier value was trusted straight into a storage write, handing out
paid-tier limits for free. No fuzzing, no exploit — just an unverified
field from the request reaching a write. We fixed it, then wrote a
deterministic AST check so it can't happen again, and are publishing that
check as a standalone tool. This is not a general SAST platform and isn't
trying to be one — see "Relationship to CodeQL" below for where it does
and doesn't overlap with tools you may already run.
We run CodeQL too. Its default query set catches comparison-based
authorization bypass (e.g. if (userId !== req.params.userId)).
entitlement-guard tracks the opposite path: a client-controlled value
that is never compared, just carried straight into a storage write or API
response. Overlapping territory, different mechanism, and a narrower
scope on purpose. If you already run CodeQL's security-extended pack, you
have partial coverage of this bug class already — this tool closes one
specific gap in it, nothing more.
npm install
npm run build # tsc -> dist/node dist/cli.js scan <path>Scans every .ts/.tsx/.js/.jsx/.mjs/.cjs file under <path>
(skipping node_modules, .git, dist, build, .wrangler, out,
.next), and prints JSON to stdout:
{
"findings": [
{
"file": "src/index.ts",
"line": 254,
"rule": "unverified-client-tier",
"snippet": ".bind(userId, keyName, keyHash, tier, monthlyLimit)",
"suggestion": "Do not trust client-supplied tier/plan/role. Grant only the free/default tier at registration; upgrade only via a Stripe-signature-verified webhook handler."
}
]
}Exit code 1 if findings.length > 0, exit code 0 if clean — usable
directly as a CI gate. A composite Action wrapper (action.yml) is
included so any repo can wire this in via:
- uses: <owner>/<repo>@<ref>
with:
scan-path: .If you npm link the package, entitlement-guard scan <path> works too
(the bin entry points at dist/cli.js).
Put entitlement-guard-ignore in a comment on the finding's own line, or
the line directly above it, and that finding is dropped:
await c.env.DB
.prepare('INSERT INTO api_keys (id, tier) VALUES (?, ?)')
// entitlement-guard-ignore: tier is re-validated server-side downstream (TICKET-123)
.bind(userId, tier)
.run();Every real CI-gating static analysis tool needs one of these (// nosemgrep,
# nosec, //nolint:gosec, ESLint's eslint-disable-next-line) — without
it, a single false positive on a repo that gates CI on this tool has no fix
short of restructuring working code to dodge the heuristic, or dropping the
tool from CI entirely. Write a reason after a colon, same as you would for
any other suppression comment — it's not parsed, but the next person
reading the diff will want to know why.
This is matched as a literal substring, not parsed as an actual code
comment — entitlement-guard-ignore appearing anywhere on either line
(including inside a string literal) suppresses that line's finding. Same
pragmatic tradeoff as the rest of this scanner: distinctive enough in
practice that comment-awareness isn't worth the added surface.
Not a generic taint-tracking engine — a narrow, pragmatic heuristic over a
real AST (@babel/parser, TypeScript syntax enabled), on purpose:
- Source: an extraction of a sensitive field name —
tier,plan,role,isAdmin/is_admin,subscription,entitlement,permission,accessLevel/access_level(case/underscore-insensitive) — from a request-shaped object. Covers both:- Call form:
c.req.query('tier'),c.req.header('tier'),req.headers.get('tier'), orform.get('tier')whereformwas itself assigned fromc.req.formData()/c.req.json(). - Member form:
req.query.tier,req.body['tier']. - next-safe-action Server Action form: a
parsedInputfunction parameter (bare or destructured, optionally renamed), e.g..action(async ({ parsedInput }) => { ...parsedInput.tier... }). - tRPC procedure form: an
inputfunction parameter (bare or destructured, optionally renamed) of the callback passed to a procedure builder's.mutation(...)/.query(...), e.g..input(schema).mutation(async ({ input }) => { ...input.tier... }).
- Call form:
- Taint propagation: the extracted value stays "tainted" through
simple pass-throughs only — direct identifier references,
.trim()/.toLowerCase()/.toUpperCase()/.toString(),? :ternaries, and??/||fallbacks. It deliberately does not propagate through comparisons (x === 'pro') or arbitrary function calls — a boolean derived from a tainted value is a different value, not the same secret escaping through a back door, and treating it as such would flag ordinary UI-hint code that never touches the real entitlement. - Sink: the tainted value reaches either:
- A storage-write call —
.bind(...),.put(...),.insert(...),.update(...),.set(...)(D1/KV/generic ORM idioms), or - A
returnof the bare identifier, or of an object literal (optionally one call-layer deep, e.g.c.json({...})) with a sensitive key whose value is tainted.
- A storage-write call —
- Gate check: if the nearest enclosing function (or the file, if the
sink is top-level) contains a call that looks like Stripe signature
verification —
stripe.webhooks.constructEvent[Async], or an identifier/method matchingverify*Stripe*Sign*/*Stripe*Verify*Sign*(covers our ownverifyStripeSignaturehelper) — the finding is suppressed. That's the fixed pattern: upgrades only happen inside the verified webhook handler.
- No cross-file analysis — a source and its sink must be in the same file.
- No propagation through object/array indexing (
TIER_LIMITS[tier]) — only direct identifier / simple-wrapper chains and single-level object destructuring straight off a request-shaped source (const { role } = req.body— see below). Nested destructuring (const { user: { role } } = req.body) and rest elements are still out of scope. - String-literal field/key names only — dynamic property access
(
req.query[someVar]) is out of scope. - One rule (
unverified-client-tier). This is by design per the premortem gate: prove the one rule works against real ground truth before growing the rule set. - The gate check only recognizes Stripe-webhook verification as "trusted
server-side gating" — an authorization/permission check (e.g.
verifyFolderAccess(...)) does not suppress a finding. This means a legitimate admin action that grants a resource-levelrole/permissionto a different user, gated by authorization rather than Stripe, will be flagged as a false positive. This is intentional, not a bug — see "Cycle #51" below for why broadening the gate to trust generic permission-check calls was considered and rejected. Suppress real instances withentitlement-guard-ignore. - No sink-side reasoning about whether the destination actually grants
anything — any storage write of a sensitive-named field is flagged, even
when the destination is a pure record/log (an analytics or feedback
table storing a user's self-reported
tierfor later reporting, never read back to make an authorization decision) rather than an entitlement-bearing row likeusers.roleorteams.plan. Distinguishing the two would require knowing what every downstream read of that column is used for — full dataflow analysis, explicitly out of scope for this narrow MVP (see "One rule" above). Found dogfooding against a real Next.js app (Sherlemious/IGCSE-Pseudocode-Online-Compiler): aPOST /api/feedbackhandler stores a client-reportedtierstring alongside a rating/comment for analytics, with no code anywhere reading it back to gate access. Suppress withentitlement-guard-ignore.
npm test # builds, then runs test/run-tests.mjsGround truth is a minimal reproduction of a real production bug this tool
was built to catch (a client-controlled tier form field trusted straight
into a storage write, fixed by gating the upgrade behind Stripe webhook
verification):
-
test/fixtures/unverified-tier-write.ts— the buggy shape: a client-suppliedtierreaches a storage write with no verification. Must producefindings.length >= 1. -
test/fixtures/verified-tier-write.ts— the fixed shape: the same write path, now gated behind Stripe webhook signature verification. Must producefindings.length === 0. -
test/fixtures/benign-display-only.ts— synthetic false-positive guard: readsreq.query.tierbut only logs it; the DB write always uses a literal'free'. Must producefindings.length === 0. -
test/fixtures/destructured-role.ts— regression fixture for a real recall gap found scanning an unrelated open-source repo (see below):const { role } = req.bodyreaching a.bind()storage write with no Stripe verification. Must producefindings.length >= 1. -
test/fixtures/trpc-input-tier.ts— regression fixture for a real recall gap found dogfooding against a real tRPC-based production app (see "Cycle #70" below):input.tier, via tRPC's.input(schema).mutation(async ({ input }) => {...})convention, reaching a storage write with no Stripe verification. Must producefindings.length >= 1. -
test/fixtures/jsx-server-action-tier.tsx— regression fixture for a real recall gap found dogfooding against a real, JSX-heavy Next.js monorepo (see "Cycle #72" below): a next-safe-actionparsedInput.tierreaching an unverified DB write, colocated with its form component in a real.tsxfile (actual JSX syntax, not just a.tsxextension on TS-only content). Must producefindings.length >= 1. -
test/fixtures/angle-bracket-cast-ts.ts— guard fixture for the fix behind the above: a plain.tsfile using TypeScript's legacy angle-bracket cast syntax (<Foo>bar) must still parse and still trigger a finding. Locks in that the.tsxparsing fix is scoped by extension, not applied unconditionally. -
test/fixtures/decorator-class-tier-write.ts— regression fixture for a real recall gap found dogfooding against a real, decorator-heavy production app (see "Cycle #81" below): a.tsfile using TypeScript legacy decorator syntax (experimentalDecorators: true) reaching an unverified DB write. Must producefindings.length >= 1.
Last verified run:
21 passed, 0 failed
Checked the false-positive rate against real, unrelated codebases (not written by us) before treating this as usable:
stripe-samples/subscription-use-cases(47 TS/JS files, real Stripe subscription/webhook code) — 0 findings, and grep-confirmed it has notier/role/plan/etc. field names at all, so this was a weak test.boxyhq/saas-starter-kit(deliberately chosen for heavyrole-based team-permission code, e.g.pages/api/teams/[slug]/members.ts) — 0 findings. This repo genuinely has a client →role→ DB-write path, but it extracts the field via destructuring (const { role } = req.body), which the source-detection heuristic did not cover at the time (see Known limitations above) — a false negative, not a false positive.
Result: 0/2 false positives. The real risk this surfaced is the
opposite one — destructuring is a very common real-world extraction
pattern and this missed it, so recall against real code was likely lower
than fixture-only tests suggested. That gap has since been fixed: single-
level object destructuring off a request-shaped source (const { role } = req.body) is now tracked into the taint set the same as a direct
member/call extraction. See test/fixtures/destructured-role.ts for the
regression test, modeled directly on the boxyhq pattern found here.
One real bug this validation caught: scanning a directory recursively also
picked up this package's own test/fixtures/unverified-tier-write.ts,
which intentionally contains the buggy pattern — that would have made a
CI gate permanently red on this very repo. Fixed by adding
test/tests/fixtures/__fixtures__/__tests__ to the scanner's
ignored-directories list (src/scanner.ts).
The sink check only recognized CallExpression nodes (.bind(),
.set({...}), .create({...})), so a TaggedTemplateExpression — Prisma's
raw-query escape hatch prisma.$executeRaw`UPDATE users SET role = ${role}`,
or a bare sql`...` tag from @vercel/postgres/postgres.js/Kysely —
was structurally invisible to it, despite being the same unverified-
client-tier bug expressed as raw SQL instead of an ORM call. Confirmed
against a synthetic repro (zero findings before the fix) and checked for
false positives against real usage of $executeRaw/$executeRawUnsafe
found via GitHub code search (gab-cat/merchtrack-mobile,
Recuperiamo/gestionale-recuperiamo, untlsn/classy) — all real
occurrences found were parameterless TRUNCATE TABLE "X" seed-script
calls with no client-tainted interpolation, correctly producing 0
findings. Fixed by adding a dedicated TaggedTemplateExpression sink
check: $executeRaw/$executeRawUnsafe tags are always in scope; a bare
sql tag is only in scope when its literal text carries an
UPDATE/INSERT/REPLACE keyword (tag name alone is ambiguous with a
read-only SELECT). See test/fixtures/raw-sql-tagged-template-write.ts
and test/fixtures/raw-sql-readonly-query.ts for the regression tests.
Every prior cycle's dogfooding checked entitlement-guard's detection
against real external repos, but never checked its documentation and
config conventions against how other real static-analysis tools present
themselves. Doing that this cycle surfaced a gap: entitlement-guard is
explicitly pitched above as "usable directly as a CI gate," but had no way
to suppress an individual false positive — every comparable CI-gating tool
(semgrep's // nosemgrep, Bandit's # nosec, gosec's //nolint:gosec,
ESLint's eslint-disable-next-line) ships one, because without it a single
false positive on a gated repo has no fix short of restructuring working
code to dodge the heuristic, or dropping the tool from CI outright. Fixed
by adding src/suppress.ts: a finding is dropped if its own line, or the
line directly above it, contains the literal token
entitlement-guard-ignore in a comment (see "Suppressing a false positive"
above). See test/fixtures/suppressed-tier-write.ts for the regression
test.
Every prior cycle's dogfooding checked entitlement-guard against Express/
Hono-style req-object handlers. Running it against a real, large,
production Next.js app with genuine Stripe subscription/tier logic
(formbricks/formbricks) for the first time surfaced a structural blind
spot: the source-detection heuristic only recognized req.*/c.req.*
chains, so any app using next-safe-action
Server Actions — where the client-supplied, schema-validated payload
arrives as a parsedInput function parameter, not a request-object chain
(.action(async ({ parsedInput }) => { ...parsedInput.tier... })) — was
structurally invisible to the rule, regardless of whether it was
vulnerable. Confirmed with a synthetic textbook case (parsedInput.tier
flowing straight into an unverified db.organization.update()): zero
findings before the fix. Fixed by treating a parsedInput function
parameter (bare or destructured, optionally renamed) the same as a req
source. Formbricks' own actions were re-scanned after the fix and still
produce zero findings — its real plan-change code only ever calls Stripe
checkout/webhook helpers, and its real role-management code delegates the
actual DB write to a separate function in another file, which stays out of
scope per this tool's documented same-file-only sink analysis (see Known
limitations). See test/fixtures/action-parsed-input-tier.ts for the
regression test.
Running entitlement-guard against dubinc/dub (~20k-star, production
link-management SaaS built on real Prisma + next-safe-action Server
Actions) surfaced a genuine false positive:
apps/web/lib/actions/folders/update-folder-user-role.ts takes
parsedInput.role and writes it straight into
prisma.folderUser.upsert(...) in the same file, with no Stripe
verification call anywhere in the function — entitlement-guard flags it.
This is not the bug class the tool exists to catch. The role here is not
the caller's own account/subscription role; it is a folder-collaborator
permission being granted to a different user
(if (user.id === userId) throw ... explicitly guards against
self-grants), gated by a plan-capability check
(getPlanCapabilities(workspace.plan).canManageFolderPermissions) and an
authorization check
(verifyFolderAccess({ ..., requiredPermission: "folders.users.write" }))
— legitimate admin-grants-permission-to-teammate code, not client-side
subscription self-escalation.
Considered and rejected: teaching the gate check to also recognize generic
authorization/permission-check calls (e.g. a name matching
verify.*Access/check.*Permission) as a trusted server-side gate, the
same way Stripe verification is recognized. Rejected because that is
precisely the bug class this tool explicitly declines to own (see
"Relationship to CodeQL" above) — an insufficient or wrong authorization
check is the single most common real broken-access-control bug, and a
name-based allowlist for "looks like a permission check" would silently
suppress genuine findings where the check exists but doesn't check the
right thing. Widening the gate this way would trade a rare false-positive
class for a much larger, silent false-negative class in the tool's actual
target bug.
No code change. Documented as a known limitation (see above):
entitlement-guard will flag legitimate other-user role/permission grants
that are authorization-gated rather than Stripe-gated. Suppress real
instances with entitlement-guard-ignore (see "Suppressing a false
positive"). test/fixtures/authorized-role-grant-known-fp.ts locks in
this intentional behavior as a regression test, so it can't be silently
"fixed" into a false negative later without the tradeoff above being
revisited on purpose.
Running entitlement-guard against Portabase/portabase (real production
Next.js SaaS, next-safe-action + Drizzle) surfaced a recall gap:
src/features/organizations/actions/role-member.action.ts takes
parsedInput.role and writes it into
.update(member).set(withUpdatedAt({ role: parsedInput.role })) — same
file, no Stripe verification anywhere in the function, textbook unverified-
role-write. entitlement-guard produced zero findings.
Root cause: the storage-sink check only recognized a bare object-literal
argument (.set({ role }), Cycle #29) or one nested under a named ORM
wrapper key (.create({ data: { role } }), also Cycle #29). It never
unwrapped a single-argument helper call wrapping the object literal —
withUpdatedAt(...)/withTimestamps(...)-style helpers that spread the
caller's fields plus add a server-set timestamp are a common idiom right
before an ORM write. The response-leak sink (checkLeakedReturn) already
unwrapped exactly this shape for c.json({...})-style calls; the
storage-sink check never got the equivalent treatment. Fixed by
extracting that unwrap into a shared findObjectLiteralArg helper and
using it in both sink checks. See
test/fixtures/wrapped-object-literal-write.ts for the regression test.
Running entitlement-guard against documenso/documenso (real production
e-signature SaaS, tRPC + Prisma, teams/organisations/roles + Stripe billing)
produced zero findings. Grepping the codebase for sensitive field names
combined with request-shaped sources confirmed this was not simply a clean
repo — team-role-management code genuinely takes a client-supplied role
and reaches a storage write in the same file — but every such path was
either destructured through an extra non-sensitive intermediate (const { data } = input; ...data.role..., out of scope per the already-documented
nested-destructuring limitation) or authorization-gated in a way that would
have fallen under the existing Cycle #51 accepted-false-positive class if
detected at all.
The actual, general gap surfaced by isolating a synthetic textbook case: a
direct .input(schema).mutation(async ({ input }) => { const { tier } = input; ...unverified DB write... }) — structurally identical to
stripe-samples' sibling parsedInput pattern from Cycle #50, just under
tRPC's naming convention instead of next-safe-action's — produced zero
findings. Root cause: matchSensitiveExtraction/isRequestObjectChain only
recognized req.*/c.req.* chains and a parsedInput function parameter
as request-shaped sources; tRPC's input parameter, delivered the same way
by the callback passed to a procedure builder's .mutation(...)/
.query(...), was never seeded into reqSourceVars at all. tRPC is one of
the most common type-safe API patterns in the TS ecosystem (used by
documenso, cal.com, and most T3-stack apps), so this was a structural blind
spot on a widely-used idiom, not a rare edge case.
Fixed by adding seedTrpcInputSourceVars: for any ArrowFunctionExpression/
FunctionExpression that is literally the callback argument of a
.mutation(...)/.query(...) call, its input parameter (bare or
destructured, optionally renamed) is seeded into reqSourceVars the same
way parsedInput already was. Scoped to that specific call-site shape
(not "any function with a parameter named input") to avoid misfiring on
unrelated code using that common parameter name — confirmed with a
synthetic check that a generic .query(async ({ input }) => {...}) call
whose input.tier only reaches a logger.info(...) call (never a sink)
still produces zero findings. See test/fixtures/trpc-input-tier.ts for
the regression test. Re-scanning documenso after the fix still produces
zero findings — its team-role-write paths reach the sink through the
nested-destructuring shape mentioned above, which remains a separate,
already-documented limitation, not this cycle's fix target.
Running entitlement-guard against openstatusHQ/openstatus (real production
uptime-monitoring SaaS, tRPC + Drizzle, workspace plans/roles + Stripe
billing) produced zero findings. Manual verification of every plausible
client-input-to-storage-write path (invitation accept/create, SSO
just-in-time provisioning, the Stripe checkout/addon/webhook routers, the
workspace router) confirmed this was a genuinely clean codebase for this bug
class, not a missed finding: role is only ever set to a server-controlled
literal ("member") or copied from a trusted, already-persisted invitation
row, plan changes only ever reach the database from the Stripe-webhook
handler, and no member-role-update endpoint exists in the API at all.
Instrumenting the scan directly (bypassing the CLI's aggregate output)
surfaced a much larger problem than "this repo is clean," though: 825 of the
repo's 856 .tsx files (96%) were throwing a parse error and hitting
analyzeFile's catch-all, which returns [] silently with no signal
anywhere that the file was skipped rather than clean. Root cause:
@babel/parser's jsx plugin has to be opted into separately from
typescript — passing only plugins: ['typescript'], as this tool always
had, throws on any file containing actual JSX syntax, which is nearly every
real .tsx/.jsx file. .tsx is one of the six extensions this tool
advertises scanning (see "Usage" above); this was not a rare edge case, it
was the common case failing silently on one of two TypeScript source
extensions.
Fixed by scoping the jsx plugin to file extension via a new
babelPluginsFor helper: enabled for every extension except plain .ts.
.ts is deliberately excluded — TypeScript's legacy angle-bracket cast
syntax (<Foo>bar) is valid there but disallowed in .tsx for exactly this
reason (a genuine parse ambiguity with JSX when both plugins are active
together), so enabling jsx unconditionally would have traded the .tsx
false negative for a new .ts one. Re-scanning openstatus after the fix:
0 parse failures across all 2125 scanned files (was 825), findings still
0 — matching the manual line-by-line verification above. See
test/fixtures/jsx-server-action-tier.tsx (the recall-gap regression) and
test/fixtures/angle-bracket-cast-ts.ts (the .ts-must-stay-unaffected
guard) for the regression tests.
Widened the dogfooding search strategy this cycle: instead of searching for
generic "saas stripe" repos (six straight true negatives across five prior
cycles), searched GitHub code search directly for the vulnerable pattern
(req.body.role, req.body.isAdmin) to find repos more likely to contain
the actual bug shape. This surfaced two new targets:
charmverse/app.charmverse.io (a production DAO-ops SaaS) and
OneUptime/oneuptime (~7k-star production monitoring/observability SaaS).
charmverse produced one finding
(apps/webapp/pages/api/spaces/[id]/members/[userId]/index.ts:66,
isAdmin: req.body.isAdmin) that, on inspection, is another instance of the
already-documented Cycle #51 accepted false-positive class: the write is
gated by requireSpaceMembership({ adminOnly: true }) authorization
middleware, not Stripe verification, and explicitly guards against
self-grants. No code change — confirms the Cycle #51 tradeoff is still the
right call, this time against a different real codebase.
OneUptime produced zero findings, but instrumenting the scan directly
(the same check that caught Cycle #72's .tsx parsing gap) surfaced a much
larger problem: 1,015 of the repo's 7,042 scanned files (14.4%) were
throwing a parse error and hitting analyzeFile's catch-all, silently
returning zero findings with no signal anywhere that the file was skipped.
Root cause: TypeScript's legacy decorator syntax
(experimentalDecorators: true in the repo's own tsconfig.json, used
throughout its class-based ORM models and DI-heavy services —
@CaptureSpan(), @ColumnAccessControl()-style class/method decorators) is
not standard typescript-plugin syntax in @babel/parser; it requires the
separate decorators-legacy plugin, the same way real JSX syntax required
the separate jsx plugin in Cycle #72. Same failure shape, different
opted-out syntax, found the same way: instrumenting parse failures directly
instead of trusting the CLI's silent per-file catch on faith.
Fixed by adding decorators-legacy to babelPluginsFor, applied
unconditionally (not scoped by extension like jsx — unlike the JSX/
angle-bracket-cast ambiguity, decorators-legacy has no known parse
conflict with plain .ts files that don't use decorator syntax at all).
Re-scanning OneUptime after the fix: 0 parse failures across all 7,042
files (was 1,015), findings still 0 — a genuine true negative now backed by
full parse coverage, not a partial scan. Regression-checked against two
previously-clean dogfood targets (boxyhq/saas-starter-kit,
formbricks/formbricks) to confirm the new plugin introduces no new false
positives: both still 0 findings, 100% parse coverage, unchanged. See
test/fixtures/decorator-class-tier-write.ts for the regression test.
This is a narrow, free tool maintained best-effort. Issues and PRs are welcome; response time is not guaranteed.