diff --git a/AGENTS.md b/AGENTS.md index 4a82dc0..874a91d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,8 @@ cargo clippy --all-features -- -D warnings ### Language support - v0.1: TypeScript, JavaScript, Java, Python, Go, C# -- v0.2 (planned): Kotlin, Ruby, PHP +- v0.2: Kotlin (Spring, Ktor) +- planned: Ruby, PHP ## Conventional Commits & Versioning diff --git a/Cargo.lock b/Cargo.lock index f6cdbff..e781ddd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2506,6 +2506,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-kotlin-ng" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e800ebbda938acfbf224f4d2c34947a31994b1295ee6e819b65226c7b51b4450" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-language" version = "0.1.7" @@ -3261,6 +3271,7 @@ dependencies = [ "tree-sitter-go", "tree-sitter-java", "tree-sitter-javascript", + "tree-sitter-kotlin-ng", "tree-sitter-python", "tree-sitter-typescript", "url", diff --git a/Cargo.toml b/Cargo.toml index 268d184..94658f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ tree-sitter-javascript = "0.25" tree-sitter-python = "0.25" tree-sitter-go = "0.25" tree-sitter-c-sharp = "0.23" +tree-sitter-kotlin-ng = "1.1" ignore = "0.4" sha2 = "0.11" regex = "1" diff --git a/README.md b/README.md index 56c96dd..f0070f6 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ zift report . # detailed findings report 1. **Structural scan** (tree-sitter) — fast, deterministic, zero-cost. Finds known authorization patterns: role checks, permission guards, auth middleware, security annotations. -2. **Semantic scan** (`--deep`, opt-in) — sends candidate code regions to an LLM that classifies authorization logic the structural pass missed or misjudged. Useful for business rules that implicitly encode access control, and for languages where structural support hasn't shipped yet (Kotlin, Ruby, PHP, etc.). +2. **Semantic scan** (`--deep`, opt-in) — sends candidate code regions to an LLM that classifies authorization logic the structural pass missed or misjudged. Useful for business rules that implicitly encode access control, and for languages where structural support hasn't shipped yet (Ruby, PHP, etc.). ## Supported languages @@ -38,7 +38,7 @@ zift report . # detailed findings report | Python | yes (v0.1) | yes (v0.1) | Django, Flask, FastAPI | | Go | yes (v0.1) | yes (v0.1) | Gin, Echo | | C# | yes (v0.2) | yes (v0.1) | ASP.NET Core | -| Kotlin | planned (v0.2) | yes (v0.1) | Spring (Kotlin) | +| Kotlin | yes (v0.2) | yes (v0.1) | Spring (Kotlin), Ktor | | Ruby | planned (v0.2) | yes (v0.1) | Rails | | PHP | planned (v0.2) | yes (v0.1) | Laravel | diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 392465b..abdae0e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -200,7 +200,7 @@ allow if { ## Language support -These priorities describe release milestones. C# ships in the v0.2 milestone. +These priorities describe release milestones. C# shipped in the v0.2 milestone; Kotlin (Spring + Ktor) followed in v0.2.x. ### Priority 1 (v0.1) @@ -217,12 +217,12 @@ These priorities describe release milestones. C# ships in the v0.2 milestone. | Python | Django (`@permission_required`, `has_perm()`), Flask-Login, FastAPI `Depends()` | | Go | Custom middleware, Casbin, chi/gorilla middleware chains, `if claims.Role` | | C# | ASP.NET Core `[Authorize]`, policy-based authorization, `ClaimsPrincipal` checks | +| Kotlin | Spring Security (same patterns as Java), Ktor `install(Authentication)` + `authenticate { ... }`, idiomatic role checks | ### Priority 3 (v0.3) | Language | Key frameworks / patterns | |----------|--------------------------| -| Kotlin | Spring Security (same patterns as Java), Ktor auth plugins | | Ruby | Pundit, CanCanCan, Devise, `before_action` guards | | PHP | Laravel Gates/Policies, Symfony Voters | diff --git a/docs/corpus/README.md b/docs/corpus/README.md index 51ff479..e348d81 100644 --- a/docs/corpus/README.md +++ b/docs/corpus/README.md @@ -29,6 +29,7 @@ We are **not** shipping policies for these projects. The runs exist to stress-te | Python | [zulip/zulip](https://github.com/zulip/zulip) | 39 | 28 (decorator + lib/users subset) | 14 of 25 deep findings in `decorator.py` are the `@require_*` family — adding one rule converts them all to structural. See [python.md](python.md). | | Go | [go-gitea/gitea](https://github.com/go-gitea/gitea) | 18 | 23 (perm subset) | Deep surfaces the entire `IsAdmin`/`IsOwner`/`Has*` family the structural pass missed; one predicate widening on `go-has-role-call` closes most of the gap. See [go.md](go.md). | | C# | [bitwarden/server](https://github.com/bitwarden/server) | 318 | 88 (AdminConsole subset) | ASP.NET Core resource authorization dominates structurally; deep surfaces generic `[Authorize]`, ownership checks, and helper gates. See [csharp.md](csharp.md). | +| Kotlin | [ktorio/ktor-samples](https://github.com/ktorio/ktor-samples) | 13 | — | Ktor `install(Authentication)` + named `authenticate(...) { ... }` route guards account for every finding; Spring-Kotlin rules need a separate corpus target to calibrate. See [kotlin.md](kotlin.md). | > The "deep" column is intentionally a **scoped subset** rather than the whole repo — running deep against 5,000+ files per language is neither cheap nor necessary to surface gaps. Each per-language doc explains the subset and why. > diff --git a/docs/corpus/kotlin.md b/docs/corpus/kotlin.md new file mode 100644 index 0000000..703956a --- /dev/null +++ b/docs/corpus/kotlin.md @@ -0,0 +1,71 @@ +# Kotlin — Ktor Samples + +Real-world results from running Zift against [ktorio/ktor-samples](https://github.com/ktorio/ktor-samples), the canonical Ktor sample apps maintained by JetBrains. + +## Why this target + +Ktor is the dominant Kotlin-native server framework, and `ktor-samples` is the corpus the Ktor team itself uses to demo and regression-test the framework's authentication, sessions, and routing DSLs. It exercises every Ktor auth shape Zift's structural pass aims at: `install(Authentication) { ... }` plugin registration with `jwt`, `basic`, `digest`, `oauth`, and `bearer` providers; `authenticate("name") { ... }` route guards (named and no-args); `install(Sessions)` cookie-backed sessions. There is no Spring Security here and no idiomatic role-comparison code — this target stresses the **Ktor side** of v0.2's Kotlin rule set, leaving the Spring-Kotlin rules to be exercised against future targets. + +## Target metadata + +| | | +|---|---| +| Repo | [ktorio/ktor-samples](https://github.com/ktorio/ktor-samples) | +| Commit | `c89f051e` | +| Kotlin files (`.kt` + `.kts`) | 204 | +| LOC (`.kt`) | 12,254 | +| Externalized PaC | None observed | + +## Structural pass + +```bash +zift scan ~/zift-corpus/kotlin/ktor-samples --format json -o structural.json +``` + +| | | +|---|---| +| Wall time | 0.96s | +| Peak RSS | ~54 MB | +| Total findings | **13** | +| Files with findings | 8 | +| Externalized % | 0% (no policy-import enforcement points emitted) | + +**Findings per rule** + +| Rule | Count | +|------|------:| +| `kotlin-ktor-authenticate-block` | 7 | +| `kotlin-ktor-install-authentication` | 6 | + +**Findings per category** + +| Category | Count | +|----------|------:| +| `middleware` | 13 | + +**Top findings (sample)** + +| File | Line | Snippet | +|------|-----:|---------| +| `httpbin/.../httpbin/Auth.kt` | 15 | `authenticate("basic")` | +| `httpbin/.../httpbin/Auth.kt` | 38 | `authenticate("bearer")` | +| `httpbin/.../httpbin/Auth.kt` | 61 | `authenticate("digest")` | +| `httpbin/.../httpbin/Server.kt` | 108 | `install(Authentication)` | +| `jwt-auth-tests/.../jwtauth/Main.kt` | 54 | `install(Authentication)` | +| `jwt-auth-tests/.../jwtauth/Main.kt` | 90 | `authenticate("auth-jwt")` | +| `openapi/.../Routing.kt` | 13 | `authenticate("auth-oauth-google")` | +| `kweet/.../kweet/KweetApplication.kt` | 145 | `install(Sessions)` | + +The Ktor rules pick up every named-authenticator route guard cleanly and the entire `install(Authentication | Sessions)` family across the sample tree. + +## Gaps & follow-ups + +**Expected zero spring-kotlin findings.** Ktor samples don't use Spring Security, so the `kotlin-spring-preauthorize`, `kotlin-spring-secured`, `kotlin-roles-allowed`, `kotlin-has-role-call`, `kotlin-role-equals-check`, and `kotlin-role-collection-contains` rules contribute nothing here. They're exercised by the inline rule tests (`cargo run -- rules test`) and need a different corpus target — a real Spring-Boot-Kotlin codebase — for end-to-end calibration. + +**FP risk: low.** Every match is a real Ktor auth surface. The `install(Sessions)` matches are deliberate — Ktor sessions back authentication state in nearly every sample that uses them, and the rule's `Authentication | Authorization | Sessions` predicate is calibrated to keep them in scope. + +**Coverage caveat.** `ktor-samples` is intentionally minimal — the `httpbin` and `jwt-auth-tests` modules carry most of the auth surface area. The 13 findings here are not a stress test of Kotlin scale, just of Ktor pattern coverage. A larger Kotlin corpus target (e.g., a real Spring-Kotlin service) would be the natural next addition for v0.2.x. + +## Deep pass + +Not run for this target. The structural pass cleanly enumerates the Ktor auth surface; deep would primarily add noise on the credential-validator lambdas (`validate { credentials -> ... }`) that the structural pass already attributes to their enclosing `authenticate { ... }` block. diff --git a/rules/kotlin/has-role-call.toml b/rules/kotlin/has-role-call.toml new file mode 100644 index 0000000..9d895a6 --- /dev/null +++ b/rules/kotlin/has-role-call.toml @@ -0,0 +1,103 @@ +[rule] +id = "kotlin-has-role-call" +languages = ["kotlin"] +category = "rbac" +confidence = "high" +description = "Spring Security hasRole/hasAuthority method call (Kotlin)" +# Kotlin call expressions on a navigation chain look like +# `(call_expression (navigation_expression ... (identifier) @method_name) ...)`. +# We match the trailing identifier of the navigation as the method name and +# the first string argument as the role value. Field-access args (e.g. +# `Role.ADMIN`) and bare identifiers (e.g. `roleName`) are accepted too — +# the Rego stub flags them as TODO but the finding still records. +query = """ +(call_expression + (navigation_expression + (_) + (identifier) @method_name) + (value_arguments + (value_argument + [ + (string_literal (string_content) @role_value) + (navigation_expression) @role_value + (identifier) @role_value + ])) +) @match +""" + +[rule.predicates.method_name] +match = "^(hasRole|hasAnyRole|hasAuthority|hasAnyAuthority)$" + +[rule.rego_template] +template = """ +default allow := false + +# granted_authority covers both roles (e.g. "ROLE_ADMIN") and +# authorities/permissions (e.g. "SCOPE_read") per Spring Security's +# GrantedAuthority abstraction. +allow if { + input.user.granted_authority in {"{{role_value}}"} +} +""" + + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" +[[rule.tests]] +input = """ +class Service { + fun check(acct: Account) { + if (!acct.hasRole("ADMIN")) { throw Forbidden() } + } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +class Service { + fun check(acct: Account) { + if (acct.hasAuthority("SCOPE_reports.read")) { allow() } + } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +class Service { + fun check(acct: Account) { + if (!acct.hasRole(Role.ADMIN)) { throw Forbidden() } + } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +class Service { + fun check(acct: Account, roleName: String) { + if (acct.hasRole(roleName)) { allow() } + } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +class Service { + fun open(req: Request) { + req.permitAll() + } +} +""" +expect_match = false diff --git a/rules/kotlin/ktor-authenticate-block.toml b/rules/kotlin/ktor-authenticate-block.toml new file mode 100644 index 0000000..f7cb554 --- /dev/null +++ b/rules/kotlin/ktor-authenticate-block.toml @@ -0,0 +1,86 @@ +[rule] +id = "kotlin-ktor-authenticate-block" +languages = ["kotlin"] +category = "middleware" +confidence = "high" +description = "Ktor authenticate { ... } route guard (Kotlin)" +# Ktor's `authenticate { ... }` and `authenticate(\"auth-jwt\") { ... }` both +# emit a `call_expression` whose function is the bare identifier +# `authenticate`. The form WITHOUT string args looks like +# `(call_expression (identifier) (annotated_lambda ...))`; the form WITH +# string args wraps in an inner call: +# `(call_expression (call_expression (identifier) (value_arguments ...)) +# (annotated_lambda ...))`. We match the inner shape — the bare-identifier +# call with a value_arguments list — which fires once per `authenticate(...)` +# regardless of whether a trailing lambda follows. The no-args +# `authenticate { ... }` form is caught by the alternate query branch. +query = """ +[ + (call_expression + (identifier) @method_name + (value_arguments + (value_argument + (string_literal (string_content) @config_name)))) + (call_expression + (identifier) @method_name + (annotated_lambda) @lambda) +] @match +""" + +[rule.predicates.method_name] +eq = "authenticate" + +[rule.rego_template] +template = """ +default allow := false + +# Ktor authenticate { ... } guard. The chosen authenticator (named via +# `authenticate("config")` or the default authenticator otherwise) governs +# which principals reach the enclosed routes. Translate to a policy that +# asserts the principal is authenticated via the matching config. +allow if { + input.user.authenticated == true +} +""" + + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal has authenticated && principal.authenticated == true +}; +""" +[[rule.tests]] +input = """ +fun Application.module() { + authenticate("auth-jwt") { + get("/admin") { call.respondText("hi") } + } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +fun Application.module() { + authenticate { + get("/admin") { call.respondText("hi") } + } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +fun Application.module() { + routing { + get("/public") { call.respondText("hi") } + } +} +""" +expect_match = false diff --git a/rules/kotlin/ktor-install-authentication.toml b/rules/kotlin/ktor-install-authentication.toml new file mode 100644 index 0000000..a8a80b9 --- /dev/null +++ b/rules/kotlin/ktor-install-authentication.toml @@ -0,0 +1,86 @@ +[rule] +id = "kotlin-ktor-install-authentication" +languages = ["kotlin"] +category = "middleware" +confidence = "high" +description = "Ktor install() of an auth-adjacent plugin (Kotlin)" +# `install(Authentication) { ... }` and the bare `install(Authentication)` +# form both emit an inner `call_expression` whose function is the identifier +# `install` and whose first value-argument is the bare identifier +# `Authentication`. The trailing lambda lives on the outer call_expression +# and is irrelevant for detection. We also fire on `install(Sessions)` — +# Ktor sessions back authentication state in nearly every real app, so they +# are an auth-adjacent enforcement point in practice. +query = """ +(call_expression + (identifier) @method_name + (value_arguments + (value_argument + (identifier) @plugin))) + @match +""" + +[rule.predicates.method_name] +eq = "install" + +[rule.predicates.plugin] +match = "^(Authentication|Sessions)$" + +[rule.rego_template] +template = """ +default allow := false + +# Ktor install({{plugin}}) registers an auth-adjacent plugin. Translate the +# chained authenticator/session configs into per-authenticator allow conditions. +allow if { + input.user.authenticated == true +} +""" + + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal has authenticated && principal.authenticated == true +}; +""" +[[rule.tests]] +input = """ +fun Application.module() { + install(Authentication) { + jwt("auth-jwt") { } + } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +fun Application.module() { + install(Authentication) +} +""" +expect_match = true + +[[rule.tests]] +input = """ +fun Application.module() { + install(Sessions) { + cookie("SESSION") + } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +fun Application.module() { + install(CallLogging) +} +""" +expect_match = false diff --git a/rules/kotlin/role-collection-contains.toml b/rules/kotlin/role-collection-contains.toml new file mode 100644 index 0000000..9eea333 --- /dev/null +++ b/rules/kotlin/role-collection-contains.toml @@ -0,0 +1,73 @@ +[rule] +id = "kotlin-role-collection-contains" +languages = ["kotlin"] +category = "rbac" +confidence = "high" +description = "Membership check on roles/authorities/permissions collection (Kotlin)" +# Matches `user.roles.contains("admin")` and friends — the second-most common +# idiomatic Kotlin RBAC shape. The collection identifier (`@collection`) +# carries the property name; predicate restricts to roles-shaped names so +# unrelated `tags.contains("...")` doesn't fire. +query = """ +(call_expression + (navigation_expression + (navigation_expression + (_) + (identifier) @collection) + (identifier) @method_name) + (value_arguments + (value_argument + (string_literal (string_content) @role_value))) +) @match +""" + +[rule.predicates.method_name] +eq = "contains" + +[rule.predicates.collection] +match = "(?i)^(roles|permissions|authorities|grantedAuthorities|scopes)$" + +[rule.rego_template] +template = """ +default allow := false + +allow if { + "{{role_value}}" in input.user.roles +} +""" + + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" +[[rule.tests]] +input = """ +fun check(user: User) { + if (user.roles.contains("admin")) { allow() } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +fun check(user: User) { + if (user.authorities.contains("SCOPE_read")) { allow() } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +fun check(user: User) { + if (user.tags.contains("admin")) { tag() } +} +""" +expect_match = false diff --git a/rules/kotlin/role-equals-check.toml b/rules/kotlin/role-equals-check.toml new file mode 100644 index 0000000..c2e3029 --- /dev/null +++ b/rules/kotlin/role-equals-check.toml @@ -0,0 +1,74 @@ +[rule] +id = "kotlin-role-equals-check" +languages = ["kotlin"] +category = "rbac" +confidence = "high" +description = "Inline role/permission equality check (Kotlin)" +# Matches `user.role == "admin"` and similar — the idiomatic Kotlin shape for +# embedded RBAC. The navigation_expression captures `.`; we +# require the trailing identifier to look role/permission-shaped via predicate. +query = """ +(binary_expression + left: (navigation_expression + (_) + (identifier) @prop) + operator: "==" + right: (string_literal (string_content) @role_value) +) @match +""" + +[rule.predicates.prop] +match = "(?i)^(role|roles|permission|permissions|authority|authorities)$" + +[rule.rego_template] +template = """ +default allow := false + +allow if { + input.user.role == "{{role_value}}" +} +""" + + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" +[[rule.tests]] +input = """ +fun check(user: User) { + if (user.role == "admin") { allow() } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +fun check(user: User) { + if (user.permission == "write") { allow() } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +fun check(user: User) { + if (user.name == "admin") { greet() } +} +""" +expect_match = false + +[[rule.tests]] +input = """ +fun check(user: User) { + if (user.role != "admin") { deny() } +} +""" +expect_match = false diff --git a/rules/kotlin/roles-allowed.toml b/rules/kotlin/roles-allowed.toml new file mode 100644 index 0000000..cf6d1ce --- /dev/null +++ b/rules/kotlin/roles-allowed.toml @@ -0,0 +1,78 @@ +[rule] +id = "kotlin-roles-allowed" +languages = ["kotlin"] +category = "rbac" +confidence = "high" +description = "Jakarta/JSR-250 @RolesAllowed annotation (Kotlin)" +# Mirrors the Java rule shape, adapted to Kotlin's `user_type` flattening of +# qualified annotation names. See spring-preauthorize for the regex-on- +# user-type pattern. +query = """ +(annotation + (constructor_invocation + (user_type) @anno_type + (value_arguments + (value_argument + (string_literal (string_content) @role_value)))) +) @match +""" + +[rule.predicates.anno_type] +match = "(^|\\.)RolesAllowed$" + +[rule.rego_template] +template = """ +default allow := false + +allow if { + input.user.role in {"{{role_value}}"} +} +""" + + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" +[[rule.tests]] +input = """ +class UserController { + @RolesAllowed("admin") + fun deleteUser(id: Long) { } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +class UserController { + @jakarta.annotation.security.RolesAllowed("admin") + fun deleteUser(id: Long) { } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +class UserController { + @javax.annotation.security.RolesAllowed("admin") + fun deleteUser(id: Long) { } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +class UserController { + @Transactional + fun deleteUser(id: Long) { } +} +""" +expect_match = false diff --git a/rules/kotlin/spring-preauthorize.toml b/rules/kotlin/spring-preauthorize.toml new file mode 100644 index 0000000..1179e7f --- /dev/null +++ b/rules/kotlin/spring-preauthorize.toml @@ -0,0 +1,84 @@ +[rule] +id = "kotlin-spring-preauthorize" +languages = ["kotlin"] +category = "rbac" +confidence = "high" +description = "Spring Security @PreAuthorize annotation (Kotlin)" +# Kotlin's tree-sitter grammar models annotations as +# `(annotation (constructor_invocation (user_type ...) (value_arguments ...)))`. +# `user_type` is a flat sequence of identifiers — `(identifier)` for bare +# `@PreAuthorize` and `(identifier) (identifier) ...` for the fully-qualified +# `@org.springframework.security.access.prepost.PreAuthorize` form. Capturing +# the whole `user_type` and matching the regex `(^|\.)PreAuthorize$` covers +# both shapes with a single rule and avoids the multi-match explosion you get +# from capturing each identifier individually. +query = """ +(annotation + (constructor_invocation + (user_type) @anno_type + (value_arguments + (value_argument + (string_literal (string_content) @expr)))) +) @match +""" + +[rule.predicates.anno_type] +match = "(^|\\.)PreAuthorize$" + +[rule.rego_template] +template = """ +default allow := false + +allow if { + # TODO: translate SpEL expression: {{expr}} + input.user.role in {"TODO"} +} +""" + + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "TODO" +}; +""" +[[rule.tests]] +input = """ +class AdminController { + @PreAuthorize("hasRole('ADMIN')") + fun deleteUser(id: Long) { } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +class AdminController { + @PreAuthorize("hasAuthority('SCOPE_read')") + fun readData() { } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +class AdminController { + @org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')") + fun deleteUser(id: Long) { } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +class AdminController { + @Override + fun deleteUser(id: Long) { } +} +""" +expect_match = false diff --git a/rules/kotlin/spring-secured.toml b/rules/kotlin/spring-secured.toml new file mode 100644 index 0000000..362ae98 --- /dev/null +++ b/rules/kotlin/spring-secured.toml @@ -0,0 +1,71 @@ +[rule] +id = "kotlin-spring-secured" +languages = ["kotlin"] +category = "rbac" +confidence = "high" +description = "Spring Security @Secured annotation (Kotlin)" +# See spring-preauthorize for the `user_type` + regex pattern. `@Secured` may +# carry one or more string roles; this rule fires once per string argument, +# which is intentional — the matcher's per-finding dedup keys on (rule_id, +# file, line, snippet), so two `@Secured("A", "B")` strings on the same line +# collapse to a single finding once dedup runs. +query = """ +(annotation + (constructor_invocation + (user_type) @anno_type + (value_arguments + (value_argument + (string_literal (string_content) @role_value)))) +) @match +""" + +[rule.predicates.anno_type] +match = "(^|\\.)Secured$" + +[rule.rego_template] +template = """ +default allow := false + +allow if { + input.user.role in {"{{role_value}}"} +} +""" + + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" +[[rule.tests]] +input = """ +class UserController { + @Secured("ROLE_ADMIN") + fun deleteUser(id: Long) { } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +class UserController { + @org.springframework.security.access.annotation.Secured("ROLE_ADMIN") + fun deleteUser(id: Long) { } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +class UserController { + @Override + fun deleteUser(id: Long) { } +} +""" +expect_match = false diff --git a/src/deep/candidate.rs b/src/deep/candidate.rs index 8a06fed..957e019 100644 --- a/src/deep/candidate.rs +++ b/src/deep/candidate.rs @@ -9,7 +9,7 @@ //! 2. **Cold regions** — file regions discovered by regex over auth-y //! function names. Capped at 30% of `max_candidates` so escalations get //! priority. Runs on **all** languages in the [`Language`] enum, including -//! those without structural parser support (Kotlin, Ruby, PHP) — +//! those without structural parser support (Ruby, PHP) — //! see plans/todo/01-pr1-deep-http-transport.md §6 for rationale. //! //! Candidates are sorted deterministically by `(file, line_start)`. diff --git a/src/rules/embedded.rs b/src/rules/embedded.rs index 428e18c..24104ea 100644 --- a/src/rules/embedded.rs +++ b/src/rules/embedded.rs @@ -328,6 +328,39 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ "csharp-aws-verified-permissions", include_str!("../../rules/csharp/aws-verified-permissions.toml"), ), + // -- Kotlin -- + ( + "kotlin-spring-preauthorize", + include_str!("../../rules/kotlin/spring-preauthorize.toml"), + ), + ( + "kotlin-spring-secured", + include_str!("../../rules/kotlin/spring-secured.toml"), + ), + ( + "kotlin-roles-allowed", + include_str!("../../rules/kotlin/roles-allowed.toml"), + ), + ( + "kotlin-has-role-call", + include_str!("../../rules/kotlin/has-role-call.toml"), + ), + ( + "kotlin-role-equals-check", + include_str!("../../rules/kotlin/role-equals-check.toml"), + ), + ( + "kotlin-role-collection-contains", + include_str!("../../rules/kotlin/role-collection-contains.toml"), + ), + ( + "kotlin-ktor-authenticate-block", + include_str!("../../rules/kotlin/ktor-authenticate-block.toml"), + ), + ( + "kotlin-ktor-install-authentication", + include_str!("../../rules/kotlin/ktor-install-authentication.toml"), + ), ]; pub fn load_embedded_rules() -> Result> { diff --git a/src/scanner/discovery.rs b/src/scanner/discovery.rs index adee83c..531c909 100644 --- a/src/scanner/discovery.rs +++ b/src/scanner/discovery.rs @@ -25,14 +25,15 @@ pub fn detect_language(path: &Path) -> Option<(Language, bool)> { "py" | "pyi" => Some((Language::Python, false)), "go" => Some((Language::Go, false)), "cs" => Some((Language::CSharp, false)), + "kt" | "kts" => Some((Language::Kotlin, false)), _ => None, } } /// Extension → language map covering **all** languages in the [`Language`] -/// enum, including those without structural parser support yet (C#, Kotlin, -/// Ruby, PHP). Used by the deep (semantic) scan, which can run regex-based -/// cold-region detection on any language regardless of grammar availability. +/// enum, including those without structural parser support yet (Ruby, PHP). +/// Used by the deep (semantic) scan, which can run regex-based cold-region +/// detection on any language regardless of grammar availability. pub fn detect_language_for_deep(path: &Path) -> Option<(Language, bool)> { let ext = path.extension()?.to_str()?.to_ascii_lowercase(); match ext.as_str() { @@ -192,6 +193,18 @@ mod tests { ); } + #[test] + fn detect_kotlin_extensions() { + assert_eq!( + detect_language(Path::new("Foo.kt")), + Some((Language::Kotlin, false)) + ); + assert_eq!( + detect_language(Path::new("build.kts")), + Some((Language::Kotlin, false)) + ); + } + #[test] fn detect_unknown_extension() { assert_eq!(detect_language(Path::new("foo.rs")), None); @@ -266,9 +279,8 @@ mod tests { // Sanity: the structural detector must NOT include languages without // a wired-up tree-sitter grammar — otherwise the structural pass // would try to parse files it can't handle. The deep detector picks - // them up; the structural one doesn't. (C# was here before C# - // structural support.) - assert_eq!(detect_language(Path::new("Foo.kt")), None); + // them up; the structural one doesn't. (Kotlin / C# were here + // before their structural support landed.) assert_eq!(detect_language(Path::new("foo.rb")), None); assert_eq!(detect_language(Path::new("foo.php")), None); } @@ -282,6 +294,7 @@ mod tests { fs::write(dir.path().join("b.py"), "x = 1\n").unwrap(); fs::write(dir.path().join("c.go"), "package main\n").unwrap(); fs::write(dir.path().join("d.cs"), "class C {}").unwrap(); + fs::write(dir.path().join("e.kt"), "class K\n").unwrap(); let structural = discover_files(dir.path(), &[], &[]); let structural_langs: HashSet<_> = structural.iter().map(|f| f.language).collect(); @@ -291,9 +304,10 @@ mod tests { Language::TypeScript, Language::Python, Language::Go, - Language::CSharp + Language::CSharp, + Language::Kotlin, ]), - "structural should include TS + Python + Go + C#", + "structural should include TS + Python + Go + C# + Kotlin", ); let deep = discover_files_for_deep(dir.path(), &[], &[]); @@ -304,9 +318,10 @@ mod tests { Language::TypeScript, Language::Python, Language::Go, - Language::CSharp + Language::CSharp, + Language::Kotlin, ]), - "deep should include TS + Python + Go + C#", + "deep should include TS + Python + Go + C# + Kotlin", ); } } diff --git a/src/scanner/matcher.rs b/src/scanner/matcher.rs index 8d0b2e7..3db18b2 100644 --- a/src/scanner/matcher.rs +++ b/src/scanner/matcher.rs @@ -1281,6 +1281,281 @@ public class MyService implements Serializable { ); } + // -- Kotlin rule tests -- + + fn parse_and_match_kotlin(source: &str, rule_toml: &str) -> Vec { + let rule = rules::parse_rule_for_test(rule_toml); + let mut ts_parser = tree_sitter::Parser::new(); + let lang = Language::Kotlin; + let ts_lang = parser::get_language(lang, false).unwrap(); + let tree = parser::parse_source(&mut ts_parser, source.as_bytes(), lang, false).unwrap(); + let compiled = compile_rule(&rule, &ts_lang).unwrap(); + execute_query( + &compiled, + &tree, + source.as_bytes(), + Path::new("Test.kt"), + lang, + ) + .unwrap() + } + + #[test] + fn kotlin_preauthorize_matches() { + let findings = parse_and_match_kotlin( + r#" +class Ctrl { + @PreAuthorize("hasRole('ADMIN')") + fun delete() { } +} +"#, + include_str!("../../rules/kotlin/spring-preauthorize.toml"), + ); + assert!(!findings.is_empty(), "should match @PreAuthorize in Kotlin"); + assert_eq!(findings[0].category, crate::types::AuthCategory::Rbac); + } + + #[test] + fn kotlin_preauthorize_qualified_matches() { + // Kotlin's grammar flattens fully-qualified annotation names into a + // single `user_type` node with one identifier per dotted segment. + // The rule regex matches the trailing segment, so both bare and + // qualified forms produce exactly one finding (no per-identifier + // explosion). + let findings = parse_and_match_kotlin( + r#" +class Ctrl { + @org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')") + fun delete() { } +} +"#, + include_str!("../../rules/kotlin/spring-preauthorize.toml"), + ); + assert_eq!( + dedup_findings(findings).len(), + 1, + "qualified annotation should collapse to a single finding after dedup", + ); + } + + #[test] + fn kotlin_secured_matches() { + let findings = parse_and_match_kotlin( + r#" +class Ctrl { + @Secured("ROLE_ADMIN") + fun delete() { } +} +"#, + include_str!("../../rules/kotlin/spring-secured.toml"), + ); + assert!(!findings.is_empty(), "should match @Secured in Kotlin"); + } + + #[test] + fn kotlin_secured_multi_string_dedups() { + // `@Secured("A", "B")` fires the query twice (once per string arg) + // but both raw findings share the same (rule_id, file, line range, + // snippet) — the whole annotation node — so dedup collapses them + // to a single finding. This guards the dedup claim in + // rules/kotlin/spring-secured.toml. + let findings = parse_and_match_kotlin( + r#" +class Ctrl { + @Secured("ROLE_ADMIN", "ROLE_USER") + fun delete() { } +} +"#, + include_str!("../../rules/kotlin/spring-secured.toml"), + ); + assert_eq!( + findings.len(), + 2, + "raw matcher should fire once per string arg", + ); + assert_eq!( + dedup_findings(findings).len(), + 1, + "multi-string @Secured should collapse to one finding after dedup", + ); + } + + #[test] + fn kotlin_roles_allowed_matches() { + let findings = parse_and_match_kotlin( + r#" +class Ctrl { + @RolesAllowed("admin") + fun delete() { } +} +"#, + include_str!("../../rules/kotlin/roles-allowed.toml"), + ); + assert!(!findings.is_empty(), "should match @RolesAllowed in Kotlin"); + } + + #[test] + fn kotlin_has_role_call_matches() { + let findings = parse_and_match_kotlin( + r#" +fun check(acct: Account) { + if (!acct.hasRole("ADMIN")) { throw Forbidden() } +} +"#, + include_str!("../../rules/kotlin/has-role-call.toml"), + ); + assert!(!findings.is_empty(), "should match hasRole(\"...\")"); + } + + #[test] + fn kotlin_has_role_call_field_arg_matches() { + let findings = parse_and_match_kotlin( + r#" +fun check(acct: Account) { + if (!acct.hasRole(Role.ADMIN)) { throw Forbidden() } +} +"#, + include_str!("../../rules/kotlin/has-role-call.toml"), + ); + assert!( + !findings.is_empty(), + "should match hasRole with field-access arg" + ); + } + + #[test] + fn kotlin_role_equals_check_matches() { + let findings = parse_and_match_kotlin( + "fun check(user: User) {\n if (user.role == \"admin\") { allow() }\n}\n", + include_str!("../../rules/kotlin/role-equals-check.toml"), + ); + assert!(!findings.is_empty(), "should match user.role == \"admin\""); + assert_eq!(findings[0].category, crate::types::AuthCategory::Rbac); + } + + #[test] + fn kotlin_role_equals_check_excludes_unrelated_property() { + let findings = parse_and_match_kotlin( + "fun check(user: User) {\n if (user.name == \"admin\") { greet() }\n}\n", + include_str!("../../rules/kotlin/role-equals-check.toml"), + ); + assert!( + findings.is_empty(), + "must not match unrelated property comparisons" + ); + } + + #[test] + fn kotlin_role_equals_check_excludes_inequality_operator() { + let findings = parse_and_match_kotlin( + "fun check(user: User) {\n if (user.role != \"admin\") { deny() }\n}\n", + include_str!("../../rules/kotlin/role-equals-check.toml"), + ); + assert!( + findings.is_empty(), + "must not match `!=` — this rule covers equality only" + ); + } + + #[test] + fn kotlin_role_collection_contains_matches() { + let findings = parse_and_match_kotlin( + "fun check(user: User) {\n if (user.roles.contains(\"admin\")) { allow() }\n}\n", + include_str!("../../rules/kotlin/role-collection-contains.toml"), + ); + assert!( + !findings.is_empty(), + "should match user.roles.contains(\"...\")" + ); + } + + #[test] + fn kotlin_ktor_authenticate_block_matches() { + let findings = parse_and_match_kotlin( + r#" +fun Application.module() { + authenticate("auth-jwt") { + get("/admin") { call.respondText("hi") } + } +} +"#, + include_str!("../../rules/kotlin/ktor-authenticate-block.toml"), + ); + assert!( + !findings.is_empty(), + "should match Ktor authenticate(...) {{ ... }}" + ); + assert_eq!(findings[0].category, crate::types::AuthCategory::Middleware); + } + + #[test] + fn kotlin_ktor_authenticate_no_args_matches() { + let findings = parse_and_match_kotlin( + r#" +fun Application.module() { + authenticate { + get("/admin") { call.respondText("hi") } + } +} +"#, + include_str!("../../rules/kotlin/ktor-authenticate-block.toml"), + ); + assert!( + !findings.is_empty(), + "should match Ktor authenticate {{ ... }} no-args form" + ); + } + + #[test] + fn kotlin_ktor_install_authentication_matches() { + let findings = parse_and_match_kotlin( + r#" +fun Application.module() { + install(Authentication) { + jwt("auth-jwt") { } + } +} +"#, + include_str!("../../rules/kotlin/ktor-install-authentication.toml"), + ); + assert!( + !findings.is_empty(), + "should match install(Authentication) with block" + ); + } + + #[test] + fn kotlin_ktor_install_authentication_no_block_matches() { + let findings = parse_and_match_kotlin( + r#" +fun Application.module() { + install(Authentication) +} +"#, + include_str!("../../rules/kotlin/ktor-install-authentication.toml"), + ); + assert!( + !findings.is_empty(), + "should match install(Authentication) without a trailing lambda" + ); + } + + #[test] + fn kotlin_ktor_install_authentication_rejects_other_plugins() { + let findings = parse_and_match_kotlin( + r#" +fun Application.module() { + install(CallLogging) +} +"#, + include_str!("../../rules/kotlin/ktor-install-authentication.toml"), + ); + assert!( + findings.is_empty(), + "must not match unrelated plugin installs" + ); + } + // -- cross_predicates tests (synthetic rules) -- /// A synthetic rule shaped like ownership-check: two getters in an diff --git a/src/scanner/parser.rs b/src/scanner/parser.rs index df47977..a6cdb3e 100644 --- a/src/scanner/parser.rs +++ b/src/scanner/parser.rs @@ -17,6 +17,7 @@ pub fn get_language(lang: Language, is_tsx_jsx: bool) -> Result Ok(tree_sitter_python::LANGUAGE.into()), (Language::Go, _) => Ok(tree_sitter_go::LANGUAGE.into()), (Language::CSharp, _) => Ok(tree_sitter_c_sharp::LANGUAGE.into()), + (Language::Kotlin, _) => Ok(tree_sitter_kotlin_ng::LANGUAGE.into()), _ => Err(ZiftError::UnsupportedLanguage(lang)), } } @@ -125,16 +126,30 @@ public class AdminController : ControllerBase { assert!(is_language_supported(Language::CSharp)); } + #[test] + fn parse_kotlin() { + let mut parser = tree_sitter::Parser::new(); + let source = b"class Foo {\n fun bar() {}\n}\n"; + let tree = parse_source(&mut parser, source, Language::Kotlin, false).unwrap(); + assert!(!tree.root_node().has_error()); + } + + #[test] + fn kotlin_is_supported() { + assert!(is_language_supported(Language::Kotlin)); + } + #[test] fn unsupported_language_returns_error() { - // Kotlin has no structural grammar wired up yet — kept as the canary + // Ruby has no structural grammar wired up yet — kept as the canary // that `unsupported_language_returns_error` keeps testing what its - // name says it does. (Was C# before C# structural support.) - let err = get_language(Language::Kotlin, false).unwrap_err(); + // name says it does. (Was Kotlin before Kotlin structural support; + // C# before that.) + let err = get_language(Language::Ruby, false).unwrap_err(); assert!(matches!( err, - ZiftError::UnsupportedLanguage(Language::Kotlin) + ZiftError::UnsupportedLanguage(Language::Ruby) )); - assert!(!is_language_supported(Language::Kotlin)); + assert!(!is_language_supported(Language::Ruby)); } }