diff --git a/rules/go/permission-predicate-call.toml b/rules/go/permission-predicate-call.toml new file mode 100644 index 0000000..6e01d97 --- /dev/null +++ b/rules/go/permission-predicate-call.toml @@ -0,0 +1,143 @@ +[rule] +id = "go-permission-predicate-call" +languages = ["go"] +category = "rbac" +confidence = "medium" +description = "Boolean permission predicate (e.g. IsAdmin(), IsOwner(), CanRead(unit), HasAnyUnitAccess())" +# Sister rule to `go-has-role-call` and `go-permission-check-call`. Those +# require a *string-literal* argument so the rego template can substitute +# the role/permission name. This rule covers the very common Go shape +# where the call is a boolean predicate with no string-literal argument: +# +# user.IsAdmin() +# repo.IsOwner(account) +# perm.CanRead(unit) +# perm.HasAnyUnitAccess() +# +# Surfaced by the corpus shakedown on Gitea, where the entire permission +# model is hand-rolled around predicates of this shape. None matched any +# string-literal-arg rule. +# +# No rego template: the role/permission being checked isn't recoverable +# from the AST, so any generated stub would be misleading. The finding +# itself is still valuable as a policy-decision marker. Confidence is +# `medium` because predicate-style names like `IsActive` or `HasMembers` +# can also describe non-authz state — but the anchored alternation below +# is conservative (admin/owner/access/membership/permission keywords). +# +# Predicate names are explicit alternations rather than a broad +# `Has[A-Z][A-Za-z]+` to avoid duplicate findings with the existing +# string-literal rules (which would also match `HasRole`/`HasPermission`). +query = """ +(call_expression + function: [ + (identifier) @fn_name + (selector_expression field: (field_identifier) @fn_name) + ] +) @match +""" + +[rule.predicates.fn_name] +match = "^(IsAdmin|IsOwner|Is[A-Z][A-Za-z]+Admin|Is[A-Z][A-Za-z]+Owner|Can[A-Z][A-Za-z]+|HasAnyUnitAccess|HasUnitAccess|HasAccess|HasMembership|HasOwnership|Owns[A-Z][A-Za-z]+)$" + +# -- Positive: Gitea-shape predicates -- + +[[rule.tests]] +input = """ +package main + +func handle(repo *Repository) { + if repo.IsAdmin(user) { + deleteRepo() + } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +package main + +func handle(perm *Permission) { + if perm.IsOwner() { + manage() + } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +package main + +func handle(perm *Permission) { + if perm.HasAnyUnitAccess() { + list() + } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +package main + +func handle(perm *Permission, unit Unit) { + if perm.CanRead(unit) { + read() + } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +package main + +func handle(repo *Repository) { + if IsRepoAdmin(repo, user) { + approve() + } +} +""" +expect_match = true + +# -- Negative: not authz-shaped -- + +[[rule.tests]] +input = """ +package main + +func handle(s *Server) { + if s.IsRunning() { + serve() + } +} +""" +expect_match = false + +[[rule.tests]] +input = """ +package main + +func handle(c *Cache) { + if c.HasValue("foo") { + useIt() + } +} +""" +expect_match = false + +# -- Negative: covered by go-has-role-call (kept narrow to avoid duplicates) -- + +[[rule.tests]] +input = """ +package main + +func handle() { + if HasRole("admin") { + deleteAll() + } +} +""" +expect_match = false diff --git a/rules/java/authorized-annotation.toml b/rules/java/authorized-annotation.toml new file mode 100644 index 0000000..5417b22 --- /dev/null +++ b/rules/java/authorized-annotation.toml @@ -0,0 +1,76 @@ +[rule] +id = "java-authorized-annotation" +languages = ["java"] +category = "rbac" +confidence = "high" +description = "OpenMRS-style @Authorized({\"PRIV\"}) annotation" +# OpenMRS's primary policy surface is `@Authorized({"Manage Users"})` +# applied to service/method calls. Surfaced by the corpus shakedown: +# 24 files use it; before this rule we matched zero. +# +# Two query patterns cover the common forms: +# 1. Single string: @Authorized("Manage Users") +# 2. Array of strings: @Authorized({"Manage Users", "Edit Users"}) +# Both feed the same {{role_value}} capture so the rego template emits +# a single privilege-check shape. +query = """ +[ + (annotation + name: (identifier) @anno_name + arguments: (annotation_argument_list + (string_literal (string_fragment) @role_value))) + (annotation + name: (identifier) @anno_name + arguments: (annotation_argument_list + (element_value_array_initializer + (string_literal (string_fragment) @role_value)))) +] @match +""" + +[rule.predicates.anno_name] +eq = "Authorized" + +[rule.rego_template] +template = """ +default allow := false + +allow if { + "{{role_value}}" in input.user.privileges +} +""" + +[[rule.tests]] +input = """ +public class UserService { + @Authorized("Manage Users") + public void delete(User u) { } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +public class UserService { + @Authorized({"Manage Users", "Edit Users"}) + public void update(User u) { } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +public class UserService { + @Override + public void delete(User u) { } +} +""" +expect_match = false + +[[rule.tests]] +input = """ +public class UserService { + @Transactional + public void update(User u) { } +} +""" +expect_match = false diff --git a/rules/python/check-helper-call.toml b/rules/python/check-helper-call.toml new file mode 100644 index 0000000..d3ef82b --- /dev/null +++ b/rules/python/check-helper-call.toml @@ -0,0 +1,87 @@ +[rule] +id = "py-check-helper-call" +languages = ["python"] +category = "abac" +confidence = "medium" +description = "Permission check_* helper call (e.g. check_can_invite_users(user), check_basic_stream_access(...))" +# Surfaced by the corpus shakedown on Zulip, where the actual policy +# decision points are spelled `check_can_*`, `check_basic_*`, +# `check_message_*`, `check_stream_*`, `check_*_access` — bare module +# functions invoked from views/lib code. Sister rule to +# `py-permission-check-call` (which only matches *attribute* calls like +# `user.can("delete")`). +# +# No rego template: the policy intent is encoded in the function name +# itself, not a string argument. Confidence is medium because some +# `check_*` helpers are validation rather than authz (e.g. +# `check_message_format`); the anchored alternation keeps it tight by +# requiring a permission/access keyword in the suffix. +query = """ +(call + function: (identifier) @fn_name +) @match +""" + +[rule.predicates.fn_name] +match = "^(check_can_[a-z_]+|check_basic_[a-z_]+|check_message_[a-z_]+access|check_stream_[a-z_]+access|check_[a-z_]+_access|check_[a-z_]+_permission|check_[a-z_]+_authz)$" + +# -- Positive: Zulip-shape policy helpers -- + +[[rule.tests]] +input = """ +def maybe_invite(): + check_can_invite_users(user_profile, invite_as) + invite() +""" +expect_match = true + +[[rule.tests]] +input = """ +def read_stream(stream): + check_basic_stream_access(user_profile, stream) + return stream.messages.all() +""" +expect_match = true + +[[rule.tests]] +input = """ +def edit(message): + check_message_edit_access(user_profile, message) + do_edit() +""" +expect_match = true + +[[rule.tests]] +input = """ +def view(stream_id): + check_stream_access(user_profile, stream_id) + return render() +""" +expect_match = true + +# -- Negative: validation helpers, not authz -- + +[[rule.tests]] +input = """ +def parse(payload): + check_format(payload) + return payload +""" +expect_match = false + +[[rule.tests]] +input = """ +def post(message): + check_message_format(message) + deliver() +""" +expect_match = false + +# -- Negative: covered by py-permission-check-call -- + +[[rule.tests]] +input = """ +if user.can("delete"): + delete_resource() +""" +expect_match = false diff --git a/rules/python/require-decorator.toml b/rules/python/require-decorator.toml new file mode 100644 index 0000000..e8a94c4 --- /dev/null +++ b/rules/python/require-decorator.toml @@ -0,0 +1,101 @@ +[rule] +id = "py-require-decorator" +languages = ["python"] +category = "middleware" +confidence = "high" +description = "@require_* authz decorator (Zulip-style: @require_realm_admin, @require_member_or_admin, ...)" +# Surfaced by the corpus shakedown on Zulip, where view functions are +# gated by `@require_realm_admin`, `@require_organization_member`, +# `@require_member_or_admin`, etc. Sister rule to +# `py-login-required-decorator` (which matches the exact name +# `login_required`); this one matches the open-ended `require_` +# family. +# +# Accepts both decorator forms (marker and call), as bare identifier or +# attribute reference, mirroring py-login-required-decorator's shape. +query = """ +(decorator + [ + (identifier) @decorator_name + (attribute attribute: (identifier) @decorator_name) + (call + function: [ + (identifier) @decorator_name + (attribute attribute: (identifier) @decorator_name) + ]) + ] +) @match +""" + +[rule.predicates.decorator_name] +match = "^require_[a-z][a-z0-9_]*$" + +# `require_GET` / `require_POST` from django.views.decorators.http are +# HTTP method gates, not authz. The predicate's lowercase-only suffix +# already excludes them; explicit negative test below locks that in. + +[[rule.tests]] +input = """ +@require_realm_admin +def administer(request): + pass +""" +expect_match = true + +[[rule.tests]] +input = """ +@require_member_or_admin +def view_members(request): + pass +""" +expect_match = true + +[[rule.tests]] +input = """ +@require_organization_member(realm_id=1) +def list_streams(request): + pass +""" +expect_match = true + +[[rule.tests]] +input = """ +@auth.require_admin +def manage(request): + pass +""" +expect_match = true + +# -- Negative: HTTP method gates and non-authz decorators -- + +[[rule.tests]] +input = """ +@require_GET +def index(request): + pass +""" +expect_match = false + +[[rule.tests]] +input = """ +@require_POST +def submit(request): + pass +""" +expect_match = false + +[[rule.tests]] +input = """ +@app.route('/') +def index(): + pass +""" +expect_match = false + +[[rule.tests]] +input = """ +@staticmethod +def helper(): + pass +""" +expect_match = false diff --git a/rules/typescript/chained-permission-call.toml b/rules/typescript/chained-permission-call.toml new file mode 100644 index 0000000..daa8db9 --- /dev/null +++ b/rules/typescript/chained-permission-call.toml @@ -0,0 +1,95 @@ +[rule] +id = "ts-chained-permission-call" +languages = ["typescript", "javascript"] +category = "abac" +confidence = "medium" +description = "Chained permission DSL (e.g. canThis(user).edit.post(post), CASL/Pundit-style ability checks)" +# Matches the shape `(...)..(...)` where the +# leftmost callee is a permission entry point. Surfaced by the Ghost +# permission DSL (`canThis(user).edit.post(post)`) and by CASL/Pundit +# ports that follow the same pattern. +# +# Predicate on the callee is a prefix match so common variants like +# `canThis`, `mayDo`, `allowFor`, `checkAbility` all qualify. +# Confidence is medium because the shape is distinctive but the callee +# predicate (`can`/`may`/`allow`/`check`/`ability`) is generic English. +query = """ +(call_expression + function: (member_expression + object: (member_expression + object: (call_expression + function: (identifier) @callee) + property: (property_identifier) @verb) + property: (property_identifier) @resource) +) @match +""" + +[rule.predicates.callee] +match = "(?i)^(can|may|allow|check|ability)([A-Z]\\w*)?$" + +# Stub Rego template. The verb/resource pair is captured as identifiers +# (not string literals), so {{verb}} and {{resource}} substitute in the +# *names* (e.g. "edit", "post") rather than runtime values. Imprecise +# by design — generated policies are meant for manual review. Sister +# rules without literal-arg captures (e.g. java-spring-preauthorize) +# follow the same TODO-stub convention. +[rule.rego_template] +template = """ +default allow := false + +# TODO: chained DSL — captured verb/resource are identifiers, not +# runtime values. Replace input.action/input.resource with the actual +# inputs your policy receives. +allow if { + input.action == "{{verb}}" + input.resource == "{{resource}}" +} +""" + +# -- Positive: Ghost-style permission DSL -- + +[[rule.tests]] +input = """ +if (canThis(user).edit.post(post)) { + editPost(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (canThis(req.user).browse.users()) { + listUsers(); +} +""" +expect_match = true + +# CASL-style: `can(user).read.Article(article)` — the actual CASL DSL is +# slightly different (`ability.can('read', Article)`), but ports/wrappers +# often expose this chained shape. +[[rule.tests]] +input = """ +if (ability(user).read.article(article)) { + showArticle(); +} +""" +expect_match = true + +# -- Negative: not a chained permission DSL -- + +[[rule.tests]] +input = """ +if (api.get.user(id)) { + load(); +} +""" +expect_match = false + +# Wrong shape: only one property between the call and the final invocation. +[[rule.tests]] +input = """ +if (canThis(user).post(post)) { + edit(); +} +""" +expect_match = false diff --git a/rules/typescript/jwt-sign-issue.toml b/rules/typescript/jwt-sign-issue.toml new file mode 100644 index 0000000..fc0258f --- /dev/null +++ b/rules/typescript/jwt-sign-issue.toml @@ -0,0 +1,64 @@ +[rule] +id = "ts-jwt-sign-issue" +languages = ["typescript", "javascript"] +category = "custom" +confidence = "low" +description = "JWT token issuance (jwt.sign) — typically outbound integration tokens, not an authz decision" +# Split out from the original `ts-jwt-token-check` rule. `jwt.sign(...)` +# is token *issuance*, not a policy decision: a service signing a token +# to send outbound (webhook, integration, downstream API) is doing +# crypto, not authorization. Surfaced by the corpus shakedown on Ghost, +# whose report was dominated by sign calls of this shape — deep mode +# would dismiss most. +# +# Kept as a separate rule (rather than dropped entirely) because: +# 1. Token issuance is still relevant during a security review — +# key handling, audience scoping, expiry — even if it isn't an +# authz decision point. +# 2. Users who want to suppress this noise can disable a single +# rule by ID without losing verify/decode coverage. +# +# Confidence is `low` and category is `custom` so it sits below the +# middleware/rbac/abac signal in default reports. +query = """ +(call_expression + function: (member_expression + object: (identifier) @obj + property: (property_identifier) @method) + arguments: (arguments + (_) @arg) +) @match +""" + +[rule.predicates.obj] +match = "(?i)^(jwt|jsonwebtoken|jose|token|auth)$" + +[rule.predicates.method] +eq = "sign" + +[[rule.tests]] +input = """ +const token = jwt.sign({ sub: userId }, secret); +""" +expect_match = true + +[[rule.tests]] +input = """ +const t = jsonwebtoken.sign(payload, secret, { expiresIn: "1h" }); +""" +expect_match = true + +# Verify/decode belong to ts-jwt-verify-decode. +[[rule.tests]] +input = """ +const decoded = jwt.verify(token, secret); +""" +expect_match = false + +# Unrelated signer that happens to expose `.sign` — predicate on the +# object name (jwt|jsonwebtoken|jose|token|auth) keeps these out. +[[rule.tests]] +input = """ +signer.sign(httpRequest); +""" +expect_match = false diff --git a/rules/typescript/jwt-token-check.toml b/rules/typescript/jwt-token-check.toml deleted file mode 100644 index 75123c8..0000000 --- a/rules/typescript/jwt-token-check.toml +++ /dev/null @@ -1,39 +0,0 @@ -[rule] -id = "ts-jwt-token-check" -languages = ["typescript", "javascript"] -category = "middleware" -confidence = "medium" -description = "JWT token verification or authorization header access" -query = """ -(call_expression - function: (member_expression - object: (identifier) @obj - property: (property_identifier) @method) - arguments: (arguments - (_) @arg) -) @match -""" - -[rule.predicates.obj] -match = "(?i)^(jwt|jsonwebtoken|jose|token|auth)$" - -[rule.predicates.method] -match = "^(verify|decode|sign)$" - -[[rule.tests]] -input = """ -const decoded = jwt.verify(token, secret); -""" -expect_match = true - -[[rule.tests]] -input = """ -signer.sign(httpRequest); -""" -expect_match = false - -[[rule.tests]] -input = """ -decoder.decode(value, { stream: true }); -""" -expect_match = false diff --git a/rules/typescript/jwt-verify-decode.toml b/rules/typescript/jwt-verify-decode.toml new file mode 100644 index 0000000..4477b5d --- /dev/null +++ b/rules/typescript/jwt-verify-decode.toml @@ -0,0 +1,64 @@ +[rule] +id = "ts-jwt-verify-decode" +languages = ["typescript", "javascript"] +category = "middleware" +confidence = "medium" +description = "JWT token verification or decode (authz decision point)" +# Split out from the original `ts-jwt-token-check` rule. The `verify` +# and `decode` family are authz decision points — they extract the +# claims the request will be authorized against. The companion rule +# `ts-jwt-sign-issue` covers `sign`, which is token *issuance* +# (typically for outbound integrations) — different category, lower +# confidence, kept separate so users can filter without losing the +# authz signal. +# +# Surfaced by the corpus shakedown on Ghost: its report was dominated +# by `jwt.sign(...)` calls — outbound token issuance, not authz +# decisions. Keeping verify/decode here at medium confidence preserves +# the authz signal while moving sign-issuance noise to its own rule. +query = """ +(call_expression + function: (member_expression + object: (identifier) @obj + property: (property_identifier) @method) + arguments: (arguments + (_) @arg) +) @match +""" + +[rule.predicates.obj] +match = "(?i)^(jwt|jsonwebtoken|jose|token|auth)$" + +[rule.predicates.method] +match = "^(verify|decode)$" + +[[rule.tests]] +input = """ +const decoded = jwt.verify(token, secret); +""" +expect_match = true + +[[rule.tests]] +input = """ +const claims = jsonwebtoken.decode(token); +""" +expect_match = true + +# `sign` now belongs to ts-jwt-sign-issue. +[[rule.tests]] +input = """ +const token = jwt.sign({ sub: userId }, secret); +""" +expect_match = false + +[[rule.tests]] +input = """ +signer.sign(httpRequest); +""" +expect_match = false + +[[rule.tests]] +input = """ +decoder.decode(value, { stream: true }); +""" +expect_match = false diff --git a/rules/typescript/membership-check-call.toml b/rules/typescript/membership-check-call.toml new file mode 100644 index 0000000..8124234 --- /dev/null +++ b/rules/typescript/membership-check-call.toml @@ -0,0 +1,95 @@ +[rule] +id = "ts-membership-check-call" +languages = ["typescript", "javascript"] +category = "rbac" +confidence = "high" +description = "Membership or role helper call (e.g. belongsTo(\"team-42\"), isTeamAdmin(\"team-42\"), userIsOwner(\"org-1\"))" +# Sister rule to ts-permission-check-call. Surfaced by the Cal.com +# corpus shakedown: methods like `belongsTo`, `isTeamAdmin`, +# `isOrgOwner`, `hasMembership`, and the `userIs` family are +# first-class authz checks, but the literal argument is typically a +# *resource identifier* (e.g. "team-42", "org-1") rather than a +# permission name. Mixing them into ts-permission-check-call would +# misshape the generated rego (`input.action == "team-42"` is +# nonsense), so they live here with a membership-shaped template. +# +# Per the project learning: keep role-checking and permission-checking +# rule logic intentionally separated. +query = """ +(call_expression + function: (member_expression + property: (property_identifier) @method) + arguments: (arguments + (string) @resource_id) +) @match +""" + +[rule.predicates.method] +match = "^(belongsTo|isTeamAdmin|isOrgOwner|hasMembership|userIs[A-Z][A-Za-z]*)$" + +# Stub template. {{resource_id}} substitutes the captured resource/team +# identifier. The membership shape is encoded as a set lookup against +# input.user.memberships — adjust to match your policy's actual claim +# shape (e.g. nested objects with role+resource pairs). +[rule.rego_template] +template = """ +default allow := false + +# TODO: membership check — replace with your policy's actual membership +# shape. Captured resource_id is a team/org/resource identifier, not a +# permission name. +allow if { + "{{resource_id}}" in input.user.memberships +} +""" + +[[rule.tests]] +input = """ +if (membership.belongsTo("team-42")) { + showTeam(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (acl.isTeamAdmin("team-42")) { + manageTeam(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (session.userIsOwner("org-1")) { + manageOrg(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (user.hasMembership("workspace-7")) { + enter(); +} +""" +expect_match = true + +# Negative: covered by ts-permission-check-call (kept separate per +# the role-vs-permission separation rule). +[[rule.tests]] +input = """ +if (user.can("delete")) { + remove(); +} +""" +expect_match = false + +# Negative: not a membership helper. +[[rule.tests]] +input = """ +if (cache.has("foo")) { + use(); +} +""" +expect_match = false diff --git a/rules/typescript/permission-check-call.toml b/rules/typescript/permission-check-call.toml index 983a3eb..774b543 100644 --- a/rules/typescript/permission-check-call.toml +++ b/rules/typescript/permission-check-call.toml @@ -4,6 +4,17 @@ languages = ["typescript", "javascript"] category = "abac" confidence = "high" description = "Permission or capability check function call" +# Allow-style ABAC verbs: can, hasPermission, checkPermission, isAllowed, +# allows, hasAccess, checkAccess, hasClaim. Deny-style (cannot, denies) +# are accepted for completeness, though the rego template below is +# allow-shaped. +# +# Role/membership helpers (belongsTo, isTeamAdmin, isOrgOwner, +# hasMembership, userIs) intentionally live in +# ts-membership-check-call. Their literal arg is typically a *resource +# identifier* (e.g. "team-42") rather than a permission name, so they +# need a different rego template shape. Keeping the rules separated +# preserves the project's role-vs-permission separation. query = """ (call_expression function: (member_expression diff --git a/rules/typescript/role-check-conditional.toml b/rules/typescript/role-check-conditional.toml index 08553f8..7b5a362 100644 --- a/rules/typescript/role-check-conditional.toml +++ b/rules/typescript/role-check-conditional.toml @@ -4,19 +4,31 @@ languages = ["typescript", "javascript"] category = "rbac" confidence = "high" description = "Direct role comparison in conditional expression" +# Matches `if (.role === "ADMIN")` and the negated variant +# `if (.role !== "ADMIN")`. The LHS is a member_expression so the +# query already accepts arbitrarily nested paths like `session.user.role` +# (Next.js/Cal.com handlers) — the property captured is the rightmost +# identifier. +# +# The captured @op is currently informational; the rego template emits an +# allow-shaped `==` regardless of operator. For `!==` matches the +# generated stub will be inverted from the original intent — flagged here +# rather than silently dropping the finding, since the policy decision +# point itself is what matters; the operator inversion is a small manual +# fix during extract. query = """ (if_statement condition: (parenthesized_expression (binary_expression left: (member_expression property: (property_identifier) @prop) - operator: ["==" "==="] + operator: ["==" "===" "!=" "!=="] @op right: (string) @role_value)) ) @match """ [rule.predicates.prop] -match = "^(role|roles|userRole|userType)$" +match = "^(role|roles|roleName|userRole|userType)$" [rule.predicates.role_value] not_match = "^[\"'](assistant|user|system|tool|function)[\"']$" @@ -38,26 +50,47 @@ if (user.role === "admin") { """ expect_match = true +# Cal.com / Next.js handler shape: nested member access on the LHS. [[rule.tests]] input = """ -if (user.name === "admin") { - greet(); +if (session.user.role === "ADMIN") { + showAdminPanel(); } """ -expect_match = false +expect_match = true +# Negated comparison: typical "block if not admin" guard. We match this +# now (per corpus shakedown — Cal.com uses `!==` extensively). [[rule.tests]] input = """ -if (msg.role === "assistant") { - processResponse(); +if (session.user.role !== "ADMIN") { + throw new ForbiddenError(); +} +""" +expect_match = true + +# `roleName` is a common variant (e.g. NextAuth session.user.roleName). +[[rule.tests]] +input = """ +if (session.user.roleName === "ADMIN") { + manage(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (user.name === "admin") { + greet(); } """ expect_match = false +# LLM message-role conversation pattern, not authz. [[rule.tests]] input = """ -if (user.role !== "banned") { - proceed(); +if (msg.role === "assistant") { + processResponse(); } """ expect_match = false diff --git a/rules/typescript/trpc-procedure.toml b/rules/typescript/trpc-procedure.toml new file mode 100644 index 0000000..37a0be7 --- /dev/null +++ b/rules/typescript/trpc-procedure.toml @@ -0,0 +1,87 @@ +[rule] +id = "ts-trpc-procedure" +languages = ["typescript", "javascript"] +category = "middleware" +confidence = "high" +description = "tRPC authz procedure builder used at a call site (protectedProcedure, adminProcedure, ...)" +# tRPC's idiomatic authz pattern is a procedure builder: +# +# export const fooRouter = createTRPCRouter({ +# bar: protectedProcedure.input(z.object({...})).query(...), +# baz: adminProcedure.mutation(...), +# }); +# +# Each `protectedProcedure.(...)` chain is a distinct policy +# decision point: the procedure builder injects the authz middleware +# (auth check, role gate) into every endpoint built from it. +# +# Surfaced by the corpus shakedown on Cal.com — this rule wraps every +# endpoint declared via an authed procedure builder. Sister rule to +# `ts-nestjs-use-guards` (same role for NestJS controllers). +# +# Match shape: `(member_expression object: (identifier) @proc_name)` +# fires exactly once per chain (the leftmost member access whose object +# is a bare identifier), so `protectedProcedure.use(mw).input(x).query(y)` +# yields a single finding rather than one per chained method. +# +# Predicate is `[a-z][A-Za-z]*Procedure` so well-known builders +# (protectedProcedure, adminProcedure, authedProcedure, orgProcedure, +# teamProcedure) and project-specific ones all qualify. Anchored to +# avoid `someUnrelatedProcedure` slipping through unintentionally — +# the suffix is distinctive enough that false positives are rare in +# practice. +# +# No rego template: the policy lives in the procedure builder's +# middleware, not at the call site, so a stub generated here would be +# misleading. The finding is the policy-decision-point marker. +query = """ +(member_expression + object: (identifier) @proc_name +) @match +""" + +[rule.predicates.proc_name] +match = "^[a-z][A-Za-z]*Procedure$" + +# -- Positive: tRPC authz builders -- + +[[rule.tests]] +input = """ +export const list = protectedProcedure + .input(z.object({ teamId: z.string() })) + .query(({ ctx, input }) => ctx.db.team.findMany()); +""" +expect_match = true + +[[rule.tests]] +input = """ +export const remove = adminProcedure.mutation(({ ctx }) => ctx.db.team.delete()); +""" +expect_match = true + +[[rule.tests]] +input = """ +export const m = authedProcedure.use(rateLimit).mutation(handler); +""" +expect_match = true + +# Custom org-scoped procedure +[[rule.tests]] +input = """ +export const x = orgProcedure.query(handler); +""" +expect_match = true + +# -- Negative: not a procedure builder -- + +[[rule.tests]] +input = """ +const result = api.user.findMany(); +""" +expect_match = false + +[[rule.tests]] +input = """ +const x = obj.method(); +""" +expect_match = false diff --git a/src/rules/embedded.rs b/src/rules/embedded.rs index dde9352..17e5fab 100644 --- a/src/rules/embedded.rs +++ b/src/rules/embedded.rs @@ -28,6 +28,10 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ "nestjs-use-guards", include_str!("../../rules/typescript/nestjs-use-guards.toml"), ), + ( + "trpc-procedure", + include_str!("../../rules/typescript/trpc-procedure.toml"), + ), ( "nestjs-roles-decorator", include_str!("../../rules/typescript/nestjs-roles-decorator.toml"), @@ -40,13 +44,25 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ "permission-check-call", include_str!("../../rules/typescript/permission-check-call.toml"), ), + ( + "chained-permission-call", + include_str!("../../rules/typescript/chained-permission-call.toml"), + ), + ( + "membership-check-call", + include_str!("../../rules/typescript/membership-check-call.toml"), + ), ( "session-auth-check", include_str!("../../rules/typescript/session-auth-check.toml"), ), ( - "jwt-token-check", - include_str!("../../rules/typescript/jwt-token-check.toml"), + "jwt-verify-decode", + include_str!("../../rules/typescript/jwt-verify-decode.toml"), + ), + ( + "jwt-sign-issue", + include_str!("../../rules/typescript/jwt-sign-issue.toml"), ), ( "ownership-check", @@ -73,6 +89,10 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ "java-roles-allowed-array", include_str!("../../rules/java/spring-roles-allowed-array.toml"), ), + ( + "java-authorized-annotation", + include_str!("../../rules/java/authorized-annotation.toml"), + ), ( "java-spring-permit-all", include_str!("../../rules/java/spring-permit-all.toml"), @@ -146,6 +166,10 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ "py-login-required-decorator", include_str!("../../rules/python/login-required-decorator.toml"), ), + ( + "py-require-decorator", + include_str!("../../rules/python/require-decorator.toml"), + ), ( "py-django-user-passes-test", include_str!("../../rules/python/django-user-passes-test.toml"), @@ -170,6 +194,10 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ "py-permission-check-call", include_str!("../../rules/python/permission-check-call.toml"), ), + ( + "py-check-helper-call", + include_str!("../../rules/python/check-helper-call.toml"), + ), ( "py-ownership-check", include_str!("../../rules/python/ownership-check.toml"), @@ -187,6 +215,10 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ "go-permission-check-call", include_str!("../../rules/go/permission-check-call.toml"), ), + ( + "go-permission-predicate-call", + include_str!("../../rules/go/permission-predicate-call.toml"), + ), ( "go-role-check-conditional", include_str!("../../rules/go/role-check-conditional.toml"),