mirror of
https://github.com/elicpeter/nyx.git
synced 2026-07-12 21:02:11 +02:00
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:
parent
79c29b394d
commit
82f18184b1
348 changed files with 48731 additions and 2925 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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! {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue