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
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 @@ -34,6 +34,7 @@ tree-sitter-go = "0.25"
tree-sitter-c-sharp = "0.23"
tree-sitter-kotlin-ng = "1.1"
tree-sitter-ruby = "0.23"
tree-sitter-php = "0.24"
ignore = "0.4"
sha2 = "0.11"
regex = "1"
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

Sift through your codebase for embedded authorization logic. Extract it into Policy as Code (PaC) — [Rego](https://www.openpolicyagent.org/docs/latest/policy-language/) for [OPA](https://www.openpolicyagent.org/), or [Cedar](https://www.cedarpolicy.com/) for [AWS Verified Permissions](https://aws.amazon.com/verified-permissions/), Arbiter, and other Cedar-compatible engines.

> **Status:** v0.2 — structural scanning ready for TypeScript, JavaScript, Java, Python, Go, C#, Kotlin, and Ruby. `--deep` (LLM-assisted) mode functional via any OpenAI-compatible endpoint or MCP-capable agent host.
> **Status:** v0.2 — structural scanning ready for TypeScript, JavaScript, Java, Python, Go, C#, Kotlin, Ruby, and PHP. `--deep` (LLM-assisted) mode functional via any OpenAI-compatible endpoint or MCP-capable agent host.

## What is zift?

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

## Supported languages

Expand All @@ -40,7 +40,7 @@ zift report . # detailed findings report
| C# | yes (v0.2) | yes (v0.1) | ASP.NET Core |
| Kotlin | yes (v0.2) | yes (v0.1) | Spring (Kotlin), Ktor |
| Ruby | yes (v0.2) | yes (v0.1) | Rails, Pundit, CanCanCan, Devise |
| PHP | planned (v0.2) | yes (v0.1) | Laravel |
| PHP | yes (v0.2) | yes (v0.1) | Laravel, Symfony |

Deep mode walks the full source tree by extension and detects auth-y function names with regex — so it produces useful results in any language well before structural support lands.

Expand Down
1 change: 1 addition & 0 deletions docs/corpus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ We are **not** shipping policies for these projects. The runs exist to stress-te
| 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). |
| Ruby | [discourse/discourse](https://github.com/discourse/discourse) | 339 | — | Rails `before_action` filters (180) and `current_user.<role>?` predicates (159) carry every finding; Discourse's `Guardian` call sites are the gap. Pundit/CanCanCan rules need a Pundit-flavored target to calibrate. See [ruby.md](ruby.md). |
| PHP | [monicahq/monica](https://github.com/monicahq/monica) + [symfony/demo](https://github.com/symfony/demo) | 41 + 4 | — | Laravel Gates + Policies + route-middleware carry every Monica finding (41); the Symfony demo exercises every Voter / `#[IsGranted]` rule with no overlap. See [php.md](php.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
138 changes: 138 additions & 0 deletions docs/corpus/php.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# PHP — Monica + Symfony demo

Real-world results from running Zift against two open-source PHP codebases: [monicahq/monica](https://github.com/monicahq/monica) (Laravel) and [symfony/demo](https://github.com/symfony/demo) (Symfony). The pair is intentional — neither framework appears in the other's authz idioms, so they calibrate the Laravel and Symfony rule families independently.

## Why these targets

Monica is one of the largest mature open-source Laravel apps in active use (CRM/contact manager). It uses Laravel Gates *and* Policies *and* `Route::middleware('can:…')` simultaneously — so every Laravel rule has a real target to fire against, with no Spatie permissions plugin in the way to muddy the signal. The Symfony demo is the official example app maintained by the Symfony team; it's small but exercises the modern PHP 8 attribute syntax (`#[IsGranted]`), the docblock-free Voter idiom, and `denyAccessUnlessGranted` — every Symfony rule has a hit.

The pair confirms two things: that the Laravel rules don't fire on Symfony code (and vice versa), and that the Symfony attribute rule survives the PHP 8 `name: 'value'` argument shape without false positives on neighbouring attributes (`#[Route]`, `#[Cache]`, …).

## Target metadata

### Monica (Laravel)

| | |
|---|---|
| Repo | [monicahq/monica](https://github.com/monicahq/monica) |
| Commit | `e08e917` |
| PHP files (excl. `vendor/`) | 1,656 |
| LOC (`.php`, excl. `vendor/`) | 134,702 |
| Externalized PaC | None observed |
| Zift version | 0.2.2 |

### Symfony demo

| | |
|---|---|
| Repo | [symfony/demo](https://github.com/symfony/demo) |
| Commit | `83d4ac1` |
| PHP files (excl. `vendor/`) | 52 |
| LOC (`.php`, excl. `vendor/`) | 6,256 |
| Externalized PaC | None observed |
| Zift version | 0.2.2 |

## Structural pass — Monica

```bash
zift scan ~/zift-corpus/php/monica --language php --format json -o structural.json
```

| | |
|---|---|
| Wall time | 1.4s |
| Total findings | **41** |
| Files with findings | 13 |
| Externalized % | 0% (no policy-import enforcement points emitted) |

**Findings per rule**

| Rule | Count |
|------|------:|
| `php-laravel-route-middleware` | 13 |
| `php-laravel-gate-define` | 11 |
| `php-laravel-gate-allows-denies` | 7 |
| `php-laravel-authorize-helper` | 5 |
| `php-laravel-policy-class` | 5 |

**Findings per category**

| Category | Count |
|----------|------:|
| `rbac` | 28 |
| `middleware` | 13 |

**Top findings (sample)**

| File | Line | Snippet |
|------|-----:|---------|
| `app/Providers/AuthServiceProvider.php` | 32 | `Gate::define('administrator', function (User $user): bool { … })` |
| `app/Providers/AuthServiceProvider.php` | 42 | `Gate::define('vault-editor', function (User $user, $vault): bool { … })` |
| `app/Policies/VaultPolicy.php` | 22 | `public function view(User $user, Vault $vault): bool { … }` |
| `app/Policies/VaultPolicy.php` | 46 | `public function update(User $user, Vault $vault): bool { … }` |
| `app/Domains/Vault/ManageVault/Api/Controllers/VaultController.php` | 23 | `$this->middleware('abilities:read')` |
| `app/Domains/Contact/ManageContact/Web/Controllers/ContactController.php` | 57 | `Gate::authorize('vault-editor', $vault)` |
| `routes/web.php` | 199 | `Route::middleware('can:vault-viewer,vault')` |
| `routes/web.php` | 250 | `Route::middleware('can:contact-owner,vault,contact')` |

The shape lines up exactly with how Monica's authz is laid out: a single `AuthServiceProvider::boot()` declares the ability vocabulary (11 `Gate::define`s — `administrator`, `vault-editor`, `vault-viewer`, `vault-manager`, `contact-owner`, …), the controllers call `Gate::authorize('vault-editor', …)` per request, and `routes/web.php` attaches `can:<ability>,<resource>` middleware to grouped route trees. `VaultPolicy` carries the CRUD method shape the policy-class rule was designed for. Five `php-laravel-authorize-helper` matches in `tests/Feature/Auth/*` are real `$token->can('read')` calls in test fixtures — they're authz state assertions in tests, which is the right behaviour for the rule.

## Structural pass — Symfony demo

```bash
zift scan ~/zift-corpus/php/demo --language php --format json -o structural.json
```

| | |
|---|---|
| Wall time | 0.08s |
| Total findings | **4** |
| Files with findings | 3 |
| Externalized % | 0% |

**Findings per rule**

| Rule | Count |
|------|------:|
| `php-symfony-is-granted-attribute` | 3 |
| `php-symfony-voter-class` | 1 |

**All findings**

| File | Line | Snippet |
|------|-----:|---------|
| `src/Controller/Admin/BlogController.php` | 138 | `IsGranted('edit', subject: 'post', message: 'Posts can only be edited by their authors.')` |
| `src/Controller/Admin/BlogController.php` | 161 | `IsGranted('delete', subject: 'post')` |
| `src/Controller/BlogController.php` | 107 | `IsGranted('IS_AUTHENTICATED')` |
| `src/Security/PostVoter.php` | 30 | `final class PostVoter extends Voter { … }` |

Every Symfony idiom the demo uses gets exactly one hit per call site. `PostVoter` is the canonical Symfony Voter shape — class extends the framework `Voter` parent. The three `#[IsGranted]` attributes cover the PHP 8 attribute form including the named-argument syntax (`subject: 'post'`, `message: '…'`); the anchor in the rule's query keeps the captured `@role` pinned to the first positional argument so the downstream Rego template emits `IS_AUTHENTICATED` / `edit` / `delete` and not the message string.

## Zero-coverage rules (intentional)

Six rules fired zero findings across both targets — and that's correct:

| Rule | Monica | Symfony demo | Why zero is expected |
|------|------:|------:|---------------------|
| `php-symfony-voter-class` | 0 | 1 | Laravel doesn't use Voters; Symfony does. |
| `php-symfony-is-granted` | 0 | 0 | Symfony demo uses `#[IsGranted]` attributes exclusively; `$this->denyAccessUnlessGranted(…)` does appear in `BlogController.php:127` but its first positional argument is `PostVoter::SHOW` (a class constant ref), which the rule deliberately doesn't capture — see [Gaps & follow-ups](#gaps--follow-ups). |
| `php-symfony-is-granted-attribute` | 0 | 3 | Laravel/Monica isn't on Symfony's attribute family. |
| `php-role-equals-check` | 0 | 0 | Neither codebase spells RBAC as `$user->role === 'admin'` — both use Gates/Voters. |
| `php-in-array-role-check` | 0 | 0 | Same — neither hand-rolls `in_array('manager', $user->roles)`. |
| `php-has-role-call` | 0 | 0 | Neither pulls in spatie/laravel-permission, so `$user->hasRole(…)` doesn't appear. |

The three idiomatic rules (`role-equals-check`, `in-array-role-check`, `has-role-call`) are exercised by the inline rule tests (`cargo run -- rules test`) and need a hand-rolled-authz Laravel app or a Spatie-flavoured target for end-to-end calibration.

## Gaps & follow-ups

**Constant-ref first argument is intentionally dropped.** Symfony's `denyAccessUnlessGranted(PostVoter::SHOW, $post, …)` in `BlogController.php:127` *is* an authz call, but the first positional argument is a class constant (`PostVoter::SHOW`) — Zift can't resolve it to a literal at scan time, so the rule's anchor lets the call slip through structurally rather than fabricate a Rego template against the trailing message string. The trade-off here is a known FN on `<Voter>::CONSTANT`-shaped calls in exchange for honest output on the calls that *do* expose a literal. A follow-up could either widen the capture (record the constant ref textually as `@attribute` and let the template TODO-out the value) or pair the structural miss with a deep-pass rule. Tracked as a future ruleset refinement.

**`Gate::authorize` lives under the `allows`/`denies` rule.** Laravel ships `Gate::authorize($ability, $resource)` as a fourth verb alongside `allows`/`denies`/`check`. The rule's `method_name` regex includes it, so all seven `Gate::authorize('vault-editor', …)` calls in Monica land in `php-laravel-gate-allows-denies` rather than a dedicated rule. The category is correct (`rbac`); the verbosity is the only cost. Worth splitting only if downstream consumers need to bucket "decision boundaries" (`allows`/`denies`) separately from "imperative throws" (`authorize`) for reporting.

**`$this->middleware(…)` works inside controller constructors.** Monica uses both `Route::middleware(…)` at the route-tree level and `$this->middleware('abilities:read')` inside `__construct` of API controllers (e.g. `VaultController.php:23`). Both surface here because the rule matches any call literally named `middleware` whose argument list looks like an auth alias — the receiver isn't constrained. This is the right call for Laravel where both shapes are idiomatic, but worth pinning if a Lumen/Slim-flavoured corpus target later turns up other libraries that expose a same-named no-op.

**FP risk: low across the board.** Every match on both targets is a real authz surface. No false positives observed; the rule-level predicates (method-name regex, scope check, arg-shape constraints) carry the load. The one rule that survives both targets without firing — `php-role-equals-check` — would benefit from a corpus target that genuinely uses the `$user->role === 'admin'` shape to confirm the predicate breadth on the property name (`role|roles|user_role|account_type|user_type|permission|permissions`) is calibrated correctly.

## Deep pass

Not run for either target. Monica's structural pass yields a tight, high-signal slice of the privileged routes, gate definitions, and policy methods; Symfony demo is small enough that the structural pass essentially exhausts the visible authz surface. Deep would primarily add value on the parts of Monica's `Service` layer that wrap authz inside business logic without a Gate call — those are the gap to investigate first if a deep pass is run later.
74 changes: 74 additions & 0 deletions rules/php/has-role-call.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
[rule]
id = "php-has-role-call"
languages = ["php"]
category = "rbac"
confidence = "high"
description = "hasRole / hasPermission / hasAnyRole call (PHP)"
# Matches `$user->hasRole('admin')`, `$user->hasPermission('posts.edit')`,
# `$user->hasAnyRole(['admin', 'editor'])` — the Spatie/laravel-permission
# and hand-rolled idiom. The method name is gated to the role/permission
# family so generic predicates (`hasMany`, `hasField`) don't fire.
query = """
(member_call_expression
name: (name) @method_name
arguments: (arguments
.
(argument
[
(string (string_content) @role_value)
(encapsed_string (string_content) @role_value)
]))
) @match
"""

[rule.predicates.method_name]
match = "^(hasRole|hasAnyRole|hasAllRoles|hasPermission|hasPermissionTo|hasAnyPermission|hasAllPermissions)$"

[rule.rego_template]
template = """
default allow := false

# ->{{method_name}}('{{role_value}}').
allow if {
"{{role_value}}" in input.user.roles
}
"""

[rule.cedar_template]
template = """
permit (
principal,
action,
resource
)
when {
principal.roles.contains("{{role_value}}")
};
"""

[[rule.tests]]
input = """
<?php
function isAdmin($user) {
return $user->hasRole('admin');
}
"""
expect_match = true

[[rule.tests]]
input = """
<?php
function canEdit($user) {
return $user->hasPermissionTo('posts.edit');
}
"""
expect_match = true

[[rule.tests]]
input = """
<?php
function loadComments($post) {
return $post->hasMany('App\\Comment');
}
"""
expect_match = false
81 changes: 81 additions & 0 deletions rules/php/in-array-role-check.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
[rule]
id = "php-in-array-role-check"
languages = ["php"]
category = "rbac"
confidence = "high"
description = "in_array role/permission membership check (PHP)"
# Matches `in_array('manager', $user->roles)` — the canonical PHP shape for
# "is this principal in this role collection?". First arg is the role
# literal; second arg's trailing property name must be role-shaped
# (`roles`, `permissions`, `authorities`, ...) so unrelated `in_array($x,
# $widgets)` calls don't fire.
query = """
(function_call_expression
function: (name) @fn
arguments: (arguments
.
(argument
[
(string (string_content) @role_value)
(encapsed_string (string_content) @role_value)
])
.
(argument
(member_access_expression
name: (name) @collection)))
) @match
"""

[rule.predicates.fn]
eq = "in_array"

[rule.predicates.collection]
match = "(?i)^(roles|permissions|authorities|scopes|granted_authorities|groups)$"

[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.roles.contains("{{role_value}}")
};
"""

[[rule.tests]]
input = """
<?php
function managerOnly($user) {
return in_array('manager', $user->roles);
}
"""
expect_match = true

[[rule.tests]]
input = """
<?php
function hasRead($user) {
return in_array("read", $user->permissions);
}
"""
expect_match = true

[[rule.tests]]
input = """
<?php
function tagged($post) {
return in_array('featured', $post->tags);
}
"""
expect_match = false
Loading