Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

entitlement-guard

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.

Why this exists

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.

Relationship to CodeQL

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.

Install / build

npm install
npm run build      # tsc -> dist/

Usage

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).

Suppressing a false positive

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.

How detection works

Not a generic taint-tracking engine — a narrow, pragmatic heuristic over a real AST (@babel/parser, TypeScript syntax enabled), on purpose:

  1. 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'), or form.get('tier') where form was itself assigned from c.req.formData() / c.req.json().
    • Member form: req.query.tier, req.body['tier'].
    • next-safe-action Server Action form: a parsedInput function parameter (bare or destructured, optionally renamed), e.g. .action(async ({ parsedInput }) => { ...parsedInput.tier... }).
    • tRPC procedure form: an input function 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... }).
  2. 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.
  3. Sink: the tainted value reaches either:
    • A storage-write call — .bind(...), .put(...), .insert(...), .update(...), .set(...) (D1/KV/generic ORM idioms), or
    • A return of 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.
  4. 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 matching verify*Stripe*Sign* / *Stripe*Verify*Sign* (covers our own verifyStripeSignature helper) — the finding is suppressed. That's the fixed pattern: upgrades only happen inside the verified webhook handler.

Known limitations (intentional, for this narrow MVP)

  • 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-level role/permission to 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 with entitlement-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 tier for later reporting, never read back to make an authorization decision) rather than an entitlement-bearing row like users.role or teams.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): a POST /api/feedback handler stores a client-reported tier string alongside a rating/comment for analytics, with no code anywhere reading it back to gate access. Suppress with entitlement-guard-ignore.

Regression test (the actual proof)

npm test     # builds, then runs test/run-tests.mjs

Ground 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-supplied tier reaches a storage write with no verification. Must produce findings.length >= 1.

  • test/fixtures/verified-tier-write.ts — the fixed shape: the same write path, now gated behind Stripe webhook signature verification. Must produce findings.length === 0.

  • test/fixtures/benign-display-only.ts — synthetic false-positive guard: reads req.query.tier but only logs it; the DB write always uses a literal 'free'. Must produce findings.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.body reaching a .bind() storage write with no Stripe verification. Must produce findings.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 produce findings.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-action parsedInput.tier reaching an unverified DB write, colocated with its form component in a real .tsx file (actual JSX syntax, not just a .tsx extension on TS-only content). Must produce findings.length >= 1.

  • test/fixtures/angle-bracket-cast-ts.ts — guard fixture for the fix behind the above: a plain .ts file using TypeScript's legacy angle-bracket cast syntax (<Foo>bar) must still parse and still trigger a finding. Locks in that the .tsx parsing 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 .ts file using TypeScript legacy decorator syntax (experimentalDecorators: true) reaching an unverified DB write. Must produce findings.length >= 1.

Last verified run:

21 passed, 0 failed

False-positive validation

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 no tier/role/plan/etc. field names at all, so this was a weak test.
  • boxyhq/saas-starter-kit (deliberately chosen for heavy role-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).

Cycle #34: raw-SQL tagged-template sink

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.

Cycle #36: no inline suppression mechanism

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.

Cycle #50: next-safe-action parsedInput payload

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.

Cycle #51: authorized other-user role grants are a documented false-positive class

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.

Cycle #68: storage sink didn't unwrap a helper-call-wrapped object literal

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.

Cycle #70: tRPC input payload was structurally invisible to the source heuristic

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.

Cycle #72: .tsx files were structurally invisible to the parser, not just the rule

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.

Cycle #81: legacy decorator syntax was structurally invisible to the parser, not just the rule

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.

Maintenance

This is a narrow, free tool maintained best-effort. Issues and PRs are welcome; response time is not guaranteed.

About

Static-analysis CLI (GitHub Action) that flags client-controlled tier/plan/role values reaching an entitlement decision without Stripe-webhook-verified gating.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages