Skip to content
Merged
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
187 changes: 176 additions & 11 deletions crates/aegis-core/src/security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,10 +320,21 @@ fn check_tls_off(name: &str, node: Node, src: &[u8], out: &mut Vec<SecurityViola
// collapse to a canonical form. Lowercase the value side only.
let normalized = normalize_kv(text);
let bad: &[(&str, &str)] = &[
// Python `requests`: requests.get(url, verify=False)
("verify=false", "verify=False"),
// Node.js: `{ rejectUnauthorized: false }` in https/tls/agent options
("rejectunauthorized:false", "rejectUnauthorized: false"),
// Go: tls.Config{InsecureSkipVerify: true}
("insecureskipverify:true", "InsecureSkipVerify: true"),
// .NET: assigning a callback that returns true unconditionally
("servercertificatevalidationcallback", "ServerCertificateValidationCallback"),
// Ruby OpenSSL: `verify_mode = OpenSSL::SSL::VERIFY_NONE`
// (the `:` in OpenSSL::SSL gets normalized but the keyword
// chunk we anchor on contains no `:`)
("verify_none", "OpenSSL::SSL::VERIFY_NONE"),
// Java: trust-everything HostnameVerifier shape
("noophostnameverifier", "NoopHostnameVerifier"),
("trustallhostnameverifier", "TrustAllHostnameVerifier"),
];
for (needle, display) in bad {
if normalized.contains(needle) {
Expand All @@ -332,6 +343,26 @@ fn check_tls_off(name: &str, node: Node, src: &[u8], out: &mut Vec<SecurityViola
return;
}
}
// PHP curl: positional args `curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0)`
// and array `array(CURLOPT_SSL_VERIFYPEER => false)`. Both shapes
// have whitespace and either `,` or `=>` between key and value
// that normalize_kv doesn't unify, so check with raw text.
let lower = text.to_ascii_lowercase();
let php_curl_disabled = (
lower.contains("curlopt_ssl_verifypeer")
&& (lower.contains(",false") || lower.contains(", false")
|| lower.contains(",0)") || lower.contains(", 0)")
|| lower.contains("=>false") || lower.contains("=> false")
|| lower.contains("=>0") || lower.contains("=> 0"))
) || (
lower.contains("curlopt_ssl_verifyhost")
&& (lower.contains(",0)") || lower.contains(", 0)")
|| lower.contains("=>0") || lower.contains("=> 0"))
);
if php_curl_disabled {
push(out, node, "SEC003", "block",
"TLS verification disabled (`CURLOPT_SSL_VERIFYPEER`/`VERIFYHOST` set to 0/false) — exposes traffic to MITM".into());
}
}

/// Collapse whitespace around `=` and `:` and lowercase the whole
Expand Down Expand Up @@ -455,8 +486,30 @@ fn text_has_interp(text: &str) -> bool {

// ─── Rule SEC005: SQL string concat ──────────────────────────────
fn check_sql_concat(name: &str, node: Node, src: &[u8], out: &mut Vec<SecurityViolation>) {
let last = name.rsplit('.').next().unwrap_or(name);
if !matches!(last, "execute" | "executemany" | "query" | "executeQuery" | "executeUpdate") {
let last = leaf_method_name(name);
// Database-execution method names across Python / JDBC / Node /
// Go / PHP / Ruby. False positives only fire when the call's
// first arg also contains a SQL keyword in a string literal —
// contains_sql_in_string_literal does that gating below, so
// adding receiver names here is low-risk.
let is_db_call = matches!(
last,
// Python (cursor.execute / .executemany)
"execute" | "executemany"
// JDBC: PreparedStatement / Statement
| "executeQuery" | "executeUpdate" | "executeLargeUpdate"
| "executeBatch"
// Node.js / Go / generic: connection.query / db.Query / db.Exec
| "query" | "Query" | "QueryRow" | "QueryContext"
| "Exec" | "ExecContext"
// PHP PDO / mysqli
| "exec" | "prepare"
// ActiveRecord style
| "find_by_sql"
// PHP global functions
| "mysqli_query" | "mysql_query" | "pg_query" | "sqlite_query"
);
if !is_db_call {
return;
}
let Ok(text) = node.utf8_text(src) else { return };
Expand All @@ -470,12 +523,9 @@ fn check_sql_concat(name: &str, node: Node, src: &[u8], out: &mut Vec<SecurityVi
return;
}

let has_interp = text.contains("${")
|| text.contains("f\"") || text.contains("f'")
|| text.contains(".format(")
|| text.contains(" + ")
|| text.contains(" % ");
if has_interp {
// PR #19: use shared text_has_interp so PHP `.` concat works.
// Also keep SEC005's specific `% ` Python-printf formatter check.
if text_has_interp(text) || text.contains(" % ") {
push(out, node, "SEC005", "warn",
"SQL query with string interpolation/concat — use parameterized queries".into());
}
Expand All @@ -486,7 +536,14 @@ fn check_sql_concat(name: &str, node: Node, src: &[u8], out: &mut Vec<SecurityVi
/// where `select(...)` is a builder, not a string.
fn contains_sql_in_string_literal(node: Node, src: &[u8]) -> bool {
let kind = node.kind();
if matches!(kind, "string" | "string_literal" | "interpreted_string_literal" | "string_fragment") {
if matches!(
kind,
"string" | "string_literal" | "interpreted_string_literal" | "string_fragment"
// PHP: regular `"..."` strings parse as `encapsed_string`
| "encapsed_string" | "string_value"
// Heredoc / template literals
| "heredoc" | "template_string"
) {
if let Ok(text) = node.utf8_text(src) {
let upper = text.to_ascii_uppercase();
return ["SELECT ", "INSERT ", "UPDATE ", "DELETE ", "DROP "]
Expand Down Expand Up @@ -613,11 +670,26 @@ fn check_jwt_unsafe(
// ─── Rule SEC008: insecure deserialization ───────────────────────
fn check_insecure_deserialization(name: &str, node: Node, src: &[u8], out: &mut Vec<SecurityViolation>) {
let dangerous = [
// Python
"pickle.loads", "pickle.load",
"marshal.loads", "marshal.load",
"yaml.load",
"node-serialize.unserialize",
"yaml.load", // unsafe by default; SafeLoader is the safe alternative
"shelve.open", "dill.loads", "dill.load",
// Node.js: serialize-javascript / node-serialize family
"node-serialize.unserialize", "serialize.unserialize",
// Java: ObjectInputStream / XStream / SnakeYaml
"ObjectInputStream", "readObject",
"XMLDecoder.readObject",
// PHP: native unserialize is the textbook unsafe path
"unserialize",
// Ruby: Marshal.load / YAML.load (default is unsafe Psych load)
"Marshal.load", "YAML.load", "Oj.load",
// C# / .NET: BinaryFormatter is deprecated for security reasons
"BinaryFormatter.Deserialize",
"SoapFormatter.Deserialize",
"NetDataContractSerializer.ReadObject",
// Go: gob.NewDecoder + Decode on attacker-controlled bytes
"gob.NewDecoder",
];
if !dangerous.iter().any(|n| name.contains(n)) {
return;
Expand Down Expand Up @@ -1557,6 +1629,99 @@ mod tests {
}

// ─── SEC004 multi-language (PR #18) ──────────────────────────
// ─── SEC003 multi-language extensions (PR #19) ───────────────
#[test]
fn sec003_php_curl_verifypeer_zero_blocks() {
let v = check(
".php",
"<?php\n\
$ch = curl_init();\n\
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);\n",
);
assert!(v.iter().any(|v| v.rule_id == "SEC003"), "got {v:?}");
}

#[test]
fn sec003_php_curl_verifypeer_inline_array_blocks() {
// Inline array form — array() expression is inside the call
// and contains the unsafe option, so check_tls_off (which
// walks the call node's text) sees it.
// The deferred-via-variable form
// (`$opts = array(...); curl_setopt_array($ch, $opts);`)
// is intentionally a known FN: that requires dataflow.
let v = check(
".php",
"<?php\n\
curl_setopt_array($ch, array(CURLOPT_SSL_VERIFYPEER => false));\n",
);
assert!(v.iter().any(|v| v.rule_id == "SEC003"), "got {v:?}");
}

#[test]
fn sec003_php_curl_verifypeer_one_does_not_block() {
// 1 / true is the safe (default) value.
let v = check(
".php",
"<?php\n\
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 1);\n",
);
assert!(!v.iter().any(|v| v.rule_id == "SEC003"), "got {v:?}");
}

// ─── SEC005 multi-language extensions (PR #19) ───────────────
#[test]
fn sec005_go_db_query_with_concat_warns() {
let v = check(
".go",
"package main\n\n\
import \"database/sql\"\n\n\
func find(db *sql.DB, userID string) {\n \
_, _ = db.Query(\"SELECT * FROM users WHERE id = \" + userID)\n\
}\n",
);
assert!(v.iter().any(|v| v.rule_id == "SEC005"), "got {v:?}");
}

#[test]
fn sec005_php_mysqli_query_with_concat_warns() {
let v = check(
".php",
"<?php\n\
function find($conn, $userID) {\n \
return mysqli_query($conn, \"SELECT * FROM users WHERE id = \" . $userID);\n\
}\n",
);
assert!(v.iter().any(|v| v.rule_id == "SEC005"), "got {v:?}");
}

// ─── SEC008 multi-language extensions (PR #19) ───────────────
#[test]
fn sec008_php_unserialize_blocks() {
let v = check(
".php",
"<?php\n\
function load($payload) {\n \
return unserialize($payload);\n\
}\n",
);
assert!(v.iter().any(|v| v.rule_id == "SEC008"), "got {v:?}");
}

#[test]
fn sec008_csharp_binary_formatter_blocks() {
let v = check(
".cs",
"using System.Runtime.Serialization.Formatters.Binary;\n\n\
public class Loader {\n \
public static object Load(System.IO.Stream s) {\n \
var formatter = new BinaryFormatter();\n \
return BinaryFormatter.Deserialize(s);\n \
}\n\
}\n",
);
assert!(v.iter().any(|v| v.rule_id == "SEC008"), "got {v:?}");
}

#[test]
fn sec004_node_child_process_exec_concat_blocks() {
let v = check(
Expand Down
Loading