Prerelease cleanup (#46)

* feat: Add const_bound_vars tracking to prevent false positives in ownership checks

* feat: Introduce field interner and typed bounded vars for enhanced type tracking

* feat: Add typed_call_receivers and typed_bounded_dto_fields for enhanced type tracking

* feat: Centralize method name extraction with bare_method_name helper

* feat: Implement Phase-6 hierarchy fan-out for runtime virtual dispatch

* feat: Enhance C++ taint tracking with additional container operations and inline method resolution

* feat: Introduce field-sensitive points-to analysis for enhanced resource tracking

* feat: Implement Pointer-Phase 6 subscript handling for enhanced container analysis

* test: Add comprehensive tests for JavaScript control flow constructs and lattice operations

* docs: Update advanced analysis documentation with field-sensitive points-to and hierarchy fan-out details

* test: Add comprehensive tests for lattice algebra laws and SSA edge cases

* feat: Add destructured session user handling and safe user ID access patterns

* feat: Implement row-population reverse-walk for enhanced authorization checks

* feat: Enhance authorization checks with local alias chain for self-actor types

* feat: Introduce ActiveRecord query safety checks and enhance snippet extraction

* feat: Implement chained method call inner-gate rebinding for SSRF prevention

* feat: Add observability and error modules, enhance debug functionality, and implement theme context

* feat: Remove Auth Analysis page and update navigation to redirect to Explorer

* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor

* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor

* feat: Reset path-safe-suppressed spans before lowering to maintain analysis integrity

* fix(ssa): ungate debug_assert_bfs_ordering for release-tests build

The helper at src/ssa/lower.rs was gated `#[cfg(debug_assertions)]` while
the unit test at the bottom of the file was gated only `#[cfg(test)]`.
Since `cfg(test)` is set in release builds with `--tests` but
`cfg(debug_assertions)` is not, `cargo build --release --tests` failed
with E0425. Removing the gate fixes the build; the body is `debug_assert!`
only, so the helper is free in release. Also drop the gate at the call
site to avoid a `dead_code` warning when the lib is built without
`--tests`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(closure-capture): flip JS/TS fixtures to required-finding

The JS and TS closure-capture fixtures pinned the old broken behaviour
via `forbidden_findings: [{ "id_prefix": "taint-" }]`. The engine now
correctly traces taint through the closure boundary (env source captured
by an arrow function, sunk via `child_process.exec` inside the body), so
the formerly-forbidden finding is a true positive.

Match the Python sibling's shape — `required_findings` with
`id_prefix` + `min_count` plus a small `noise_budget` — and rewrite the
companion READMEs and the phase8_fragility_tests doc-comments from
"known gap" to "regression guard".

Verified:
- cargo test --release --test phase8_fragility_tests → 8/8 pass
- cargo test --release --lib bfs_assertion → pass
- corpus benchmark F1 = 0.9976 (TP=205, FP=1, FN=0) — unchanged

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: Add OWASP mapping and baseline mutation hooks for enhanced security analysis

* feat: Introduce health module and enhance health score computation with calibration tests

* feat: Add expectations configuration and cleanup .gitignore for log files

* feat: Implement theme selection and enhance settings panel for triage sync

* feat: Suppress false positives for strcpy calls with literal sources in AST

* feat: Update analyse_function_ssa to return body CFG for accurate analysis

* feat: Add bug report and feature request templates for improved issue tracking

* feat: removed dev scripts

* feat: update README.md for clarity and consistency in fixture descriptions

* feat: removed dev docs

* feat: clean up error handling and UI elements for improved user experience

* feat: adjust button sizes in HeaderBar for better UI consistency

* feat: enhance taint analysis with additional context for sanitizer and taint findings

* cargo fmt

* prettier

* refactor: simplify conditional checks and improve code readability in AST and screenshot capture scripts

* feat: add script to frame PNG screenshots with brand gradient

* feat: add fuzzing support with new targets and CI workflows

* refactor: streamline match expressions and improve formatting in CLI and output handling

* feat: enhance configuration display with detailed output options

* feat: stage demo configuration for improved CLI screenshot output

* feat: expose merge_configs function for user-configurable settings

* refactor: simplify code structure and improve readability in config handling

* refactor: improve descriptions for vulnerability patterns in various languages

* feat: update MIT License section with additional usage details and copyright information

* feat: update screenshots

* refactor: update build process and paths for frontend assets

* feat: add cross-file taint fuzzing target and supporting dictionary

* refactor: clean up formatting and comments in fuzz configuration and example files

* refactor: remove outdated comments and clean up CI configuration files

* chore: update changelog dates and improve formatting in documentation

* refactor: update Cargo.toml and CI configuration for improved packaging and build process

* refactor: enhance quote-stripping logic to prevent panics and add regression tests

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eli Peter 2026-04-29 00:58:38 -04:00 committed by GitHub
parent 79c29b394d
commit 82f18184b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
348 changed files with 48731 additions and 2925 deletions

View file

@ -34,13 +34,19 @@ pub static RULES: &[LabelRule] = &[
case_sensitive: false,
},
// Type conversion sanitizers (C++ STL forms).
// The full `std::sto*` family (including 64-bit `*ll`/`*ull` and `*ld`)
// returns an integral or floating value; downstream string-injection
// caps no longer apply.
LabelRule {
matchers: &[
"std::stoi",
"std::stol",
"std::stoll",
"std::stoul",
"std::stoull",
"std::stof",
"std::stod",
"std::stold",
],
label: DataLabel::Sanitizer(Cap::all()),
case_sensitive: false,
@ -111,9 +117,19 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! {
"lambda_expression" => Kind::Function,
// Namespace bodies and C++ class bodies descend as plain Blocks so the
// CFG builder can reach the nested function_definitions/lambdas inside
// and extract them as separate bodies.
// and extract them as separate bodies. Without these, a
// `class_specifier` / `struct_specifier` falls through to the
// generic `_ =>` arm in `build_sub`, which records a leaf `Seq`
// node and never walks the body — so inline member-function
// definitions (and methods of nested classes) are silently dropped.
"declaration_list" => Kind::Block,
"field_declaration_list" => Kind::Block,
"class_specifier" => Kind::Block,
"struct_specifier" => Kind::Block,
"union_specifier" => Kind::Block,
"enum_specifier" => Kind::Block,
"template_declaration" => Kind::Block,
"linkage_specification" => Kind::Block,
// data-flow
"call_expression" => Kind::CallFn,

View file

@ -81,6 +81,13 @@ pub static RULES: &[LabelRule] = &[
"os.Create",
"ioutil.ReadFile",
"os.ReadFile",
// Mutating filesystem operations. Path-traversal CVEs commonly
// sink into delete/write rather than read (Owncast CVE-2024-31450
// sinks into `os.Remove(filepath.Join(root, userInput))`).
"os.Remove",
"os.RemoveAll",
"os.WriteFile",
"ioutil.WriteFile",
],
label: DataLabel::Sink(Cap::FILE_IO),
case_sensitive: false,
@ -94,10 +101,22 @@ pub static RULES: &[LabelRule] = &[
matchers: &[
"http.Get",
"http.Post",
"http.Head",
"http.NewRequest",
"http.NewRequestWithContext",
"net.Dial",
"net.DialTimeout",
// `http.DefaultClient` is the package-level default `*http.Client`.
// Idiomatic Go SSRF sinks (Owncast CVE-2023-3188) use the
// `http.DefaultClient.Get(url)` form rather than the bare
// `http.Get(url)` helper, so the suffix-matched callee text needs
// an explicit entry here — bare `Get/Post/Do/Head` would
// over-match unrelated method names.
"http.DefaultClient.Get",
"http.DefaultClient.Post",
"http.DefaultClient.Head",
"http.DefaultClient.Do",
"http.DefaultClient.PostForm",
],
label: DataLabel::Sink(Cap::SSRF),
case_sensitive: false,

View file

@ -505,6 +505,38 @@ pub static GATED_SINKS: &[SinkGate] = &[
object_destination_fields: &["host", "hostname", "path", "protocol", "port", "origin"],
},
},
// Node `http.get(options[, cb])` / `https.get(options[, cb])` —
// convenience wrappers around `.request()` that auto-call `.end()`.
// Same destination semantics as `.request`. Motivated by
// CVE-2025-64430 (Parse Server SSRF via http.get(uri)).
SinkGate {
callee_matcher: "http.get",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::SSRF),
case_sensitive: false,
payload_args: &[0],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &["host", "hostname", "path", "protocol", "port", "origin"],
},
},
SinkGate {
callee_matcher: "https.get",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::SSRF),
case_sensitive: false,
payload_args: &[0],
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &["host", "hostname", "path", "protocol", "port", "origin"],
},
},
];
pub static KINDS: Map<&'static str, Kind> = phf_map! {

View file

@ -5,7 +5,7 @@ mod java;
mod javascript;
mod php;
mod python;
mod ruby;
pub(crate) mod ruby;
mod rust;
mod typescript;
@ -689,9 +689,13 @@ fn ends_with_cs(haystack: &[u8], needle: &[u8], case_sensitive: bool) -> bool {
}
}
/// Prefix check with configurable case sensitivity.
/// Prefix check with configurable case sensitivity. The `=` exact-match
/// sigil is meaningless for prefix matchers (which by definition match many
/// suffixes); it is stripped if present so a malformed matcher like
/// `=foo_` still behaves predictably.
#[inline]
fn starts_with_cs(haystack: &[u8], needle: &[u8], case_sensitive: bool) -> bool {
let (needle, _) = unpack_matcher(needle);
if needle.len() > haystack.len() {
return false;
}
@ -708,14 +712,37 @@ fn starts_with_cs(haystack: &[u8], needle: &[u8], case_sensitive: bool) -> bool
/// Word-boundary suffix match with configurable case sensitivity.
#[inline]
fn match_suffix_cs(text: &[u8], matcher: &[u8], case_sensitive: bool) -> bool {
if ends_with_cs(text, matcher, case_sensitive) {
let start = text.len() - matcher.len();
start == 0 || matches!(text[start - 1], b'.' | b':')
let (m, exact_only) = unpack_matcher(matcher);
if ends_with_cs(text, m, case_sensitive) {
let start = text.len() - m.len();
if exact_only {
// `=foo` matchers fire only when `text` IS `foo` (no `Mod.foo`,
// `Class::foo`, or any preceding namespace). Lets a label rule
// distinguish bare `Kernel#open` from `File.open` — the former
// shells out on `|cmd`, the latter never does (CVE-2020-8130).
start == 0
} else {
start == 0 || matches!(text[start - 1], b'.' | b':')
}
} else {
false
}
}
/// Strip an optional `=` "exact-match" sigil from the start of a matcher.
/// Matchers prefixed with `=` (e.g. `"=open"`) only fire when the candidate
/// text equals the matcher exactly — the boundary-`.`-or-`:` allowance is
/// suppressed. Used to distinguish bare-callee Ruby/Python builtins from
/// methods of the same name on a typed receiver.
#[inline]
fn unpack_matcher(matcher: &[u8]) -> (&[u8], bool) {
if matcher.first() == Some(&b'=') {
(&matcher[1..], true)
} else {
(matcher, false)
}
}
/// Try to classify a piece of syntax text.
/// `lang` is the canonicalised language key ("rust", "javascript", ...).
///
@ -1063,6 +1090,29 @@ pub fn normalize_chained_call_for_classify(text: &str) -> String {
normalize_chained_call(text)
}
/// Return the bare method-name segment of a callee text.
///
/// Centralised replacement for the textual `callee.rsplit('.').next().unwrap_or(callee)`
/// pattern that used to be scattered across the codebase.
///
/// Behaviour-preserving across the Phase 2 SSA chain decomposition rollout:
/// - When SSA lowering rewrites a chained-receiver call (`c.mu.Lock()` →
/// `Call("Lock", [v_mu])`), the call's `callee` is already the bare method
/// name, so this helper is a no-op pass-through.
/// - For 1-dot callees (`obj.method`) and for languages where Phase 2 lowering
/// doesn't run yet (PHP/Ruby) the helper still extracts the trailing method
/// from the textual form, exactly as the old per-callsite split did.
/// - For bare callees (no dot), it returns the input unchanged.
///
/// Use this helper when you need the *terminal* method name from a callee
/// string regardless of whether the call had a chained receiver. When you
/// have an `SsaOp::Call` in hand, prefer reading `callee` directly and
/// walking `receiver` through `FieldProj` ops — that's the precise path.
/// This helper is the textual fallback for callsites that only see a `&str`.
pub fn bare_method_name(callee: &str) -> &str {
callee.rsplit('.').next().unwrap_or(callee)
}
/// Normalize a chained method call: strip `()` between `.` segments.
/// e.g. `r.URL.Query().Get` → `r.URL.Query.Get`
/// e.g. `r.URL.Query().Get("host")` → `r.URL.Query.Get`
@ -1260,6 +1310,26 @@ pub fn custom_rule_id(lang: &str, kind: &str, matchers: &[String]) -> String {
mod tests {
use super::*;
#[test]
fn bare_method_name_strips_chain() {
// No-dot input → returned as-is.
assert_eq!(bare_method_name("foo"), "foo");
// 1-dot → trailing segment (Phase 2 leaves these alone in SSA).
assert_eq!(bare_method_name("obj.method"), "method");
// Multi-dot → trailing segment (matches AST-only callees from
// PHP/Ruby and any pre-Phase-2 textual paths kept around in
// `callee_text` for display).
assert_eq!(bare_method_name("a.b.c.method"), "method");
// Trailing dot → empty trailing segment, matching the legacy
// `rsplit('.').next()` behaviour bit-for-bit.
assert_eq!(bare_method_name("foo."), "");
// Empty input.
assert_eq!(bare_method_name(""), "");
// Phase 2 invariant: when SSA decomposed a chain, `callee` is
// the bare method already and the helper is a no-op.
assert_eq!(bare_method_name("Lock"), "Lock");
}
#[test]
fn handler_param_names_exact_and_prefix() {
// Exact names still match.
@ -1376,6 +1446,115 @@ mod tests {
assert_eq!(result, None);
}
// CVE Hunt Session 2 (Go CVE-2024-31450 Owncast path traversal):
// mutating filesystem helpers (`os.Remove`, `os.WriteFile`,
// `os.RemoveAll`, `ioutil.WriteFile`) sink path-traversal flows that
// the prior Go ruleset only saw on the read side (`os.Open`,
// `os.ReadFile`).
#[test]
fn classify_go_os_remove_is_file_io_sink() {
let result = classify("go", "os.Remove", None);
assert_eq!(result, Some(DataLabel::Sink(Cap::FILE_IO)));
}
#[test]
fn classify_go_os_write_file_is_file_io_sink() {
let result = classify("go", "os.WriteFile", None);
assert_eq!(result, Some(DataLabel::Sink(Cap::FILE_IO)));
}
#[test]
fn classify_go_os_remove_all_is_file_io_sink() {
let result = classify("go", "os.RemoveAll", None);
assert_eq!(result, Some(DataLabel::Sink(Cap::FILE_IO)));
}
// CVE Hunt Session 2 (Go CVE-2023-3188 Owncast SSRF):
// `http.DefaultClient.Get/Post/Head/Do/PostForm` is the idiomatic Go
// SSRF sink shape (`http.DefaultClient` is the package-level shared
// `*http.Client`). Bare `Get`/`Post` matchers would over-match
// unrelated method names; the explicit `http.DefaultClient.*` matcher
// restricts the suffix-match to the stdlib helper while leaving
// user-defined `myClient.Get` alone (no false positives).
#[test]
fn classify_go_http_default_client_get_is_ssrf_sink() {
let result = classify("go", "http.DefaultClient.Get", None);
assert_eq!(result, Some(DataLabel::Sink(Cap::SSRF)));
}
#[test]
fn classify_go_http_default_client_post_is_ssrf_sink() {
let result = classify("go", "http.DefaultClient.Post", None);
assert_eq!(result, Some(DataLabel::Sink(Cap::SSRF)));
}
#[test]
fn classify_go_http_default_client_do_is_ssrf_sink() {
let result = classify("go", "http.DefaultClient.Do", None);
assert_eq!(result, Some(DataLabel::Sink(Cap::SSRF)));
}
#[test]
fn classify_go_user_client_get_is_not_ssrf_sink() {
// `client.Get` on a user-named *http.Client variable should NOT
// match — the Go SSRF set is restricted to the stdlib package
// helper `http.DefaultClient`. Type-aware resolution would be the
// path to a broader rule, not a bare-name match.
let result = classify("go", "client.Get", None);
assert_eq!(result, None);
}
// CVE Hunt Session 3 (Ruby CVE-2020-8130 rake `Kernel#open` CMDI):
// bare `open(path)` interprets a leading `|` as a shell pipe. The
// `=` exact-match sigil distinguishes the dangerous bare-callee form
// from `File.open` / `IO.open` / `URI.open`, each of which has its
// own non-piping semantics. Without the sigil, the suffix-with-
// boundary matcher would over-fire on every `X.open` call.
#[test]
fn classify_ruby_bare_open_is_shell_escape_sink() {
let result = classify("ruby", "open", None);
assert_eq!(result, Some(DataLabel::Sink(Cap::SHELL_ESCAPE)));
}
#[test]
fn classify_ruby_file_open_is_not_shell_escape_sink() {
// The exact-match sigil on `=open` must NOT fire on `File.open`.
// `File.open` is a separate FILE_IO sink (existing rule); the
// CMDI rule must not double-classify it.
let result = classify_all("ruby", "File.open", None);
// FILE_IO from the existing `File.open` matcher is allowed.
assert!(result.contains(&DataLabel::Sink(Cap::FILE_IO)));
// SHELL_ESCAPE from the new bare-`open` matcher must NOT appear.
assert!(!result.contains(&DataLabel::Sink(Cap::SHELL_ESCAPE)));
}
#[test]
fn classify_ruby_io_open_is_not_shell_escape_sink() {
// `IO.open` takes a file descriptor — never pipes. The bare-
// open CMDI rule must leave it alone.
let result = classify("ruby", "IO.open", None);
assert_ne!(result, Some(DataLabel::Sink(Cap::SHELL_ESCAPE)));
}
#[test]
fn classify_ruby_uri_open_remains_ssrf_sink() {
// `URI.open` is the existing SSRF sink. Adding `=open` as a
// CMDI rule must not break or shadow it.
let result = classify("ruby", "URI.open", None);
assert_eq!(result, Some(DataLabel::Sink(Cap::SSRF)));
}
#[test]
fn unpack_matcher_strips_exact_sigil() {
let (m, exact) = unpack_matcher(b"=open");
assert_eq!(m, b"open");
assert!(exact);
let (m, exact) = unpack_matcher(b"open");
assert_eq!(m, b"open");
assert!(!exact);
}
#[test]
fn classify_case_sensitive_suffix_boundary() {
let extras = vec![RuntimeLabelRule {
@ -1391,6 +1570,29 @@ mod tests {
assert_eq!(result, None);
}
#[test]
fn classify_cpp_sto_family_is_sanitizer() {
// Phase 1: full `std::sto*` family (including 64-bit and `long
// double` variants) clears every taint cap that flows through it,
// matching the existing `std::stoi`/`std::stol` rule.
for callee in [
"std::stoi",
"std::stol",
"std::stoll",
"std::stoul",
"std::stoull",
"std::stof",
"std::stod",
"std::stold",
] {
assert_eq!(
classify("cpp", callee, None),
Some(DataLabel::Sanitizer(Cap::all())),
"{callee} should be a Cap::all() sanitizer",
);
}
}
#[test]
fn parse_cap_works() {
assert_eq!(parse_cap("html_escape"), Some(Cap::HTML_ESCAPE));

View file

@ -73,6 +73,19 @@ pub static RULES: &[LabelRule] = &[
label: DataLabel::Sink(Cap::SHELL_ESCAPE),
case_sensitive: false,
},
// Bare `Kernel#open(path)` interprets a path beginning with `|` as a
// shell command (`open("|cmd")` runs `cmd`). `=open` exact-matcher
// syntax limits this rule to the bare call — `File.open`, `IO.open`,
// `URI.open` etc. each have their own non-pipe semantics and are
// covered by their own labels (or intentionally not labeled as CMDI).
// CVE-2020-8130 (rake `Rake::FileList#egrep`) was the canonical
// exploit: an attacker-supplied filename starting with `|` ran through
// `open(fn, "r")`. The fix replaced the call with `File.open(fn, "r")`.
LabelRule {
matchers: &["=open"],
label: DataLabel::Sink(Cap::SHELL_ESCAPE),
case_sensitive: false,
},
// Backtick shell execution: tree-sitter-ruby represents `` `cmd` `` as a
// `subshell` node with no callee field. push_node normalises the synthetic
// callee name to "subshell" and extract_arg_uses lifts interpolation
@ -225,6 +238,60 @@ pub static PARAM_CONFIG: ParamConfig = ParamConfig {
ident_fields: &["name"],
};
/// ActiveRecord query methods that the static [`RULES`] table classifies as
/// `Sink(Cap::SQL_QUERY)`. These are SQL injection vectors only when arg 0
/// is a string with interpolation (`#{x}`) or a non-literal identifier — the
/// hash form (`where(id: x)`) and the parameterised form (`where("a = ?", x)`)
/// are intrinsically safe because Rails escapes the values.
const AR_QUERY_METHOD_NAMES: &[&str] = &["where", "order", "group", "having", "joins", "pluck"];
/// Tree-sitter argument-0 node kinds that mark an ActiveRecord query call as
/// shape-safe. Hash literals (`pair`, `hash`), symbol literals
/// (`simple_symbol`, `hash_key_symbol`), array literals (`array`), and pure
/// string literals without `#{...}` interpolation are all safe. Strings WITH
/// interpolation and identifiers / method calls are *not* in this list —
/// callers must check `has_interpolation` and the kind separately.
const AR_QUERY_SAFE_ARG0_KINDS: &[&str] = &[
"pair",
"hash",
"simple_symbol",
"hash_key_symbol",
"array",
"string",
"string_literal",
];
/// Returns `true` when a Ruby `call` node is an ActiveRecord query method
/// (`where`, `order`, `pluck`, …) whose argument 0 has a parameter-safe shape.
///
/// Used by [`crate::cfg`] to synthesise a `Sanitizer(SQL_QUERY)` label on
/// the same node as the `Sink(SQL_QUERY)` label, suppressing both
/// `taint-unsanitised-flow` (sanitiser sees taint at the sink) and
/// `cfg-unguarded-sink` (sanitiser dominates the sink reflexively).
///
/// Real-world FP shapes this closes (redmine, mastodon, diaspora):
/// * `Issue.where(:id => params[:id])` — hash form
/// * `Model.where(id: x, name: y)` — keyword-shorthand pairs
/// * `Project.order(:created_at)` — symbol literal
/// * `Issue.pluck(:id, :name)` — symbol literals
/// * `Model.where("active = ?", x)` — parameterised string
///
/// Real-world TPs preserved:
/// * `User.where("name = '#{name}'")` — string with interpolation
/// * `Model.where(some_string_var)` — dynamic identifier (conservative)
pub fn ar_query_safe_shape(callee_text: &str, arg0_kind: &str, has_interpolation: bool) -> bool {
// Match the callee's last segment ("Model.where" → "where", "where" → "where").
let leaf = callee_text.rsplit(['.', ':']).next().unwrap_or(callee_text);
if !AR_QUERY_METHOD_NAMES.contains(&leaf) {
return false;
}
// Strings are safe only when they don't contain `#{...}` interpolation.
if matches!(arg0_kind, "string" | "string_literal") && has_interpolation {
return false;
}
AR_QUERY_SAFE_ARG0_KINDS.contains(&arg0_kind)
}
/// Framework-conditional rules for Ruby.
pub fn framework_rules(ctx: &FrameworkContext) -> Vec<RuntimeLabelRule> {
let mut rules = Vec::new();
@ -249,3 +316,61 @@ pub fn framework_rules(ctx: &FrameworkContext) -> Vec<RuntimeLabelRule> {
rules
}
#[cfg(test)]
mod ar_query_tests {
use super::ar_query_safe_shape;
#[test]
fn hash_form_is_safe() {
// Model.where(:id => x) — pair node directly in argument_list
assert!(ar_query_safe_shape("Model.where", "pair", false));
// Model.where(id: x)
assert!(ar_query_safe_shape("where", "pair", false));
}
#[test]
fn symbol_form_is_safe() {
assert!(ar_query_safe_shape("Project.order", "simple_symbol", false));
assert!(ar_query_safe_shape("Issue.pluck", "simple_symbol", false));
assert!(ar_query_safe_shape("Model.joins", "simple_symbol", false));
}
#[test]
fn parameterised_string_is_safe() {
// Model.where("a = ?", x) — first arg is a string literal w/o interpolation
assert!(ar_query_safe_shape("where", "string", false));
assert!(ar_query_safe_shape("where", "string_literal", false));
}
#[test]
fn interpolated_string_is_dangerous() {
// Model.where("a = #{x}") — string node WITH interpolation child
assert!(!ar_query_safe_shape("where", "string", true));
}
#[test]
fn dynamic_identifier_is_dangerous() {
// Model.where(some_var) — kind is identifier, not in safe list
assert!(!ar_query_safe_shape("where", "identifier", false));
}
#[test]
fn array_form_is_safe() {
// Model.pluck([:id, :name]) — uncommon but valid
assert!(ar_query_safe_shape("pluck", "array", false));
}
#[test]
fn non_ar_method_is_never_suppressed() {
// find_by_sql is a real raw-SQL sink — never suppress.
assert!(!ar_query_safe_shape("find_by_sql", "string", false));
assert!(!ar_query_safe_shape("connection.execute", "pair", false));
}
#[test]
fn callee_with_module_path_resolves_leaf() {
assert!(ar_query_safe_shape("Foo::Bar.where", "pair", false));
assert!(ar_query_safe_shape("a.b.c.where", "pair", false));
}
}