From db5b843fc87a30baf4c94ab93b78e8e6df2443b1 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Wed, 6 May 2026 09:42:05 -0400 Subject: [PATCH 1/4] feat: add Cedar as a second policy engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A of #27 — adds Cedar/AVP as a peer to Rego/OPA without breaking the existing JSON or MCP API. - `Finding::cedar_stub` parallel to `rego_stub` (serde-defaulted, BC) - `[rule..cedar_template]` parallel to `rego_template`; 45 existing rules carry an inferred Cedar template, the rest fall through to a category-default Cedar stub so coverage stays at 100% - new `src/cedar/` module: templates, validator (cedar-policy 4.x), per-source-file grouping with `.cedar` extension - `zift extract --engine rego|cedar` (default rego); `--package-prefix` renamed to `--policy-prefix` with the old name kept as an alias - new MCP tools `suggest_policy` / `validate_policy` taking an `engine` arg; `suggest_rego` / `validate_rego` retained as Rego-pinned aliases so existing agent host wiring keeps working - `zift rules validate` now type-checks Cedar templates too --- Cargo.lock | 853 +++++++++++++++++- Cargo.toml | 1 + rules/csharp/aspnet-authorize-attribute.toml | 12 + .../aspnet-authorize-policy-shorthand.toml | 13 + rules/csharp/aspnet-authorize-policy.toml | 13 + rules/csharp/aspnet-authorize-roles.toml | 12 + .../csharp/aspnet-require-authorization.toml | 12 + ...authorization-service-authorize-async.toml | 13 + rules/csharp/has-claim-call.toml | 13 + rules/csharp/is-in-role-call.toml | 12 + rules/go/access-descriptor-builder.toml | 13 + rules/go/casbin-enforce.toml | 12 + rules/go/feature-gate-check.toml | 12 + rules/go/gin-auth-middleware.toml | 12 + rules/go/has-role-call.toml | 12 + rules/go/opa-rego-eval.toml | 13 + rules/go/ownership-check.toml | 12 + rules/go/permission-check-call.toml | 12 + rules/go/role-check-conditional.toml | 12 + rules/java/authorized-annotation.toml | 12 + rules/java/has-role-call.toml | 12 + rules/java/is-user-in-role.toml | 12 + rules/java/role-equals-check.toml | 12 + rules/java/shiro-is-permitted.toml | 12 + rules/java/shiro-requires-permissions.toml | 12 + rules/java/shiro-requires-roles-array.toml | 12 + rules/java/shiro-requires-roles.toml | 12 + rules/java/spring-preauthorize.toml | 12 + rules/java/spring-roles-allowed-array.toml | 12 + rules/java/spring-roles-allowed.toml | 12 + rules/java/spring-secured.toml | 12 + rules/python/django-permission-required.toml | 12 + rules/python/has-perm-call.toml | 12 + rules/python/has-role-call.toml | 12 + rules/python/ownership-check.toml | 12 + rules/python/permission-check-call.toml | 12 + rules/python/role-check-conditional.toml | 12 + rules/typescript/authorize-function-call.toml | 12 + rules/typescript/chained-permission-call.toml | 14 + rules/typescript/feature-gate-check.toml | 12 + rules/typescript/has-role-call.toml | 12 + rules/typescript/membership-check-call.toml | 12 + rules/typescript/nestjs-roles-decorator.toml | 12 + rules/typescript/ownership-check.toml | 12 + rules/typescript/permission-check-call.toml | 12 + rules/typescript/role-check-conditional.toml | 12 + rules/typescript/role-includes-check.toml | 12 + src/cedar/grouping.rs | 214 +++++ src/cedar/mod.rs | 6 + src/cedar/templates.rs | 307 +++++++ src/cedar/validator.rs | 96 ++ src/cli.rs | 55 +- src/commands/extract.rs | 152 +++- src/commands/rules.rs | 9 + src/deep/candidate.rs | 2 + src/deep/context.rs | 1 + src/deep/finding.rs | 2 + src/deep/merge.rs | 1 + src/deep/prompt.rs | 1 + src/lib.rs | 4 +- src/mcp/tools.rs | 268 +++++- src/output/json.rs | 1 + src/output/text.rs | 1 + src/rego/grouping.rs | 3 + src/rules/mod.rs | 10 + src/scanner/matcher.rs | 8 + src/types.rs | 7 + tests/deep_http_integration.rs | 1 + tests/deep_subprocess_integration.rs | 1 + 69 files changed, 2487 insertions(+), 65 deletions(-) create mode 100644 src/cedar/grouping.rs create mode 100644 src/cedar/mod.rs create mode 100644 src/cedar/templates.rs create mode 100644 src/cedar/validator.rs diff --git a/Cargo.lock b/Cargo.lock index 7d9fd6b..47b7e10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -67,6 +76,30 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -95,12 +128,36 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.12.0" @@ -110,6 +167,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "bytes", + "cfg_aliases", +] + [[package]] name = "bstr" version = "1.12.1" @@ -142,6 +209,69 @@ dependencies = [ "shlex", ] +[[package]] +name = "cedar-policy" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5ac4e9c213c70c8bba1b4e7c93143129b889c44bb52de70f0498377373e4df" +dependencies = [ + "cedar-policy-core", + "cedar-policy-formatter", + "itertools", + "linked-hash-map", + "miette", + "ref-cast", + "semver", + "serde", + "serde_json", + "serde_with", + "smol_str", + "thiserror", +] + +[[package]] +name = "cedar-policy-core" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d3a8fa85292d3b6cbbaeffd9fa776dde108c55d50f138cfd0d078949309621" +dependencies = [ + "chrono", + "educe", + "either", + "itertools", + "lalrpop", + "lalrpop-util", + "linked-hash-map", + "linked_hash_set", + "miette", + "nonempty", + "ref-cast", + "regex", + "rustc-literal-escaper", + "serde", + "serde_json", + "serde_with", + "smol_str", + "stacker", + "thiserror", + "unicode-security", +] + +[[package]] +name = "cedar-policy-formatter" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34306a24ad75b78fb39de84f182352918bf5ca1f4066a465bafc4a4ebf44fdc" +dependencies = [ + "cedar-policy-core", + "itertools", + "logos", + "miette", + "pretty", + "regex", + "smol_str", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -154,6 +284,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "clap" version = "4.6.1" @@ -215,6 +357,21 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -249,6 +406,16 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.1" @@ -258,15 +425,69 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.0", "const-oid", - "crypto-common", + "crypto-common 0.2.1", ] [[package]] @@ -280,6 +501,59 @@ dependencies = [ "syn", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -308,6 +582,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "fnv" version = "1.0.7" @@ -378,6 +658,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -443,13 +733,19 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -471,6 +767,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.0" @@ -586,6 +888,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -674,6 +1000,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -711,6 +1043,17 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -745,6 +1088,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -763,6 +1115,47 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "lalrpop" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -781,6 +1174,24 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +dependencies = [ + "serde", +] + +[[package]] +name = "linked_hash_set" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -808,6 +1219,38 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "logos" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" +dependencies = [ + "fnv", + "proc-macro2", + "quote", + "regex-automata", + "regex-syntax", + "syn", +] + +[[package]] +name = "logos-derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" +dependencies = [ + "logos-codegen", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -829,6 +1272,29 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "serde", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "mio" version = "1.2.0" @@ -865,6 +1331,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +dependencies = [ + "serde", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -884,6 +1365,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-integer" version = "0.1.46" @@ -902,6 +1389,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -943,6 +1439,31 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -958,6 +1479,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -967,6 +1494,23 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "pretty" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156" +dependencies = [ + "arrayvec", + "typed-arena", + "unicode-width 0.2.2", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -986,6 +1530,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1100,6 +1654,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.3" @@ -1205,6 +1779,12 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc-literal-escaper" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be87abb9e40db7466e0681dc8ecd9dcfd40360cb10b4c8fe24a7c4c3669b198" + [[package]] name = "rustix" version = "1.1.4" @@ -1274,6 +1854,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1322,7 +1926,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -1351,6 +1955,37 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha2" version = "0.11.0" @@ -1358,8 +1993,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", ] [[package]] @@ -1383,6 +2028,12 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -1395,6 +2046,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + [[package]] name = "socket2" version = "0.6.3" @@ -1417,12 +2078,37 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "streaming-iterator" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1479,6 +2165,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1508,6 +2203,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1577,7 +2303,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", "serde_spanned", "toml_datetime", @@ -1802,6 +2528,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typenum" version = "1.20.0" @@ -1814,6 +2546,43 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-security" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50" +dependencies = [ + "unicode-normalization", + "unicode-script", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1856,6 +2625,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -1971,7 +2746,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -1984,7 +2759,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -2026,12 +2801,65 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2228,7 +3056,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn", "wasm-metadata", @@ -2259,7 +3087,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -2278,7 +3106,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -2401,6 +3229,7 @@ dependencies = [ name = "zift" version = "0.2.0" dependencies = [ + "cedar-policy", "clap", "ignore", "libc", diff --git a/Cargo.toml b/Cargo.toml index f5d9671..75ed961 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ sha2 = "0.11" regex = "1" streaming-iterator = "0.1" regorus = { version = "0.9", default-features = false, features = ["arc"] } +cedar-policy = "4" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } url = "2" diff --git a/rules/csharp/aspnet-authorize-attribute.toml b/rules/csharp/aspnet-authorize-attribute.toml index 45bad3c..dcc39db 100644 --- a/rules/csharp/aspnet-authorize-attribute.toml +++ b/rules/csharp/aspnet-authorize-attribute.toml @@ -28,6 +28,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + context.authenticated == true +}; +""" [[rule.tests]] input = """ using Microsoft.AspNetCore.Authorization; diff --git a/rules/csharp/aspnet-authorize-policy-shorthand.toml b/rules/csharp/aspnet-authorize-policy-shorthand.toml index 063a0a3..c34c8eb 100644 --- a/rules/csharp/aspnet-authorize-policy-shorthand.toml +++ b/rules/csharp/aspnet-authorize-policy-shorthand.toml @@ -32,6 +32,19 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + // TODO: verify attribute check + principal.attr == "TODO" +}; +""" [[rule.tests]] input = """ using Microsoft.AspNetCore.Authorization; diff --git a/rules/csharp/aspnet-authorize-policy.toml b/rules/csharp/aspnet-authorize-policy.toml index 0f0aca7..e35a48e 100644 --- a/rules/csharp/aspnet-authorize-policy.toml +++ b/rules/csharp/aspnet-authorize-policy.toml @@ -33,6 +33,19 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + // TODO: verify attribute check + principal.attr == "TODO" +}; +""" [[rule.tests]] input = """ using Microsoft.AspNetCore.Authorization; diff --git a/rules/csharp/aspnet-authorize-roles.toml b/rules/csharp/aspnet-authorize-roles.toml index 0075b2e..d6d3b39 100644 --- a/rules/csharp/aspnet-authorize-roles.toml +++ b/rules/csharp/aspnet-authorize-roles.toml @@ -33,6 +33,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "TODO" +}; +""" [[rule.tests]] input = """ using Microsoft.AspNetCore.Authorization; diff --git a/rules/csharp/aspnet-require-authorization.toml b/rules/csharp/aspnet-require-authorization.toml index 7cdf864..9006f3a 100644 --- a/rules/csharp/aspnet-require-authorization.toml +++ b/rules/csharp/aspnet-require-authorization.toml @@ -24,6 +24,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + context.authenticated == true +}; +""" [[rule.tests]] input = """ var app = builder.Build(); diff --git a/rules/csharp/authorization-service-authorize-async.toml b/rules/csharp/authorization-service-authorize-async.toml index 4adf59c..9a50f66 100644 --- a/rules/csharp/authorization-service-authorize-async.toml +++ b/rules/csharp/authorization-service-authorize-async.toml @@ -28,6 +28,19 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + // TODO: verify attribute check + principal.attr == "TODO" +}; +""" [[rule.tests]] input = """ var result = await _authorizationService.AuthorizeAsync(User, resource, "CanDeleteUsers"); diff --git a/rules/csharp/has-claim-call.toml b/rules/csharp/has-claim-call.toml index df4d2d1..a4f0346 100644 --- a/rules/csharp/has-claim-call.toml +++ b/rules/csharp/has-claim-call.toml @@ -36,6 +36,19 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + // TODO: verify attribute check + principal.attr == "TODO" +}; +""" [[rule.tests]] input = """ if (User.HasClaim("scope", "users.delete")) { diff --git a/rules/csharp/is-in-role-call.toml b/rules/csharp/is-in-role-call.toml index 47c677b..4537740 100644 --- a/rules/csharp/is-in-role-call.toml +++ b/rules/csharp/is-in-role-call.toml @@ -30,6 +30,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" [[rule.tests]] input = """ if (User.IsInRole("Admin")) { diff --git a/rules/go/access-descriptor-builder.toml b/rules/go/access-descriptor-builder.toml index 07c82a6..3f2968b 100644 --- a/rules/go/access-descriptor-builder.toml +++ b/rules/go/access-descriptor-builder.toml @@ -46,6 +46,19 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + // TODO: verify attribute check + principal.attr == "TODO" +}; +""" [[rule.tests]] input = """ package main diff --git a/rules/go/casbin-enforce.toml b/rules/go/casbin-enforce.toml index 420c322..2d14804 100644 --- a/rules/go/casbin-enforce.toml +++ b/rules/go/casbin-enforce.toml @@ -35,6 +35,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "TODO" +}; +""" [[rule.tests]] input = """ package main diff --git a/rules/go/feature-gate-check.toml b/rules/go/feature-gate-check.toml index 804f1b5..90ce7dc 100644 --- a/rules/go/feature-gate-check.toml +++ b/rules/go/feature-gate-check.toml @@ -40,6 +40,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.plan == "TODO" +}; +""" [[rule.tests]] input = """ package main diff --git a/rules/go/gin-auth-middleware.toml b/rules/go/gin-auth-middleware.toml index 397ea91..2c87c0a 100644 --- a/rules/go/gin-auth-middleware.toml +++ b/rules/go/gin-auth-middleware.toml @@ -49,6 +49,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + context.authenticated == true +}; +""" [[rule.tests]] input = """ package main diff --git a/rules/go/has-role-call.toml b/rules/go/has-role-call.toml index 2d0ab14..455b146 100644 --- a/rules/go/has-role-call.toml +++ b/rules/go/has-role-call.toml @@ -40,6 +40,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" [[rule.tests]] input = """ package main diff --git a/rules/go/opa-rego-eval.toml b/rules/go/opa-rego-eval.toml index 3a9d268..fce420a 100644 --- a/rules/go/opa-rego-eval.toml +++ b/rules/go/opa-rego-eval.toml @@ -49,6 +49,19 @@ allow if { } """ + +[rule.cedar_template] +template = """ +// TODO: custom authorization pattern — review and implement manually +// permit ( +// principal, +// action, +// resource +// ) +// when { +// ... +// }; +""" [[rule.tests]] input = """ package main diff --git a/rules/go/ownership-check.toml b/rules/go/ownership-check.toml index ff67f84..7131de4 100644 --- a/rules/go/ownership-check.toml +++ b/rules/go/ownership-check.toml @@ -51,6 +51,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + resource.owner == principal +}; +""" [[rule.tests]] input = """ package main diff --git a/rules/go/permission-check-call.toml b/rules/go/permission-check-call.toml index e14b6c7..11f0fc0 100644 --- a/rules/go/permission-check-call.toml +++ b/rules/go/permission-check-call.toml @@ -46,6 +46,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.permissions.contains("{{perm_value}}") +}; +""" [[rule.tests]] input = """ package main diff --git a/rules/go/role-check-conditional.toml b/rules/go/role-check-conditional.toml index bb3c3cc..3b0cbff 100644 --- a/rules/go/role-check-conditional.toml +++ b/rules/go/role-check-conditional.toml @@ -34,6 +34,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" [[rule.tests]] input = """ package main diff --git a/rules/java/authorized-annotation.toml b/rules/java/authorized-annotation.toml index 5417b22..a7c57ee 100644 --- a/rules/java/authorized-annotation.toml +++ b/rules/java/authorized-annotation.toml @@ -39,6 +39,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "TODO" +}; +""" [[rule.tests]] input = """ public class UserService { diff --git a/rules/java/has-role-call.toml b/rules/java/has-role-call.toml index 3ac861f..2808369 100644 --- a/rules/java/has-role-call.toml +++ b/rules/java/has-role-call.toml @@ -35,6 +35,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "TODO" +}; +""" [[rule.tests]] input = """ public class SecurityConfig { diff --git a/rules/java/is-user-in-role.toml b/rules/java/is-user-in-role.toml index 885404b..1f1491f 100644 --- a/rules/java/is-user-in-role.toml +++ b/rules/java/is-user-in-role.toml @@ -30,6 +30,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" [[rule.tests]] input = """ public class MyServlet { diff --git a/rules/java/role-equals-check.toml b/rules/java/role-equals-check.toml index 3ff7dbc..59f0ba8 100644 --- a/rules/java/role-equals-check.toml +++ b/rules/java/role-equals-check.toml @@ -35,6 +35,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" [[rule.tests]] input = """ public class Service { diff --git a/rules/java/shiro-is-permitted.toml b/rules/java/shiro-is-permitted.toml index d099611..d0a4845 100644 --- a/rules/java/shiro-is-permitted.toml +++ b/rules/java/shiro-is-permitted.toml @@ -30,6 +30,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.permissions.contains("{{perm_value}}") +}; +""" [[rule.tests]] input = """ public class Service { diff --git a/rules/java/shiro-requires-permissions.toml b/rules/java/shiro-requires-permissions.toml index f144047..85084cd 100644 --- a/rules/java/shiro-requires-permissions.toml +++ b/rules/java/shiro-requires-permissions.toml @@ -25,6 +25,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.permissions.contains("{{perm_value}}") +}; +""" [[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 3e287d5..c0eb7c7 100644 --- a/rules/java/shiro-requires-roles-array.toml +++ b/rules/java/shiro-requires-roles-array.toml @@ -25,6 +25,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "TODO" +}; +""" [[rule.tests]] input = """ public class AdminController { diff --git a/rules/java/shiro-requires-roles.toml b/rules/java/shiro-requires-roles.toml index 5b07968..5e697f5 100644 --- a/rules/java/shiro-requires-roles.toml +++ b/rules/java/shiro-requires-roles.toml @@ -25,6 +25,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" [[rule.tests]] input = """ public class AdminController { diff --git a/rules/java/spring-preauthorize.toml b/rules/java/spring-preauthorize.toml index d35f5fe..4bb2de8 100644 --- a/rules/java/spring-preauthorize.toml +++ b/rules/java/spring-preauthorize.toml @@ -26,6 +26,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "TODO" +}; +""" [[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 07ed6fe..53dcd15 100644 --- a/rules/java/spring-roles-allowed-array.toml +++ b/rules/java/spring-roles-allowed-array.toml @@ -25,6 +25,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "TODO" +}; +""" [[rule.tests]] input = """ public class UserController { diff --git a/rules/java/spring-roles-allowed.toml b/rules/java/spring-roles-allowed.toml index 6089305..317ebde 100644 --- a/rules/java/spring-roles-allowed.toml +++ b/rules/java/spring-roles-allowed.toml @@ -25,6 +25,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" [[rule.tests]] input = """ public class UserController { diff --git a/rules/java/spring-secured.toml b/rules/java/spring-secured.toml index 5769b28..1dd1260 100644 --- a/rules/java/spring-secured.toml +++ b/rules/java/spring-secured.toml @@ -25,6 +25,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" [[rule.tests]] input = """ public class UserController { diff --git a/rules/python/django-permission-required.toml b/rules/python/django-permission-required.toml index 582f686..27248a4 100644 --- a/rules/python/django-permission-required.toml +++ b/rules/python/django-permission-required.toml @@ -33,6 +33,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + context.authenticated == true +}; +""" [[rule.tests]] input = """ @permission_required('app.delete_user') diff --git a/rules/python/has-perm-call.toml b/rules/python/has-perm-call.toml index 0e47a69..ecf67e4 100644 --- a/rules/python/has-perm-call.toml +++ b/rules/python/has-perm-call.toml @@ -32,6 +32,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "TODO" +}; +""" [[rule.tests]] input = """ if request.user.has_perm('app.delete_user'): diff --git a/rules/python/has-role-call.toml b/rules/python/has-role-call.toml index 0bdcfdb..f83a661 100644 --- a/rules/python/has-role-call.toml +++ b/rules/python/has-role-call.toml @@ -24,6 +24,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" [[rule.tests]] input = """ if has_role("manager"): diff --git a/rules/python/ownership-check.toml b/rules/python/ownership-check.toml index ece9106..cab634c 100644 --- a/rules/python/ownership-check.toml +++ b/rules/python/ownership-check.toml @@ -31,6 +31,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + resource.owner == principal +}; +""" [[rule.tests]] input = """ if resource.owner_id == user.id: diff --git a/rules/python/permission-check-call.toml b/rules/python/permission-check-call.toml index 49541c9..a2109e0 100644 --- a/rules/python/permission-check-call.toml +++ b/rules/python/permission-check-call.toml @@ -34,6 +34,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + action == Action::"{{permission}}" +}; +""" [[rule.tests]] input = """ if user.can("delete"): diff --git a/rules/python/role-check-conditional.toml b/rules/python/role-check-conditional.toml index 4bda20c..3266a48 100644 --- a/rules/python/role-check-conditional.toml +++ b/rules/python/role-check-conditional.toml @@ -35,6 +35,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" [[rule.tests]] input = """ if user.role == "admin": diff --git a/rules/typescript/authorize-function-call.toml b/rules/typescript/authorize-function-call.toml index 1d740b7..67cb550 100644 --- a/rules/typescript/authorize-function-call.toml +++ b/rules/typescript/authorize-function-call.toml @@ -24,6 +24,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.permissions.contains("{{perm_value}}") +}; +""" [[rule.tests]] input = """ if (hasPermission("orders:read")) { diff --git a/rules/typescript/chained-permission-call.toml b/rules/typescript/chained-permission-call.toml index daa8db9..605d384 100644 --- a/rules/typescript/chained-permission-call.toml +++ b/rules/typescript/chained-permission-call.toml @@ -46,6 +46,20 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + // TODO: chained DSL — captured verb/resource are identifiers, not + // runtime values. Map onto your Cedar action/resource entities. + action == Action::"{{verb}}" +}; +""" # -- Positive: Ghost-style permission DSL -- [[rule.tests]] diff --git a/rules/typescript/feature-gate-check.toml b/rules/typescript/feature-gate-check.toml index bcc6523..10ed9fa 100644 --- a/rules/typescript/feature-gate-check.toml +++ b/rules/typescript/feature-gate-check.toml @@ -26,6 +26,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.plan == "{{plan_value}}" +}; +""" [[rule.tests]] input = """ if (user.plan === "enterprise") { diff --git a/rules/typescript/has-role-call.toml b/rules/typescript/has-role-call.toml index 4bafaf1..1a4a5a7 100644 --- a/rules/typescript/has-role-call.toml +++ b/rules/typescript/has-role-call.toml @@ -24,6 +24,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" [[rule.tests]] input = """ if (hasRole("manager")) { diff --git a/rules/typescript/membership-check-call.toml b/rules/typescript/membership-check-call.toml index 8124234..78e8492 100644 --- a/rules/typescript/membership-check-call.toml +++ b/rules/typescript/membership-check-call.toml @@ -43,6 +43,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.memberships.contains("{{resource_id}}") +}; +""" [[rule.tests]] input = """ if (membership.belongsTo("team-42")) { diff --git a/rules/typescript/nestjs-roles-decorator.toml b/rules/typescript/nestjs-roles-decorator.toml index 35502af..c5ff006 100644 --- a/rules/typescript/nestjs-roles-decorator.toml +++ b/rules/typescript/nestjs-roles-decorator.toml @@ -25,6 +25,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" [[rule.tests]] input = """ @Roles("admin") diff --git a/rules/typescript/ownership-check.toml b/rules/typescript/ownership-check.toml index eb4a01c..77e51c7 100644 --- a/rules/typescript/ownership-check.toml +++ b/rules/typescript/ownership-check.toml @@ -29,6 +29,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + resource.owner == principal +}; +""" [[rule.tests]] input = """ if (resource.ownerId === user.id) { diff --git a/rules/typescript/permission-check-call.toml b/rules/typescript/permission-check-call.toml index 774b543..fbac920 100644 --- a/rules/typescript/permission-check-call.toml +++ b/rules/typescript/permission-check-call.toml @@ -36,6 +36,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + action == Action::"{{permission}}" +}; +""" [[rule.tests]] input = """ if (user.can("delete", resource)) { diff --git a/rules/typescript/role-check-conditional.toml b/rules/typescript/role-check-conditional.toml index 7b5a362..aac4178 100644 --- a/rules/typescript/role-check-conditional.toml +++ b/rules/typescript/role-check-conditional.toml @@ -42,6 +42,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +}; +""" [[rule.tests]] input = """ if (user.role === "admin") { diff --git a/rules/typescript/role-includes-check.toml b/rules/typescript/role-includes-check.toml index a6fffe8..49ecfee 100644 --- a/rules/typescript/role-includes-check.toml +++ b/rules/typescript/role-includes-check.toml @@ -30,6 +30,18 @@ allow if { } """ + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.roles.contains("{{role_value}}") +}; +""" [[rule.tests]] input = """ if (user.roles.includes("admin")) { diff --git a/src/cedar/grouping.rs b/src/cedar/grouping.rs new file mode 100644 index 0000000..d7ab140 --- /dev/null +++ b/src/cedar/grouping.rs @@ -0,0 +1,214 @@ +//! Group findings into Cedar policy files. Cedar's file model is flatter +//! than Rego's (no packages, no module hierarchy) — one `.cedar` file per +//! source file is the natural unit, with the `--policy-prefix` becoming a +//! filename prefix instead of a package prefix. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use crate::types::Finding; + +use super::templates; + +pub struct CedarFile { + /// A human-friendly identifier for the file (used in CLI output). + /// Cedar has no `package` keyword, so this is purely informational — + /// it does NOT appear in the file body. + pub label: String, + pub output_path: PathBuf, + pub content: String, + pub finding_count: usize, +} + +/// Derive the output `.cedar` file path. Mirrors +/// [`crate::rego::grouping::derive_output_path`] but with a `.cedar` +/// extension and a flat `policy_prefix` filename prefix instead of nested +/// packages. +fn derive_output_path(file_path: &Path, output_dir: &Path, policy_prefix: &str) -> PathBuf { + let mut stem = file_path.with_extension("cedar"); + for common in &["src", "lib"] { + if let Ok(stripped) = stem.strip_prefix(common) + && !stripped.as_os_str().is_empty() + { + stem = stripped.to_path_buf(); + break; + } + } + let safe_stem: PathBuf = stem + .components() + .filter(|c| matches!(c, std::path::Component::Normal(_))) + .collect(); + + if policy_prefix.is_empty() { + return output_dir.join(safe_stem); + } + // Prepend the prefix as an extra directory segment so multi-tenant + // outputs stay organized (`policies/cedar//api/orders.cedar`). + output_dir.join(policy_prefix).join(safe_stem) +} + +/// Build a label for CLI output. Cedar has no package concept; we re-use +/// the dotted-path shape from Rego just so the user-facing log lines look +/// consistent across engines. +fn derive_label(file_path: &Path, prefix: &str) -> String { + let mut stem = file_path.with_extension(""); + for common in &["src", "lib"] { + if let Ok(stripped) = stem.strip_prefix(common) + && !stripped.as_os_str().is_empty() + { + stem = stripped.to_path_buf(); + break; + } + } + let path_str = stem.to_string_lossy(); + let dotted = path_str.replace(['/', '\\'], ".").replace('-', "_"); + if prefix.is_empty() { + dotted + } else if dotted.is_empty() { + prefix.to_string() + } else { + format!("{prefix}.{dotted}") + } +} + +pub fn group_findings( + findings: &[Finding], + policy_prefix: &str, + output_dir: &Path, +) -> Vec { + let mut groups: BTreeMap<&Path, Vec<&Finding>> = BTreeMap::new(); + for finding in findings { + groups.entry(&finding.file).or_default().push(finding); + } + + groups + .into_iter() + .map(|(file_path, file_findings)| { + let label = derive_label(file_path, policy_prefix); + let output_path = derive_output_path(file_path, output_dir, policy_prefix); + let content = build_cedar_content(file_path, &file_findings); + let finding_count = file_findings.len(); + + CedarFile { + label, + output_path, + content, + finding_count, + } + }) + .collect() +} + +fn build_cedar_content(source_file: &Path, findings: &[&Finding]) -> String { + let mut lines = Vec::new(); + + lines.push("// Generated by zift".to_string()); + lines.push(format!("// Source: {}", source_file.display())); + lines.push(format!("// Findings: {}", findings.len())); + lines.push(String::new()); + + for (i, finding) in findings.iter().enumerate() { + if i > 0 { + lines.push(String::new()); + } + + lines.push(format!( + "// [{}] {}:{} | {} | {}", + i + 1, + finding.file.display(), + finding.line_start, + finding.category, + finding.confidence, + )); + + let first_line = finding.code_snippet.lines().next().unwrap_or(""); + let truncated = if first_line.chars().count() > 80 { + let s: String = first_line.chars().take(77).collect(); + format!("{s}...") + } else { + first_line.to_string() + }; + lines.push(format!("// Original: {}", truncated.trim())); + + let stub = match &finding.cedar_stub { + Some(s) => s.clone(), + None => templates::generate_default_stub(finding.category, &finding.code_snippet), + }; + let wrapped = templates::apply_confidence_wrapping(&stub, finding.confidence); + for line in wrapped.lines() { + lines.push(line.to_string()); + } + } + + lines.push(String::new()); + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::*; + + fn finding(file: &str, snippet: &str, category: AuthCategory) -> Finding { + Finding { + id: "x".into(), + file: PathBuf::from(file), + line_start: 10, + line_end: 12, + code_snippet: snippet.into(), + language: Language::TypeScript, + category, + confidence: Confidence::High, + description: "test".into(), + pattern_rule: Some("test-rule".into()), + rego_stub: None, + cedar_stub: None, + pass: ScanPass::Structural, + surface: Surface::Backend, + } + } + + #[test] + fn output_path_with_prefix() { + let p = derive_output_path( + Path::new("src/api/orders.ts"), + Path::new("./policies"), + "app", + ); + assert_eq!(p, PathBuf::from("./policies/app/api/orders.cedar")); + } + + #[test] + fn output_path_without_prefix() { + let p = derive_output_path(Path::new("src/api/orders.ts"), Path::new("./policies"), ""); + assert_eq!(p, PathBuf::from("./policies/api/orders.cedar")); + } + + #[test] + fn group_findings_single_file_emits_permit() { + let findings = vec![finding( + "src/api/orders.ts", + r#"if (user.role === "admin") {}"#, + AuthCategory::Rbac, + )]; + let files = group_findings(&findings, "app", Path::new("./policies")); + assert_eq!(files.len(), 1); + assert!(files[0].content.contains("permit")); + assert!(files[0].content.contains("// Generated by zift")); + assert!(files[0].content.contains("admin")); + } + + #[test] + fn group_findings_multiple_files() { + let findings = vec![ + finding("src/a.ts", r#"hasRole("admin")"#, AuthCategory::Rbac), + finding( + "src/b.ts", + "req.isAuthenticated()", + AuthCategory::Middleware, + ), + ]; + let files = group_findings(&findings, "app", Path::new("./policies")); + assert_eq!(files.len(), 2); + } +} diff --git a/src/cedar/mod.rs b/src/cedar/mod.rs new file mode 100644 index 0000000..37e46a8 --- /dev/null +++ b/src/cedar/mod.rs @@ -0,0 +1,6 @@ +pub mod grouping; +pub mod templates; +pub mod validator; + +pub use grouping::group_findings; +pub use templates::render_template; diff --git a/src/cedar/templates.rs b/src/cedar/templates.rs new file mode 100644 index 0000000..8267f57 --- /dev/null +++ b/src/cedar/templates.rs @@ -0,0 +1,307 @@ +//! Cedar policy template rendering, category-default stubs, and +//! confidence wrapping. Mirror of [`crate::rego::templates`] for the Cedar +//! engine — same primitives, Cedar-flavored output. Cedar uses `//` line +//! comments and the `permit (...) when { ... };` policy form. + +use std::collections::HashMap; + +use regex::Regex; + +use crate::types::{AuthCategory, Confidence}; + +/// Render a Cedar template by replacing `{{key}}` placeholders with values. +/// String values captured from tree-sitter are stripped of surrounding quotes. +pub fn render_template(template: &str, vars: &HashMap) -> String { + let re = Regex::new(r"\{\{(\w+)\}\}").unwrap(); + re.replace_all(template, |caps: ®ex::Captures| { + let key = &caps[1]; + match vars.get(key) { + Some(val) if key.ends_with("_set") => val.to_string(), + Some(val) => strip_quotes(val).to_string(), + None => caps[0].to_string(), + } + }) + .to_string() +} + +fn strip_quotes(s: &str) -> &str { + let s = s.trim(); + if s.len() >= 2 + && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\''))) + { + &s[1..s.len() - 1] + } else { + s + } +} + +/// Extract quoted string literals from a code snippet. Same shape as the +/// Rego helper; kept separate so the two engines can diverge if Cedar grows +/// engine-specific extraction (e.g. principal/action splits) without +/// destabilising the Rego path. +pub fn extract_string_literals(code: &str) -> Vec { + let re = Regex::new(r#"["']([^"']+)["']"#).unwrap(); + re.captures_iter(code).map(|c| c[1].to_string()).collect() +} + +/// Return a default Cedar policy template for a given category. +/// +/// These are intentionally minimal `permit`/`forbid` shells — Cedar's type +/// system is strict, so we keep entity references generic +/// (`principal`/`resource`) and let users specialize. The templates compile +/// against the empty/unconstrained schema; callers wanting strict +/// type-checking should provide their own schema during validation. +pub fn default_template(category: AuthCategory) -> &'static str { + match category { + AuthCategory::Rbac => { + r#"permit ( + principal, + action, + resource +) +when { + principal.role == "{{role_value}}" +};"# + } + AuthCategory::Abac => { + r#"permit ( + principal, + action, + resource +) +when { + // TODO: verify attribute check + principal.{{attribute}} == "{{value}}" +};"# + } + AuthCategory::Middleware => { + r#"permit ( + principal, + action, + resource +) +when { + context.authenticated == true +};"# + } + AuthCategory::Ownership => { + r#"permit ( + principal, + action, + resource +) +when { + resource.owner == principal +};"# + } + AuthCategory::BusinessRule => { + r#"// TODO: business rule — review and implement manually +// permit ( +// principal, +// action, +// resource +// ) +// when { +// ... +// };"# + } + AuthCategory::FeatureGate => { + r#"permit ( + principal, + action, + resource +) +when { + principal.plan == "{{plan_value}}" +};"# + } + AuthCategory::Custom => { + r#"// TODO: custom authorization pattern — review and implement manually +// permit ( +// principal, +// action, +// resource +// ) +// when { +// ... +// };"# + } + } +} + +/// Build a Cedar stub for a finding using category defaults + extracted +/// string literals. Mirrors [`crate::rego::templates::generate_default_stub`] +/// for parity, with category-specific shaping where Cedar's grammar differs +/// from Rego (sets use `[a, b]`, comparisons use `==`/`in`). +pub fn generate_default_stub(category: AuthCategory, code_snippet: &str) -> String { + let literals = extract_string_literals(code_snippet); + let mut vars = HashMap::new(); + + match category { + AuthCategory::Rbac => { + // Permission-shaped strings (e.g. "orders:read") collapse into a + // membership check against `principal.permissions`. Pure roles + // get a single equality or `in` check. + if literals.iter().any(|s| s.contains(':')) { + let perms: Vec<&String> = literals.iter().filter(|s| s.contains(':')).collect(); + let body = if perms.len() == 1 { + format!(" \"{}\" in principal.permissions", perms[0]) + } else { + let items = perms + .iter() + .map(|p| format!("\"{p}\"")) + .collect::>() + .join(", "); + format!(" [{items}].containsAny(principal.permissions)") + }; + return format!( + "permit (\n principal,\n action,\n resource\n)\nwhen {{\n{body}\n}};" + ); + } + let role_value = literals.first().cloned().unwrap_or_else(|| "TODO".into()); + if literals.len() <= 1 { + vars.insert("role_value".to_string(), role_value); + return render_template(default_template(category), &vars); + } + // Multiple roles → emit a set membership check rather than a + // single equality. This is the structural-template counterpart + // to Rego's `input.user.role in {"a","b"}` form. + let items = literals + .iter() + .map(|r| format!("\"{r}\"")) + .collect::>() + .join(", "); + return format!( + "permit (\n principal,\n action,\n resource\n)\nwhen {{\n principal.role in [{items}]\n}};" + ); + } + AuthCategory::FeatureGate => { + let plan_value = literals.first().cloned().unwrap_or_else(|| "TODO".into()); + if literals.len() <= 1 { + vars.insert("plan_value".to_string(), plan_value); + } else { + let items = literals + .iter() + .map(|p| format!("\"{p}\"")) + .collect::>() + .join(", "); + return format!( + "permit (\n principal,\n action,\n resource\n)\nwhen {{\n principal.plan in [{items}]\n}};" + ); + } + } + AuthCategory::Abac => { + vars.insert( + "attribute".to_string(), + literals.first().cloned().unwrap_or("TODO".into()), + ); + vars.insert( + "value".to_string(), + literals.get(1).cloned().unwrap_or("TODO".into()), + ); + } + _ => {} + } + + render_template(default_template(category), &vars) +} + +/// Wrap a Cedar stub based on confidence level. Cedar uses `//` for line +/// comments — different from Rego's `#` — so the wrapping helpers can't be +/// shared with the Rego engine. +pub fn apply_confidence_wrapping(cedar_body: &str, confidence: Confidence) -> String { + match confidence { + Confidence::High => cedar_body.to_string(), + Confidence::Medium => format!("// TODO: verify this policy\n{cedar_body}"), + Confidence::Low => { + let commented: String = cedar_body + .lines() + .map(|line| { + if line.is_empty() || line.starts_with("//") { + line.to_string() + } else { + format!("// {line}") + } + }) + .collect::>() + .join("\n"); + format!("// SUGGESTION: review and uncomment if correct\n{commented}") + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_simple_template() { + let mut vars = HashMap::new(); + vars.insert("role_value".to_string(), "\"admin\"".to_string()); + let out = render_template("principal.role == \"{{role_value}}\"", &vars); + assert_eq!(out, "principal.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 out = render_template("principal.role in [{{roles_set}}]", &vars); + assert_eq!(out, "principal.role in [\"admin\", \"manager\"]"); + } + + #[test] + fn extract_literals() { + let lits = extract_string_literals(r#"hasRole("admin", "manager")"#); + assert_eq!(lits, vec!["admin", "manager"]); + } + + #[test] + fn default_stub_rbac_role() { + let stub = generate_default_stub(AuthCategory::Rbac, r#"if (user.role === "admin") {}"#); + assert!(stub.contains("permit")); + assert!(stub.contains("principal.role == \"admin\"")); + } + + #[test] + fn default_stub_rbac_role_set() { + let stub = generate_default_stub( + AuthCategory::Rbac, + r#"hasRole("admin") || hasRole("manager")"#, + ); + assert!(stub.contains("principal.role in [\"admin\", \"manager\"]")); + } + + #[test] + fn default_stub_rbac_permissions() { + let stub = generate_default_stub(AuthCategory::Rbac, r#"authorize(user, "audit:read")"#); + assert!(stub.contains("\"audit:read\" in principal.permissions")); + } + + #[test] + fn default_stub_feature_gate() { + let stub = generate_default_stub( + AuthCategory::FeatureGate, + r#"if (user.plan === "enterprise") {}"#, + ); + assert!(stub.contains("principal.plan == \"enterprise\"")); + } + + #[test] + fn confidence_wrapping_low_uses_double_slash() { + let wrapped = + apply_confidence_wrapping("permit (principal, action, resource);", Confidence::Low); + assert!(wrapped.starts_with("// SUGGESTION")); + assert!(wrapped.contains("// permit (principal, action, resource);")); + } + + #[test] + fn confidence_wrapping_medium_prepends_todo() { + let wrapped = + apply_confidence_wrapping("permit (principal, action, resource);", Confidence::Medium); + assert!(wrapped.starts_with("// TODO:")); + } +} diff --git a/src/cedar/validator.rs b/src/cedar/validator.rs new file mode 100644 index 0000000..046add8 --- /dev/null +++ b/src/cedar/validator.rs @@ -0,0 +1,96 @@ +//! Cedar policy validator. Mirror of [`crate::rego::validator`] using the +//! `cedar-policy` crate's `PolicySet` parser. Schema-free: we only verify +//! that the policy parses, matching the Rego validator's "syntactic +//! correctness" contract. Type-checking against an entity schema is a +//! separate concern that callers can layer on with `cedar_policy::Validator` +//! when they have a schema in hand. + +use std::str::FromStr; + +use cedar_policy::PolicySet; + +#[derive(Debug)] +pub struct ValidationResult { + pub valid: bool, + pub error: Option, +} + +/// Parse a Cedar policy (or policy set) and report whether it's +/// syntactically well-formed. Empty strings parse to an empty PolicySet, +/// which is a degenerate but valid input — same shape as +/// [`crate::rego::validator::validate_rego`] for an empty Rego module. +pub fn validate_cedar(policy: &str) -> ValidationResult { + match PolicySet::from_str(policy) { + Ok(_) => ValidationResult { + valid: true, + error: None, + }, + Err(e) => ValidationResult { + valid: false, + error: Some(e.to_string()), + }, + } +} + +/// Validate a Cedar template by substituting placeholder values for +/// `{{var}}` markers and parsing the result. Bare placeholders (in +/// identifier position, e.g. `principal.{{attribute}}`) get a dummy +/// identifier; quoted placeholders get a dummy string. Same approach as +/// [`crate::rego::validator::validate_template`]. +pub fn validate_template(template: &str) -> ValidationResult { + let re_quoted = regex::Regex::new(r#"["']\{\{(\w+)\}\}["']"#).unwrap(); + let rendered = re_quoted + .replace_all(template, "\"placeholder\"") + .to_string(); + let re_bare = regex::Regex::new(r"\{\{(\w+)\}\}").unwrap(); + let rendered = re_bare + .replace_all(&rendered, "placeholder_value") + .to_string(); + validate_cedar(&rendered) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_policy() { + let policy = r#"permit ( + principal, + action, + resource +) +when { + principal.role == "admin" +};"#; + let res = validate_cedar(policy); + assert!(res.valid, "expected valid, got: {:?}", res.error); + } + + #[test] + fn invalid_policy() { + let res = validate_cedar("this is not cedar"); + assert!(!res.valid); + assert!(res.error.is_some()); + } + + #[test] + fn valid_template_quoted_placeholder() { + let tmpl = r#"permit (principal, action, resource) +when { + principal.role == "{{role_value}}" +};"#; + 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) +when { + principal.{{attribute}} == "{{value}}" +};"#; + let res = validate_template(tmpl); + assert!(res.valid, "expected valid, got: {:?}", res.error); + } +} diff --git a/src/cli.rs b/src/cli.rs index a23c8a9..ec2784d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -147,15 +147,39 @@ pub struct ExtractArgs { #[arg(long, default_value = "./policies/generated")] pub output_dir: PathBuf, - /// Rego package prefix - #[arg(long, default_value = "app")] - pub package_prefix: String, + /// Policy prefix + /// + /// For Rego: dotted package prefix (e.g. `app.authz`). + /// For Cedar: filename/directory prefix (Cedar has no package concept). + /// Aliased as `--package-prefix` for backward compatibility with the + /// pre-Cedar CLI. + #[arg(long, alias = "package-prefix", default_value = "app")] + pub policy_prefix: String, + + /// Policy engine to generate + #[arg(long, value_enum, default_value_t = PolicyEngine::Rego)] + pub engine: PolicyEngine, /// Skip findings below this confidence #[arg(long)] pub min_confidence: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum PolicyEngine { + Rego, + Cedar, +} + +impl std::fmt::Display for PolicyEngine { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PolicyEngine::Rego => write!(f, "rego"), + PolicyEngine::Cedar => write!(f, "cedar"), + } + } +} + // -- Report -- #[derive(Debug, clap::Args)] @@ -282,6 +306,31 @@ mod tests { ]) .unwrap(); assert!(matches!(cli.command, Some(Command::Extract(_)))); + if let Some(Command::Extract(args)) = cli.command { + assert_eq!(args.policy_prefix, "myapp"); + assert_eq!(args.engine, PolicyEngine::Rego); + } + } + + #[test] + fn extract_subcommand_with_cedar_engine() { + let cli = Cli::try_parse_from([ + "zift", + "extract", + "--input", + "findings.json", + "--policy-prefix", + "app", + "--engine", + "cedar", + ]) + .unwrap(); + if let Some(Command::Extract(args)) = cli.command { + assert_eq!(args.engine, PolicyEngine::Cedar); + assert_eq!(args.policy_prefix, "app"); + } else { + panic!("expected Extract command"); + } } #[test] diff --git a/src/commands/extract.rs b/src/commands/extract.rs index 42293d3..8b6d989 100644 --- a/src/commands/extract.rs +++ b/src/commands/extract.rs @@ -1,11 +1,11 @@ use std::io::Read; +use std::path::Path; use serde::Deserialize; -use crate::cli::ExtractArgs; +use crate::cli::{ExtractArgs, PolicyEngine}; use crate::config::ZiftConfig; use crate::error::{Result, ZiftError}; -use crate::rego::{self, templates}; use crate::types::Finding; /// Minimal struct for deserializing scan output — we only need the findings. @@ -15,13 +15,15 @@ struct ScanInput { } pub fn execute(args: ExtractArgs, config: ZiftConfig) -> Result<()> { - // Resolve config fallbacks - let package_prefix = config + // Resolve config fallbacks. The CLI default for `policy_prefix` is + // "app"; if the user didn't override that on the command line, defer to + // `[extract] package_prefix` from the config. + let policy_prefix = config .extract .package_prefix .as_deref() - .filter(|_| args.package_prefix == "app") // only use config if CLI is default - .unwrap_or(&args.package_prefix); + .filter(|_| args.policy_prefix == "app") + .unwrap_or(&args.policy_prefix); let output_dir = config .extract @@ -31,11 +33,9 @@ pub fn execute(args: ExtractArgs, config: ZiftConfig) -> Result<()> { .map(std::path::PathBuf::from) .unwrap_or_else(|| args.output_dir.clone()); - // Read findings let mut findings = read_findings(&args)?; tracing::info!("loaded {} findings", findings.len()); - // Filter by confidence if let Some(min) = args.min_confidence { findings.retain(|f| f.confidence >= min); tracing::info!("{} findings after confidence filter", findings.len()); @@ -46,8 +46,16 @@ pub fn execute(args: ExtractArgs, config: ZiftConfig) -> Result<()> { return Ok(()); } - // Ensure every finding has a rego_stub - for finding in &mut findings { + match args.engine { + PolicyEngine::Rego => extract_rego(&mut findings, policy_prefix, &output_dir), + PolicyEngine::Cedar => extract_cedar(&mut findings, policy_prefix, &output_dir), + } +} + +fn extract_rego(findings: &mut [Finding], policy_prefix: &str, output_dir: &Path) -> Result<()> { + use crate::rego::{self, templates}; + + for finding in findings.iter_mut() { if finding.rego_stub.is_none() { finding.rego_stub = Some(templates::generate_default_stub( finding.category, @@ -56,49 +64,15 @@ pub fn execute(args: ExtractArgs, config: ZiftConfig) -> Result<()> { } } - // Group and generate files - let rego_files = rego::group_findings(&findings, package_prefix, &output_dir); + let rego_files = rego::group_findings(findings, policy_prefix, output_dir); - // Write files and validate let mut total_files = 0; let mut validation_warnings = 0; for rego_file in ®o_files { - // Validate generated Rego syntax let validation = rego::validator::validate_rego(®o_file.content); let status = if validation.valid { "OK" } else { "WARN" }; - if let Some(parent) = rego_file.output_path.parent() { - std::fs::create_dir_all(parent)?; - } - // Verify the resolved output path stays within the output directory - // by canonicalizing the parent (which now exists) and checking containment - // BEFORE writing, to prevent TOCTOU issues with symlinked directories. - let canonical_output_dir = output_dir.canonicalize().map_err(|e| { - ZiftError::General(format!( - "failed to resolve output dir '{}': {e}", - output_dir.display() - )) - })?; - let parent = rego_file.output_path.parent().ok_or_else(|| { - ZiftError::General(format!( - "output path '{}' has no parent", - rego_file.output_path.display() - )) - })?; - let canonical_parent = parent.canonicalize().map_err(|e| { - ZiftError::General(format!( - "failed to resolve parent dir '{}': {e}", - parent.display() - )) - })?; - if !canonical_parent.starts_with(&canonical_output_dir) { - return Err(ZiftError::General(format!( - "output path '{}' escapes output directory '{}'", - rego_file.output_path.display(), - output_dir.display() - ))); - } - std::fs::write(®o_file.output_path, ®o_file.content)?; + write_policy_file(®o_file.output_path, ®o_file.content, output_dir)?; total_files += 1; eprintln!( " [{status}] {} ({} findings) → {}", @@ -121,7 +95,92 @@ pub fn execute(args: ExtractArgs, config: ZiftConfig) -> Result<()> { "{validation_warnings} file(s) have Rego syntax warnings — review before deploying.", ); } + Ok(()) +} + +fn extract_cedar(findings: &mut [Finding], policy_prefix: &str, output_dir: &Path) -> Result<()> { + use crate::cedar::{self, templates}; + // Mirror of the Rego pre-fill: if a finding doesn't carry a + // cedar_stub yet (e.g. it came from an older scan, or its rule has no + // `cedar_template` block), synthesize one from the category default. + // This keeps Cedar coverage at 100% — no rule produces zero output. + for finding in findings.iter_mut() { + if finding.cedar_stub.is_none() { + finding.cedar_stub = Some(templates::generate_default_stub( + finding.category, + &finding.code_snippet, + )); + } + } + + let cedar_files = cedar::group_findings(findings, policy_prefix, output_dir); + + let mut total_files = 0; + let mut validation_warnings = 0; + for cedar_file in &cedar_files { + let validation = cedar::validator::validate_cedar(&cedar_file.content); + let status = if validation.valid { "OK" } else { "WARN" }; + + write_policy_file(&cedar_file.output_path, &cedar_file.content, output_dir)?; + total_files += 1; + eprintln!( + " [{status}] {} ({} findings) → {}", + cedar_file.label, + cedar_file.finding_count, + cedar_file.output_path.display(), + ); + if let Some(err) = validation.error { + eprintln!(" ⚠ Cedar parse warning: {err}"); + validation_warnings += 1; + } + } + + eprintln!( + "\nGenerated {total_files} Cedar files from {} findings.", + findings.len(), + ); + if validation_warnings > 0 { + eprintln!( + "{validation_warnings} file(s) have Cedar syntax warnings — review before deploying.", + ); + } + Ok(()) +} + +/// Write a policy file with TOCTOU-safe path-traversal containment. +/// Canonicalise the parent (which we just created) and verify it stays +/// inside `output_dir` before writing. +fn write_policy_file(output_path: &Path, content: &str, output_dir: &Path) -> Result<()> { + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent)?; + } + let canonical_output_dir = output_dir.canonicalize().map_err(|e| { + ZiftError::General(format!( + "failed to resolve output dir '{}': {e}", + output_dir.display() + )) + })?; + let parent = output_path.parent().ok_or_else(|| { + ZiftError::General(format!( + "output path '{}' has no parent", + output_path.display() + )) + })?; + let canonical_parent = parent.canonicalize().map_err(|e| { + ZiftError::General(format!( + "failed to resolve parent dir '{}': {e}", + parent.display() + )) + })?; + if !canonical_parent.starts_with(&canonical_output_dir) { + return Err(ZiftError::General(format!( + "output path '{}' escapes output directory '{}'", + output_path.display(), + output_dir.display() + ))); + } + std::fs::write(output_path, content)?; Ok(()) } @@ -136,7 +195,6 @@ fn read_findings(args: &ExtractArgs) -> Result> { buf }; - // Try parsing as ScanReport (with findings wrapper) first, then as bare Vec if let Ok(report) = serde_json::from_str::(&json_str) { Ok(report.findings) } else { diff --git a/src/commands/rules.rs b/src/commands/rules.rs index ca4b36a..8d2730d 100644 --- a/src/commands/rules.rs +++ b/src/commands/rules.rs @@ -59,6 +59,15 @@ pub fn execute(args: RulesArgs, config: ZiftConfig) -> Result<()> { errors += 1; } } + // Validate Cedar template if present + if let Some(ref tmpl) = rule.cedar_template { + let result = crate::cedar::validator::validate_template(tmpl); + if !result.valid { + let err = result.error.unwrap_or_default(); + eprintln!("FAIL {} cedar_template: {err}", rule.id); + errors += 1; + } + } } if errors == 0 { println!("All {} rules validated successfully.", loaded.len()); diff --git a/src/deep/candidate.rs b/src/deep/candidate.rs index 48ddd4c..173188f 100644 --- a/src/deep/candidate.rs +++ b/src/deep/candidate.rs @@ -396,6 +396,7 @@ mod tests { description: String::new(), pattern_rule: None, rego_stub: None, + cedar_stub: None, pass: ScanPass::Structural, surface: Surface::Backend, } @@ -786,6 +787,7 @@ mod tests { description: "x".into(), pattern_rule: None, rego_stub: None, + cedar_stub: None, pass: ScanPass::Structural, surface: Surface::Backend, }; diff --git a/src/deep/context.rs b/src/deep/context.rs index 5dcff3b..4ac0bf8 100644 --- a/src/deep/context.rs +++ b/src/deep/context.rs @@ -213,6 +213,7 @@ mod tests { description: String::new(), pattern_rule: None, rego_stub: None, + cedar_stub: None, pass: ScanPass::Structural, surface: Surface::Backend, } diff --git a/src/deep/finding.rs b/src/deep/finding.rs index 75a1839..beedbea 100644 --- a/src/deep/finding.rs +++ b/src/deep/finding.rs @@ -123,6 +123,7 @@ pub fn into_finding( // this finding is the model's verdict, not the structural rule's. pattern_rule: Some(rule_id), rego_stub: None, // structural-only; semantic findings have no rego template + cedar_stub: None, pass: ScanPass::Semantic, // Surface follows the source file, not the pass — same path // heuristic as structural findings so a deep-pass `web/src/foo.ts` @@ -244,6 +245,7 @@ mod tests { description: "matched custom rule".into(), pattern_rule: pattern_rule.map(String::from), rego_stub: None, + cedar_stub: None, pass: ScanPass::Structural, surface: Surface::Backend, } diff --git a/src/deep/merge.rs b/src/deep/merge.rs index 044c9e8..d572eb7 100644 --- a/src/deep/merge.rs +++ b/src/deep/merge.rs @@ -78,6 +78,7 @@ mod tests { description: String::new(), pattern_rule: None, rego_stub: None, + cedar_stub: None, pass, surface: Surface::Backend, } diff --git a/src/deep/prompt.rs b/src/deep/prompt.rs index d91e4dc..1fa0eab 100644 --- a/src/deep/prompt.rs +++ b/src/deep/prompt.rs @@ -335,6 +335,7 @@ mod tests { description: "matched custom rule".into(), pattern_rule: Some("ts-custom-1".into()), rego_stub: None, + cedar_stub: None, pass: ScanPass::Structural, surface: Surface::Backend, } diff --git a/src/lib.rs b/src/lib.rs index 09c2414..5d5f8d2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,10 +11,12 @@ //! - [`error`] — `ZiftError` and `Result` //! - [`types`] — core data types (`Finding`, `Language`, `AuthCategory`, …) //! - [`rules`] — rule loading (read-only) -//! - [`rego`] — policy generation; `rego::validator` is the stable surface +//! - [`rego`] — Rego/OPA policy generation; `rego::validator` is the stable surface +//! - [`cedar`] — Cedar policy generation; `cedar::validator` is the stable surface //! - [`run`] — binary entry point // Stable public API +pub mod cedar; pub mod cli; pub mod error; pub mod rego; diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 8a495b4..46cdf48 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -15,6 +15,7 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; use serde_json::{Value, json}; +use crate::cedar; use crate::cli::ScanArgs; use crate::deep::candidate::{Candidate, CandidateKind}; use crate::deep::context::expand_region; @@ -39,6 +40,8 @@ pub fn list_tools() -> ToolsListResult { get_rule_descriptor(), suggest_rego_descriptor(), validate_rego_descriptor(), + suggest_policy_descriptor(), + validate_policy_descriptor(), analyze_snippet_descriptor(), ], } @@ -55,6 +58,8 @@ pub fn dispatch(ctx: &ServerContext, name: &str, args: &Value) -> ToolsCallResul "get_rule" => run_or_error(get_rule(ctx, args)), "suggest_rego" => run_or_error(suggest_rego(ctx, args)), "validate_rego" => run_or_error(validate_rego_tool(args)), + "suggest_policy" => run_or_error(suggest_policy(ctx, args)), + "validate_policy" => run_or_error(validate_policy_tool(args)), "analyze_snippet" => run_or_error(analyze_snippet(ctx, args)), other => ToolsCallResult::error(format!("unknown tool: {other}")), } @@ -274,6 +279,7 @@ fn rule_summary(r: &PatternRule) -> Value { "confidence": r.confidence, "description": r.description, "has_rego_template": r.rego_template.is_some(), + "has_cedar_template": r.cedar_template.is_some(), }) } @@ -356,6 +362,7 @@ fn get_rule(ctx: &ServerContext, args: &Value) -> Result { "predicates": predicates, "cross_predicates": cross_predicates, "rego_template": rule.rego_template, + "cedar_template": rule.cedar_template, "tests": tests, })) } @@ -497,6 +504,167 @@ fn validate_rego_tool(args: &Value) -> Result { })) } +// -- suggest_policy / validate_policy ----------------------------------- +// +// Engine-agnostic peers of `suggest_rego` / `validate_rego`. Selecting an +// engine is a first-class argument so agent hosts can drive Cedar without +// learning per-engine tool names. The original Rego tools stay live as +// pinned aliases so existing prompts and integrations keep working. + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +enum PolicyEngineArg { + Rego, + Cedar, +} + +fn suggest_policy_descriptor() -> ToolDescriptor { + ToolDescriptor { + name: "suggest_policy", + description: "Suggest a policy stub for a finding in the requested engine \ + (rego or cedar). If a rule_id is supplied and the rule has a \ + template for that engine, the template wins; otherwise a \ + category-default stub is generated. The stub is wrapped in \ + confidence-appropriate guidance. `suggest_rego` remains \ + available as a Rego-pinned alias.", + input_schema: json!({ + "type": "object", + "properties": { + "engine": { + "type": "string", + "enum": ["rego", "cedar"], + "description": "Policy engine to render for. Default: rego." + }, + "category": { + "type": "string", + "enum": ["rbac", "abac", "middleware", "business_rule", + "ownership", "feature_gate", "custom"] + }, + "confidence": {"type": "string", "enum": ["low", "medium", "high"]}, + "code_snippet": {"type": "string"}, + "rule_id": { + "type": "string", + "description": "Optional. If supplied and the rule has a \ + matching template for the selected engine, \ + that template wins over the default." + } + }, + "required": ["category", "confidence", "code_snippet"], + "additionalProperties": false + }), + } +} + +#[derive(Debug, Deserialize)] +struct SuggestPolicyArgs { + #[serde(default)] + engine: Option, + category: AuthCategory, + confidence: Confidence, + code_snippet: String, + #[serde(default)] + rule_id: Option, +} + +fn suggest_policy(ctx: &ServerContext, args: &Value) -> Result { + let parsed: SuggestPolicyArgs = parse_args(args, "suggest_policy")?; + let engine = parsed.engine.unwrap_or(PolicyEngineArg::Rego); + + let rule = parsed + .rule_id + .as_deref() + .and_then(|rid| ctx.rules.iter().find(|r| r.id == rid)); + + let rendered = match engine { + PolicyEngineArg::Rego => match rule.and_then(|r| r.rego_template.as_deref()) { + Some(tmpl) => { + let vars = build_template_vars(&parsed.code_snippet); + render_template(tmpl, &vars) + } + None => generate_default_stub(parsed.category, &parsed.code_snippet), + }, + PolicyEngineArg::Cedar => match rule.and_then(|r| r.cedar_template.as_deref()) { + Some(tmpl) => { + let vars = build_template_vars(&parsed.code_snippet); + cedar::render_template(tmpl, &vars) + } + None => cedar::templates::generate_default_stub(parsed.category, &parsed.code_snippet), + }, + }; + + let wrapped = match engine { + PolicyEngineArg::Rego => apply_confidence_wrapping(&rendered, parsed.confidence), + PolicyEngineArg::Cedar => { + cedar::templates::apply_confidence_wrapping(&rendered, parsed.confidence) + } + }; + + let key = match engine { + PolicyEngineArg::Rego => "rego", + PolicyEngineArg::Cedar => "cedar", + }; + Ok(json!({ + "engine": key, + "policy": wrapped, + // Echo the engine-specific key for ergonomic agent prompts. + key: wrapped, + })) +} + +fn validate_policy_descriptor() -> ToolDescriptor { + ToolDescriptor { + name: "validate_policy", + description: "Validate a policy string in the requested engine (rego or \ + cedar). Returns valid=true on success or valid=false plus \ + the parse error. `validate_rego` remains available as a \ + Rego-pinned alias.", + input_schema: json!({ + "type": "object", + "properties": { + "engine": { + "type": "string", + "enum": ["rego", "cedar"], + "description": "Policy engine. Default: rego." + }, + "policy": {"type": "string", "description": "Full policy to validate."} + }, + "required": ["policy"], + "additionalProperties": false + }), + } +} + +#[derive(Debug, Deserialize)] +struct ValidatePolicyArgs { + #[serde(default)] + engine: Option, + policy: String, +} + +fn validate_policy_tool(args: &Value) -> Result { + let parsed: ValidatePolicyArgs = parse_args(args, "validate_policy")?; + let engine = parsed.engine.unwrap_or(PolicyEngineArg::Rego); + let (valid, error) = match engine { + PolicyEngineArg::Rego => { + let r = validate_rego(&parsed.policy); + (r.valid, r.error) + } + PolicyEngineArg::Cedar => { + let r = cedar::validator::validate_cedar(&parsed.policy); + (r.valid, r.error) + } + }; + let key = match engine { + PolicyEngineArg::Rego => "rego", + PolicyEngineArg::Cedar => "cedar", + }; + Ok(json!({ + "engine": key, + "valid": valid, + "error": error, + })) +} + // -- analyze_snippet ----------------------------------------------------- fn analyze_snippet_descriptor() -> ToolDescriptor { @@ -606,6 +774,7 @@ fn analyze_snippet(_ctx: &ServerContext, args: &Value) -> Result description: s.description.clone(), pattern_rule: s.pattern_rule.clone(), rego_stub: None, + cedar_stub: None, pass: ScanPass::Structural, surface: Surface::classify(&PathBuf::from(&parsed.file)), }); @@ -689,9 +858,9 @@ mod tests { } #[test] - fn list_tools_returns_seven_tools_with_schemas() { + fn list_tools_returns_nine_tools_with_schemas() { let result = list_tools(); - assert_eq!(result.tools.len(), 7); + assert_eq!(result.tools.len(), 9); for t in &result.tools { // Every tool must have an object input schema. assert_eq!(t.input_schema["type"], "object"); @@ -938,6 +1107,101 @@ function check(user: { role: string }) { assert!(rego.starts_with("# SUGGESTION")); } + #[test] + fn suggest_policy_cedar_returns_permit_form() { + let dir = tempdir().unwrap(); + let ctx = ctx_with_root(dir.path().canonicalize().unwrap()); + let res = dispatch( + &ctx, + "suggest_policy", + &json!({ + "engine": "cedar", + "category": "rbac", + "confidence": "high", + "code_snippet": "if (user.role === \"admin\") {}" + }), + ); + assert!(!res.is_error); + let payload: Value = match &res.content[0] { + crate::mcp::protocol::ContentBlock::Text { text } => { + serde_json::from_str(text).unwrap() + } + }; + assert_eq!(payload["engine"], "cedar"); + let cedar = payload["cedar"].as_str().unwrap(); + assert!(cedar.contains("permit")); + assert!(cedar.contains("admin")); + } + + #[test] + fn suggest_policy_defaults_to_rego() { + let dir = tempdir().unwrap(); + let ctx = ctx_with_root(dir.path().canonicalize().unwrap()); + let res = dispatch( + &ctx, + "suggest_policy", + &json!({ + "category": "rbac", + "confidence": "high", + "code_snippet": "if (user.role === \"admin\") {}" + }), + ); + assert!(!res.is_error); + let payload: Value = match &res.content[0] { + crate::mcp::protocol::ContentBlock::Text { text } => { + serde_json::from_str(text).unwrap() + } + }; + assert_eq!(payload["engine"], "rego"); + assert!( + payload["rego"] + .as_str() + .unwrap() + .contains("input.user.role") + ); + } + + #[test] + fn validate_policy_cedar_accepts_valid_policy() { + let dir = tempdir().unwrap(); + let ctx = ctx_with_root(dir.path().canonicalize().unwrap()); + let res = dispatch( + &ctx, + "validate_policy", + &json!({ + "engine": "cedar", + "policy": "permit (principal, action, resource);" + }), + ); + assert!(!res.is_error); + let payload: Value = match &res.content[0] { + crate::mcp::protocol::ContentBlock::Text { text } => { + serde_json::from_str(text).unwrap() + } + }; + assert_eq!(payload["valid"], true); + assert_eq!(payload["engine"], "cedar"); + } + + #[test] + fn validate_policy_cedar_reports_invalid_policy() { + let dir = tempdir().unwrap(); + let ctx = ctx_with_root(dir.path().canonicalize().unwrap()); + let res = dispatch( + &ctx, + "validate_policy", + &json!({"engine": "cedar", "policy": "this is not cedar"}), + ); + assert!(!res.is_error); + let payload: Value = match &res.content[0] { + crate::mcp::protocol::ContentBlock::Text { text } => { + serde_json::from_str(text).unwrap() + } + }; + assert_eq!(payload["valid"], false); + assert!(payload["error"].is_string()); + } + #[test] fn analyze_snippet_returns_system_user_and_schema() { let dir = tempdir().unwrap(); diff --git a/src/output/json.rs b/src/output/json.rs index d97ee8a..069abeb 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -127,6 +127,7 @@ mod tests { description: "embedded role check".into(), pattern_rule: None, rego_stub: None, + cedar_stub: None, pass: ScanPass::Structural, surface: Surface::Backend, } diff --git a/src/output/text.rs b/src/output/text.rs index ec61246..a004df4 100644 --- a/src/output/text.rs +++ b/src/output/text.rs @@ -140,6 +140,7 @@ mod tests { description: "embedded role check".into(), pattern_rule: None, rego_stub: None, + cedar_stub: None, pass: ScanPass::Structural, surface: Surface::Backend, } diff --git a/src/rego/grouping.rs b/src/rego/grouping.rs index 9d71a61..7625d52 100644 --- a/src/rego/grouping.rs +++ b/src/rego/grouping.rs @@ -233,6 +233,7 @@ mod tests { description: "test".into(), pattern_rule: Some("test-rule".into()), rego_stub: None, + cedar_stub: None, pass: ScanPass::Structural, surface: Surface::Backend, }]; @@ -260,6 +261,7 @@ mod tests { description: "test".into(), pattern_rule: None, rego_stub: None, + cedar_stub: None, pass: ScanPass::Structural, surface: Surface::Backend, }, @@ -275,6 +277,7 @@ mod tests { description: "test".into(), pattern_rule: None, rego_stub: None, + cedar_stub: None, pass: ScanPass::Structural, surface: Surface::Backend, }, diff --git a/src/rules/mod.rs b/src/rules/mod.rs index feefd79..d10354c 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -19,6 +19,7 @@ pub struct PatternRule { pub predicates: Vec<(String, Predicate)>, pub cross_predicates: Vec, pub rego_template: Option, + pub cedar_template: Option, pub tests: Vec, } @@ -105,6 +106,7 @@ struct RuleToml { #[serde(default)] cross_predicates: Vec, rego_template: Option, + cedar_template: Option, #[serde(default)] tests: Vec, } @@ -138,6 +140,11 @@ struct RegoTemplateToml { template: String, } +#[derive(Debug, Deserialize)] +struct CedarTemplateToml { + template: String, +} + #[derive(Debug, Deserialize)] struct RuleTestToml { input: String, @@ -260,6 +267,7 @@ fn parse_rule(toml_str: &str, source: &str) -> Result { predicates, cross_predicates, rego_template: r.rego_template.map(|t| t.template), + cedar_template: r.cedar_template.map(|t| t.template), tests: r .tests .into_iter() @@ -469,6 +477,7 @@ match = ".*" predicates: vec![], cross_predicates: vec![], rego_template: None, + cedar_template: None, tests: vec![], }; let r2 = PatternRule { @@ -481,6 +490,7 @@ match = ".*" predicates: vec![], cross_predicates: vec![], rego_template: None, + cedar_template: None, tests: vec![], }; diff --git a/src/scanner/matcher.rs b/src/scanner/matcher.rs index 8ca53eb..e993b0b 100644 --- a/src/scanner/matcher.rs +++ b/src/scanner/matcher.rs @@ -163,6 +163,13 @@ pub fn execute_query( add_template_derived_values(&mut owned); crate::rego::render_template(tmpl, &owned) }), + cedar_stub: compiled.rule.cedar_template.as_ref().map(|tmpl| { + let owned: HashMap = captures + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(); + crate::cedar::render_template(tmpl, &owned) + }), pass: ScanPass::Structural, surface: Surface::classify(file_path), }); @@ -1349,6 +1356,7 @@ match = ".*" description: "test".into(), pattern_rule: None, rego_stub: None, + cedar_stub: None, pass: ScanPass::Structural, surface: Surface::Backend, }; diff --git a/src/types.rs b/src/types.rs index ab42472..8609e66 100644 --- a/src/types.rs +++ b/src/types.rs @@ -17,6 +17,13 @@ pub struct Finding { pub description: String, pub pattern_rule: Option, pub rego_stub: Option, + /// Cedar policy stub. Populated when `--engine cedar` is used during + /// extract, or when a deep-mode response carries one. Kept parallel to + /// `rego_stub` (rather than collapsed into a `policy_outputs` collection) + /// so persisted findings JSON stays backward-compatible — older consumers + /// see an extra optional field they can ignore. + #[serde(default)] + pub cedar_stub: Option, pub pass: ScanPass, /// Where in a typical app this finding lives — frontend (UI/client) or /// backend (server/API). Inferred from the file path via simple diff --git a/tests/deep_http_integration.rs b/tests/deep_http_integration.rs index f3d0ddf..59679d7 100644 --- a/tests/deep_http_integration.rs +++ b/tests/deep_http_integration.rs @@ -478,6 +478,7 @@ fn structural_finding(file: &str, line: usize) -> Finding { description: "matched custom rule".into(), pattern_rule: Some("ts-custom".into()), rego_stub: None, + cedar_stub: None, pass: ScanPass::Structural, surface: Surface::Backend, } diff --git a/tests/deep_subprocess_integration.rs b/tests/deep_subprocess_integration.rs index 6799ae2..462417f 100644 --- a/tests/deep_subprocess_integration.rs +++ b/tests/deep_subprocess_integration.rs @@ -58,6 +58,7 @@ fn structural_finding(file: &str, line: usize) -> Finding { description: "matched custom rule".into(), pattern_rule: Some("ts-custom".into()), rego_stub: None, + cedar_stub: None, pass: ScanPass::Structural, surface: Surface::Backend, } From c21811987e3b6088542c613d76eb92986380bd37 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Wed, 6 May 2026 09:44:12 -0400 Subject: [PATCH 2/4] docs: drop Phase B gating from Cedar memo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B is now tracked as its own work item (#71). Removed the 'wait for real users' gate and the 'JSON-schema break needs a major version bump' gate. The *_stub → policy_outputs migration ships with a deserialization shim that reads the legacy fields, so it doesn't need a coordinated version cut — that shim is part of Phase B's scope, not a separate migration. --- docs/CEDAR_SUPPORT.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/CEDAR_SUPPORT.md b/docs/CEDAR_SUPPORT.md index 4f66964..bbe7a4a 100644 --- a/docs/CEDAR_SUPPORT.md +++ b/docs/CEDAR_SUPPORT.md @@ -88,9 +88,10 @@ So: log the design now while context is fresh; revisit when v0.2 is shipping. - Add `suggest_policy(finding_id, engine)` and `validate_policy(content, engine)`. - Keep `suggest_rego` and `validate_rego` as Rego-pinned aliases that internally call the new tools with `engine="rego"`. Document them as deprecated but supported. -### Phase B — clean up the abstraction (later, ~150 lines + refactor) +### Phase B — clean up the abstraction (~150 lines + refactor) -Once Phase A has shipped and users have actually generated Cedar in anger: +Tracked separately as its own work item so Phase A can ship and Phase B can +land on its own merits, not on a wait-for-feedback gate. 6. **Extract a `PolicyGenerator` trait** ```rust @@ -103,14 +104,14 @@ Once Phase A has shipped and users have actually generated Cedar in anger: ``` `RegoGenerator` and `CedarGenerator` implement it. The `extract` pipeline becomes generic over `dyn PolicyGenerator`, dispatched off the `--engine` flag. -7. **Consider a `PolicyOutput` collection on `Finding`** - Replace the parallel `*_stub` fields with `policy_outputs: Vec` once enough engines exist that the parallel-field pattern becomes embarrassing. This is a JSON-schema break — gate it behind a major-version bump. +7. **Replace parallel `*_stub` fields with a `PolicyOutput` collection on `Finding`** + Swap `rego_stub` / `cedar_stub` for `policy_outputs: Vec`. Provide a deserialization shim that folds legacy `*_stub` fields into the new collection so existing findings files keep loading; the shim is part of Phase B, not a separate migration. ## Risks and migrations | Risk | Detail | Mitigation | |---|---|---| -| Persisted JSON findings drift | Users who store findings files will have a mix of `rego_stub`-only and `rego_stub` + `cedar_stub` records | Keep both fields; never remove `rego_stub` without a major-version bump | +| Persisted JSON findings drift | Users who store findings files will have a mix of `rego_stub`-only and `rego_stub` + `cedar_stub` records | Phase A keeps both fields. Phase B replaces them with `policy_outputs` and ships a deserialization shim that reads the legacy fields, so old findings files keep loading without a coordinated bump | | MCP tool-name contract | Agent hosts have `suggest_rego` / `validate_rego` wired up | Keep them as aliases indefinitely; new tools are opt-in | | Half-baked Cedar templates | Shipping Cedar coverage for some rules but not others reads as broken | Document Cedar coverage per-rule; CLI warns when extracting Cedar from rules that have no Cedar template | | `regorus` and `cedar-policy` binary size | Two policy engines linked into one binary | Both are Rust-native; combined overhead should be ~3–5MB. Acceptable. Revisit with `--features rego,cedar` if it bloats | @@ -133,3 +134,4 @@ Once Phase A has shipped and users have actually generated Cedar in anger: ## Decision log - **2026-05-03** — memo drafted alongside the user-facing rename from "Rego for OPA" to "Policy as Code (PaC)." No code changes proposed yet; doc captures the investigation while context is fresh. +- **2026-05-06** — Phase A landed (`feat: add Cedar as a second policy engine`). Phase B's "wait for users" gate and "JSON-schema break needs a major-version bump" gate dropped: the `*_stub` → `policy_outputs` migration is in Phase B's scope and ships with a deserialization shim, so it doesn't need a coordinated version cut. From edf2d74ea3ed3b05e1d6925736d289a1ffb5d249 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Wed, 6 May 2026 10:11:36 -0400 Subject: [PATCH 3/4] fix: render captured values in Cedar templates Several rule cedar_template blocks (mostly C#) hardcoded "TODO" instead of substituting captured values, while their Rego siblings expanded placeholders correctly. Output for those rules was strictly worse than the category-default stub. Use the captured placeholders, and add a cedar-flavored derived-vars helper (`add_cedar_template_derived_values` producing `cedar_roles_set`) so multi-role rules like `csharp-aspnet-authorize-roles` can emit a real Cedar set membership check. Wrap Cedar regexes in OnceLock and hoist `output_dir.canonicalize()` out of the per-file extract loop. --- .../aspnet-authorize-policy-shorthand.toml | 3 +- rules/csharp/aspnet-authorize-policy.toml | 3 +- rules/csharp/aspnet-authorize-roles.toml | 2 +- ...authorization-service-authorize-async.toml | 4 +- rules/csharp/has-claim-call.toml | 3 +- rules/java/shiro-requires-roles-array.toml | 2 +- rules/java/spring-roles-allowed-array.toml | 2 +- src/cedar/templates.rs | 37 ++++++++----- src/commands/extract.rs | 52 +++++++++++++------ src/scanner/matcher.rs | 40 ++++++++++++-- 10 files changed, 106 insertions(+), 42 deletions(-) diff --git a/rules/csharp/aspnet-authorize-policy-shorthand.toml b/rules/csharp/aspnet-authorize-policy-shorthand.toml index c34c8eb..bda5292 100644 --- a/rules/csharp/aspnet-authorize-policy-shorthand.toml +++ b/rules/csharp/aspnet-authorize-policy-shorthand.toml @@ -41,8 +41,7 @@ permit ( resource ) when { - // TODO: verify attribute check - principal.attr == "TODO" + context.policy == "{{policy}}" }; """ [[rule.tests]] diff --git a/rules/csharp/aspnet-authorize-policy.toml b/rules/csharp/aspnet-authorize-policy.toml index e35a48e..e4aaa05 100644 --- a/rules/csharp/aspnet-authorize-policy.toml +++ b/rules/csharp/aspnet-authorize-policy.toml @@ -42,8 +42,7 @@ permit ( resource ) when { - // TODO: verify attribute check - principal.attr == "TODO" + context.policy == "{{policy}}" }; """ [[rule.tests]] diff --git a/rules/csharp/aspnet-authorize-roles.toml b/rules/csharp/aspnet-authorize-roles.toml index d6d3b39..889a1ec 100644 --- a/rules/csharp/aspnet-authorize-roles.toml +++ b/rules/csharp/aspnet-authorize-roles.toml @@ -42,7 +42,7 @@ permit ( resource ) when { - principal.role == "TODO" + principal.role in [{{cedar_roles_set}}] }; """ [[rule.tests]] diff --git a/rules/csharp/authorization-service-authorize-async.toml b/rules/csharp/authorization-service-authorize-async.toml index 9a50f66..e0d513b 100644 --- a/rules/csharp/authorization-service-authorize-async.toml +++ b/rules/csharp/authorization-service-authorize-async.toml @@ -37,8 +37,8 @@ permit ( resource ) when { - // TODO: verify attribute check - principal.attr == "TODO" + // TODO: translate ASP.NET authorization service call: {{arguments}} + principal.authenticated == true }; """ [[rule.tests]] diff --git a/rules/csharp/has-claim-call.toml b/rules/csharp/has-claim-call.toml index a4f0346..be620c2 100644 --- a/rules/csharp/has-claim-call.toml +++ b/rules/csharp/has-claim-call.toml @@ -45,8 +45,7 @@ permit ( resource ) when { - // TODO: verify attribute check - principal.attr == "TODO" + principal.claims["{{claim_type}}"] == "{{claim_value}}" }; """ [[rule.tests]] diff --git a/rules/java/shiro-requires-roles-array.toml b/rules/java/shiro-requires-roles-array.toml index c0eb7c7..60d5b44 100644 --- a/rules/java/shiro-requires-roles-array.toml +++ b/rules/java/shiro-requires-roles-array.toml @@ -34,7 +34,7 @@ permit ( resource ) when { - principal.role == "TODO" + principal.role == "{{role_value}}" }; """ [[rule.tests]] diff --git a/rules/java/spring-roles-allowed-array.toml b/rules/java/spring-roles-allowed-array.toml index 53dcd15..8d5a34b 100644 --- a/rules/java/spring-roles-allowed-array.toml +++ b/rules/java/spring-roles-allowed-array.toml @@ -34,7 +34,7 @@ permit ( resource ) when { - principal.role == "TODO" + principal.role == "{{role_value}}" }; """ [[rule.tests]] diff --git a/src/cedar/templates.rs b/src/cedar/templates.rs index 8267f57..0079eab 100644 --- a/src/cedar/templates.rs +++ b/src/cedar/templates.rs @@ -4,24 +4,35 @@ //! comments and the `permit (...) when { ... };` policy form. use std::collections::HashMap; +use std::sync::OnceLock; use regex::Regex; use crate::types::{AuthCategory, Confidence}; +fn placeholder_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"\{\{(\w+)\}\}").unwrap()) +} + +fn string_literal_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r#"["']([^"']+)["']"#).unwrap()) +} + /// Render a Cedar template by replacing `{{key}}` placeholders with values. /// String values captured from tree-sitter are stripped of surrounding quotes. pub fn render_template(template: &str, vars: &HashMap) -> String { - let re = Regex::new(r"\{\{(\w+)\}\}").unwrap(); - re.replace_all(template, |caps: ®ex::Captures| { - let key = &caps[1]; - match vars.get(key) { - Some(val) if key.ends_with("_set") => val.to_string(), - Some(val) => strip_quotes(val).to_string(), - None => caps[0].to_string(), - } - }) - .to_string() + placeholder_re() + .replace_all(template, |caps: ®ex::Captures| { + let key = &caps[1]; + match vars.get(key) { + Some(val) if key.ends_with("_set") => val.to_string(), + Some(val) => strip_quotes(val).to_string(), + None => caps[0].to_string(), + } + }) + .to_string() } fn strip_quotes(s: &str) -> &str { @@ -40,8 +51,10 @@ fn strip_quotes(s: &str) -> &str { /// engine-specific extraction (e.g. principal/action splits) without /// destabilising the Rego path. pub fn extract_string_literals(code: &str) -> Vec { - let re = Regex::new(r#"["']([^"']+)["']"#).unwrap(); - re.captures_iter(code).map(|c| c[1].to_string()).collect() + string_literal_re() + .captures_iter(code) + .map(|c| c[1].to_string()) + .collect() } /// Return a default Cedar policy template for a given category. diff --git a/src/commands/extract.rs b/src/commands/extract.rs index 8b6d989..79027c2 100644 --- a/src/commands/extract.rs +++ b/src/commands/extract.rs @@ -66,13 +66,29 @@ fn extract_rego(findings: &mut [Finding], policy_prefix: &str, output_dir: &Path let rego_files = rego::group_findings(findings, policy_prefix, output_dir); + // Canonicalize once before writing any files. Each generated file shares + // the same `output_dir`, so canonicalizing per-file inside the loop was + // redundant work and a stray `output_dir` rename mid-loop would break + // anyway. + std::fs::create_dir_all(output_dir)?; + let canonical_output_dir = output_dir.canonicalize().map_err(|e| { + ZiftError::General(format!( + "failed to resolve output dir '{}': {e}", + output_dir.display() + )) + })?; + let mut total_files = 0; let mut validation_warnings = 0; for rego_file in ®o_files { let validation = rego::validator::validate_rego(®o_file.content); let status = if validation.valid { "OK" } else { "WARN" }; - write_policy_file(®o_file.output_path, ®o_file.content, output_dir)?; + write_policy_file( + ®o_file.output_path, + ®o_file.content, + &canonical_output_dir, + )?; total_files += 1; eprintln!( " [{status}] {} ({} findings) → {}", @@ -116,13 +132,25 @@ fn extract_cedar(findings: &mut [Finding], policy_prefix: &str, output_dir: &Pat let cedar_files = cedar::group_findings(findings, policy_prefix, output_dir); + std::fs::create_dir_all(output_dir)?; + let canonical_output_dir = output_dir.canonicalize().map_err(|e| { + ZiftError::General(format!( + "failed to resolve output dir '{}': {e}", + output_dir.display() + )) + })?; + let mut total_files = 0; let mut validation_warnings = 0; for cedar_file in &cedar_files { let validation = cedar::validator::validate_cedar(&cedar_file.content); let status = if validation.valid { "OK" } else { "WARN" }; - write_policy_file(&cedar_file.output_path, &cedar_file.content, output_dir)?; + write_policy_file( + &cedar_file.output_path, + &cedar_file.content, + &canonical_output_dir, + )?; total_files += 1; eprintln!( " [{status}] {} ({} findings) → {}", @@ -150,34 +178,28 @@ fn extract_cedar(findings: &mut [Finding], policy_prefix: &str, output_dir: &Pat /// Write a policy file with TOCTOU-safe path-traversal containment. /// Canonicalise the parent (which we just created) and verify it stays -/// inside `output_dir` before writing. -fn write_policy_file(output_path: &Path, content: &str, output_dir: &Path) -> Result<()> { - if let Some(parent) = output_path.parent() { - std::fs::create_dir_all(parent)?; - } - let canonical_output_dir = output_dir.canonicalize().map_err(|e| { - ZiftError::General(format!( - "failed to resolve output dir '{}': {e}", - output_dir.display() - )) - })?; +/// inside the already-canonicalized `output_dir` before writing. Callers +/// canonicalize `output_dir` once before the per-file loop and pass it in +/// here. +fn write_policy_file(output_path: &Path, content: &str, canonical_output_dir: &Path) -> Result<()> { let parent = output_path.parent().ok_or_else(|| { ZiftError::General(format!( "output path '{}' has no parent", output_path.display() )) })?; + std::fs::create_dir_all(parent)?; let canonical_parent = parent.canonicalize().map_err(|e| { ZiftError::General(format!( "failed to resolve parent dir '{}': {e}", parent.display() )) })?; - if !canonical_parent.starts_with(&canonical_output_dir) { + if !canonical_parent.starts_with(canonical_output_dir) { return Err(ZiftError::General(format!( "output path '{}' escapes output directory '{}'", output_path.display(), - output_dir.display() + canonical_output_dir.display() ))); } std::fs::write(output_path, content)?; diff --git a/src/scanner/matcher.rs b/src/scanner/matcher.rs index e993b0b..463bbb1 100644 --- a/src/scanner/matcher.rs +++ b/src/scanner/matcher.rs @@ -164,10 +164,11 @@ pub fn execute_query( crate::rego::render_template(tmpl, &owned) }), cedar_stub: compiled.rule.cedar_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_cedar_template_derived_values(&mut owned); crate::cedar::render_template(tmpl, &owned) }), pass: ScanPass::Structural, @@ -179,15 +180,26 @@ pub fn execute_query( } fn add_template_derived_values(vars: &mut HashMap) { + if let Some(roles) = vars.get("roles") { + vars.insert("roles_set".to_string(), comma_separated_quoted_items(roles)); + } +} + +/// Cedar-flavored counterpart to [`add_template_derived_values`]. Cedar's set +/// syntax is `[a, b]` (templates wrap the brackets) — the raw item list is +/// engine-neutral, but we keep this helper separate so the Rego and Cedar +/// derivation paths can diverge without dragging each other along (e.g. if +/// Cedar grows entity-type prefixes like `Role::"admin"` for its set items). +fn add_cedar_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), + "cedar_roles_set".to_string(), + comma_separated_quoted_items(roles), ); } } -fn comma_separated_rego_set_items(value: &str) -> String { +fn comma_separated_quoted_items(value: &str) -> String { value .trim() .trim_matches('"') @@ -374,6 +386,26 @@ mod tests { assert!(!findings.is_empty()); } + #[test] + fn csharp_authorize_roles_splits_comma_separated_roles_in_cedar() { + // Regression: the cedar_template path used to drop the captured + // role list and emit `principal.role == "TODO"`. With the + // `cedar_roles_set` derived var it should now expand to a Cedar + // set membership check matching the Rego sibling. + 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 cedar = findings[0].cedar_stub.as_deref().unwrap(); + assert!( + cedar.contains(r#"principal.role in ["Admin", "Manager"]"#), + "cedar should split ASP.NET comma-separated roles into a set; got: {cedar}" + ); + } + #[test] fn csharp_authorize_roles_splits_comma_separated_roles_in_rego() { let findings = parse_and_match( From 18eaa42fd5965a68465a0053ff93ef80fbbe7f0a Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Wed, 6 May 2026 10:51:21 -0400 Subject: [PATCH 4/4] fix: address PR review feedback - Replace hardcoded "TODO" with captured values in Cedar templates for java-authorized-annotation, java-has-role-call, py-has-perm-call. - Switch java array rules (shiro-requires-roles-array, spring-roles-allowed-array) to cedar_roles_set membership, mirroring the C# aspnet-authorize-roles approach. - Extend add_cedar_template_derived_values to derive cedar_roles_set from role_value when no roles capture exists. - Tighten string_literal_re to require matching quote delimiters via alternation, preventing mismatched-quote pairs. --- rules/java/authorized-annotation.toml | 2 +- rules/java/has-role-call.toml | 2 +- rules/java/shiro-requires-roles-array.toml | 2 +- rules/java/spring-roles-allowed-array.toml | 2 +- rules/python/has-perm-call.toml | 2 +- src/cedar/templates.rs | 22 ++++++++++++++++++++-- src/scanner/matcher.rs | 5 +++-- 7 files changed, 28 insertions(+), 9 deletions(-) diff --git a/rules/java/authorized-annotation.toml b/rules/java/authorized-annotation.toml index a7c57ee..e6402b6 100644 --- a/rules/java/authorized-annotation.toml +++ b/rules/java/authorized-annotation.toml @@ -48,7 +48,7 @@ permit ( resource ) when { - principal.role == "TODO" + principal.role == "{{role_value}}" }; """ [[rule.tests]] diff --git a/rules/java/has-role-call.toml b/rules/java/has-role-call.toml index 2808369..be374f3 100644 --- a/rules/java/has-role-call.toml +++ b/rules/java/has-role-call.toml @@ -44,7 +44,7 @@ permit ( resource ) when { - principal.role == "TODO" + principal.role == "{{role_value}}" }; """ [[rule.tests]] diff --git a/rules/java/shiro-requires-roles-array.toml b/rules/java/shiro-requires-roles-array.toml index 60d5b44..447be41 100644 --- a/rules/java/shiro-requires-roles-array.toml +++ b/rules/java/shiro-requires-roles-array.toml @@ -34,7 +34,7 @@ permit ( resource ) when { - principal.role == "{{role_value}}" + principal.role in [{{cedar_roles_set}}] }; """ [[rule.tests]] diff --git a/rules/java/spring-roles-allowed-array.toml b/rules/java/spring-roles-allowed-array.toml index 8d5a34b..a7f5f5d 100644 --- a/rules/java/spring-roles-allowed-array.toml +++ b/rules/java/spring-roles-allowed-array.toml @@ -34,7 +34,7 @@ permit ( resource ) when { - principal.role == "{{role_value}}" + principal.role in [{{cedar_roles_set}}] }; """ [[rule.tests]] diff --git a/rules/python/has-perm-call.toml b/rules/python/has-perm-call.toml index ecf67e4..6ba63b2 100644 --- a/rules/python/has-perm-call.toml +++ b/rules/python/has-perm-call.toml @@ -41,7 +41,7 @@ permit ( resource ) when { - principal.role == "TODO" + "{{perm_name}}" in principal.permissions }; """ [[rule.tests]] diff --git a/src/cedar/templates.rs b/src/cedar/templates.rs index 0079eab..cb5e3d1 100644 --- a/src/cedar/templates.rs +++ b/src/cedar/templates.rs @@ -17,7 +17,7 @@ fn placeholder_re() -> &'static Regex { fn string_literal_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r#"["']([^"']+)["']"#).unwrap()) + RE.get_or_init(|| Regex::new(r#""([^"]+)"|'([^']+)'"#).unwrap()) } /// Render a Cedar template by replacing `{{key}}` placeholders with values. @@ -53,7 +53,11 @@ fn strip_quotes(s: &str) -> &str { pub fn extract_string_literals(code: &str) -> Vec { string_literal_re() .captures_iter(code) - .map(|c| c[1].to_string()) + .filter_map(|c| { + c.get(1) + .or_else(|| c.get(2)) + .map(|m| m.as_str().to_string()) + }) .collect() } @@ -272,6 +276,20 @@ mod tests { assert_eq!(lits, vec!["admin", "manager"]); } + #[test] + fn extract_literals_mixed_quote_styles() { + let lits = extract_string_literals(r#"check("admin"); check('manager');"#); + assert_eq!(lits, vec!["admin", "manager"]); + } + + #[test] + fn extract_literals_skips_mismatched_quotes() { + // The old `["']([^"']+)["']` pattern would pair the opening `"` with + // the closing `'`; alternation enforces matching delimiters. + let lits = extract_string_literals(r#"check("foo')"#); + assert!(lits.is_empty()); + } + #[test] fn default_stub_rbac_role() { let stub = generate_default_stub(AuthCategory::Rbac, r#"if (user.role === "admin") {}"#); diff --git a/src/scanner/matcher.rs b/src/scanner/matcher.rs index 463bbb1..1cfad9e 100644 --- a/src/scanner/matcher.rs +++ b/src/scanner/matcher.rs @@ -191,10 +191,11 @@ fn add_template_derived_values(vars: &mut HashMap) { /// derivation paths can diverge without dragging each other along (e.g. if /// Cedar grows entity-type prefixes like `Role::"admin"` for its set items). fn add_cedar_template_derived_values(vars: &mut HashMap) { - if let Some(roles) = vars.get("roles") { + let source = vars.get("roles").or_else(|| vars.get("role_value")); + if let Some(value) = source { vars.insert( "cedar_roles_set".to_string(), - comma_separated_quoted_items(roles), + comma_separated_quoted_items(value), ); } }