diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 26b6f25..9e90fd2 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -5,9 +5,9 @@ echo "Running cargo fmt check..." cargo fmt -- --check echo "Running cargo clippy..." -cargo clippy -- -D warnings +cargo clippy --all-features -- -D warnings echo "Running cargo test..." -cargo test +cargo test --all-features echo "Pre-commit checks passed." diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7d7cfb3..23d7575 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -22,8 +22,8 @@ Thanks for opening a PR! A few quick notes: - [ ] `cargo fmt` -- [ ] `cargo clippy -- -D warnings` -- [ ] `cargo test` +- [ ] `cargo clippy --all-features -- -D warnings` +- [ ] `cargo test --all-features` ## Checklist diff --git a/AGENTS.md b/AGENTS.md index acb2746..d996f39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,9 +25,9 @@ definitions. ```bash cargo build cargo build --release -cargo test +cargo test --all-features cargo fmt # required before committing -cargo clippy -- -D warnings +cargo clippy --all-features -- -D warnings ``` ## Architecture @@ -46,8 +46,8 @@ cargo clippy -- -D warnings ### Language support -- v0.1: TypeScript, JavaScript, Java, Python, Go -- v0.2 (planned): C#, Kotlin, Ruby, PHP +- v0.1: TypeScript, JavaScript, Java, Python, Go, C# +- v0.2 (planned): Kotlin, Ruby, PHP ## Conventional Commits & Versioning diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 74fb8cf..1c36f53 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ cd zift git config core.hooksPath .githooks # enables the pre-commit hook cargo build -cargo test +cargo test --all-features ``` CI runs `rustfmt`, `clippy`, and the test suite. Please run all three locally @@ -23,8 +23,8 @@ before pushing: ```bash cargo fmt -cargo clippy -- -D warnings -cargo test +cargo clippy --all-features -- -D warnings +cargo test --all-features ``` ## Reporting bugs and asking questions @@ -37,7 +37,7 @@ cargo test 1. Fork the repo and create a topic branch from `main`. 2. Make your changes. Add tests where it makes sense. -3. Run `cargo fmt`, `cargo clippy -- -D warnings`, and `cargo test`. +3. Run `cargo fmt`, `cargo clippy --all-features -- -D warnings`, and `cargo test --all-features`. 4. Sign off your commits — see [DCO](#developer-certificate-of-origin) below. 5. Use a [Conventional Commits](https://www.conventionalcommits.org/) prefix in the PR title. 6. Open the PR. Be ready to iterate on review feedback. diff --git a/Cargo.lock b/Cargo.lock index bef4424..3db9717 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1730,6 +1730,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-go" version = "0.25.0" @@ -2408,6 +2418,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tree-sitter", + "tree-sitter-c-sharp", "tree-sitter-go", "tree-sitter-java", "tree-sitter-javascript", diff --git a/Cargo.toml b/Cargo.toml index b5f6661..3f94c6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ tree-sitter-java = "0.23" tree-sitter-javascript = "0.25" tree-sitter-python = "0.25" tree-sitter-go = "0.25" +tree-sitter-c-sharp = "0.23" ignore = "0.4" sha2 = "0.11" regex = "1" @@ -48,8 +49,8 @@ mockito = "1" # Integration tests in `tests/` reach into modules gated behind `unstable` # (deep, mcp, scanner, config). Mark them as requiring the feature so -# default `cargo test` skips them cleanly; CI runs `cargo test --features -# unstable` to exercise them. +# default `cargo test` skips them cleanly; CI runs `cargo test --all-features` +# to exercise them. [[test]] name = "deep_http_integration" required-features = ["unstable"] diff --git a/README.md b/README.md index 1924f22..a1c1b12 100644 --- a/README.md +++ b/README.md @@ -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/) today, with other engines (e.g. Cedar) on the roadmap. -> **Status:** v0.1 — structural scanning ready for TypeScript, JavaScript, Java, Python, and Go. `--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, and C#. `--deep` (LLM-assisted) mode functional via any OpenAI-compatible endpoint or MCP-capable agent host. ## What is zift? @@ -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 (C#, Kotlin, 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 (Kotlin, Ruby, PHP, etc.). ## Supported languages @@ -37,7 +37,7 @@ zift report . # detailed findings report | Java | yes (v0.1) | yes (v0.1) | Spring Security, Jakarta Security | | Python | yes (v0.1) | yes (v0.1) | Django, Flask, FastAPI | | Go | yes (v0.1) | yes (v0.1) | Gin, Echo | -| C# | planned (v0.2) | yes (v0.1) | ASP.NET Core | +| C# | yes (v0.2) | yes (v0.1) | ASP.NET Core | | Kotlin | planned (v0.2) | yes (v0.1) | Spring (Kotlin) | | 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 dcc5dcd..e0c7cf7 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -200,6 +200,8 @@ allow if { ## Language support +These priorities describe release milestones. C# ships in the v0.2 milestone. + ### Priority 1 (v0.1) | Language | Key frameworks / patterns | @@ -214,12 +216,12 @@ allow if { |----------|--------------------------| | 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 | ### Priority 3 (v0.3) | Language | Key frameworks / patterns | |----------|--------------------------| -| C# | ASP.NET `[Authorize]`, policy-based authorization, `ClaimsPrincipal` checks | | 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 a503377..51ff479 100644 --- a/docs/corpus/README.md +++ b/docs/corpus/README.md @@ -28,6 +28,7 @@ We are **not** shipping policies for these projects. The runs exist to stress-te | Java | [openmrs/openmrs-core](https://github.com/openmrs/openmrs-core) | 76 | 20 (context subset) | Deep surfaces a daemon-thread privilege bypass — a real authz decision invisible to any pattern rule. See [java.md](java.md). | | 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). | > 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/csharp.md b/docs/corpus/csharp.md new file mode 100644 index 0000000..fee7670 --- /dev/null +++ b/docs/corpus/csharp.md @@ -0,0 +1,147 @@ +# C# — Bitwarden Server + +Real-world results from running Zift against [bitwarden/server](https://github.com/bitwarden/server), Bitwarden's server-side application. + +## Why this target + +Bitwarden is a production ASP.NET Core codebase with a mature authorization model. It uses controller attributes, policy names, anonymous endpoint overrides, and resource-based checks through `IAuthorizationService`. That makes it a strong first C# corpus target: it exercises framework-level middleware and service-layer authorization, not just toy `[Authorize]` examples. + +## Target metadata + +| | | +|---|---| +| Repo | [bitwarden/server](https://github.com/bitwarden/server) | +| Commit | `99923132` | +| C# files (excl. `bin/`, `obj/`) | 4,534 | +| LOC (C#) | 1,540,794 | +| Externalized PaC | None observed | +| Zift version | 0.1.9 | + +## Structural pass + +```bash +zift scan ~/zift-corpus/csharp/server --format json -o structural.json +``` + +| | | +|---|---| +| Wall time | 111.35s | +| Peak RSS | ~32 MB | +| Total findings | **318** | +| Files with findings | 116 | +| Externalized % | 0% (no policy-import enforcement points emitted) | + +The full-repo result includes tests because the corpus methodology scans the shallow clone as-is. Production paths account for 172 findings under `src/` plus 4 under `bitwarden_license/src/`; tests account for 143 findings. + +**Findings per rule** + +| Rule | Count | +|------|------:| +| `csharp-authorization-service-authorize-async` | 186 | +| `csharp-aspnet-authorize-policy-shorthand` | 82 | +| `csharp-aspnet-allow-anonymous` | 34 | +| `csharp-aspnet-authorize-attribute` | 16 | + +**Findings per category** + +| Category | Count | +|----------|------:| +| `abac` | 268 | +| `middleware` | 50 | + +**Top findings (sample)** + +| File | Line | Snippet | +|------|-----:|---------| +| `src/Api/AdminConsole/Controllers/CollectionsController.cs` | 23 | `Authorize("Application")` | +| `src/Api/AdminConsole/Controllers/CollectionsController.cs` | 62 | `_authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.Read)` | +| `src/Api/AdminConsole/Controllers/CollectionsController.cs` | 118 | `_authorizationService.AuthorizeAsync(User, CollectionOperations.ReadAll(orgId))` | +| `src/Api/AdminConsole/Controllers/GroupsController.cs` | 23 | `Authorize("Application")` | +| `src/Api/AdminConsole/Controllers/GroupsController.cs` | 134 | `_authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.ModifyGroupAccess)` | +| `src/Api/AdminConsole/Controllers/OrganizationUsersController.cs` | 206 | `_authorizationService.AuthorizeAsync(User, new ManageUsersRequirement())` | +| `src/Api/Controllers/DevicesController.cs` | 243 | `AllowAnonymous` | +| `src/Admin/Controllers/UsersController.cs` | 19 | `Authorize` | + +## Gaps & follow-ups + +**FN: generic Bitwarden authorization attributes.** + +Bitwarden uses a custom generic attribute form extensively: + +```csharp +[Authorize] +``` + +A quick grep found 108 `Authorize` occurrences in the shallow clone. The current C# rules catch the ASP.NET built-in `[Authorize]`, `[Authorize("Policy")]`, `[Authorize(Policy = "...")]`, and `[Authorize(Roles = "...")]` shapes, but not the generic Bitwarden helper. A dedicated `csharp-bitwarden-authorize-requirement` rule, or a more general generic-attribute rule, would close a large chunk of structural coverage. + +**FN: `AuthorizeOrThrowAsync`.** + +Bitwarden wraps authorization in `AuthorizeOrThrowAsync(...)` at command/service boundaries. A grep found 21 occurrences. The current `AuthorizeAsync` rule catches the standard ASP.NET method name but misses this wrapper. This is a good candidate for either widening the method predicate or adding a separate high-confidence C# rule. + +**FN: policy-builder lambdas.** + +Startup code contains policy assertions such as `policy.RequireAssertion(ctx => ctx.User.HasClaim(...))`. The direct `HasClaim(...)` call shape can be detected, but policy-builder context and lambda-based registration deserve their own rule if they show up across more ASP.NET projects. + +**FP risk: low for the main structural hits.** The sampled `AuthorizeAsync`, `[Authorize("Application")]`, and `[AllowAnonymous]` findings are real authorization sites. Test files add noise to the whole-repo total, but they are still useful for rule-shape validation because they exercise the same authorization APIs through mocks and assertions. + +## Deep pass + +Run scoped to the Admin Console controllers — 14 C# files, ~3,400 LOC, concentrated around organization and collection management: + +```bash +zift scan ~/zift-corpus/csharp/server/src/Api/AdminConsole/Controllers/ \ + --deep --agent-cmd "claude -p --output-format json" \ + --format json -o deep.json +``` + +| | | +|---|---| +| Transport | subprocess (`claude -p --output-format json`) | +| Wall time | 177.98s | +| Total findings | 88 (29 structural retained + 59 semantic) | +| Files with findings | 13 | +| Cost reported | $0.00 | + +One candidate was skipped because the agent returned non-JSON for that prompt; Zift treated it as a per-candidate bad response and continued. + +**Findings by pass** + +| Pass | Count | +|------|------:| +| `semantic` | 59 | +| `structural` | 29 | + +**Deep findings by category** + +| Category | Count | +|----------|------:| +| `custom` | 23 | +| `middleware` | 22 | +| `ownership` | 5 | +| `abac` | 3 | +| `feature_gate` | 3 | +| `rbac` | 2 | +| `business_rule` | 1 | + +**Notable deep-only findings** + +| File | Line | Category | Description | +|------|-----:|----------|-------------| +| `GroupsController.cs` | 68 | middleware | `[Authorize]` gates the endpoint | +| `GroupsController.cs` | 232 | ownership | `group.OrganizationId != orgId` tenant ownership check | +| `GroupsController.cs` | 254 | ownership | Bulk delete validates every group's `OrganizationId` | +| `OrganizationConnectionsController.cs` | 60 | custom | `HasPermissionAsync(...)` gates connection creation | +| `OrganizationConnectionsController.cs` | 178 | rbac | `HasPermissionAsync` chooses `ManageScim` vs `OrganizationOwner` | +| `OrganizationInviteLinksController.cs` | 15 | feature_gate | `[RequireFeature(FeatureFlagKeys.GenerateInviteLink)]` gates the controller | + +## Diff structural ↔ deep + +The subset had 36 structural findings in the whole-repo structural report. The deep run retained 29 structural findings and added 59 semantic findings. + +| Bucket | Count | Notes | +|--------|------:|-------| +| Structural retained | 29 | Mostly `AuthorizeAsync`, `[Authorize("Application")]`, and `[AllowAnonymous]` | +| Deep-only | 59 | Generic authorization attributes, tenant ownership checks, helper gates, and feature flags | +| Structural not retained | 7 | Mostly lower-context structural candidates that deep did not echo back, plus one skipped malformed agent response | + +The biggest actionable rule gap is the generic attribute family. In this subset alone, deep surfaced 19 `[Authorize]` findings; across the full shallow clone, grep found 108 occurrences. diff --git a/rules/csharp/aspnet-allow-anonymous.toml b/rules/csharp/aspnet-allow-anonymous.toml new file mode 100644 index 0000000..f83f2be --- /dev/null +++ b/rules/csharp/aspnet-allow-anonymous.toml @@ -0,0 +1,33 @@ +[rule] +id = "csharp-aspnet-allow-anonymous" +languages = ["csharp"] +category = "middleware" +confidence = "high" +description = "ASP.NET Core [AllowAnonymous] attribute" +query = """ +(attribute + name: [ + (identifier) @attribute_name + (qualified_name) @attribute_name + ] +) @match +""" + +[rule.predicates.attribute_name] +match = "(^|\\.)AllowAnonymous(Attribute)?$" + +[[rule.tests]] +input = """ +using Microsoft.AspNetCore.Authorization; + +[AllowAnonymous] +public IActionResult Health() => Ok(); +""" +expect_match = true + +[[rule.tests]] +input = """ +[Authorize] +public IActionResult Index() => Ok(); +""" +expect_match = false diff --git a/rules/csharp/aspnet-authorize-attribute.toml b/rules/csharp/aspnet-authorize-attribute.toml new file mode 100644 index 0000000..45bad3c --- /dev/null +++ b/rules/csharp/aspnet-authorize-attribute.toml @@ -0,0 +1,65 @@ +[rule] +id = "csharp-aspnet-authorize-attribute" +languages = ["csharp"] +category = "middleware" +confidence = "high" +description = "ASP.NET Core [Authorize] attribute" +query = """ +(attribute + name: [ + (identifier) @attribute_name + (qualified_name) @attribute_name + ] +) @match +""" + +[rule.predicates.attribute_name] +match = "(^|\\.)Authorize(Attribute)?$" + +[rule.predicates.match] +not_match = "\\(" + +[rule.rego_template] +template = """ +default allow := false + +allow if { + input.user.authenticated == true +} +""" + +[[rule.tests]] +input = """ +using Microsoft.AspNetCore.Authorization; + +[Authorize] +public class AdminController : ControllerBase { + public IActionResult Index() => Ok(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +using Microsoft.AspNetCore.Authorization; + +[Microsoft.AspNetCore.Authorization.Authorize] +public class AdminController : ControllerBase { + public IActionResult Index() => Ok(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +[HttpGet] +public IActionResult Index() => Ok(); +""" +expect_match = false + +[[rule.tests]] +input = """ +[Authorize(Roles = "Admin")] +public IActionResult Delete(int id) => Ok(); +""" +expect_match = false diff --git a/rules/csharp/aspnet-authorize-policy-shorthand.toml b/rules/csharp/aspnet-authorize-policy-shorthand.toml new file mode 100644 index 0000000..063a0a3 --- /dev/null +++ b/rules/csharp/aspnet-authorize-policy-shorthand.toml @@ -0,0 +1,63 @@ +[rule] +id = "csharp-aspnet-authorize-policy-shorthand" +languages = ["csharp"] +category = "abac" +confidence = "high" +description = "ASP.NET Core [Authorize(...)] policy restriction" +query = """ +(attribute + name: [ + (identifier) @attribute_name + (qualified_name) @attribute_name + ] + (attribute_argument_list + (attribute_argument + (string_literal + (string_literal_content) @policy))) +) @match +""" + +[rule.predicates.attribute_name] +match = "(^|\\.)Authorize(Attribute)?$" + +[rule.predicates.match] +not_match = "(\\(|,)\\s*[A-Za-z_][A-Za-z0-9_]*\\s*=" + +[rule.rego_template] +template = """ +default allow := false + +allow if { + input.policy == "{{policy}}" +} +""" + +[[rule.tests]] +input = """ +using Microsoft.AspNetCore.Authorization; + +[Authorize("CanReadReports")] +public IActionResult Reports() => Ok(); +""" +expect_match = true + +[[rule.tests]] +input = """ +[Authorize(Policy = "CanDeleteUsers")] +public IActionResult Delete(int id) => Ok(); +""" +expect_match = false + +[[rule.tests]] +input = """ +[Authorize(Roles = "Admin")] +public IActionResult Delete(int id) => Ok(); +""" +expect_match = false + +[[rule.tests]] +input = """ +[Authorize(AuthenticationSchemes = "Bearer")] +public IActionResult Api() => Ok(); +""" +expect_match = false diff --git a/rules/csharp/aspnet-authorize-policy.toml b/rules/csharp/aspnet-authorize-policy.toml new file mode 100644 index 0000000..0f0aca7 --- /dev/null +++ b/rules/csharp/aspnet-authorize-policy.toml @@ -0,0 +1,57 @@ +[rule] +id = "csharp-aspnet-authorize-policy" +languages = ["csharp"] +category = "abac" +confidence = "high" +description = "ASP.NET Core [Authorize(Policy = ...)] policy restriction" +query = """ +(attribute + name: [ + (identifier) @attribute_name + (qualified_name) @attribute_name + ] + (attribute_argument_list + (attribute_argument + name: (identifier) @argument_name + (string_literal + (string_literal_content) @policy))) +) @match +""" + +[rule.predicates.attribute_name] +match = "(^|\\.)Authorize(Attribute)?$" + +[rule.predicates.argument_name] +eq = "Policy" + +[rule.rego_template] +template = """ +default allow := false + +allow if { + input.policy == "{{policy}}" +} +""" + +[[rule.tests]] +input = """ +using Microsoft.AspNetCore.Authorization; + +[Authorize(Policy = "CanDeleteUsers")] +public IActionResult Delete(int id) => Ok(); +""" +expect_match = true + +[[rule.tests]] +input = """ +[Authorize("CanReadReports")] +public IActionResult Reports() => Ok(); +""" +expect_match = false + +[[rule.tests]] +input = """ +[Authorize(Roles = "Admin")] +public IActionResult Delete(int id) => Ok(); +""" +expect_match = false diff --git a/rules/csharp/aspnet-authorize-roles.toml b/rules/csharp/aspnet-authorize-roles.toml new file mode 100644 index 0000000..0075b2e --- /dev/null +++ b/rules/csharp/aspnet-authorize-roles.toml @@ -0,0 +1,57 @@ +[rule] +id = "csharp-aspnet-authorize-roles" +languages = ["csharp"] +category = "rbac" +confidence = "high" +description = "ASP.NET Core [Authorize(Roles = ...)] role restriction" +query = """ +(attribute + name: [ + (identifier) @attribute_name + (qualified_name) @attribute_name + ] + (attribute_argument_list + (attribute_argument + name: (identifier) @argument_name + (string_literal + (string_literal_content) @roles))) +) @match +""" + +[rule.predicates.attribute_name] +match = "(^|\\.)Authorize(Attribute)?$" + +[rule.predicates.argument_name] +eq = "Roles" + +[rule.rego_template] +template = """ +default allow := false + +allow if { + input.user.role in {{{roles_set}}} +} +""" + +[[rule.tests]] +input = """ +using Microsoft.AspNetCore.Authorization; + +[Authorize(Roles = "Admin")] +public IActionResult Delete(int id) => Ok(); +""" +expect_match = true + +[[rule.tests]] +input = """ +[Authorize(Roles = "Admin,Manager")] +public IActionResult Delete(int id) => Ok(); +""" +expect_match = true + +[[rule.tests]] +input = """ +[Authorize(Policy = "CanDeleteUsers")] +public IActionResult Delete(int id) => Ok(); +""" +expect_match = false diff --git a/rules/csharp/aspnet-endpoint-allow-anonymous.toml b/rules/csharp/aspnet-endpoint-allow-anonymous.toml new file mode 100644 index 0000000..d0b4a2e --- /dev/null +++ b/rules/csharp/aspnet-endpoint-allow-anonymous.toml @@ -0,0 +1,30 @@ +[rule] +id = "csharp-aspnet-endpoint-allow-anonymous" +languages = ["csharp"] +category = "middleware" +confidence = "high" +description = "ASP.NET Core endpoint AllowAnonymous() call" +query = """ +(invocation_expression + function: (member_access_expression + name: (identifier) @method_name) + arguments: (argument_list) +) @match +""" + +[rule.predicates.method_name] +eq = "AllowAnonymous" + +[[rule.tests]] +input = """ +var app = builder.Build(); +app.MapGet("/health", () => "ok").AllowAnonymous(); +""" +expect_match = true + +[[rule.tests]] +input = """ +var app = builder.Build(); +app.MapGet("/admin", () => "ok").RequireAuthorization(); +""" +expect_match = false diff --git a/rules/csharp/aspnet-require-authorization.toml b/rules/csharp/aspnet-require-authorization.toml new file mode 100644 index 0000000..7cdf864 --- /dev/null +++ b/rules/csharp/aspnet-require-authorization.toml @@ -0,0 +1,46 @@ +[rule] +id = "csharp-aspnet-require-authorization" +languages = ["csharp"] +category = "middleware" +confidence = "high" +description = "ASP.NET Core endpoint RequireAuthorization() call" +query = """ +(invocation_expression + function: (member_access_expression + name: (identifier) @method_name) + arguments: (argument_list) @arguments +) @match +""" + +[rule.predicates.method_name] +eq = "RequireAuthorization" + +[rule.rego_template] +template = """ +default allow := false + +allow if { + input.user.authenticated == true +} +""" + +[[rule.tests]] +input = """ +var app = builder.Build(); +app.MapGet("/admin", () => "ok").RequireAuthorization(); +""" +expect_match = true + +[[rule.tests]] +input = """ +var app = builder.Build(); +app.MapGet("/admin", () => "ok").RequireAuthorization("AdminsOnly"); +""" +expect_match = true + +[[rule.tests]] +input = """ +var app = builder.Build(); +app.MapGet("/health", () => "ok"); +""" +expect_match = false diff --git a/rules/csharp/authorization-service-authorize-async.toml b/rules/csharp/authorization-service-authorize-async.toml new file mode 100644 index 0000000..4adf59c --- /dev/null +++ b/rules/csharp/authorization-service-authorize-async.toml @@ -0,0 +1,62 @@ +[rule] +id = "csharp-authorization-service-authorize-async" +languages = ["csharp"] +category = "abac" +confidence = "high" +description = "ASP.NET Core IAuthorizationService.AuthorizeAsync(...) policy check" +query = """ +(invocation_expression + function: (member_access_expression + name: (identifier) @method_name) + arguments: (argument_list) @arguments +) @match +""" + +[rule.predicates.method_name] +eq = "AuthorizeAsync" + +[rule.predicates.arguments] +match = "(Policy|Requirement|Operations\\.|\"[^\"]+\"|(?i:[A-Za-z_][A-Za-z0-9_]*?(policy|requirement)s?))" + +[rule.rego_template] +template = """ +default allow := false + +allow if { + # TODO: translate ASP.NET authorization service call: {{arguments}} + input.user.authenticated == true +} +""" + +[[rule.tests]] +input = """ +var result = await _authorizationService.AuthorizeAsync(User, resource, "CanDeleteUsers"); +if (!result.Succeeded) { + return Forbid(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +var result = await authz.AuthorizeAsync(User, document, Operations.Read); +""" +expect_match = true + +[[rule.tests]] +input = """ +var result = await authz.AuthorizeAsync(User, document, policyName); +""" +expect_match = true + +[[rule.tests]] +input = """ +var result = await authz.AuthorizeAsync(User, resource, requirements); +""" +expect_match = true + +[[rule.tests]] +input = """ +var value = await client.SendAsync(request); +""" +expect_match = false diff --git a/rules/csharp/has-claim-call.toml b/rules/csharp/has-claim-call.toml new file mode 100644 index 0000000..df4d2d1 --- /dev/null +++ b/rules/csharp/has-claim-call.toml @@ -0,0 +1,61 @@ +[rule] +id = "csharp-has-claim-call" +languages = ["csharp"] +category = "abac" +confidence = "high" +description = "C# HasClaim(...) authorization check" +query = """ +(invocation_expression + function: (member_access_expression + name: (identifier) @method_name) + arguments: (argument_list + (argument + [ + (string_literal (string_literal_content) @claim_type) + (member_access_expression) @claim_type + (identifier) @claim_type + ]) + (argument + [ + (string_literal (string_literal_content) @claim_value) + (member_access_expression) @claim_value + (identifier) @claim_value + ])) +) @match +""" + +[rule.predicates.method_name] +eq = "HasClaim" + +[rule.rego_template] +template = """ +default allow := false + +allow if { + input.user.claims["{{claim_type}}"] == "{{claim_value}}" +} +""" + +[[rule.tests]] +input = """ +if (User.HasClaim("scope", "users.delete")) { + return Ok(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (principal.HasClaim(ClaimTypes.Role, "Admin")) { + return Ok(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (User.IsInRole("Admin")) { + return Ok(); +} +""" +expect_match = false diff --git a/rules/csharp/is-in-role-call.toml b/rules/csharp/is-in-role-call.toml new file mode 100644 index 0000000..47c677b --- /dev/null +++ b/rules/csharp/is-in-role-call.toml @@ -0,0 +1,55 @@ +[rule] +id = "csharp-is-in-role-call" +languages = ["csharp"] +category = "rbac" +confidence = "high" +description = "C# IsInRole(...) role check" +query = """ +(invocation_expression + function: (member_access_expression + name: (identifier) @method_name) + arguments: (argument_list + (argument + [ + (string_literal (string_literal_content) @role_value) + (member_access_expression) @role_value + (identifier) @role_value + ])) +) @match +""" + +[rule.predicates.method_name] +eq = "IsInRole" + +[rule.rego_template] +template = """ +default allow := false + +allow if { + input.user.role in {"{{role_value}}"} +} +""" + +[[rule.tests]] +input = """ +if (User.IsInRole("Admin")) { + return Ok(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (principal.IsInRole(Roles.Admin)) { + return Ok(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (User.Identity.IsAuthenticated) { + return Ok(); +} +""" +expect_match = false diff --git a/src/deep/candidate.rs b/src/deep/candidate.rs index 4579050..48ddd4c 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 (Go, C#, Kotlin, Ruby, PHP) — +//! those without structural parser support (Kotlin, 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/deep/context.rs b/src/deep/context.rs index 25ddce8..5dcff3b 100644 --- a/src/deep/context.rs +++ b/src/deep/context.rs @@ -5,7 +5,7 @@ //! - **Fast path**: line-window `[start-5, end+15]` plus the first 20 lines //! of the file as imports. Works for all languages. **Implemented here.** //! - **Smart path**: tree-sitter walk to enclosing function. Only available -//! for languages with an integrated grammar (TS/JS/Java today). **TODO**: +//! for languages with an integrated grammar. **TODO**: //! land in a follow-up commit; primary path is fast-path which is //! sufficient for v1. Most local 7B-14B models can figure out function //! boundaries from a generous line window with imports included. diff --git a/src/rego/templates.rs b/src/rego/templates.rs index 8a2d0a0..68fa700 100644 --- a/src/rego/templates.rs +++ b/src/rego/templates.rs @@ -11,6 +11,7 @@ pub fn render_template(template: &str, vars: &HashMap) -> String re.replace_all(template, |caps: ®ex::Captures| { let key = &caps[1]; match vars.get(key) { + Some(val) if key == "roles_set" => val.to_string(), Some(val) => strip_quotes(val).to_string(), None => caps[0].to_string(), // leave placeholder } @@ -196,6 +197,25 @@ mod tests { assert_eq!(result, "role == \"admin\""); } + #[test] + fn render_set_template_keeps_item_quotes() { + let mut vars = HashMap::new(); + vars.insert( + "roles_set".to_string(), + "\"Admin\", \"Manager\"".to_string(), + ); + let result = render_template("role in {{{roles_set}}}", &vars); + assert_eq!(result, "role in {\"Admin\", \"Manager\"}"); + } + + #[test] + fn render_unknown_set_template_strips_outer_quotes() { + let mut vars = HashMap::new(); + vars.insert("claims_set".to_string(), "\"Admin\"".to_string()); + let result = render_template("claim == \"{{claims_set}}\"", &vars); + assert_eq!(result, "claim == \"Admin\""); + } + #[test] fn render_missing_var_leaves_placeholder() { let vars = HashMap::new(); diff --git a/src/rules/embedded.rs b/src/rules/embedded.rs index 17e5fab..b26ecc3 100644 --- a/src/rules/embedded.rs +++ b/src/rules/embedded.rs @@ -247,6 +247,47 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ "go-access-descriptor-builder", include_str!("../../rules/go/access-descriptor-builder.toml"), ), + // -- C# / ASP.NET Core -- + ( + "csharp-aspnet-authorize-attribute", + include_str!("../../rules/csharp/aspnet-authorize-attribute.toml"), + ), + ( + "csharp-aspnet-authorize-roles", + include_str!("../../rules/csharp/aspnet-authorize-roles.toml"), + ), + ( + "csharp-aspnet-authorize-policy", + include_str!("../../rules/csharp/aspnet-authorize-policy.toml"), + ), + ( + "csharp-aspnet-authorize-policy-shorthand", + include_str!("../../rules/csharp/aspnet-authorize-policy-shorthand.toml"), + ), + ( + "csharp-aspnet-allow-anonymous", + include_str!("../../rules/csharp/aspnet-allow-anonymous.toml"), + ), + ( + "csharp-aspnet-require-authorization", + include_str!("../../rules/csharp/aspnet-require-authorization.toml"), + ), + ( + "csharp-aspnet-endpoint-allow-anonymous", + include_str!("../../rules/csharp/aspnet-endpoint-allow-anonymous.toml"), + ), + ( + "csharp-is-in-role-call", + include_str!("../../rules/csharp/is-in-role-call.toml"), + ), + ( + "csharp-has-claim-call", + include_str!("../../rules/csharp/has-claim-call.toml"), + ), + ( + "csharp-authorization-service-authorize-async", + include_str!("../../rules/csharp/authorization-service-authorize-async.toml"), + ), ]; pub fn load_embedded_rules() -> Result> { diff --git a/src/scanner/discovery.rs b/src/scanner/discovery.rs index 997742e..adee83c 100644 --- a/src/scanner/discovery.rs +++ b/src/scanner/discovery.rs @@ -24,6 +24,7 @@ pub fn detect_language(path: &Path) -> Option<(Language, bool)> { "java" => Some((Language::Java, false)), "py" | "pyi" => Some((Language::Python, false)), "go" => Some((Language::Go, false)), + "cs" => Some((Language::CSharp, false)), _ => None, } } @@ -183,6 +184,14 @@ mod tests { ); } + #[test] + fn detect_csharp_extension() { + assert_eq!( + detect_language(Path::new("Foo.cs")), + Some((Language::CSharp, false)) + ); + } + #[test] fn detect_unknown_extension() { assert_eq!(detect_language(Path::new("foo.rs")), None); @@ -257,9 +266,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. (Go was here before v0.2 - // added Go structural support.) - assert_eq!(detect_language(Path::new("Foo.cs")), None); + // them up; the structural one doesn't. (C# was here before C# + // structural support.) assert_eq!(detect_language(Path::new("Foo.kt")), None); assert_eq!(detect_language(Path::new("foo.rb")), None); assert_eq!(detect_language(Path::new("foo.php")), None); @@ -279,8 +287,13 @@ mod tests { let structural_langs: HashSet<_> = structural.iter().map(|f| f.language).collect(); assert_eq!( structural_langs, - HashSet::from([Language::TypeScript, Language::Python, Language::Go]), - "structural should include TS + Python + Go (Go gained structural support in v0.2)", + HashSet::from([ + Language::TypeScript, + Language::Python, + Language::Go, + Language::CSharp + ]), + "structural should include TS + Python + Go + C#", ); let deep = discover_files_for_deep(dir.path(), &[], &[]); diff --git a/src/scanner/imports.rs b/src/scanner/imports.rs index 5bebac9..3b54aa5 100644 --- a/src/scanner/imports.rs +++ b/src/scanner/imports.rs @@ -130,6 +130,7 @@ pub fn find_policy_imports( Language::Go => find_go_policy_imports(tree, source), Language::Python => find_py_policy_imports(tree, source), Language::Java => find_java_policy_imports(tree, source), + Language::CSharp => find_csharp_policy_imports(tree, source), // Other languages: no import detection yet. _ => HashSet::new(), }; @@ -452,6 +453,56 @@ fn find_java_policy_imports(tree: &tree_sitter::Tree, source: &[u8]) -> HashSet< policy_names } +fn find_csharp_policy_imports(tree: &tree_sitter::Tree, source: &[u8]) -> HashSet { + let mut policy_names = HashSet::new(); + + iter_named_descendants(tree.root_node(), |node| { + if node.kind() != "using_directive" { + return; + } + + // `using Alias = Company.Policy.Authorizer;` binds `Alias`. + if let Some(alias_node) = node.child_by_field_name("name") { + let Some(target) = csharp_using_alias_target(node, alias_node) else { + return; + }; + let Ok(full_text) = target.utf8_text(source) else { + return; + }; + + if is_policy_path(full_text) + && let Ok(alias) = alias_node.utf8_text(source) + { + policy_names.insert(alias.to_string()); + } + } + }); + + policy_names +} + +fn csharp_using_alias_target<'a>( + node: tree_sitter::Node<'a>, + alias_node: tree_sitter::Node<'a>, +) -> Option> { + for field in ["value", "target", "path", "type"] { + if let Some(target) = node.child_by_field_name(field) { + return Some(target); + } + } + + let mut cursor = node.walk(); + node.named_children(&mut cursor) + .find(|child| child.id() != alias_node.id() && csharp_node_can_be_policy_path(*child)) +} + +fn csharp_node_can_be_policy_path(node: tree_sitter::Node) -> bool { + matches!( + node.kind(), + "qualified_name" | "identifier" | "generic_name" | "member_access_expression" + ) +} + /// Walk the tree once, collecting `(lhs_name, rhs_source_text)` edges from /// assignment-shaped nodes. The propagation step then checks each RHS for /// any current binding and adds the LHS if it matches. @@ -473,6 +524,7 @@ fn extract_propagation_edges( Language::Go => visit_go_edge(node, source, &mut edges), Language::Python => visit_py_edge(node, source, &mut edges), Language::Java => visit_java_edge(node, source, &mut edges), + Language::CSharp => visit_csharp_edge(node, source, &mut edges), _ => {} }); @@ -772,6 +824,83 @@ fn visit_java_edge(node: tree_sitter::Node, source: &[u8], edges: &mut Vec<(Stri } } +fn csharp_lhs_name(node: tree_sitter::Node, source: &[u8]) -> Option { + match node.kind() { + "identifier" => node.utf8_text(source).ok().map(str::to_string), + // `this.factory = Policy.X` — propagate `factory`. + "member_access_expression" => node + .child_by_field_name("name") + .and_then(|n| n.utf8_text(source).ok()) + .map(str::to_string), + _ => None, + } +} + +fn csharp_variable_declarator_value<'a>( + node: tree_sitter::Node<'a>, + name: tree_sitter::Node<'a>, +) -> Option> { + let mut cursor = node.walk(); + node.named_children(&mut cursor) + .find(|child| child.id() != name.id()) +} + +fn csharp_parent_type_text(node: tree_sitter::Node, source: &[u8]) -> Option { + node.parent() + .filter(|parent| parent.kind() == "variable_declaration") + .and_then(|parent| parent.child_by_field_name("type")) + .and_then(|ty| ty.utf8_text(source).ok()) + .map(str::to_string) +} + +fn visit_csharp_edge(node: tree_sitter::Node, source: &[u8], edges: &mut Vec<(String, String)>) { + match node.kind() { + // Covers local declarations and field declarations. C# grammar leaves + // the initializer as the first non-name child of `variable_declarator`. + "variable_declarator" => { + let Some(name) = node.child_by_field_name("name") else { + return; + }; + if name.kind() != "identifier" { + return; + } + let lhs = name.utf8_text(source).unwrap_or(""); + if let Some(value) = csharp_variable_declarator_value(node, name) { + push_edge(lhs, value.utf8_text(source).unwrap_or(""), edges); + } else if let Some(type_text) = csharp_parent_type_text(node, source) { + push_edge(lhs, &type_text, edges); + } + } + // Constructor/method DI: `Controller(Authz.IAuthorizer authz)`. + "parameter" => { + let (Some(name), Some(ty)) = ( + node.child_by_field_name("name"), + node.child_by_field_name("type"), + ) else { + return; + }; + if name.kind() != "identifier" { + return; + } + let lhs = name.utf8_text(source).unwrap_or(""); + let rhs = ty.utf8_text(source).unwrap_or(""); + push_edge(lhs, rhs, edges); + } + "assignment_expression" => { + let (Some(left), Some(right)) = ( + node.child_by_field_name("left"), + node.child_by_field_name("right"), + ) else { + return; + }; + if let Some(lhs) = csharp_lhs_name(left, source) { + push_edge(&lhs, right.utf8_text(source).unwrap_or(""), edges); + } + } + _ => {} + } +} + fn visit_ts_js_edge(node: tree_sitter::Node, source: &[u8], edges: &mut Vec<(String, String)>) { match node.kind() { // `const x = ...`, `let x = ...`, `var x = ...`. @@ -1286,6 +1415,49 @@ import com.example.policy.Authorize; )); } + // ---------- C# ---------- + + #[test] + fn csharp_detects_using_policy_alias() { + let source = r#" +using Company.Authz; +using PolicyAlias = Company.Policy.Authorizer; +using System.Collections.Generic; +"#; + let tree = parse_lang(source, Language::CSharp); + let imports = find_policy_imports(&tree, source.as_bytes(), Language::CSharp); + + assert!(!imports.contains("Authz"), "got: {imports:?}"); + assert!(imports.contains("PolicyAlias"), "got: {imports:?}"); + assert!(!imports.contains("Collections")); + } + + #[test] + fn csharp_alias_checks_target_not_alias_name() { + let source = r#" +using PolicyAlias = Company.Utils.Helper; +"#; + let tree = parse_lang(source, Language::CSharp); + let imports = find_policy_imports(&tree, source.as_bytes(), Language::CSharp); + + assert!(!imports.contains("PolicyAlias"), "got: {imports:?}"); + } + + #[test] + fn csharp_enforcement_point_check() { + let source = r#" +using PolicyAlias = Company.Policy.Authorizer; +"#; + let tree = parse_lang(source, Language::CSharp); + let imports = find_policy_imports(&tree, source.as_bytes(), Language::CSharp); + + assert!(is_enforcement_point( + "PolicyAlias.AuthorizeAsync(User, doc, \"Read\")", + &imports, + )); + assert!(!is_enforcement_point("User.IsInRole(\"Admin\")", &imports)); + } + // ---------- Local data-flow propagation (option #2) ---------- #[test] @@ -1525,6 +1697,69 @@ const direct = authorize; assert!(imports.contains("direct"), "var decl: {imports:?}"); } + #[test] + fn csharp_propagates_through_constructor_di_parameter_and_field() { + let source = r#" +using Authz = Company.Policy.Authorizer; + +public class Service { + private readonly Authz _authz; + + public Service(Authz authz) { + _authz = authz; + } + + public Task Check(User user, Document doc) { + return _authz.AuthorizeAsync(user, doc, "Read"); + } +} +"#; + let tree = parse_lang(source, Language::CSharp); + let imports = find_policy_imports(&tree, source.as_bytes(), Language::CSharp); + + assert!(imports.contains("Authz"), "got: {imports:?}"); + assert!( + imports.contains("authz"), + "parameter type should seed constructor DI parameter; got: {imports:?}" + ); + assert!( + imports.contains("_authz"), + "field assignment should propagate from constructor parameter; got: {imports:?}" + ); + assert!(is_enforcement_point( + r#"_authz.AuthorizeAsync(user, doc, "Read")"#, + &imports, + )); + } + + #[test] + fn csharp_propagates_through_object_initializer_and_local_variable() { + let source = r#" +using Policy = Company.Policy; + +public class Service { + public void Build() { + var direct = Policy.Authorizer; + var bag = new AccessBag { Checker = direct }; + bag.Checker.AuthorizeAsync(User, doc, "Read"); + } +} +"#; + let tree = parse_lang(source, Language::CSharp); + let imports = find_policy_imports(&tree, source.as_bytes(), Language::CSharp); + + assert!(imports.contains("Policy"), "got: {imports:?}"); + assert!(imports.contains("direct"), "local var: {imports:?}"); + assert!( + imports.contains("Checker"), + "object initializer: {imports:?}" + ); + assert!(is_enforcement_point( + r#"bag.Checker.AuthorizeAsync(User, doc, "Read")"#, + &imports, + )); + } + #[test] fn propagation_is_a_no_op_when_no_policy_imports() { // Defense in depth: even if the file is full of assignments, with diff --git a/src/scanner/matcher.rs b/src/scanner/matcher.rs index 9a2bebc..8ca53eb 100644 --- a/src/scanner/matcher.rs +++ b/src/scanner/matcher.rs @@ -156,10 +156,11 @@ pub fn execute_query( description: compiled.rule.description.clone(), pattern_rule: Some(compiled.rule.id.clone()), rego_stub: compiled.rule.rego_template.as_ref().map(|tmpl| { - let owned: HashMap = captures + let mut owned: HashMap = captures .iter() .map(|(k, v)| (k.to_string(), v.clone())) .collect(); + add_template_derived_values(&mut owned); crate::rego::render_template(tmpl, &owned) }), pass: ScanPass::Structural, @@ -170,6 +171,28 @@ pub fn execute_query( Ok(findings) } +fn add_template_derived_values(vars: &mut HashMap) { + if let Some(roles) = vars.get("roles") { + vars.insert( + "roles_set".to_string(), + comma_separated_rego_set_items(roles), + ); + } +} + +fn comma_separated_rego_set_items(value: &str) -> String { + value + .trim() + .trim_matches('"') + .trim_matches('\'') + .split(',') + .map(str::trim) + .filter(|role| !role.is_empty()) + .map(|role| format!("\"{role}\"")) + .collect::>() + .join(", ") +} + fn check_predicates(predicates: &[(String, Predicate)], captures: &HashMap<&str, String>) -> bool { for (capture_name, predicate) in predicates { let Some(text) = captures.get(capture_name.as_str()) else { @@ -344,6 +367,55 @@ mod tests { assert!(!findings.is_empty()); } + #[test] + fn csharp_authorize_roles_splits_comma_separated_roles_in_rego() { + let findings = parse_and_match( + r#"[Authorize(Roles = "Admin,Manager")] +public IActionResult Delete(int id) => Ok();"#, + include_str!("../../rules/csharp/aspnet-authorize-roles.toml"), + ); + + assert_eq!(findings.len(), 1); + let rego = findings[0].rego_stub.as_deref().unwrap(); + assert!( + rego.contains(r#"input.user.role in {"Admin", "Manager"}"#), + "rego should split ASP.NET comma-separated roles; got: {rego}" + ); + } + + #[test] + fn csharp_has_claim_rego_includes_claim_value() { + let findings = parse_and_match( + r#"if (User.HasClaim("scope", "users.delete")) { + return Ok(); +}"#, + include_str!("../../rules/csharp/has-claim-call.toml"), + ); + + assert_eq!(findings.len(), 1); + let rego = findings[0].rego_stub.as_deref().unwrap(); + assert!( + rego.contains(r#"input.user.claims["scope"] == "users.delete""#), + "rego should require the claim value; got: {rego}" + ); + } + + #[test] + fn csharp_authorize_policy_shorthand_matches() { + let findings = parse_and_match( + r#"[Authorize("CanReadReports")] +public IActionResult Reports() => Ok();"#, + include_str!("../../rules/csharp/aspnet-authorize-policy-shorthand.toml"), + ); + + assert_eq!(findings.len(), 1); + let rego = findings[0].rego_stub.as_deref().unwrap(); + assert!( + rego.contains(r#"input.policy == "CanReadReports""#), + "rego should include the shorthand policy name; got: {rego}" + ); + } + #[test] fn express_auth_middleware_matches() { let findings = parse_and_match( diff --git a/src/scanner/parser.rs b/src/scanner/parser.rs index 963b508..df47977 100644 --- a/src/scanner/parser.rs +++ b/src/scanner/parser.rs @@ -16,6 +16,7 @@ pub fn get_language(lang: Language, is_tsx_jsx: bool) -> Result Ok(tree_sitter_java::LANGUAGE.into()), (Language::Python, _) => Ok(tree_sitter_python::LANGUAGE.into()), (Language::Go, _) => Ok(tree_sitter_go::LANGUAGE.into()), + (Language::CSharp, _) => Ok(tree_sitter_c_sharp::LANGUAGE.into()), _ => Err(ZiftError::UnsupportedLanguage(lang)), } } @@ -104,16 +105,36 @@ mod tests { assert!(is_language_supported(Language::Go)); } + #[test] + fn parse_csharp() { + let mut parser = tree_sitter::Parser::new(); + let source = br#" +using Microsoft.AspNetCore.Authorization; + +[Authorize(Roles = "Admin")] +public class AdminController : ControllerBase { + public IActionResult Delete() => Ok(); +} +"#; + let tree = parse_source(&mut parser, source, Language::CSharp, false).unwrap(); + assert!(!tree.root_node().has_error()); + } + + #[test] + fn csharp_is_supported() { + assert!(is_language_supported(Language::CSharp)); + } + #[test] fn unsupported_language_returns_error() { - // C# has no structural grammar wired up yet — kept as the canary + // Kotlin 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 Go before v0.2 added Go support.) - let err = get_language(Language::CSharp, false).unwrap_err(); + // name says it does. (Was C# before C# structural support.) + let err = get_language(Language::Kotlin, false).unwrap_err(); assert!(matches!( err, - ZiftError::UnsupportedLanguage(Language::CSharp) + ZiftError::UnsupportedLanguage(Language::Kotlin) )); - assert!(!is_language_supported(Language::CSharp)); + assert!(!is_language_supported(Language::Kotlin)); } } diff --git a/tests/scanner_enforcement_points.rs b/tests/scanner_enforcement_points.rs index 64fee1f..87b3b5b 100644 --- a/tests/scanner_enforcement_points.rs +++ b/tests/scanner_enforcement_points.rs @@ -285,3 +285,53 @@ public class OrderService { .collect::>(), ); } + +#[test] +fn enforcement_points_increments_for_csharp_policy_di() { + // The C# import tracker seeds `Authz` from the policy alias, then propagates + // through constructor DI (`authz`) and the backing field (`_authz`). The + // resulting `_authz.AuthorizeAsync(...)` call is externalized, so it should + // count as an enforcement point rather than an embedded finding. + let result = scan_fixture( + "OrderController.cs", + r#"using Authz = Company.Policy.Authorizer; + +public class OrderController { + private readonly Authz _authz; + + public OrderController(Authz authz) { + _authz = authz; + } + + public async Task List(User user, Document document) { + var result = await _authz.AuthorizeAsync(user, document, "CanReadOrders"); + if (!result.Succeeded) { + return Forbid(); + } + return Ok(); + } +} +"#, + ); + + assert!( + result.enforcement_points >= 1, + "expected the C# policy DI AuthorizeAsync call to count as an enforcement point; \ + got {} (findings: {:?})", + result.enforcement_points, + result + .findings + .iter() + .map(|f| (f.pattern_rule.clone(), f.line_start)) + .collect::>(), + ); + assert!( + !result + .findings + .iter() + .any(|f| f.pattern_rule.as_deref() + == Some("csharp-authorization-service-authorize-async")), + "policy-routed C# call leaked into findings: {:?}", + result.findings, + ); +}