use crate::parse::UrlLike;
use crate::verdict::{Evidence, Finding, RuleId, Severity};
pub fn check(url: &UrlLike, in_sink_context: bool) -> Vec<Finding> {
let mut findings = Vec::new();
check_plain_http_to_sink(url, in_sink_context, &mut findings);
check_shortened_url(url, &mut findings);
if matches!(url, UrlLike::SchemelessHostPath { .. }) && in_sink_context {
findings.push(Finding {
rule_id: RuleId::SchemelessToSink,
severity: Severity::Medium,
title: "Schemeless URL in sink context".to_string(),
description:
"URL without explicit scheme passed to a command that downloads/executes content"
.to_string(),
evidence: vec![Evidence::Url { raw: url.raw_str() }],
});
}
findings
}
fn check_plain_http_to_sink(url: &UrlLike, in_sink: bool, findings: &mut Vec<Finding>) {
if let Some(scheme) = url.scheme() {
if scheme == "http" && in_sink {
findings.push(Finding {
rule_id: RuleId::PlainHttpToSink,
severity: Severity::High,
title: "Plain HTTP URL in execution context".to_string(),
description: format!(
"URL '{}' uses unencrypted HTTP and is being passed to a command that downloads or executes content. An attacker on the network could modify the content.",
url.raw_str()
),
evidence: vec![Evidence::Url { raw: url.raw_str() }],
});
}
}
}
fn check_shortened_url(url: &UrlLike, findings: &mut Vec<Finding>) {
let shorteners = [
"bit.ly",
"t.co",
"tinyurl.com",
"is.gd",
"v.gd",
"goo.gl",
"ow.ly",
];
if let Some(host) = url.host() {
let host_lower = host.to_lowercase();
if shorteners.iter().any(|s| host_lower == *s) {
findings.push(Finding {
rule_id: RuleId::ShortenedUrl,
severity: Severity::Medium,
title: "Shortened URL detected".to_string(),
description: format!(
"URL uses shortener '{host}' which hides the actual destination"
),
evidence: vec![Evidence::Url { raw: url.raw_str() }],
});
}
}
}
fn strip_quotes_simple(s: &str) -> String {
let s = s.trim();
if s.len() >= 2
&& ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
{
s[1..s.len() - 1].to_string()
} else {
s.to_string()
}
}
pub fn check_insecure_flags(args: &[String], in_sink: bool) -> Vec<Finding> {
let mut findings = Vec::new();
let insecure_flags = ["-k", "--insecure", "--no-check-certificate"];
for arg in args {
let clean = strip_quotes_simple(arg);
if insecure_flags.contains(&clean.as_str()) {
let severity = if in_sink {
Severity::High
} else {
Severity::Medium
};
findings.push(Finding {
rule_id: RuleId::InsecureTlsFlags,
severity,
title: "Insecure TLS flag detected".to_string(),
description: format!(
"Flag '{arg}' disables TLS certificate verification, allowing MITM attacks"
),
evidence: vec![Evidence::CommandPattern {
pattern: "insecure TLS flag".to_string(),
matched: arg.to_string(),
}],
});
}
}
findings
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quoted_insecure_flags() {
let args = vec!["\"-k\"".to_string(), "https://example.com".to_string()];
let findings = check_insecure_flags(&args, true);
assert!(!findings.is_empty(), "should detect -k even when quoted");
}
#[test]
fn test_single_quoted_insecure_flags() {
let args = vec!["'-k'".to_string()];
let findings = check_insecure_flags(&args, true);
assert!(
!findings.is_empty(),
"should detect -k even when single-quoted"
);
}
#[test]
fn test_unquoted_insecure_flags_still_work() {
let args = vec!["-k".to_string()];
let findings = check_insecure_flags(&args, true);
assert!(!findings.is_empty());
}
}