From ed805b582fb8302658eddfd848be270af5e9a158 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 7 May 2026 09:41:49 -0400 Subject: [PATCH 1/6] feat: cover javax/jakarta annotation forms and add JAX-RS rule (#58) Java annotation rules previously matched only bare-identifier names, so fully-qualified usages like `@jakarta.annotation.security.RolesAllowed` and the `javax.*` siblings were silently missed across the Jakarta EE 9 package migration boundary. Extend each annotation rule's `name:` field to accept `scoped_identifier` alongside `identifier`, mirroring the alternation already in `access-decision-voter.toml` and `security-interface-impl.toml`. Predicate matching stays on the trailing identifier so `eq`/`match` rules apply uniformly. Add a new `jaxrs-endpoint.toml` rule covering `@GET`/`@POST`/`@PUT`/ `@DELETE`/`@PATCH`/`@HEAD`/`@OPTIONS`/`@Path` from both `javax.ws.rs.*` and `jakarta.ws.rs.*` so unprotected REST endpoints are visible to the structural pass. Inline `[[rule.tests]]` cover bare and fully-qualified forms (jakarta, javax, framework-qualified) on every touched rule. --- rules/java/authorized-annotation.toml | 19 +++- rules/java/jaxrs-endpoint.toml | 107 ++++++++++++++++++ rules/java/shiro-requires-authentication.toml | 14 ++- rules/java/shiro-requires-permissions.toml | 14 ++- rules/java/shiro-requires-roles-array.toml | 14 ++- rules/java/shiro-requires-roles.toml | 14 ++- rules/java/spring-permit-all.toml | 23 +++- rules/java/spring-preauthorize.toml | 14 ++- rules/java/spring-roles-allowed-array.toml | 23 +++- rules/java/spring-roles-allowed.toml | 28 ++++- rules/java/spring-secured.toml | 14 ++- src/rules/embedded.rs | 4 + 12 files changed, 277 insertions(+), 11 deletions(-) create mode 100644 rules/java/jaxrs-endpoint.toml diff --git a/rules/java/authorized-annotation.toml b/rules/java/authorized-annotation.toml index e6402b6..db47fea 100644 --- a/rules/java/authorized-annotation.toml +++ b/rules/java/authorized-annotation.toml @@ -16,11 +16,17 @@ description = "OpenMRS-style @Authorized({\"PRIV\"}) annotation" query = """ [ (annotation - name: (identifier) @anno_name + name: [ + (identifier) @anno_name + (scoped_identifier name: (identifier) @anno_name) + ] arguments: (annotation_argument_list (string_literal (string_fragment) @role_value))) (annotation - name: (identifier) @anno_name + name: [ + (identifier) @anno_name + (scoped_identifier name: (identifier) @anno_name) + ] arguments: (annotation_argument_list (element_value_array_initializer (string_literal (string_fragment) @role_value)))) @@ -78,6 +84,15 @@ public class UserService { """ expect_match = false +[[rule.tests]] +input = """ +public class UserService { + @org.openmrs.annotation.Authorized("Manage Users") + public void delete(User u) { } +} +""" +expect_match = true + [[rule.tests]] input = """ public class UserService { diff --git a/rules/java/jaxrs-endpoint.toml b/rules/java/jaxrs-endpoint.toml new file mode 100644 index 0000000..9a24136 --- /dev/null +++ b/rules/java/jaxrs-endpoint.toml @@ -0,0 +1,107 @@ +[rule] +id = "java-jaxrs-endpoint" +languages = ["java"] +category = "middleware" +confidence = "medium" +description = "JAX-RS / Jakarta REST resource method (@GET, @POST, @Path, ...)" +# JAX-RS resource methods are tagged with one of `@Path`, `@GET`, `@POST`, +# `@PUT`, `@DELETE`, `@PATCH`, `@HEAD`, or `@OPTIONS` from `javax.ws.rs.*` +# (Java EE 8 and earlier) or `jakarta.ws.rs.*` (Jakarta EE 9+, Jersey 3+, +# RESTEasy 6+). The annotation set is identical across the migration boundary +# — only the package prefix changes — so the rule needs to accept bare and +# fully-qualified forms for both. +# +# Without this rule a bare `@GET` REST endpoint is invisible to the +# structural pass; the existing annotation rules cover Spring Security, JSR-250, +# and Shiro but say nothing about REST surface area, so unprotected JAX-RS +# methods (a `@GET` with no auth annotation) wouldn't surface at all. +query = """ +[ + (marker_annotation + name: [ + (identifier) @anno_name + (scoped_identifier name: (identifier) @anno_name) + ]) + (annotation + name: [ + (identifier) @anno_name + (scoped_identifier name: (identifier) @anno_name) + ]) +] @match +""" + +[rule.predicates.anno_name] +match = "^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|Path)$" + +[[rule.tests]] +input = """ +public class UserResource { + @GET + public List list() { return null; } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +public class UserResource { + @Path("/users") + public class Inner {} +} +""" +expect_match = true + +[[rule.tests]] +input = """ +public class UserResource { + @POST + @Path("/users") + public Response create(User u) { return null; } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +public class UserResource { + @jakarta.ws.rs.GET + public List list() { return null; } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +public class UserResource { + @javax.ws.rs.GET + public List list() { return null; } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +public class UserResource { + @jakarta.ws.rs.Path("/users") + public Response find() { return null; } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +public class UserController { + @Override + public void delete() { } +} +""" +expect_match = false + +[[rule.tests]] +input = """ +public class UserController { + @Transactional + public void save() { } +} +""" +expect_match = false diff --git a/rules/java/shiro-requires-authentication.toml b/rules/java/shiro-requires-authentication.toml index be2ab66..869e3c6 100644 --- a/rules/java/shiro-requires-authentication.toml +++ b/rules/java/shiro-requires-authentication.toml @@ -6,7 +6,10 @@ confidence = "high" description = "Apache Shiro @RequiresAuthentication, @RequiresGuest, or @RequiresUser annotation" query = """ (marker_annotation - name: (identifier) @anno_name + name: [ + (identifier) @anno_name + (scoped_identifier name: (identifier) @anno_name) + ] ) @match """ @@ -40,6 +43,15 @@ public class SecureController { """ expect_match = true +[[rule.tests]] +input = """ +public class SecureController { + @org.apache.shiro.authz.annotation.RequiresAuthentication + public void secureAction() { } +} +""" +expect_match = true + [[rule.tests]] input = """ public class Controller { diff --git a/rules/java/shiro-requires-permissions.toml b/rules/java/shiro-requires-permissions.toml index 85084cd..c859968 100644 --- a/rules/java/shiro-requires-permissions.toml +++ b/rules/java/shiro-requires-permissions.toml @@ -6,7 +6,10 @@ confidence = "high" description = "Apache Shiro @RequiresPermissions annotation" query = """ (annotation - name: (identifier) @anno_name + name: [ + (identifier) @anno_name + (scoped_identifier name: (identifier) @anno_name) + ] arguments: (annotation_argument_list (string_literal (string_fragment) @perm_value)) @@ -46,6 +49,15 @@ public class UserController { """ expect_match = true +[[rule.tests]] +input = """ +public class UserController { + @org.apache.shiro.authz.annotation.RequiresPermissions("user:delete") + public void deleteUser(Long id) { } +} +""" +expect_match = true + [[rule.tests]] input = """ public class UserController { diff --git a/rules/java/shiro-requires-roles-array.toml b/rules/java/shiro-requires-roles-array.toml index 447be41..0905ef5 100644 --- a/rules/java/shiro-requires-roles-array.toml +++ b/rules/java/shiro-requires-roles-array.toml @@ -6,7 +6,10 @@ confidence = "high" description = "Apache Shiro @RequiresRoles annotation with array of roles" query = """ (annotation - name: (identifier) @anno_name + name: [ + (identifier) @anno_name + (scoped_identifier name: (identifier) @anno_name) + ] arguments: (annotation_argument_list (element_value_array_initializer (string_literal) @role_value)) @@ -46,6 +49,15 @@ public class AdminController { """ expect_match = true +[[rule.tests]] +input = """ +public class AdminController { + @org.apache.shiro.authz.annotation.RequiresRoles({"admin", "manager"}) + public void adminAction() { } +} +""" +expect_match = true + [[rule.tests]] input = """ public class AdminController { diff --git a/rules/java/shiro-requires-roles.toml b/rules/java/shiro-requires-roles.toml index 5e697f5..3bac1df 100644 --- a/rules/java/shiro-requires-roles.toml +++ b/rules/java/shiro-requires-roles.toml @@ -6,7 +6,10 @@ confidence = "high" description = "Apache Shiro @RequiresRoles annotation" query = """ (annotation - name: (identifier) @anno_name + name: [ + (identifier) @anno_name + (scoped_identifier name: (identifier) @anno_name) + ] arguments: (annotation_argument_list (string_literal (string_fragment) @role_value)) @@ -46,6 +49,15 @@ public class AdminController { """ expect_match = true +[[rule.tests]] +input = """ +public class AdminController { + @org.apache.shiro.authz.annotation.RequiresRoles("admin") + public void adminAction() { } +} +""" +expect_match = true + [[rule.tests]] input = """ public class AdminController { diff --git a/rules/java/spring-permit-all.toml b/rules/java/spring-permit-all.toml index 7848c53..b387a4b 100644 --- a/rules/java/spring-permit-all.toml +++ b/rules/java/spring-permit-all.toml @@ -6,7 +6,10 @@ confidence = "medium" description = "Spring Security @PermitAll or @DenyAll annotation" query = """ (marker_annotation - name: (identifier) @anno_name + name: [ + (identifier) @anno_name + (scoped_identifier name: (identifier) @anno_name) + ] ) @match """ @@ -31,6 +34,24 @@ public class AdminController { """ expect_match = true +[[rule.tests]] +input = """ +public class PublicController { + @jakarta.annotation.security.PermitAll + public void publicEndpoint() { } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +public class AdminController { + @javax.annotation.security.DenyAll + public void lockedEndpoint() { } +} +""" +expect_match = true + [[rule.tests]] input = """ public class UserController { diff --git a/rules/java/spring-preauthorize.toml b/rules/java/spring-preauthorize.toml index 4bb2de8..61a866c 100644 --- a/rules/java/spring-preauthorize.toml +++ b/rules/java/spring-preauthorize.toml @@ -6,7 +6,10 @@ confidence = "high" description = "Spring Security @PreAuthorize annotation" query = """ (annotation - name: (identifier) @anno_name + name: [ + (identifier) @anno_name + (scoped_identifier name: (identifier) @anno_name) + ] arguments: (annotation_argument_list (string_literal (string_fragment) @expr)) @@ -56,6 +59,15 @@ public class AdminController { """ expect_match = true +[[rule.tests]] +input = """ +public class AdminController { + @org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')") + public void deleteUser(Long id) { } +} +""" +expect_match = true + [[rule.tests]] input = """ public class AdminController { diff --git a/rules/java/spring-roles-allowed-array.toml b/rules/java/spring-roles-allowed-array.toml index a7f5f5d..158d139 100644 --- a/rules/java/spring-roles-allowed-array.toml +++ b/rules/java/spring-roles-allowed-array.toml @@ -6,7 +6,10 @@ confidence = "high" description = "Jakarta/JSR-250 @RolesAllowed annotation with array of roles" query = """ (annotation - name: (identifier) @anno_name + name: [ + (identifier) @anno_name + (scoped_identifier name: (identifier) @anno_name) + ] arguments: (annotation_argument_list (element_value_array_initializer (string_literal) @role_value)) @@ -46,6 +49,24 @@ public class UserController { """ expect_match = true +[[rule.tests]] +input = """ +public class UserController { + @jakarta.annotation.security.RolesAllowed({"admin", "manager"}) + public void deleteUser(Long id) { } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +public class UserController { + @javax.annotation.security.RolesAllowed({"admin", "manager"}) + public void deleteUser(Long id) { } +} +""" +expect_match = true + [[rule.tests]] input = """ public class UserController { diff --git a/rules/java/spring-roles-allowed.toml b/rules/java/spring-roles-allowed.toml index 317ebde..5ed53f7 100644 --- a/rules/java/spring-roles-allowed.toml +++ b/rules/java/spring-roles-allowed.toml @@ -4,9 +4,17 @@ languages = ["java"] category = "rbac" confidence = "high" description = "Jakarta/JSR-250 @RolesAllowed annotation" +# Accept both bare and fully-qualified annotation names. Tree-sitter parses +# `@RolesAllowed` as `(identifier)` and `@jakarta.annotation.security.RolesAllowed` +# as `(scoped_identifier name: (identifier))` — alternation here means the +# `anno_name` capture always binds to the trailing identifier so the predicate +# matches both forms across the javax/jakarta migration boundary. query = """ (annotation - name: (identifier) @anno_name + name: [ + (identifier) @anno_name + (scoped_identifier name: (identifier) @anno_name) + ] arguments: (annotation_argument_list (string_literal (string_fragment) @role_value)) @@ -46,6 +54,24 @@ public class UserController { """ expect_match = true +[[rule.tests]] +input = """ +public class UserController { + @jakarta.annotation.security.RolesAllowed("admin") + public void deleteUser(Long id) { } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +public class UserController { + @javax.annotation.security.RolesAllowed("admin") + public void deleteUser(Long id) { } +} +""" +expect_match = true + [[rule.tests]] input = """ public class UserController { diff --git a/rules/java/spring-secured.toml b/rules/java/spring-secured.toml index 1dd1260..6e284c7 100644 --- a/rules/java/spring-secured.toml +++ b/rules/java/spring-secured.toml @@ -6,7 +6,10 @@ confidence = "high" description = "Spring Security @Secured annotation" query = """ (annotation - name: (identifier) @anno_name + name: [ + (identifier) @anno_name + (scoped_identifier name: (identifier) @anno_name) + ] arguments: (annotation_argument_list (string_literal (string_fragment) @role_value)) @@ -46,6 +49,15 @@ public class UserController { """ expect_match = true +[[rule.tests]] +input = """ +public class UserController { + @org.springframework.security.access.annotation.Secured("ROLE_ADMIN") + public void deleteUser(Long id) { } +} +""" +expect_match = true + [[rule.tests]] input = """ public class UserController { diff --git a/src/rules/embedded.rs b/src/rules/embedded.rs index 1400d83..428e18c 100644 --- a/src/rules/embedded.rs +++ b/src/rules/embedded.rs @@ -177,6 +177,10 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ "java-aws-verified-permissions", include_str!("../../rules/java/aws-verified-permissions.toml"), ), + ( + "java-jaxrs-endpoint", + include_str!("../../rules/java/jaxrs-endpoint.toml"), + ), // -- Python -- ( "py-django-permission-required", From ee56b6c365e49e40309cb2a597b6d31af7530897 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 7 May 2026 09:51:27 -0400 Subject: [PATCH 2/6] feat: add provenance field on findings for javax/jakarta reporting (#58) Surface the package-prefix of fully-qualified Java annotations as a structured `Finding.provenance` field so downstream tooling can split `javax.*` (legacy) vs `jakarta.*` (modern) call sites without grepping the snippet text. Rule TOML grows a new opt-in `provenance_capture` field naming the capture whose matched text should flow onto the finding; `compile_rule` validates the name resolves to a real query capture. Bare-identifier annotation matches leave provenance unset because the package isn't resolvable from the call site alone. All ten Java annotation rules updated by the prior commit plus the new `jaxrs-endpoint.toml` rule now expose an `@anno_scope` capture on the scoped-identifier branch and declare `provenance_capture = "anno_scope"`. The Finding shim folds the new field through serde with `#[serde(default)]` so legacy persisted findings keep loading. --- rules/java/authorized-annotation.toml | 10 ++- rules/java/jaxrs-endpoint.toml | 10 ++- rules/java/shiro-requires-authentication.toml | 6 +- rules/java/shiro-requires-permissions.toml | 6 +- rules/java/shiro-requires-roles-array.toml | 6 +- rules/java/shiro-requires-roles.toml | 6 +- rules/java/spring-permit-all.toml | 6 +- rules/java/spring-preauthorize.toml | 6 +- rules/java/spring-roles-allowed-array.toml | 6 +- rules/java/spring-roles-allowed.toml | 11 ++- rules/java/spring-secured.toml | 6 +- src/cedar/grouping.rs | 1 + src/deep/candidate.rs | 2 + src/deep/context.rs | 1 + src/deep/finding.rs | 6 ++ src/deep/merge.rs | 1 + src/deep/prompt.rs | 1 + src/mcp/tools.rs | 1 + src/output/json.rs | 1 + src/output/text.rs | 1 + src/rego/grouping.rs | 3 + src/rules/mod.rs | 15 ++++ src/scanner/matcher.rs | 75 +++++++++++++++++++ src/types.rs | 15 ++++ tests/deep_http_integration.rs | 1 + tests/deep_subprocess_integration.rs | 1 + 26 files changed, 190 insertions(+), 14 deletions(-) diff --git a/rules/java/authorized-annotation.toml b/rules/java/authorized-annotation.toml index db47fea..cf220b9 100644 --- a/rules/java/authorized-annotation.toml +++ b/rules/java/authorized-annotation.toml @@ -18,14 +18,18 @@ query = """ (annotation name: [ (identifier) @anno_name - (scoped_identifier name: (identifier) @anno_name) + (scoped_identifier + scope: (_) @anno_scope + name: (identifier) @anno_name) ] arguments: (annotation_argument_list (string_literal (string_fragment) @role_value))) (annotation name: [ (identifier) @anno_name - (scoped_identifier name: (identifier) @anno_name) + (scoped_identifier + scope: (_) @anno_scope + name: (identifier) @anno_name) ] arguments: (annotation_argument_list (element_value_array_initializer @@ -33,6 +37,8 @@ query = """ ] @match """ +provenance_capture = "anno_scope" + [rule.predicates.anno_name] eq = "Authorized" diff --git a/rules/java/jaxrs-endpoint.toml b/rules/java/jaxrs-endpoint.toml index 9a24136..bd3ad31 100644 --- a/rules/java/jaxrs-endpoint.toml +++ b/rules/java/jaxrs-endpoint.toml @@ -20,16 +20,22 @@ query = """ (marker_annotation name: [ (identifier) @anno_name - (scoped_identifier name: (identifier) @anno_name) + (scoped_identifier + scope: (_) @anno_scope + name: (identifier) @anno_name) ]) (annotation name: [ (identifier) @anno_name - (scoped_identifier name: (identifier) @anno_name) + (scoped_identifier + scope: (_) @anno_scope + name: (identifier) @anno_name) ]) ] @match """ +provenance_capture = "anno_scope" + [rule.predicates.anno_name] match = "^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|Path)$" diff --git a/rules/java/shiro-requires-authentication.toml b/rules/java/shiro-requires-authentication.toml index 869e3c6..ca8f176 100644 --- a/rules/java/shiro-requires-authentication.toml +++ b/rules/java/shiro-requires-authentication.toml @@ -8,11 +8,15 @@ query = """ (marker_annotation name: [ (identifier) @anno_name - (scoped_identifier name: (identifier) @anno_name) + (scoped_identifier + scope: (_) @anno_scope + name: (identifier) @anno_name) ] ) @match """ +provenance_capture = "anno_scope" + [rule.predicates.anno_name] match = "^(RequiresAuthentication|RequiresGuest|RequiresUser)$" diff --git a/rules/java/shiro-requires-permissions.toml b/rules/java/shiro-requires-permissions.toml index c859968..7283205 100644 --- a/rules/java/shiro-requires-permissions.toml +++ b/rules/java/shiro-requires-permissions.toml @@ -8,7 +8,9 @@ query = """ (annotation name: [ (identifier) @anno_name - (scoped_identifier name: (identifier) @anno_name) + (scoped_identifier + scope: (_) @anno_scope + name: (identifier) @anno_name) ] arguments: (annotation_argument_list (string_literal @@ -16,6 +18,8 @@ query = """ ) @match """ +provenance_capture = "anno_scope" + [rule.predicates.anno_name] eq = "RequiresPermissions" diff --git a/rules/java/shiro-requires-roles-array.toml b/rules/java/shiro-requires-roles-array.toml index 0905ef5..eb16a63 100644 --- a/rules/java/shiro-requires-roles-array.toml +++ b/rules/java/shiro-requires-roles-array.toml @@ -8,7 +8,9 @@ query = """ (annotation name: [ (identifier) @anno_name - (scoped_identifier name: (identifier) @anno_name) + (scoped_identifier + scope: (_) @anno_scope + name: (identifier) @anno_name) ] arguments: (annotation_argument_list (element_value_array_initializer @@ -16,6 +18,8 @@ query = """ ) @match """ +provenance_capture = "anno_scope" + [rule.predicates.anno_name] eq = "RequiresRoles" diff --git a/rules/java/shiro-requires-roles.toml b/rules/java/shiro-requires-roles.toml index 3bac1df..fd2bcf9 100644 --- a/rules/java/shiro-requires-roles.toml +++ b/rules/java/shiro-requires-roles.toml @@ -8,7 +8,9 @@ query = """ (annotation name: [ (identifier) @anno_name - (scoped_identifier name: (identifier) @anno_name) + (scoped_identifier + scope: (_) @anno_scope + name: (identifier) @anno_name) ] arguments: (annotation_argument_list (string_literal @@ -16,6 +18,8 @@ query = """ ) @match """ +provenance_capture = "anno_scope" + [rule.predicates.anno_name] eq = "RequiresRoles" diff --git a/rules/java/spring-permit-all.toml b/rules/java/spring-permit-all.toml index b387a4b..b464e36 100644 --- a/rules/java/spring-permit-all.toml +++ b/rules/java/spring-permit-all.toml @@ -8,11 +8,15 @@ query = """ (marker_annotation name: [ (identifier) @anno_name - (scoped_identifier name: (identifier) @anno_name) + (scoped_identifier + scope: (_) @anno_scope + name: (identifier) @anno_name) ] ) @match """ +provenance_capture = "anno_scope" + [rule.predicates.anno_name] match = "^(PermitAll|DenyAll)$" diff --git a/rules/java/spring-preauthorize.toml b/rules/java/spring-preauthorize.toml index 61a866c..edc99b2 100644 --- a/rules/java/spring-preauthorize.toml +++ b/rules/java/spring-preauthorize.toml @@ -8,7 +8,9 @@ query = """ (annotation name: [ (identifier) @anno_name - (scoped_identifier name: (identifier) @anno_name) + (scoped_identifier + scope: (_) @anno_scope + name: (identifier) @anno_name) ] arguments: (annotation_argument_list (string_literal @@ -16,6 +18,8 @@ query = """ ) @match """ +provenance_capture = "anno_scope" + [rule.predicates.anno_name] eq = "PreAuthorize" diff --git a/rules/java/spring-roles-allowed-array.toml b/rules/java/spring-roles-allowed-array.toml index 158d139..e3f40a0 100644 --- a/rules/java/spring-roles-allowed-array.toml +++ b/rules/java/spring-roles-allowed-array.toml @@ -8,7 +8,9 @@ query = """ (annotation name: [ (identifier) @anno_name - (scoped_identifier name: (identifier) @anno_name) + (scoped_identifier + scope: (_) @anno_scope + name: (identifier) @anno_name) ] arguments: (annotation_argument_list (element_value_array_initializer @@ -16,6 +18,8 @@ query = """ ) @match """ +provenance_capture = "anno_scope" + [rule.predicates.anno_name] eq = "RolesAllowed" diff --git a/rules/java/spring-roles-allowed.toml b/rules/java/spring-roles-allowed.toml index 5ed53f7..5cec51e 100644 --- a/rules/java/spring-roles-allowed.toml +++ b/rules/java/spring-roles-allowed.toml @@ -8,12 +8,17 @@ description = "Jakarta/JSR-250 @RolesAllowed annotation" # `@RolesAllowed` as `(identifier)` and `@jakarta.annotation.security.RolesAllowed` # as `(scoped_identifier name: (identifier))` — alternation here means the # `anno_name` capture always binds to the trailing identifier so the predicate -# matches both forms across the javax/jakarta migration boundary. +# matches both forms across the javax/jakarta migration boundary. The +# `@anno_scope` capture (only present on the qualified branch) feeds +# `provenance_capture` below so consumers can split `javax` from `jakarta` +# in migration reports without re-parsing the snippet. query = """ (annotation name: [ (identifier) @anno_name - (scoped_identifier name: (identifier) @anno_name) + (scoped_identifier + scope: (_) @anno_scope + name: (identifier) @anno_name) ] arguments: (annotation_argument_list (string_literal @@ -21,6 +26,8 @@ query = """ ) @match """ +provenance_capture = "anno_scope" + [rule.predicates.anno_name] eq = "RolesAllowed" diff --git a/rules/java/spring-secured.toml b/rules/java/spring-secured.toml index 6e284c7..00e4a7a 100644 --- a/rules/java/spring-secured.toml +++ b/rules/java/spring-secured.toml @@ -8,7 +8,9 @@ query = """ (annotation name: [ (identifier) @anno_name - (scoped_identifier name: (identifier) @anno_name) + (scoped_identifier + scope: (_) @anno_scope + name: (identifier) @anno_name) ] arguments: (annotation_argument_list (string_literal @@ -16,6 +18,8 @@ query = """ ) @match """ +provenance_capture = "anno_scope" + [rule.predicates.anno_name] eq = "Secured" diff --git a/src/cedar/grouping.rs b/src/cedar/grouping.rs index aa87b9b..e4819b1 100644 --- a/src/cedar/grouping.rs +++ b/src/cedar/grouping.rs @@ -177,6 +177,7 @@ mod tests { policy_outputs: vec![], pass: ScanPass::Structural, surface: Surface::Backend, + provenance: None, }; f.set_policy_output( PolicyEngine::Cedar, diff --git a/src/deep/candidate.rs b/src/deep/candidate.rs index d9508aa..8a06fed 100644 --- a/src/deep/candidate.rs +++ b/src/deep/candidate.rs @@ -398,6 +398,7 @@ mod tests { policy_outputs: vec![], pass: ScanPass::Structural, surface: Surface::Backend, + provenance: None, } } @@ -788,6 +789,7 @@ mod tests { policy_outputs: vec![], pass: ScanPass::Structural, surface: Surface::Backend, + provenance: None, }; // Should NOT propagate Io; should return Ok with the bad escalation // skipped. (No cold-region files either, so result is empty.) diff --git a/src/deep/context.rs b/src/deep/context.rs index cc0ee38..4d41741 100644 --- a/src/deep/context.rs +++ b/src/deep/context.rs @@ -215,6 +215,7 @@ mod tests { policy_outputs: vec![], pass: ScanPass::Structural, surface: Surface::Backend, + provenance: None, } } diff --git a/src/deep/finding.rs b/src/deep/finding.rs index cd114b3..02b0306 100644 --- a/src/deep/finding.rs +++ b/src/deep/finding.rs @@ -129,6 +129,11 @@ pub fn into_finding( // heuristic as structural findings so a deep-pass `web/src/foo.ts` // finding is tagged Frontend just like its structural twin would be. surface: Surface::classify(&candidate.file), + // Semantic pass doesn't carry a structural-rule capture map; the + // model verdict has no notion of package provenance. Left `None` + // — downstream `javax`/`jakarta` reporting only consumes structural + // findings anyway. + provenance: None, } } @@ -247,6 +252,7 @@ mod tests { policy_outputs: vec![], pass: ScanPass::Structural, surface: Surface::Backend, + provenance: None, } } diff --git a/src/deep/merge.rs b/src/deep/merge.rs index 8c74ff1..25ff9a7 100644 --- a/src/deep/merge.rs +++ b/src/deep/merge.rs @@ -80,6 +80,7 @@ mod tests { policy_outputs: vec![], pass, surface: Surface::Backend, + provenance: None, } } diff --git a/src/deep/prompt.rs b/src/deep/prompt.rs index 5eed017..58d1948 100644 --- a/src/deep/prompt.rs +++ b/src/deep/prompt.rs @@ -337,6 +337,7 @@ mod tests { policy_outputs: vec![], pass: ScanPass::Structural, surface: Surface::Backend, + provenance: None, } } diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index d39fcbd..9c0be7e 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -748,6 +748,7 @@ fn analyze_snippet(_ctx: &ServerContext, args: &Value) -> Result policy_outputs: vec![], pass: ScanPass::Structural, surface: Surface::classify(&PathBuf::from(&parsed.file)), + provenance: None, }); let rendered = render(&PromptInputs { diff --git a/src/output/json.rs b/src/output/json.rs index af68171..df9171b 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -129,6 +129,7 @@ mod tests { policy_outputs: vec![], pass: ScanPass::Structural, surface: Surface::Backend, + provenance: None, } } diff --git a/src/output/text.rs b/src/output/text.rs index 914c85a..b2b9e30 100644 --- a/src/output/text.rs +++ b/src/output/text.rs @@ -142,6 +142,7 @@ mod tests { policy_outputs: vec![], pass: ScanPass::Structural, surface: Surface::Backend, + provenance: None, } } diff --git a/src/rego/grouping.rs b/src/rego/grouping.rs index 01bfe1c..f2cce3c 100644 --- a/src/rego/grouping.rs +++ b/src/rego/grouping.rs @@ -256,6 +256,7 @@ mod tests { policy_outputs: vec![], pass: ScanPass::Structural, surface: Surface::Backend, + provenance: None, })]; let files = group_findings(&findings, "app", Path::new("./policies")); @@ -283,6 +284,7 @@ mod tests { policy_outputs: vec![], pass: ScanPass::Structural, surface: Surface::Backend, + provenance: None, }), with_default_stub(Finding { id: "b".into(), @@ -298,6 +300,7 @@ mod tests { policy_outputs: vec![], pass: ScanPass::Structural, surface: Surface::Backend, + provenance: None, }), ]; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 15f180a..d6eb5d5 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -26,6 +26,16 @@ pub struct PatternRule { /// `[rule.rego_template]` / `[rule.cedar_template]` blocks. pub policy_templates: Vec, pub tests: Vec, + /// Optional capture name whose matched text should be copied onto each + /// finding's `provenance` field. Set on rules where the AST exposes a + /// package-prefix capture (e.g. `@anno_scope` on the scoped-identifier + /// branch of an annotation alternation) so consumers can distinguish + /// `javax.*` vs `jakarta.*` migrations without grepping snippets. The + /// referenced capture must exist in the rule's query — validated by + /// `compile_rule`. Left `None` when the rule has no notion of provenance + /// (most rules) or when the capture didn't fire on a particular match + /// (bare-identifier annotation form). + pub provenance_capture: Option, } #[derive(Debug, Clone)] @@ -137,6 +147,8 @@ struct RuleToml { policy_templates: Vec, #[serde(default)] tests: Vec, + #[serde(default)] + provenance_capture: Option, } #[derive(Debug, Deserialize)] @@ -349,6 +361,7 @@ fn parse_rule(toml_str: &str, source: &str) -> Result { expect_match: t.expect_match, }) .collect(), + provenance_capture: r.provenance_capture, }) } @@ -615,6 +628,7 @@ template = "permit(principal, action, resource);" cross_predicates: vec![], policy_templates: vec![], tests: vec![], + provenance_capture: None, }; let r2 = PatternRule { id: "rule-a".into(), @@ -628,6 +642,7 @@ template = "permit(principal, action, resource);" cross_predicates: vec![], policy_templates: vec![], tests: vec![], + provenance_capture: None, }; let mut base = vec![r1]; diff --git a/src/scanner/matcher.rs b/src/scanner/matcher.rs index f1f8d05..8d0b2e7 100644 --- a/src/scanner/matcher.rs +++ b/src/scanner/matcher.rs @@ -54,6 +54,22 @@ pub fn compile_rule<'a>( } } + // Validate that the optional provenance capture exists. A typo'd + // `provenance_capture` would otherwise silently produce no-provenance + // findings, which is exactly the bug we're trying to avoid. + if let Some(capture_name) = &rule.provenance_capture + && !capture_names.iter().any(|n| n == capture_name) + { + return Err(ZiftError::QueryError { + rule_id: rule.id.clone(), + message: format!( + "provenance_capture references unknown capture '{capture_name}' \ + (query captures: {})", + capture_names.join(", "), + ), + }); + } + // Validate that every cross-predicate references real captures. for (i, cp) in rule.cross_predicates.iter().enumerate() { for capture_name in cp.referenced_captures() { @@ -168,6 +184,14 @@ pub fn execute_query( }); } + let provenance = compiled + .rule + .provenance_capture + .as_deref() + .and_then(|name| captures.get(name)) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()); + findings.push(Finding { id, file: file_path.to_path_buf(), @@ -182,6 +206,7 @@ pub fn execute_query( policy_outputs, pass: ScanPass::Structural, surface: Surface::classify(file_path), + provenance, }); } @@ -602,6 +627,55 @@ public class Ctrl { include_str!("../../rules/java/spring-roles-allowed.toml"), ); assert!(!findings.is_empty(), "should match @RolesAllowed"); + // Bare-identifier annotation has no scope to capture — provenance + // must be `None`. The package isn't resolvable from the call site + // alone (we'd need the file's import statement), so leaving it + // unset is the honest answer. + assert!( + findings[0].provenance.is_none(), + "bare annotation should not carry provenance; got: {:?}", + findings[0].provenance + ); + } + + #[test] + fn java_roles_allowed_qualified_carries_provenance() { + // Fully-qualified annotation: `@jakarta.annotation.security.RolesAllowed` + // — the `scoped_identifier` exposes the package prefix, which the + // matcher copies into `Finding.provenance`. Consumers split on the + // head segment (`provenance.split('.').next()`) to bucket findings + // as `javax` (legacy) vs `jakarta` (modern) for migration reporting. + let findings = parse_and_match_java( + r#" +public class Ctrl { + @jakarta.annotation.security.RolesAllowed("admin") + public void delete() { } +} +"#, + include_str!("../../rules/java/spring-roles-allowed.toml"), + ); + assert_eq!(findings.len(), 1); + assert_eq!( + findings[0].provenance.as_deref(), + Some("jakarta.annotation.security"), + "qualified annotation should carry full package prefix as provenance", + ); + + let findings_javax = parse_and_match_java( + r#" +public class Ctrl { + @javax.annotation.security.RolesAllowed("admin") + public void delete() { } +} +"#, + include_str!("../../rules/java/spring-roles-allowed.toml"), + ); + assert_eq!(findings_javax.len(), 1); + assert_eq!( + findings_javax[0].provenance.as_deref(), + Some("javax.annotation.security"), + "javax-qualified annotation should carry javax provenance", + ); } #[test] @@ -1430,6 +1504,7 @@ match = ".*" policy_outputs: vec![], pass: ScanPass::Structural, surface: Surface::Backend, + provenance: None, }; let findings = vec![f.clone(), f]; let deduped = dedup_findings(findings); diff --git a/src/types.rs b/src/types.rs index 068cf7b..f819cf6 100644 --- a/src/types.rs +++ b/src/types.rs @@ -79,6 +79,18 @@ pub struct Finding { /// authz code). #[serde(default)] pub surface: Surface, + /// Package-namespace provenance captured from the matched code, when the + /// rule asks for it (`provenance_capture` in TOML). Populated for + /// fully-qualified Java annotations like `@jakarta.annotation.security + /// .RolesAllowed` (provenance = `"jakarta.annotation.security"`) and + /// left `None` for the bare-identifier form where the package isn't + /// resolvable from the call site alone. Drives `javax`-vs-`jakarta` + /// migration reporting downstream — consumers should split on the head + /// segment (`split('.').next()`) rather than full-string-match the value + /// so longer prefixes (e.g. `org.springframework.security.access`) keep + /// working without per-package special-cases here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance: Option, } impl Finding { @@ -126,6 +138,8 @@ struct FindingShim { pass: ScanPass, #[serde(default)] surface: Surface, + #[serde(default)] + provenance: Option, } impl From for Finding { @@ -178,6 +192,7 @@ impl From for Finding { policy_outputs, pass: s.pass, surface: s.surface, + provenance: s.provenance, } } } diff --git a/tests/deep_http_integration.rs b/tests/deep_http_integration.rs index 3f0354c..d281633 100644 --- a/tests/deep_http_integration.rs +++ b/tests/deep_http_integration.rs @@ -480,6 +480,7 @@ fn structural_finding(file: &str, line: usize) -> Finding { policy_outputs: vec![], pass: ScanPass::Structural, surface: Surface::Backend, + provenance: None, } } diff --git a/tests/deep_subprocess_integration.rs b/tests/deep_subprocess_integration.rs index fa78401..7a4022f 100644 --- a/tests/deep_subprocess_integration.rs +++ b/tests/deep_subprocess_integration.rs @@ -60,6 +60,7 @@ fn structural_finding(file: &str, line: usize) -> Finding { policy_outputs: vec![], pass: ScanPass::Structural, surface: Surface::Backend, + provenance: None, } } From 353a600f836f9baa1bacd14884153f41412565d6 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 7 May 2026 09:51:34 -0400 Subject: [PATCH 3/6] docs(commit): require dedicated branch when current branch differs The /commit flow used to only check whether HEAD was `main`. That missed the common case where someone is parked on a feature branch named for a different issue/PR than the work being committed (e.g. the #71 branch when implementing #58). Tighten the branch-check step to also cover that case and the already-merged-upstream case, and add the cherry-pick-and-stash-pop recipe so the new branch picks up any prior commits that belong to the new work. --- .agents/commands/commit.md | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.agents/commands/commit.md b/.agents/commands/commit.md index b82391d..0db03cf 100644 --- a/.agents/commands/commit.md +++ b/.agents/commands/commit.md @@ -9,13 +9,24 @@ When activated, commit the current working tree changes: 1. **Sync with remote**: - Run `git fetch origin main` to get latest upstream - Run `git log HEAD..origin/main --oneline` to check if main has moved ahead - - If it has, warn the user but don't rebase automatically + - If it has, pull main into the working state before committing — either + `git merge origin/main` on a feature branch, or use the new-branch path + in step 2 if the current branch is no longer the right home for this work + (e.g. its name references a different issue/PR than what's being committed). -2. **Ensure we're not on main**: +2. **Ensure we're on a branch dedicated to this work**: - Run `git branch --show-current` - - If on `main`, create a new feature branch: - - Look at the staged/unstaged changes to infer a branch name - - Run `git checkout -b feat/` + - Create a fresh feature branch off `origin/main` if **any** of the following hold: + - Current branch is `main` + - Current branch's name references a different issue/PR than the work being + committed (e.g. branch is `refactor/71-…` but the diff implements #58) + - Current branch already merged upstream (its work is in `origin/main`) + - To create the new branch: + - Stash uncommitted work (`git stash push -m "wip: "`) + - `git checkout -b /- origin/main` + - If a prior commit on the old branch belongs to this work, cherry-pick it: + `git cherry-pick ` + - `git stash pop` - Inform the user of the new branch name 3. **Review changes**: From 341ae720eb3308f2546cddc809e813432561dc59 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 7 May 2026 11:04:54 -0400 Subject: [PATCH 4/6] feat: add Route auth category for endpoint surface declarations (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JAX-RS / Jakarta REST `@GET`, `@POST`, `@Path` etc. were landing as `middleware`, conflating route declarations with route-level auth guards. The annotations only assert "this is an endpoint" — the absence of a paired auth annotation is what matters — so they belong in their own bucket. Add `AuthCategory::Route`, wire it through the slug, Display, MCP tool / resource enums, deep-scan system prompt, and output schema. Both engines get TODO-style default templates since route declarations don't generate concrete policies on their own. Reclassify `java-jaxrs-endpoint` from `middleware` to `route` and swap the awkward inner-class fixture for a class-level `@Path` placement. --- rules/java/jaxrs-endpoint.toml | 6 +++--- src/cedar/templates.rs | 11 +++++++++++ src/deep/prompt.rs | 7 +++++-- src/mcp/resources.rs | 14 ++++++++++++++ src/mcp/tools.rs | 10 +++++----- src/rego/templates.rs | 7 +++++++ src/types.rs | 3 +++ 7 files changed, 48 insertions(+), 10 deletions(-) diff --git a/rules/java/jaxrs-endpoint.toml b/rules/java/jaxrs-endpoint.toml index bd3ad31..793000f 100644 --- a/rules/java/jaxrs-endpoint.toml +++ b/rules/java/jaxrs-endpoint.toml @@ -1,7 +1,7 @@ [rule] id = "java-jaxrs-endpoint" languages = ["java"] -category = "middleware" +category = "route" confidence = "medium" description = "JAX-RS / Jakarta REST resource method (@GET, @POST, @Path, ...)" # JAX-RS resource methods are tagged with one of `@Path`, `@GET`, `@POST`, @@ -50,9 +50,9 @@ expect_match = true [[rule.tests]] input = """ +@Path("/users") public class UserResource { - @Path("/users") - public class Inner {} + public List list() { return null; } } """ expect_match = true diff --git a/src/cedar/templates.rs b/src/cedar/templates.rs index cb5e3d1..c7b1b73 100644 --- a/src/cedar/templates.rs +++ b/src/cedar/templates.rs @@ -131,6 +131,17 @@ when { when { principal.plan == "{{plan_value}}" };"# + } + AuthCategory::Route => { + r#"// TODO: route declaration with no inline auth check — add a policy or confirm intentionally public +// permit ( +// principal, +// action, +// resource +// ) +// when { +// ... +// };"# } AuthCategory::Custom => { r#"// TODO: custom authorization pattern — review and implement manually diff --git a/src/deep/prompt.rs b/src/deep/prompt.rs index 58d1948..53f79a7 100644 --- a/src/deep/prompt.rs +++ b/src/deep/prompt.rs @@ -41,6 +41,7 @@ CATEGORIES: - business_rule: domain-specific access rules - ownership: resource-owner checks - feature_gate: plan/tenant/flag-based +- route: HTTP route declaration (e.g. JAX-RS @GET, @Path) — endpoint surface, not an inline check - custom: doesn't fit the above CONFIDENCE: @@ -140,7 +141,7 @@ pub fn output_schema() -> serde_json::Value { "category": { "type": "string", "enum": ["rbac", "abac", "middleware", "business_rule", - "ownership", "feature_gate", "custom"] + "ownership", "feature_gate", "route", "custom"] }, "confidence": { "type": "string", @@ -350,7 +351,7 @@ mod tests { } #[test] - fn system_prompt_lists_all_seven_categories() { + fn system_prompt_lists_all_categories() { for cat in [ "rbac", "abac", @@ -358,6 +359,7 @@ mod tests { "business_rule", "ownership", "feature_gate", + "route", "custom", ] { assert!( @@ -404,6 +406,7 @@ mod tests { "business_rule", "ownership", "feature_gate", + "route", "custom" ] ); diff --git a/src/mcp/resources.rs b/src/mcp/resources.rs index 7fff147..91ab3f1 100644 --- a/src/mcp/resources.rs +++ b/src/mcp/resources.rs @@ -128,6 +128,7 @@ const ALL_CATEGORIES: &[AuthCategory] = &[ AuthCategory::BusinessRule, AuthCategory::Ownership, AuthCategory::FeatureGate, + AuthCategory::Route, AuthCategory::Custom, ]; @@ -139,6 +140,7 @@ fn category_from_slug(slug: &str) -> Option { "business_rule" => Some(AuthCategory::BusinessRule), "ownership" => Some(AuthCategory::Ownership), "feature_gate" => Some(AuthCategory::FeatureGate), + "route" => Some(AuthCategory::Route), "custom" => Some(AuthCategory::Custom), _ => None, } @@ -175,6 +177,12 @@ fn category_description(c: AuthCategory) -> &'static str { Often misclassified as RBAC but distinguished by \ pivoting on subscription/feature state, not roles." } + AuthCategory::Route => { + "HTTP route / endpoint declaration (JAX-RS @GET, @Path; \ + Spring @RequestMapping; etc.). Surfaces the endpoint \ + without making a claim about its auth — the absence \ + of a paired auth annotation is what matters." + } AuthCategory::Custom => { "A pattern that doesn't fit any of the above. The \ deep-scan model is asked to be conservative when \ @@ -211,6 +219,11 @@ fn category_examples(c: AuthCategory) -> Vec<&'static str> { "if user.plan in {'pro', 'enterprise'} { allow }", "if !flags.is_enabled('beta-feature', user) { return 403 }", ], + AuthCategory::Route => vec![ + "@GET @Path(\"/users\")", + "@POST public Response create(...) { ... }", + "@RequestMapping(method = RequestMethod.DELETE)", + ], AuthCategory::Custom => vec!["// Bespoke check: reach out to the model to classify"], } } @@ -247,6 +260,7 @@ mod tests { "business_rule", "ownership", "feature_gate", + "route", "custom", ] { assert!( diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 9c0be7e..0cfb5e5 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -98,7 +98,7 @@ fn scan_authz_descriptor() -> ToolDescriptor { "type": "array", "items": {"type": "string", "enum": [ "rbac", "abac", "middleware", "business_rule", - "ownership", "feature_gate", "custom" + "ownership", "feature_gate", "route", "custom" ]}, "description": "Optional category filter." }, @@ -234,7 +234,7 @@ fn list_rules_descriptor() -> ToolDescriptor { "category": { "type": "string", "enum": ["rbac", "abac", "middleware", "business_rule", - "ownership", "feature_gate", "custom"], + "ownership", "feature_gate", "route", "custom"], "description": "Optional category filter." } }, @@ -389,7 +389,7 @@ fn suggest_rego_descriptor() -> ToolDescriptor { "category": { "type": "string", "enum": ["rbac", "abac", "middleware", "business_rule", - "ownership", "feature_gate", "custom"] + "ownership", "feature_gate", "route", "custom"] }, "confidence": {"type": "string", "enum": ["low", "medium", "high"]}, "code_snippet": {"type": "string"}, @@ -539,7 +539,7 @@ fn suggest_policy_descriptor() -> ToolDescriptor { "category": { "type": "string", "enum": ["rbac", "abac", "middleware", "business_rule", - "ownership", "feature_gate", "custom"] + "ownership", "feature_gate", "route", "custom"] }, "confidence": {"type": "string", "enum": ["low", "medium", "high"]}, "code_snippet": {"type": "string"}, @@ -675,7 +675,7 @@ fn analyze_snippet_descriptor() -> ToolDescriptor { "category": { "type": "string", "enum": ["rbac", "abac", "middleware", "business_rule", - "ownership", "feature_gate", "custom"] + "ownership", "feature_gate", "route", "custom"] }, "confidence": {"type": "string", "enum": ["low", "medium", "high"]}, "description": {"type": "string"}, diff --git a/src/rego/templates.rs b/src/rego/templates.rs index 68fa700..9770823 100644 --- a/src/rego/templates.rs +++ b/src/rego/templates.rs @@ -82,6 +82,13 @@ allow if { allow if { input.user.plan in {{plans}} }"# + } + AuthCategory::Route => { + r#"# TODO: route declaration with no inline auth check — add a policy or confirm intentionally public +# default allow := false +# allow if { +# ... +# }"# } AuthCategory::Custom => { r#"# TODO: custom authorization pattern — review and implement manually diff --git a/src/types.rs b/src/types.rs index f819cf6..9aa3645 100644 --- a/src/types.rs +++ b/src/types.rs @@ -293,6 +293,7 @@ pub enum AuthCategory { Ownership, #[value(name = "feature-gate")] FeatureGate, + Route, Custom, } @@ -313,6 +314,7 @@ impl AuthCategory { AuthCategory::BusinessRule => "business_rule", AuthCategory::Ownership => "ownership", AuthCategory::FeatureGate => "feature_gate", + AuthCategory::Route => "route", AuthCategory::Custom => "custom", } } @@ -392,6 +394,7 @@ impl std::fmt::Display for AuthCategory { AuthCategory::BusinessRule => write!(f, "Business Rule"), AuthCategory::Ownership => write!(f, "Ownership"), AuthCategory::FeatureGate => write!(f, "Feature Gate"), + AuthCategory::Route => write!(f, "Route"), AuthCategory::Custom => write!(f, "Custom"), } } From 314b414833b03be23740e36dc75f15af698982d6 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 7 May 2026 11:05:07 -0400 Subject: [PATCH 5/6] fix: run rule validate/test in CI and fix cedar set-context substitution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI ran only fmt/clippy/test, never `cargo run -- rules validate` or `rules test` — so broken policy templates and rule fixtures could land on main unflagged. Three cedar templates (java-roles-allowed-array, java-shiro-requires-roles-array, csharp-aspnet-authorize-roles) had been failing template validation on main without anyone noticing. Root cause was in `validate_template`: bare `{{var}}` placeholders were substituted with the literal identifier `placeholder_value`, which is fine in identifier position (`principal.{{attribute}}`) but invalid inside a set literal — `[{{cedar_roles_set}}]` became `[placeholder_value]`, which Cedar rejects. Detect the set-context shape `[{{var}}]` and substitute `["placeholder"]` so the rendered template parses cleanly. Add the two missing CI steps so this class of regression can't repeat. --- .github/workflows/ci.yml | 6 ++++++ src/cedar/validator.rs | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1988864..352a486 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,3 +32,9 @@ jobs: - name: Tests run: cargo test --all-features + + - name: Validate rule queries and policy templates + run: cargo run --all-features -- rules validate + + - name: Run rule fixtures + run: cargo run --all-features -- rules test diff --git a/src/cedar/validator.rs b/src/cedar/validator.rs index 046add8..b5cba92 100644 --- a/src/cedar/validator.rs +++ b/src/cedar/validator.rs @@ -42,6 +42,14 @@ pub fn validate_template(template: &str) -> ValidationResult { let rendered = re_quoted .replace_all(template, "\"placeholder\"") .to_string(); + // Set-context placeholder: `[{{var}}]` expands at scan time to a + // comma-separated list of quoted strings (e.g. `"admin", "manager"`). + // Substitute the bare placeholder with a single quoted string so the + // resulting `["placeholder"]` parses as a valid Cedar set literal. + let re_set = regex::Regex::new(r"\[\s*\{\{(\w+)\}\}\s*\]").unwrap(); + let rendered = re_set + .replace_all(&rendered, "[\"placeholder\"]") + .to_string(); let re_bare = regex::Regex::new(r"\{\{(\w+)\}\}").unwrap(); let rendered = re_bare .replace_all(&rendered, "placeholder_value") @@ -84,6 +92,20 @@ when { assert!(res.valid, "expected valid, got: {:?}", res.error); } + #[test] + fn valid_template_set_placeholder() { + // Regression: `[{{cedar_roles_set}}]` expands at scan time to a CSV + // of quoted strings (e.g. `"admin", "manager"`). Without set-context + // handling, validate_template would substitute the bare identifier + // and produce `[placeholder_value]`, which Cedar rejects. + let tmpl = r#"permit (principal, action, resource) +when { + principal.role in [{{cedar_roles_set}}] +};"#; + let res = validate_template(tmpl); + assert!(res.valid, "expected valid, got: {:?}", res.error); + } + #[test] fn valid_template_bare_placeholder() { let tmpl = r#"permit (principal, action, resource) From 279ece36d92e0574db550b1db70b49fdd78077b6 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 7 May 2026 13:27:52 -0400 Subject: [PATCH 6/6] fix: address PR review feedback - Guard truncation marker append in deep context when budget is too small - Add FQ-form Shiro fixtures for RequiresGuest and RequiresUser - Add unit test for Cedar Route default template --- rules/java/shiro-requires-authentication.toml | 18 ++++++++++++++++++ src/cedar/templates.rs | 7 +++++++ src/deep/context.rs | 14 ++++++++++---- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/rules/java/shiro-requires-authentication.toml b/rules/java/shiro-requires-authentication.toml index ca8f176..10b0e2a 100644 --- a/rules/java/shiro-requires-authentication.toml +++ b/rules/java/shiro-requires-authentication.toml @@ -56,6 +56,24 @@ public class SecureController { """ expect_match = true +[[rule.tests]] +input = """ +public class SecureController { + @org.apache.shiro.authz.annotation.RequiresGuest + public void guestAction() { } +} +""" +expect_match = true + +[[rule.tests]] +input = """ +public class SecureController { + @org.apache.shiro.authz.annotation.RequiresUser + public void userAction() { } +} +""" +expect_match = true + [[rule.tests]] input = """ public class Controller { diff --git a/src/cedar/templates.rs b/src/cedar/templates.rs index c7b1b73..33b1aed 100644 --- a/src/cedar/templates.rs +++ b/src/cedar/templates.rs @@ -332,6 +332,13 @@ mod tests { assert!(stub.contains("principal.plan == \"enterprise\"")); } + #[test] + fn default_stub_route_returns_commented_todo() { + let stub = generate_default_stub(AuthCategory::Route, ""); + assert!(stub.contains("TODO: route declaration")); + assert!(!stub.contains("\npermit (")); + } + #[test] fn confidence_wrapping_low_uses_double_slash() { let wrapped = diff --git a/src/deep/context.rs b/src/deep/context.rs index 4d41741..fd6cc51 100644 --- a/src/deep/context.rs +++ b/src/deep/context.rs @@ -173,13 +173,19 @@ fn expand_inner( // the combined `snippet + imports + marker` cannot exceed `max_chars`. // Round down to a UTF-8 char boundary to avoid `String::truncate` panics // on multi-byte chars (e.g. Unicode comments/identifiers in source). - let snippet_budget = max_chars - .saturating_sub(TRUNCATION_MARKER.len()) - .saturating_sub(imports_len); + let remaining = max_chars.saturating_sub(imports_len); + let marker_fits = remaining >= TRUNCATION_MARKER.len(); + let snippet_budget = if marker_fits { + remaining - TRUNCATION_MARKER.len() + } else { + remaining + }; if snippet.len() > snippet_budget { let cut = snippet.floor_char_boundary(snippet_budget); snippet.truncate(cut); - snippet.push_str(TRUNCATION_MARKER); + if marker_fits { + snippet.push_str(TRUNCATION_MARKER); + } } Ok(ExpandedContext {