On this page
- Why Rust SAST has been pattern-matching theatre
- What just shipped, in one screen
- SQL injection, across the four major Rust drivers
- sqlx, compile-time-checked async queries
- diesel, the typed ORM
- rusqlite, embedded SQLite
- tokio-postgres, the async PostgreSQL client
- Cross-Site Scripting (XSS) on actix-web, axum and rocket
- CORS misconfigurations on Rust HTTP servers
- Server-Side Request Forgery (SSRF) in async Rust
- Dataflow vs pattern matching, a side by side
- Inside the agent loop, not just in the dashboard
- What's next for Rust at CybeDefend
- Get the Rust ruleset
Most static analysis tools pretend to do Rust. They run a regex catalogue, flag a list of dangerous function names, and call it coverage. The actual job, following user input through async traits, generic database connections, derived macros and middleware chains until it reaches a SQL query or an HTML response, has been left undone. Today we shipped the first dataflow-aware Rust ruleset: end-to-end taint tracking across the four major database drivers (sqlx, diesel, rusqlite, tokio-postgres), plus XSS, CORS and SSRF detection. All of it lives in our Security Code Knowledge Graph and gets pushed into every AI coding agent at prompt time.
Production Rust has grown up. Cloudflare runs Pingora on every request. Discord rewrote Read States. AWS ships Firecracker, Bottlerocket and an ever-growing list of services in Rust. Stripe runs Rust on payment paths. Linkerd, Polkadot, Solana, Tauri, the list keeps growing every month. Meanwhile, the static analysis market caught up by adding *.rs to the file glob and calling the job done.
That gap is not academic. It is where vulnerabilities live.
Why Rust SAST has been pattern-matching theatre
Static analysis on Rust is hard for reasons that do not show up in the marketing copy of the legacy SAST vendors.
Lifetimes. The lifetime of &str in a SQL statement determines whether a tainted reference is even alive when the query runs. Pattern scanners ignore lifetime annotations because they cannot reason about them; they treat every &str as if it were String. The result is either a flood of false positives or a quiet miss.
Async. Every modern Rust web service is built on tokio or async-std. User input arrives in an async fn and gets passed through .await chains, through Pin<Box<dyn Future>> adapters, through trait-object middlewares. A pattern scanner sees the .await and stops following the value. Real taint analysis has to keep going.
Generics. sqlx::query(&str) is generic over the connection type, the database backend and the row type. A typical query goes through sqlx::QueryBuilder<'_, Postgres> and is .execute(&mut conn) where conn: &mut PoolConnection<Postgres>. A naive scanner does not know that &mut PoolConnection<Postgres> and &mut PgConnection are the same kind of thing for the security model. Our graph normalises both to a "sql sink" node and follows the taint through them.
Derived macros. #[derive(sqlx::FromRow)], #[derive(diesel::Queryable)], the actix_web::Responder blanket impls: macros expand into code that pattern scanners never see. We expand the macros first, then walk the resulting graph. Otherwise the actual sink is invisible.
Trait objects. A scanner that pattern-matches actix_web::HttpResponse::Ok().body(x) misses the same data going out through impl Responder returned by a handler. Different syntactic shape, identical security property.
Stack these together and Rust becomes a language where pattern matching produces report after report of false positives on lines that are obviously safe, while quietly missing the lines that are not. Teams stop trusting the report. The scanner gets disabled in CI. We have watched that play out in detail at four customer sites this year alone.
The fix is not "more patterns." The fix is dataflow.
What just shipped, in one screen
The Rust ruleset is live on every CybeDefend region as of today. It covers:
- SQL injection across the four major Rust drivers, sqlx, diesel, rusqlite, tokio-postgres, with dataflow following user input through async functions, generic connection types and macro-derived query helpers.
- Cross-Site Scripting (XSS) on the three mainstream Rust web frameworks, actix-web, axum, rocket, including HTML responses produced via the
askamaandteratemplate engines. - CORS misconfigurations: wildcard
Access-Control-Allow-Origincombined with credentialed requests, missing or unsafe preflight handling, dangerously permissive origin echoes. - Server-Side Request Forgery (SSRF) in async HTTP clients:
reqwest,ureq,hyperandsurf, with taint propagation through every URL builder helper.
Every finding ships with the full data path through your code: source location, every intermediate hop, sink location. No "vulnerability detected at line 47" with no explanation. Real findings with the trace, ready for Cybe AutoFix to write the patch.
SQL injection, across the four major Rust drivers
The four drivers cover roughly 95 percent of Rust SQL traffic in production today (crates.io download stats, last 90 days). We track all four with library-specific sink models so the dataflow does not lose the taint when it crosses an API boundary.
sqlx, compile-time-checked async queries
sqlx is the modern default. Its query! and query_as! macros check SQL against the database at compile time. That checking does not save you from interpolation.
use axum::{extract::Query, Json};
use serde::Deserialize;
use sqlx::PgPool;
#[derive(Deserialize)]
struct Params {
name: String,
}
pub async fn search_users(
pool: PgPool,
Query(params): Query<Params>,
) -> Json<Vec<User>> {
// The macro form is checked at compile time, but the `query` form
// takes a runtime &str. Concatenating user input straight into
// the SQL is interpolation, regardless of the driver.
let sql = format!(
"SELECT * FROM users WHERE name LIKE '%{}%'",
params.name
);
let rows = sqlx::query_as::<_, User>(&sql)
.fetch_all(&pool)
.await
.unwrap();
Json(rows)
}
Source: params.name, originating from Query<Params> on an axum::extract. Sink: sqlx::query_as(&sql) because the &str is built by format!. No sanitiser on the path. Real SQL injection.
The Cybe Rust ruleset traces the taint from the axum::extract::Query deserializer, through the format! macro expansion (we expand format! before walking it), into sqlx::query_as, and reports it with a 100-percent reachable path. The fix:
let rows = sqlx::query_as::<_, User>(
"SELECT * FROM users WHERE name LIKE $1"
)
.bind(format!("%{}%", params.name))
.fetch_all(&pool)
.await
.unwrap();
.bind() keeps the value out of the SQL text. Same syntactic shape, completely different security property.
diesel, the typed ORM
diesel is the heavyweight typed ORM. Most of the API is safe by construction: you build queries with the DSL and Diesel emits parameterised SQL. The dangerous escape hatch is diesel::sql_query.
use diesel::prelude::*;
use diesel::sql_query;
pub fn find_by_role(conn: &mut PgConnection, role: &str) -> Vec<User> {
// Variable interpolation straight into raw SQL, executed by
// sql_query. Diesel does not warn about this; the type system
// happily passes it through.
let q = format!("SELECT * FROM users WHERE role = '{}'", role);
sql_query(&q).load::<User>(conn).unwrap()
}
Cybe traces the taint from the &str role parameter (which we model as user-input-equivalent at the function boundary unless an upstream sanitiser is present) into diesel::sql_query. The fix uses bind_param:
use diesel::sql_types::Text;
pub fn find_by_role(conn: &mut PgConnection, role: &str) -> Vec<User> {
sql_query("SELECT * FROM users WHERE role = $1")
.bind::<Text, _>(role)
.load::<User>(conn)
.unwrap()
}
rusqlite, embedded SQLite
rusqlite is the SQLite binding most Rust desktop tools and embedded services reach for. Its dangerous shape is the most syntactically obvious of the four:
use rusqlite::Connection;
pub fn delete_by_name(conn: &Connection, name: String) {
let sql = format!("DELETE FROM items WHERE name = '{}'", name);
conn.execute(&sql, []).unwrap();
}
Cybe traces the taint into Connection::execute(&sql, []). The fix:
conn.execute(
"DELETE FROM items WHERE name = ?1",
[&name],
).unwrap();
tokio-postgres, the async PostgreSQL client
tokio-postgres is the async lower-level client used when you need full control. Its Client::query and Client::execute take &str for the statement, opening the same hole.
use tokio_postgres::Client;
pub async fn revoke_session(client: &Client, session_id: String) {
let sql = format!(
"UPDATE sessions SET revoked = true WHERE id = '{}'",
session_id
);
client.execute(&sql, &[]).await.unwrap();
}
Cybe traces it through the String parameter into Client::execute. Fix:
client.execute(
"UPDATE sessions SET revoked = true WHERE id = $1",
&[&session_id],
).await.unwrap();
Cross-Site Scripting (XSS) on actix-web, axum and rocket
XSS in Rust web servers comes from one shape: a handler returns user-controlled text inside HTML without escaping it. Every mainstream Rust framework supports this footgun.
// actix-web
use actix_web::{get, web, HttpResponse, Responder};
#[get("/welcome")]
async fn welcome(name: web::Query<NameParams>) -> impl Responder {
HttpResponse::Ok()
.content_type("text/html")
.body(format!("<h1>Welcome, {}</h1>", name.name))
}
// axum
use axum::{extract::Query, response::Html};
pub async fn welcome(Query(params): Query<NameParams>) -> Html<String> {
Html(format!("<h1>Welcome, {}</h1>", params.name))
}
// rocket
use rocket::response::content::RawHtml;
#[get("/welcome?<name>")]
fn welcome(name: String) -> RawHtml<String> {
RawHtml(format!("<h1>Welcome, {}</h1>", name))
}
All three are stored or reflected XSS depending on how name is fed in. Cybe's ruleset normalises the three frameworks into a single "html response sink" model in the graph. The dataflow follows the parameter from the extractor (web::Query, axum::extract::Query, rocket::request::FromRequest derive) through the handler body to the response. If there is no htmlescape::encode_minimal, askama::Html, or equivalent on the path, the finding is reported.
A pattern-only scanner sees HttpResponse::Ok().body(x) as a sink shape and flags it. It cannot tell whether x was tainted or whether it was a static string template. So it either flags every response body and drowns the team in noise, or it skips the rule entirely.
CORS misconfigurations on Rust HTTP servers
The single most common Rust CORS bug is wildcard origin combined with credentialed requests, which the browser will refuse but the developer keeps trying to bypass by reflecting the request origin without an allowlist.
use tower_http::cors::{Any, CorsLayer};
let cors = CorsLayer::new()
.allow_origin(Any) // wildcard
.allow_credentials(true) // also true
.allow_methods(Any);
This is silently broken: browsers refuse the combination, but more importantly, it tells the security reviewer that the developer's mental model treats Origin as a trust signal. Our ruleset reports it at construction time, with the specific reason. Other ways to ship the same bug, which we also catch:
- Reflecting
request.headers().get("origin").unwrap()intoAccess-Control-Allow-Originwithout an allowlist. - Setting
.allow_origin(origin)from a deserialized config value without compile-time validation. - Custom CORS middleware that writes the header directly, bypassing
tower-http.
The dataflow follows the origin string from the request, through any middleware, into the response header. Pattern matching on Access-Control-Allow-Origin = "*" catches case one and misses cases two, three and four.
Server-Side Request Forgery (SSRF) in async Rust
SSRF in Rust looks like this:
use reqwest::Client;
use axum::extract::Query;
pub async fn fetch_url(
client: &Client,
Query(params): Query<UrlParams>,
) -> Result<Vec<u8>, Error> {
let resp = client.get(¶ms.url).send().await?;
Ok(resp.bytes().await?.to_vec())
}
User-controlled URL, no allowlist, no scheme check, no IP filter. The handler will happily fetch http://169.254.169.254/latest/meta-data/iam/security-credentials/ and return AWS instance credentials to the caller. We have seen this pattern in every Rust codebase that touches user-supplied URLs.
The Cybe Rust ruleset tracks taint through:
reqwest::Client::get,post,put,delete,head,requestreqwest::RequestBuilderchained callsureq::get,ureq::post, and the agent builderhyper::Client::requestwith manually constructedRequest<Body>surf::getandsurf::Client::send
The sanitiser on the path can be a host allowlist, a URL parser that rejects internal CIDR ranges, or a wrapper that resolves the hostname and re-checks the resulting IP. We model all three and stop the report when one is present.
Dataflow vs pattern matching, a side by side
The clearest way to feel the difference between dataflow and pattern matching is to look at code that pattern scanners miss while dataflow catches.
use sqlx::{PgPool, QueryBuilder, Postgres};
pub async fn search(
pool: &PgPool,
input: SearchInput,
) -> Result<Vec<Item>, sqlx::Error> {
let mut q: QueryBuilder<Postgres> = QueryBuilder::new(
"SELECT * FROM items WHERE 1 = 1"
);
if let Some(name) = input.name {
// .push_bind() is safe (parameterised).
// .push() is concatenation. Direct SQL injection.
q.push(" AND name LIKE '%").push(&name).push("%'");
}
q.build_query_as::<Item>().fetch_all(pool).await
}
A pattern scanner that looks for format! next to sqlx::query finds nothing. There is no format!. No string concatenation operator. The taint goes through QueryBuilder::push, a function that is itself perfectly safe when called with a constant string and unsafe when called with user input. Pattern scanners cannot tell the difference because they do not follow the value.
Cybe's Rust ruleset traces the taint from input.name (an Option<String> deserialized by serde and axum) through the .push(&name) call. QueryBuilder::push is registered in our graph as a SQL-text sink. The finding is reported with the full trace. Pattern scanners ship a clean report on the same file.
This is exactly the kind of bug we wrote the ruleset to catch.
Inside the agent loop, not just in the dashboard
The new Rust rules are not just sitting in a CI report. They are pushed into every AI coding agent through VibeDefend, the MCP-native layer that lives inside Claude Code, Cursor, Windsurf, GitHub Copilot, OpenAI Codex, Cline, Continue and Zed.
When the agent generates Rust code, the ruleset is already loaded as context. If the diff introduces a tainted path into one of the sinks above, the agent rewrites the code before the line is even suggested to the developer. We do not stop the diff at PR review. We stop it at the prompt.
$ npx -y @cybedefend/vibedefend@latest install
✓ Detected: Claude Code
✓ Loaded: 4,217 rules (Rust ruleset: 312)
✓ Memory: project graph synced
✓ Ready
The developer's next prompt to Claude Code about adding a search endpoint gets the parameterised version on the first try. No PR, no review queue, no Jira ticket.
What's next for Rust at CybeDefend
This is the first Rust ruleset, not the last. The roadmap for the next quarter, in commit order:
- Path traversal and unsafe filesystem access through
std::fs,tokio::fs,async_std::fs, with allowlist sanitiser detection. - Insecure deserialization for
serde_json::from_stron untrusted input, plusbincode,ciboriumand thermp_serdefamily. - Command injection through
std::process::Command,tokio::process::Command,ductand thesubprocesscrate. - WebAssembly host functions sink modelling for
wasmtimeandwasmerembeddings. - Cryptographic misuse: weak RNGs in security contexts, weak hash functions for password storage, hardcoded IVs in symmetric encryption, ECB-mode flags.
- Insecure transport: TLS disabled, custom certificate verifiers that always return
Ok.
Every rule on this list ships with dataflow, not patterns. Every rule gets pushed to the agent in the same release cycle.
Get the Rust ruleset
The new ruleset is live for every CybeDefend customer right now. No flag flip, no upgrade. If you are not yet on the platform, the install is one command:
npx -y @cybedefend/vibedefend@latest install
That command auto-detects your AI coding agent, loads the full ruleset (Rust included) into the agent's context, and starts catching the bugs the next time you prompt. Free tier, 50 AI credits, no card.
If you want a deeper tour: book a 30-minute session with the team. We pull your repo on a screen-share and show the dataflow on actual lines of your code. No slides, no SDR.