From 8b8ab933b825fb3d48f3d3ac2b3df0fa16fc9e6b Mon Sep 17 00:00:00 2001 From: Jorge Rios Date: Mon, 11 May 2026 01:44:27 -0300 Subject: [PATCH 1/3] feat(scanner): generic Rust always-on matchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds five always-on Rust matchers gated only on the `rust` tech tag, mirroring the existing Go always-on set. Rust matchers up to now were all framework-gated (axum, actix, rocket, …), so libraries and non-web services with a Cargo.toml got no Rust coverage at all. - rs-command-injection: std::process::Command, tokio::process::Command, and shell-interpreter forms. - rs-path-traversal: zip-slip / tar-slip — Path::join / PathBuf::push combined with entry.name(), entry.path(), header.path(), or a zip/ tar/archive identifier on the same line. - rs-ssrf: reqwest / ureq / hyper / surf / isahc with formatted or concatenated URLs, Url::parse(&format!(...)), Request::builder() .uri(&format!(...)). - rs-tls-no-verify: danger_accept_invalid_certs / hostnames, rustls dangerous_configuration, hand-rolled ServerCertVerifier, openssl SslVerifyMode::NONE. - rs-untrusted-deserialization: bincode, rmp_serde, serde_json:: from_reader, ciborium, postcard — no built-in size limits. --- packages/scanner/src/matchers/index.ts | 10 +++ .../src/matchers/rs-command-injection.ts | 43 +++++++++++++ .../scanner/src/matchers/rs-path-traversal.ts | 44 +++++++++++++ packages/scanner/src/matchers/rs-ssrf.ts | 58 +++++++++++++++++ .../scanner/src/matchers/rs-tls-no-verify.ts | 48 ++++++++++++++ .../matchers/rs-untrusted-deserialization.ts | 63 +++++++++++++++++++ 6 files changed, 266 insertions(+) create mode 100644 packages/scanner/src/matchers/rs-command-injection.ts create mode 100644 packages/scanner/src/matchers/rs-path-traversal.ts create mode 100644 packages/scanner/src/matchers/rs-ssrf.ts create mode 100644 packages/scanner/src/matchers/rs-tls-no-verify.ts create mode 100644 packages/scanner/src/matchers/rs-untrusted-deserialization.ts diff --git a/packages/scanner/src/matchers/index.ts b/packages/scanner/src/matchers/index.ts index e86ba78..a08d7b8 100644 --- a/packages/scanner/src/matchers/index.ts +++ b/packages/scanner/src/matchers/index.ts @@ -168,12 +168,17 @@ import { rceMatcher } from "./rce.js"; import { responseHeaderLeakMatcher } from "./response-header-leak.js"; import { rsActixRouteMatcher } from "./rs-actix-route.js"; import { rsAxumRouteMatcher } from "./rs-axum-route.js"; +import { rsCommandInjectionMatcher } from "./rs-command-injection.js"; import { rsLambdaRuntimeMatcher } from "./rs-lambda-runtime.js"; +import { rsPathTraversalMatcher } from "./rs-path-traversal.js"; import { rsPoemRouteMatcher } from "./rs-poem-route.js"; import { rsRocketRouteMatcher } from "./rs-rocket-route.js"; import { rsSqlRawMatcher } from "./rs-sql-raw.js"; +import { rsSsrfMatcher } from "./rs-ssrf.js"; import { rsTideRouteMatcher } from "./rs-tide-route.js"; +import { rsTlsNoVerifyMatcher } from "./rs-tls-no-verify.js"; import { rsTonicGrpcMatcher } from "./rs-tonic-grpc.js"; +import { rsUntrustedDeserializationMatcher } from "./rs-untrusted-deserialization.js"; import { rsWarpFilterMatcher } from "./rs-warp-filter.js"; import { sandboxRuntimeScriptMatcher } from "./sandbox-runtime-script.js"; import { secretEnvVarMatcher } from "./secret-env-var.js"; @@ -424,6 +429,11 @@ export function createDefaultRegistry(): MatcherRegistry { registry.register(rsPoemRouteMatcher); registry.register(rsTonicGrpcMatcher); registry.register(rsLambdaRuntimeMatcher); + registry.register(rsCommandInjectionMatcher); + registry.register(rsPathTraversalMatcher); + registry.register(rsSsrfMatcher); + registry.register(rsTlsNoVerifyMatcher); + registry.register(rsUntrustedDeserializationMatcher); // JVM registry.register(jvmSpringControllerMatcher); registry.register(jvmKtorRouteMatcher); diff --git a/packages/scanner/src/matchers/rs-command-injection.ts b/packages/scanner/src/matchers/rs-command-injection.ts new file mode 100644 index 0000000..45bc002 --- /dev/null +++ b/packages/scanner/src/matchers/rs-command-injection.ts @@ -0,0 +1,43 @@ +import type { MatcherPlugin } from "../types.js"; +import { regexMatcher } from "./utils.js"; + +export const rsCommandInjectionMatcher: MatcherPlugin = { + noiseTier: "precise" as const, + slug: "rs-command-injection", + description: "Rust std::process / tokio::process Command with potentially dynamic arguments", + filePatterns: ["**/*.rs"], + requires: { tech: ["rust"] }, + examples: [ + `let output = std::process::Command::new("ls").arg(dir).output()?;`, + `let child = Command::new("git").arg("clone").arg(repo_url).spawn()?;`, + `tokio::process::Command::new("sh").arg("-c").arg(user_input).status().await?;`, + `Command::new("bash").arg("-c").arg(cmd).output()?;`, + `let out = std::process::Command::new("pwsh").args(&args).output()?;`, + ], + match(content, filePath) { + if (/\/(tests|examples|benches)\//.test(filePath)) return []; + + return regexMatcher( + "rs-command-injection", + [ + { + regex: /\bstd::process::Command::new\s*\(/, + label: "std::process::Command::new", + }, + { + regex: /\btokio::process::Command::new\s*\(/, + label: "tokio::process::Command::new", + }, + { + regex: /\bCommand::new\s*\(\s*"(?:sh|bash|zsh|cmd|powershell|pwsh)"/, + label: "Command::new with shell interpreter", + }, + { + regex: /\bCommand::new\s*\(/, + label: "Command::new", + }, + ], + content, + ); + }, +}; diff --git a/packages/scanner/src/matchers/rs-path-traversal.ts b/packages/scanner/src/matchers/rs-path-traversal.ts new file mode 100644 index 0000000..4081c1a --- /dev/null +++ b/packages/scanner/src/matchers/rs-path-traversal.ts @@ -0,0 +1,44 @@ +import type { MatcherPlugin } from "../types.js"; +import { regexMatcher } from "./utils.js"; + +export const rsPathTraversalMatcher: MatcherPlugin = { + noiseTier: "normal" as const, + slug: "rs-path-traversal", + description: + "Rust archive extraction joining untrusted entry names into a base path — zip-slip / tar-slip (CVE-2025-29787 class)", + filePatterns: ["**/*.rs"], + requires: { tech: ["rust"] }, + examples: [ + `let out = base.join(entry.name());`, + `let path = dest.join(entry.path()?);`, + `target.push(header.path()?);`, + `for entry in archive.entries()? { let p = root.join(entry.path()?); }`, + `let dest = output_dir.join(zip_entry.name());`, + ], + match(content, filePath) { + if (/\/(tests|examples|benches)\//.test(filePath)) return []; + + return regexMatcher( + "rs-path-traversal", + [ + { + regex: /\.join\s*\(\s*[^)]*\bentry\.(?:name|path)\s*\(/, + label: "Path::join(entry.name()/path())", + }, + { + regex: /\.join\s*\(\s*[^)]*\bheader\.path\s*\(/, + label: "Path::join(header.path())", + }, + { + regex: /\.push\s*\(\s*[^)]*\b(?:entry|header)\.(?:name|path)\s*\(/, + label: "PathBuf::push(entry/header.path())", + }, + { + regex: /\.join\s*\([^)]*\b(?:zip|tar|archive)[_a-zA-Z]*\b/, + label: ".join(...) with zip/tar/archive identifier", + }, + ], + content, + ); + }, +}; diff --git a/packages/scanner/src/matchers/rs-ssrf.ts b/packages/scanner/src/matchers/rs-ssrf.ts new file mode 100644 index 0000000..a1cbb10 --- /dev/null +++ b/packages/scanner/src/matchers/rs-ssrf.ts @@ -0,0 +1,58 @@ +import type { MatcherPlugin } from "../types.js"; +import { regexMatcher } from "./utils.js"; + +export const rsSsrfMatcher: MatcherPlugin = { + noiseTier: "normal" as const, + slug: "rs-ssrf", + description: + "Rust HTTP clients (reqwest, ureq, hyper, surf, isahc) issuing requests against formatted or concatenated URLs — SSRF risk", + filePatterns: ["**/*.rs"], + requires: { tech: ["rust"] }, + examples: [ + `let resp = reqwest::get(&format!("https://{}/api", host)).await?;`, + `let resp = client.post(&format!("{}/users/{}", base_url, id)).send().await?;`, + `let body = ureq::get(&(base_url.to_owned() + path)).call()?;`, + `let url = Url::parse(&format!("https://{}/v1", target))?;`, + `let req = Request::builder().uri(&format!("https://{}/api", host)).body(())?;`, + `let resp = surf::get(base.to_string() + path).await?;`, + ], + match(content, filePath) { + if (/\/(tests|examples|benches)\//.test(filePath)) return []; + + return regexMatcher( + "rs-ssrf", + [ + { + regex: + /\b(?:reqwest|ureq|surf|isahc)::(?:get|post|put|patch|delete|head)\s*\(\s*&?format!\s*\(/, + label: "http client verb with format! URL", + }, + { + regex: /\.(?:get|post|put|patch|delete|head)\s*\(\s*&?format!\s*\(/, + label: "client.verb(&format!(...))", + }, + { + regex: /\b(?:reqwest|ureq|surf|isahc)::(?:get|post|put|patch|delete|head)\s*\(.*?\+\s*\w/, + label: "http client verb with concatenated URL", + }, + { + regex: /\.(?:get|post|put|patch|delete|head)\s*\(.*?\+\s*\w/, + label: "client.verb(... + path)", + }, + { + regex: /\bUrl::parse\s*\(\s*&?format!\s*\(/, + label: "Url::parse(&format!(...))", + }, + { + regex: /\bRequest::builder\s*\(\s*\)\s*\.uri\s*\(\s*&?format!\s*\(/, + label: "Request::builder().uri(&format!(...))", + }, + { + regex: /format!\s*\(\s*"https?:\/\//, + label: 'URL built via format!("http...")', + }, + ], + content, + ); + }, +}; diff --git a/packages/scanner/src/matchers/rs-tls-no-verify.ts b/packages/scanner/src/matchers/rs-tls-no-verify.ts new file mode 100644 index 0000000..e662dbf --- /dev/null +++ b/packages/scanner/src/matchers/rs-tls-no-verify.ts @@ -0,0 +1,48 @@ +import type { MatcherPlugin } from "../types.js"; +import { regexMatcher } from "./utils.js"; + +export const rsTlsNoVerifyMatcher: MatcherPlugin = { + noiseTier: "precise" as const, + slug: "rs-tls-no-verify", + description: + "Rust TLS verification disabled — danger_accept_invalid_certs/hostnames, rustls dangerous_configuration, custom ServerCertVerifier, openssl SslVerifyMode::NONE", + filePatterns: ["**/*.rs"], + requires: { tech: ["rust"] }, + examples: [ + `let client = reqwest::Client::builder().danger_accept_invalid_certs(true).build()?;`, + `let client = reqwest::Client::builder().danger_accept_invalid_hostnames(true).build()?;`, + `let cfg = ClientConfig::builder().with_safe_defaults().dangerous_configuration();`, + `impl ServerCertVerifier for NoVerify {}`, + `builder.set_verify(SslVerifyMode::NONE);`, + ], + match(content, filePath) { + if (/\/(tests|examples|benches)\//.test(filePath)) return []; + + return regexMatcher( + "rs-tls-no-verify", + [ + { + regex: /\.danger_accept_invalid_certs\s*\(\s*true\s*\)/, + label: "reqwest .danger_accept_invalid_certs(true)", + }, + { + regex: /\.danger_accept_invalid_hostnames\s*\(\s*true\s*\)/, + label: "reqwest .danger_accept_invalid_hostnames(true)", + }, + { + regex: /\bdangerous_configuration\b|\bDangerousClientConfig\b/, + label: "rustls dangerous_configuration", + }, + { + regex: /\bimpl\s+ServerCertVerifier\b/, + label: "hand-rolled ServerCertVerifier", + }, + { + regex: /\bSslVerifyMode::NONE\b/, + label: "openssl SslVerifyMode::NONE", + }, + ], + content, + ); + }, +}; diff --git a/packages/scanner/src/matchers/rs-untrusted-deserialization.ts b/packages/scanner/src/matchers/rs-untrusted-deserialization.ts new file mode 100644 index 0000000..fe4b45f --- /dev/null +++ b/packages/scanner/src/matchers/rs-untrusted-deserialization.ts @@ -0,0 +1,63 @@ +import type { MatcherPlugin } from "../types.js"; +import { regexMatcher } from "./utils.js"; + +export const rsUntrustedDeserializationMatcher: MatcherPlugin = { + noiseTier: "normal" as const, + slug: "rs-untrusted-deserialization", + description: + "Rust binary/streaming deserializers without explicit size limits — bincode, rmp_serde, serde_json::from_reader, ciborium, postcard", + filePatterns: ["**/*.rs"], + requires: { tech: ["rust"] }, + examples: [ + `let value: Payload = bincode::deserialize(&bytes)?;`, + `let (value, _): (Payload, _) = bincode::decode_from_slice(&bytes, config::standard())?;`, + `let value: Payload = bincode::decode_from_std_read(&mut reader, config::standard())?;`, + `let value: Payload = rmp_serde::from_slice(&bytes)?;`, + `let value: Payload = rmp_serde::from_read(reader)?;`, + `let value: Payload = serde_json::from_reader(reader)?;`, + `let value: Payload = ciborium::from_reader(reader)?;`, + `let value: Payload = postcard::from_bytes(&bytes)?;`, + ], + match(content, filePath) { + if (/\/(tests|examples|benches)\//.test(filePath)) return []; + + return regexMatcher( + "rs-untrusted-deserialization", + [ + { + regex: /\bbincode::deserialize\s*\(/, + label: "bincode::deserialize", + }, + { + regex: /\bbincode::decode_from_slice\s*\(/, + label: "bincode::decode_from_slice", + }, + { + regex: /\bbincode::decode_from_std_read\s*\(/, + label: "bincode::decode_from_std_read", + }, + { + regex: /\brmp_serde::from_slice\s*\(/, + label: "rmp_serde::from_slice", + }, + { + regex: /\brmp_serde::from_read\s*\(/, + label: "rmp_serde::from_read", + }, + { + regex: /\bserde_json::from_reader\s*\(/, + label: "serde_json::from_reader", + }, + { + regex: /\bciborium::from_reader\s*\(/, + label: "ciborium::from_reader", + }, + { + regex: /\bpostcard::from_bytes\s*\(/, + label: "postcard::from_bytes", + }, + ], + content, + ); + }, +}; From 378c75168c6a70dd39f6c7761b41bbd643a45726 Mon Sep 17 00:00:00 2001 From: Jorge Rios Date: Mon, 11 May 2026 01:44:33 -0300 Subject: [PATCH 2/3] docs(supported-tech): document generic Rust always-on matchers Replaces the "dedicated matchers are roadmap" note in the Rust section with a framework-gated list plus a Generic Rust (`rust`) subsection, mirroring the shape of the Generic Go (`go`) entry. --- docs/supported-tech.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/supported-tech.md b/docs/supported-tech.md index 58bc428..d279ff9 100644 --- a/docs/supported-tech.md +++ b/docs/supported-tech.md @@ -160,8 +160,17 @@ dedicated matchers (gRPC service impl already partially covered). ## Rust +### Framework-gated matchers Detection emits tags (`actix`, `axum`, `rocket`, `warp`, `tide`, `poem`, -`tonic`, `lambda-rs`) but dedicated matchers are roadmap. +`tonic`, `lambda-rs`); the matching `rs-actix-route`, `rs-axum-route`, +`rs-rocket-route`, `rs-warp-filter`, `rs-tide-route`, `rs-poem-route`, +`rs-tonic-grpc`, and `rs-lambda-runtime` matchers activate only when +their tag is detected. + +### Generic Rust (`rust`) +Always-on Rust matchers regardless of framework: `rs-sql-raw`, +`rs-command-injection`, `rs-path-traversal`, `rs-ssrf`, +`rs-tls-no-verify`, `rs-untrusted-deserialization`. ## JVM (Java / Kotlin) From 875021659651e440efe0c9e25da7be7f36bcb334 Mon Sep 17 00:00:00 2001 From: Jorge Rios Date: Mon, 11 May 2026 01:53:26 -0300 Subject: [PATCH 3/3] fix(scanner): tighten rs-ssrf / rs-tls-no-verify / rs-path-traversal / rs-command-injection --- packages/scanner/src/matchers/rs-command-injection.ts | 11 ++++++----- packages/scanner/src/matchers/rs-path-traversal.ts | 6 +----- packages/scanner/src/matchers/rs-ssrf.ts | 7 +++++-- packages/scanner/src/matchers/rs-tls-no-verify.ts | 3 ++- .../src/matchers/rs-untrusted-deserialization.ts | 2 +- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/scanner/src/matchers/rs-command-injection.ts b/packages/scanner/src/matchers/rs-command-injection.ts index 45bc002..6ec3469 100644 --- a/packages/scanner/src/matchers/rs-command-injection.ts +++ b/packages/scanner/src/matchers/rs-command-injection.ts @@ -9,8 +9,9 @@ export const rsCommandInjectionMatcher: MatcherPlugin = { requires: { tech: ["rust"] }, examples: [ `let output = std::process::Command::new("ls").arg(dir).output()?;`, - `let child = Command::new("git").arg("clone").arg(repo_url).spawn()?;`, + `let child = std::process::Command::new("git").arg("clone").arg(repo_url).spawn()?;`, `tokio::process::Command::new("sh").arg("-c").arg(user_input).status().await?;`, + `let child = process::Command::new("rsync").arg(src).arg(dst).spawn()?;`, `Command::new("bash").arg("-c").arg(cmd).output()?;`, `let out = std::process::Command::new("pwsh").args(&args).output()?;`, ], @@ -29,12 +30,12 @@ export const rsCommandInjectionMatcher: MatcherPlugin = { label: "tokio::process::Command::new", }, { - regex: /\bCommand::new\s*\(\s*"(?:sh|bash|zsh|cmd|powershell|pwsh)"/, - label: "Command::new with shell interpreter", + regex: /(?