Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |

Expand Down
4 changes: 2 additions & 2 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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 |

Expand Down
1 change: 1 addition & 0 deletions docs/corpus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TRequirement>]`, 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.
>
Expand Down
71 changes: 71 additions & 0 deletions docs/corpus/kotlin.md
Original file line number Diff line number Diff line change
@@ -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.
103 changes: 103 additions & 0 deletions rules/kotlin/has-role-call.toml
Original file line number Diff line number Diff line change
@@ -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
86 changes: 86 additions & 0 deletions rules/kotlin/ktor-authenticate-block.toml
Original file line number Diff line number Diff line change
@@ -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
Loading