Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/supported-tech.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions packages/scanner/src/matchers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
44 changes: 44 additions & 0 deletions packages/scanner/src/matchers/rs-command-injection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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 = 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()?;`,
],
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: /(?<!::)\bprocess::Command::new\s*\(/,
label: "process::Command::new",
},
{
regex: /\bCommand::new\s*\(\s*"(?:sh|bash|zsh|cmd|powershell|pwsh)"/,
label: "Command::new with shell interpreter",
},
],
content,
);
},
};
40 changes: 40 additions & 0 deletions packages/scanner/src/matchers/rs-path-traversal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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(header.path()?);`,
],
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())",
},
],
content,
);
},
};
61 changes: 61 additions & 0 deletions packages/scanner/src/matchers/rs-ssrf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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?;`,
`let resp = client.get(&format!("https://{}/api", host)).send().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:
/\b(?:client|http|reqwest|ureq|surf|isahc|web|api)\.(?: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:
/\b(?:client|http|reqwest|ureq|surf|isahc|web|api)\.(?: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,
);
},
};
49 changes: 49 additions & 0 deletions packages/scanner/src/matchers/rs-tls-no-verify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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 {}`,
`impl rustls::client::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+(?:\w+::)*ServerCertVerifier\b/,
label: "hand-rolled ServerCertVerifier",
},
{
regex: /\bSslVerifyMode::NONE\b/,
label: "openssl SslVerifyMode::NONE",
},
],
content,
);
},
};
63 changes: 63 additions & 0 deletions packages/scanner/src/matchers/rs-untrusted-deserialization.ts
Original file line number Diff line number Diff line change
@@ -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 (bincode, rmp_serde, serde_json::from_reader, ciborium, postcard) without explicit size limits. Review for unbounded untrusted payloads — internal-trust-boundary callsites are expected false positives",
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,
);
},
};