use std::sync::OnceLock;
use regex::Regex;
use crate::impl_rule;
use crate::rules::common::{
hardcoded_secret_re, is_secret_value_long_enough, make_finding, make_finding_from_offsets,
walk_tree,
};
use crate::{Finding, Language, Severity};
fn swift_weak_crypto_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"\b(CC_MD5|CC_SHA1|\.md5|\.sha1|Insecure\.MD5|Insecure\.SHA1)\b")
.expect("static Swift weak crypto regex should compile")
})
}
fn swift_sql_keywords_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?i)(SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE)\s")
.expect("static Swift SQL keyword regex should compile")
})
}
fn swift_interp_string_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r#""[^"]*\\\([^)]+\)[^"]*""#)
.expect("static Swift interpolation regex should compile")
})
}
fn swift_sql_concat_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r#"(?i)(execute|prepare|sqlite3_exec)\s*\([^)]*(?:SELECT|INSERT|UPDATE|DELETE|DROP)[^)]*\+\s*"#,
)
.expect("static Swift SQL concat regex should compile")
})
}
fn swift_keychain_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"\b(kSecAttrAccessibleAlways|kSecAttrAccessibleAlwaysThisDeviceOnly)\b")
.expect("static Swift keychain regex should compile")
})
}
fn swift_tls_expired_certs_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"allowsExpiredCertificates\s*=\s*true")
.expect("static Swift TLS regex should compile")
})
}
fn swift_tls_expired_roots_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"allowsExpiredRoots\s*=\s*true").expect("static Swift TLS regex should compile")
})
}
fn swift_tls_disable_eval_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"\.disableEvaluation").expect("static Swift TLS regex should compile")
})
}
fn swift_const_decl_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r#"(?m)\blet\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::\s*[^=\n]+?)?=\s*"([^"\\]*)""#)
.expect("static Swift const decl regex should compile")
})
}
fn swift_const_string_names(source: &str) -> std::collections::HashSet<String> {
let mut names = std::collections::HashSet::new();
for caps in swift_const_decl_re().captures_iter(source) {
if let Some(name) = caps.get(1) {
names.insert(name.as_str().to_string());
}
}
names
}
fn call_args(text: &str) -> &str {
match (text.find('('), text.rfind(')')) {
(Some(open), Some(close)) if close > open => &text[open + 1..close],
_ => text,
}
}
fn labeled_arg_value<'a>(text: &'a str, label: &str) -> Option<&'a str> {
let start = text.find(label)? + label.len();
let rest = &text[start..];
Some(first_arg(rest))
}
fn first_arg(args: &str) -> &str {
let mut depth = 0i32;
for (i, c) in args.char_indices() {
match c {
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth -= 1,
',' if depth == 0 => return &args[..i],
_ => {}
}
}
args
}
fn swift_arg_is_constant(arg: &str, consts: &std::collections::HashSet<String>) -> bool {
let trimmed = arg.trim();
if trimmed.is_empty() {
return true;
}
if trimmed.contains("\\(") {
return false;
}
for raw_operand in trimmed.split(['+', ',']) {
let mut operand = raw_operand
.trim()
.trim_start_matches(['(', '[', '{'])
.trim_end_matches([')', ']', '}'])
.trim();
if operand.is_empty() {
continue;
}
if let Some((label, rest)) = operand.split_once(':') {
if !label.is_empty()
&& label.chars().all(|c| c.is_alphanumeric() || c == '_')
&& !rest.starts_with(':')
{
operand = rest
.trim()
.trim_start_matches(['(', '[', '{'])
.trim_end_matches([')', ']', '}'])
.trim();
}
}
if operand.is_empty() {
continue;
}
if operand.starts_with('"') {
continue;
}
let ident: String = operand
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if !ident.is_empty() && operand.len() == ident.len() && consts.contains(&ident) {
continue;
}
return false;
}
true
}
pub struct NoHardcodedSecret;
impl_rule! {
NoHardcodedSecret,
id = "swift/no-hardcoded-secret",
severity = Severity::High,
cwe = Some("CWE-798"),
description = "Hardcoded secret or credential detected",
language = Language::Swift,
fn check_with_context(_self, source, tree, ctx) {
let mut findings = Vec::new();
let mut reported_lines = std::collections::HashSet::new();
let secret_pattern = hardcoded_secret_re();
walk_tree(tree.root_node(), source, &mut |node, src| {
if node.kind() == "property_declaration" {
if let Some(name_node) = node.child_by_field_name("name") {
let name = &src[name_node.byte_range()];
if secret_pattern.is_match(name) {
let mut has_string_value = false;
let mut string_val = String::new();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "line_string_literal"
|| child.kind() == "string_literal"
{
has_string_value = true;
string_val = src[child.byte_range()].to_string();
}
}
if has_string_value {
let inner = string_val.trim_matches('"');
let line = node.start_position().row;
if is_secret_value_long_enough(inner, ctx.secret_thresholds)
&& reported_lines.insert(line)
{
findings.push(make_finding(
_self.id(),
_self.severity(),
_self.cwe(),
&format!(
"Hardcoded secret in '{}' — use environment variables or Keychain",
name
),
node,
src,
));
}
}
}
}
}
if node.kind() == "value_binding_pattern" || node.kind() == "pattern" {
let name = &src[node.byte_range()];
if secret_pattern.is_match(name) {
if let Some(parent) = node.parent() {
let line = parent.start_position().row;
if reported_lines.contains(&line) {
return;
}
let mut cursor = parent.walk();
for child in parent.children(&mut cursor) {
if child.kind() == "line_string_literal"
|| child.kind() == "string_literal"
{
let val = &src[child.byte_range()];
let inner = val.trim_matches('"');
if is_secret_value_long_enough(inner, ctx.secret_thresholds)
&& reported_lines.insert(line)
{
findings.push(make_finding(
_self.id(),
_self.severity(),
_self.cwe(),
&format!(
"Hardcoded secret in '{}' — use environment variables or Keychain",
name
),
parent,
src,
));
break;
}
}
}
}
}
}
});
findings
}
}
pub struct NoCommandInjection;
impl_rule! {
NoCommandInjection,
id = "swift/no-command-injection",
severity = Severity::Critical,
cwe = Some("CWE-78"),
description = "Potential command injection via Process or NSTask with dynamic arguments",
language = Language::Swift,
fn check(_self, source, tree) {
let mut findings = Vec::new();
let consts = swift_const_string_names(source);
walk_tree(tree.root_node(), source, &mut |node, src| {
if node.kind() == "call_expression" {
let text = &src[node.byte_range()];
if text.starts_with("Process(") || text.starts_with("NSTask(") {
let args = call_args(text);
if !swift_arg_is_constant(args, &consts) {
findings.push(make_finding(
_self.id(),
_self.severity(),
_self.cwe(),
"Process/NSTask created with dynamic arguments — ensure arguments are not user-controlled to prevent command injection",
node,
src,
));
}
}
}
if node.kind() == "assignment" {
let text = &src[node.byte_range()];
if text.contains(".launchPath") || text.contains(".arguments") {
let rhs = text.split_once('=').map(|x| x.1).unwrap_or(text);
if !swift_arg_is_constant(rhs, &consts) {
findings.push(make_finding(
_self.id(),
_self.severity(),
_self.cwe(),
"Process arguments set with dynamic value — risk of command injection",
node,
src,
));
}
}
}
});
findings
}
}
pub struct NoWeakCrypto;
impl_rule! {
NoWeakCrypto,
id = "swift/no-weak-crypto",
severity = Severity::Medium,
cwe = Some("CWE-327"),
description = "Use of weak cryptographic hash (MD5/SHA1)",
language = Language::Swift,
fn check(_self, source, _tree) {
let mut findings = Vec::new();
let pattern = swift_weak_crypto_re();
for matched in pattern.find_iter(source) {
let algo = if matched.as_str().contains("MD5") || matched.as_str().contains("md5") {
"MD5"
} else {
"SHA1"
};
findings.push(make_finding_from_offsets(
_self.id(),
_self.severity(),
_self.cwe(),
&format!(
"{} is cryptographically weak — use SHA-256 or stronger",
algo
),
source,
matched.start(),
matched.end(),
));
}
findings
}
}
pub struct NoInsecureTransport;
impl_rule! {
NoInsecureTransport,
id = "swift/no-insecure-transport",
severity = Severity::High,
cwe = Some("CWE-319"),
description = "Insecure HTTP URL detected — use HTTPS instead",
language = Language::Swift,
fn check(_self, source, tree) {
let mut findings = Vec::new();
walk_tree(tree.root_node(), source, &mut |node, src| {
if node.kind() == "line_string_literal" || node.kind() == "string_literal" {
let text = &src[node.byte_range()];
if text.contains("http://")
&& !text.contains("http://localhost")
&& !text.contains("http://127.0.0.1")
{
findings.push(make_finding(
_self.id(),
_self.severity(),
_self.cwe(),
"Insecure HTTP URL — use HTTPS to protect data in transit",
node,
src,
));
}
}
});
findings
}
}
pub struct NoEvalJs;
impl_rule! {
NoEvalJs,
id = "swift/no-eval-js",
severity = Severity::Critical,
cwe = Some("CWE-95"),
description = "WKWebView evaluateJavaScript with dynamic input enables code injection",
language = Language::Swift,
fn check(_self, source, tree) {
let mut findings = Vec::new();
let consts = swift_const_string_names(source);
walk_tree(tree.root_node(), source, &mut |node, src| {
if node.kind() == "call_expression" {
let text = &src[node.byte_range()];
if text.contains("evaluateJavaScript") {
let args = first_arg(call_args(text));
let is_variable_arg = !swift_arg_is_constant(args, &consts);
if is_variable_arg {
findings.push(make_finding(
_self.id(),
_self.severity(),
_self.cwe(),
"evaluateJavaScript called with dynamic input — risk of JavaScript injection in WKWebView",
node,
src,
));
}
}
}
});
findings
}
}
pub struct NoSqlInjection;
impl_rule! {
NoSqlInjection,
id = "swift/no-sql-injection",
severity = Severity::Critical,
cwe = Some("CWE-89"),
description = "Potential SQL injection via string interpolation in SQLite queries",
language = Language::Swift,
fn check(_self, source, _tree) {
let mut findings = Vec::new();
let sql_keywords = swift_sql_keywords_re();
let interp_string = swift_interp_string_re();
for matched in interp_string.find_iter(source) {
let text = matched.as_str();
if sql_keywords.is_match(text) {
findings.push(make_finding_from_offsets(
_self.id(),
_self.severity(),
_self.cwe(),
"SQL query with string interpolation — use parameterized queries to prevent SQL injection",
source,
matched.start(),
matched.end(),
));
}
}
let sql_concat = swift_sql_concat_re();
for matched in sql_concat.find_iter(source) {
findings.push(make_finding_from_offsets(
_self.id(),
_self.severity(),
_self.cwe(),
"SQL query built with string concatenation — use parameterized queries",
source,
matched.start(),
matched.end(),
));
}
findings
}
}
pub struct NoInsecureKeychain;
impl_rule! {
NoInsecureKeychain,
id = "swift/no-insecure-keychain",
severity = Severity::High,
cwe = Some("CWE-311"),
description = "Insecure Keychain accessibility level allows access when device is locked",
language = Language::Swift,
fn check(_self, source, _tree) {
let mut findings = Vec::new();
let pattern = swift_keychain_re();
for matched in pattern.find_iter(source) {
findings.push(make_finding_from_offsets(
_self.id(),
_self.severity(),
_self.cwe(),
&format!(
"{} allows Keychain access when device is locked — use kSecAttrAccessibleWhenUnlocked",
matched.as_str()
),
source,
matched.start(),
matched.end(),
));
}
findings
}
}
pub struct NoTlsDisabled;
impl_rule! {
NoTlsDisabled,
id = "swift/no-tls-disabled",
severity = Severity::High,
cwe = Some("CWE-295"),
description = "TLS certificate validation disabled or weakened",
language = Language::Swift,
fn check(_self, source, _tree) {
let mut findings = Vec::new();
let patterns: [(&Regex, &str); 3] = [
(
swift_tls_expired_certs_re(),
"allowsExpiredCertificates = true disables certificate expiry validation",
),
(
swift_tls_expired_roots_re(),
"allowsExpiredRoots = true disables root certificate expiry validation",
),
(
swift_tls_disable_eval_re(),
".disableEvaluation disables TLS server trust evaluation entirely",
),
];
for (pattern, msg) in &patterns {
for matched in pattern.find_iter(source) {
findings.push(make_finding_from_offsets(
_self.id(),
_self.severity(),
_self.cwe(),
msg,
source,
matched.start(),
matched.end(),
));
}
}
findings
}
}
pub struct NoPathTraversal;
impl_rule! {
NoPathTraversal,
id = "swift/no-path-traversal",
severity = Severity::High,
cwe = Some("CWE-22"),
description = "Potential path traversal via FileManager with dynamic path",
language = Language::Swift,
fn check(_self, source, tree) {
let mut findings = Vec::new();
let mut reported_lines = std::collections::HashSet::new();
let consts = swift_const_string_names(source);
walk_tree(tree.root_node(), source, &mut |node, src| {
if node.kind() == "call_expression" {
let text = &src[node.byte_range()];
let fm_ops = [
"contentsOfDirectory",
"createDirectory",
"removeItem",
"copyItem",
"moveItem",
"fileExists",
"contents(atPath",
];
let has_fm_op = fm_ops.iter().any(|op| text.contains(op));
if has_fm_op {
let path_value = labeled_arg_value(text, "atPath:")
.or_else(|| labeled_arg_value(text, "path:"));
let has_dynamic_path = match path_value {
Some(v) => !swift_arg_is_constant(v, &consts),
None => false,
};
if has_dynamic_path {
let line = node.start_position().row;
if reported_lines.insert(line) {
findings.push(make_finding(
_self.id(),
_self.severity(),
_self.cwe(),
"FileManager operation with dynamic path — validate and sanitize to prevent path traversal",
node,
src,
));
}
}
}
}
});
findings
}
}
pub struct NoSsrf;
impl_rule! {
NoSsrf,
id = "swift/no-ssrf",
severity = Severity::High,
cwe = Some("CWE-918"),
description = "Potential SSRF via URLSession or URL with dynamic input",
language = Language::Swift,
fn check(_self, source, tree) {
let mut findings = Vec::new();
let mut reported_lines = std::collections::HashSet::new();
let consts = swift_const_string_names(source);
walk_tree(tree.root_node(), source, &mut |node, src| {
if node.kind() == "call_expression" {
let text = &src[node.byte_range()];
if text.starts_with("URL(string:") || text.starts_with("URL(string :") {
let value = text.split_once(':').map(|x| x.1).unwrap_or("");
if !swift_arg_is_constant(first_arg(value), &consts) {
let line = node.start_position().row;
if reported_lines.insert(line) {
findings.push(make_finding(
_self.id(),
_self.severity(),
_self.cwe(),
"URL(string:) called with dynamic value — validate and allowlist target hosts to prevent SSRF",
node,
src,
));
}
}
}
if text.contains("dataTask") && text.contains("url") {
let line = node.start_position().row;
let mentions_const = consts.iter().any(|c| {
text.split(|ch: char| !(ch.is_alphanumeric() || ch == '_'))
.any(|w| w == c)
});
if !text.contains("\"http") && !mentions_const && reported_lines.insert(line) {
findings.push(make_finding(
_self.id(),
_self.severity(),
_self.cwe(),
"URLSession.dataTask called with dynamic URL — validate and allowlist target hosts to prevent SSRF",
node,
src,
));
}
}
}
});
findings
}
}
use crate::rules::common::get_source_line;
use crate::rules::swift_taint;
struct SwiftTaintRuleMeta<'a> {
rule_id: &'a str,
severity: Severity,
cwe: Option<&'a str>,
fix_suggestion: Option<&'a str>,
format_description: fn(&str, &str) -> String,
}
fn swift_taint_sql_injection_desc(src: &str, sink: &str) -> String {
format!(
"{} flows to {} — use parameterized queries (sqlite3_bind_*) to prevent SQL injection",
src, sink
)
}
fn swift_taint_command_injection_desc(src: &str, sink: &str) -> String {
format!(
"{} flows to {} — avoid passing untrusted input to OS commands",
src, sink
)
}
fn swift_taint_js_injection_desc(src: &str, sink: &str) -> String {
format!(
"{} flows to {} — sanitize or JSON-encode untrusted input before evaluating it in a web view",
src, sink
)
}
fn swift_taint_nsexpression_desc(src: &str, sink: &str) -> String {
format!(
"{} flows to {} — never build an NSExpression format string from untrusted input",
src, sink
)
}
fn swift_taint_meta(rule_id: &str) -> Option<SwiftTaintRuleMeta<'static>> {
match rule_id {
"swift/taint-sql-injection" => Some(SwiftTaintRuleMeta {
rule_id: "swift/taint-sql-injection",
severity: Severity::Critical,
cwe: Some("CWE-89"),
fix_suggestion: None,
format_description: swift_taint_sql_injection_desc,
}),
"swift/taint-command-injection" => Some(SwiftTaintRuleMeta {
rule_id: "swift/taint-command-injection",
severity: Severity::Critical,
cwe: Some("CWE-78"),
fix_suggestion: None,
format_description: swift_taint_command_injection_desc,
}),
"swift/taint-js-injection" => Some(SwiftTaintRuleMeta {
rule_id: "swift/taint-js-injection",
severity: Severity::High,
cwe: Some("CWE-79"),
fix_suggestion: None,
format_description: swift_taint_js_injection_desc,
}),
"swift/taint-nsexpression-injection" => Some(SwiftTaintRuleMeta {
rule_id: "swift/taint-nsexpression-injection",
severity: Severity::High,
cwe: Some("CWE-95"),
fix_suggestion: None,
format_description: swift_taint_nsexpression_desc,
}),
_ => None,
}
}
fn map_swift_taint_finding(
meta: &SwiftTaintRuleMeta<'_>,
source: &str,
finding: swift_taint::TaintFinding,
) -> Finding {
Finding {
rule_id: meta.rule_id.to_string(),
severity: meta.severity,
cwe: meta.cwe.map(|s| s.to_string()),
description: (meta.format_description)(
&finding.source_description,
&finding.sink_description,
),
file: String::new(),
line: finding.sink_line,
column: finding.sink_column,
end_line: finding.sink_end_line,
end_column: finding.sink_end_column,
snippet: get_source_line(source, finding.sink_start_byte),
source_line: if finding.source_line == 0 {
None
} else {
Some(finding.source_line)
},
source_description: Some(finding.source_description),
sink_line: Some(finding.sink_line),
sink_description: Some(finding.sink_description),
fix_suggestion: meta.fix_suggestion.map(|s| s.to_string()),
sink_start_byte: None,
sink_end_byte: None,
confidence: crate::default_confidence(),
taint_hops: None,
tags: vec![],
crypto_algorithm: None,
cnsa2_deadline: None,
dep_name: None,
dep_version: None,
dep_ecosystem: None,
dep_purl: None,
dep_vulnerability_id: None,
dep_fixed_version: None,
dep_source: None,
dep_vulnerability_severity: None,
dep_path: vec![],
crypto_material: None,
}
}
pub fn run_swift_taint_batched(
source: &str,
tree: &tree_sitter::Tree,
enabled_rule_ids: &std::collections::HashSet<&str>,
) -> Vec<Finding> {
let mut findings = Vec::new();
for (rule_id, spec) in swift_taint::swift_taint_rule_specs() {
if !enabled_rule_ids.contains(rule_id) {
continue;
}
let Some(meta) = swift_taint_meta(rule_id) else {
continue;
};
let raw = swift_taint::analyze_tree(tree.root_node(), source, &spec, None);
for finding in raw {
findings.push(map_swift_taint_finding(&meta, source, finding));
}
}
findings
}
fn run_swift_taint_single(
rule_id: &str,
source: &str,
tree: &tree_sitter::Tree,
spec: &swift_taint::TaintSpec,
) -> Vec<Finding> {
let Some(meta) = swift_taint_meta(rule_id) else {
return Vec::new();
};
let raw = swift_taint::analyze_tree(tree.root_node(), source, spec, None);
raw.into_iter()
.map(|t| map_swift_taint_finding(&meta, source, t))
.collect()
}
pub struct TaintSqlInjection;
impl_rule! {
TaintSqlInjection,
id = "swift/taint-sql-injection",
severity = Severity::Critical,
cwe = Some("CWE-89"),
description = "Dynamically constructed string reaches a SQLite query sink",
language = Language::Swift,
fn check(_self, source, tree) {
let spec = swift_taint::swift_taint_rule_specs()
.into_iter()
.find(|(id, _)| *id == _self.id())
.map(|(_, spec)| spec)
.unwrap_or_default();
run_swift_taint_single(_self.id(), source, tree, &spec)
}
}
pub struct TaintCommandInjection;
impl_rule! {
TaintCommandInjection,
id = "swift/taint-command-injection",
severity = Severity::Critical,
cwe = Some("CWE-78"),
description = "Dynamically constructed string reaches an OS command sink",
language = Language::Swift,
fn check(_self, source, tree) {
let spec = swift_taint::swift_taint_rule_specs()
.into_iter()
.find(|(id, _)| *id == _self.id())
.map(|(_, spec)| spec)
.unwrap_or_default();
run_swift_taint_single(_self.id(), source, tree, &spec)
}
}
pub struct TaintJsInjection;
impl_rule! {
TaintJsInjection,
id = "swift/taint-js-injection",
severity = Severity::High,
cwe = Some("CWE-79"),
description = "Dynamically constructed string reaches WKWebView.evaluateJavaScript",
language = Language::Swift,
fn check(_self, source, tree) {
let spec = swift_taint::swift_taint_rule_specs()
.into_iter()
.find(|(id, _)| *id == _self.id())
.map(|(_, spec)| spec)
.unwrap_or_default();
run_swift_taint_single(_self.id(), source, tree, &spec)
}
}
pub struct TaintNsexpressionInjection;
impl_rule! {
TaintNsexpressionInjection,
id = "swift/taint-nsexpression-injection",
severity = Severity::High,
cwe = Some("CWE-95"),
description = "Dynamically constructed string reaches NSExpression(format:)",
language = Language::Swift,
fn check(_self, source, tree) {
let spec = swift_taint::swift_taint_rule_specs()
.into_iter()
.find(|(id, _)| *id == _self.id())
.map(|(_, spec)| spec)
.unwrap_or_default();
run_swift_taint_single(_self.id(), source, tree, &spec)
}
}