diff --git a/rules/typescript/identifier-includes-check.toml b/rules/typescript/identifier-includes-check.toml new file mode 100644 index 0000000..3605a8c --- /dev/null +++ b/rules/typescript/identifier-includes-check.toml @@ -0,0 +1,110 @@ +[rule] +id = "ts-identifier-includes-check" +languages = ["typescript", "javascript"] +category = "rbac" +confidence = "high" +description = "Role or group collection inclusion check on a bare identifier (e.g., userGroups.includes(\"admin\"))" +query = """ +(call_expression + function: (member_expression + object: (identifier) @collection + property: (property_identifier) @method) + arguments: (arguments + (string) @role_value) +) @match +""" + +[rule.predicates.collection] +match = "^(roles?|groups?|userRoles|userGroups)$" + +[rule.predicates.method] +match = "^(includes|indexOf|contains|some|has)$" + +[rule.rego_template] +template = """ +default allow := false + +allow if { + "{{role_value}}" in input.user.roles +} +""" + + +[rule.cedar_template] +template = """ +permit ( + principal, + action, + resource +) +when { + principal.roles.contains("{{role_value}}") +}; +""" + +[[rule.tests]] +input = """ +if (userGroups.includes("manager")) { + allow(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (groups.indexOf("admin") > -1) { + allow(); +} +""" +expect_match = true + +[[rule.tests]] +input = """ +if (permissions.includes("write")) { + allow(); +} +""" +expect_match = false + +[[rule.tests]] +input = """ +if (claims.has("customer")) { + allow(); +} +""" +expect_match = false +language = "javascript" + +[[rule.tests]] +input = """ +if (scope.includes("read:billing")) { + allow(); +} +""" +expect_match = false +language = "javascript" + +[[rule.tests]] +input = """ +if (scopes.has("write:billing")) { + allow(); +} +""" +expect_match = false +language = "javascript" + +[[rule.tests]] +input = """ +if (cache.has("key")) { + hit(); +} +""" +expect_match = false + +[[rule.tests]] +input = """ +if (tags.includes("foo")) { + filter(); +} +""" +expect_match = false diff --git a/src/rules/embedded.rs b/src/rules/embedded.rs index ef50d17..1400d83 100644 --- a/src/rules/embedded.rs +++ b/src/rules/embedded.rs @@ -12,6 +12,10 @@ const EMBEDDED_RULES: &[(&str, &str)] = &[ "role-includes-check", include_str!("../../rules/typescript/role-includes-check.toml"), ), + ( + "identifier-includes-check", + include_str!("../../rules/typescript/identifier-includes-check.toml"), + ), ( "has-role-call", include_str!("../../rules/typescript/has-role-call.toml"), diff --git a/src/scanner/matcher.rs b/src/scanner/matcher.rs index 1cfad9e..0f753d7 100644 --- a/src/scanner/matcher.rs +++ b/src/scanner/matcher.rs @@ -483,6 +483,36 @@ public IActionResult Reports() => Ok();"#, assert!(!findings.is_empty()); } + #[test] + fn identifier_includes_check_is_role_shaped_only() { + let findings = parse_and_match( + r#"if (userGroups.includes("manager")) { allow(); }"#, + include_str!("../../rules/typescript/identifier-includes-check.toml"), + ); + + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].category, crate::types::AuthCategory::Rbac); + let rego = findings[0].rego_stub.as_deref().unwrap(); + assert!( + rego.contains(r#""manager" in input.user.roles"#), + "rego should use a role membership check; got: {rego}" + ); + let cedar = findings[0].cedar_stub.as_deref().unwrap(); + assert!( + cedar.contains(r#"principal.roles.contains("manager")"#), + "cedar should use a role membership check; got: {cedar}" + ); + + let permission_findings = parse_and_match( + r#"if (permissions.includes("write")) { allow(); }"#, + include_str!("../../rules/typescript/identifier-includes-check.toml"), + ); + assert!( + permission_findings.is_empty(), + "bare permission collections need a permission-shaped rule" + ); + } + // -- Java rule tests -- fn parse_and_match_java(source: &str, rule_toml: &str) -> Vec {