use crate::config::Config;
use crate::finding::{Finding, ScanResult, Severity};
use crate::scanners::{is_suppressed_inline, read_file_limited, RuleInfo, Scanner};
use std::path::Path;
use std::time::Instant;
fn get_homoglyph_info(c: char) -> Option<(&'static str, &'static str)> {
match c {
'ο' => Some(("Greek o", "Latin 'o' (U+006F)")), 'ν' => Some(("Greek nu", "Latin 'v' (U+0076)")), 'τ' => Some(("Greek tau", "Latin 't' (U+0074)")), 'ρ' => Some(("Greek rho", "Latin 'p' (U+0070)")), 'α' => Some(("Greek alpha", "Latin 'a' (U+0061)")), 'β' => Some(("Greek beta", "Latin 'b' (U+0062)")), 'ε' => Some(("Greek epsilon", "Latin 'e' (U+0065)")), 'ζ' => Some(("Greek zeta", "Latin 'z' (U+007A)")), 'η' => Some(("Greek eta", "Latin 'n' (U+006E)")), 'κ' => Some(("Greek kappa", "Latin 'k' (U+006B)")), 'λ' => Some(("Greek lambda", "Latin 'l' (U+006C)")), 'μ' => Some(("Greek mu", "Latin 'u' (U+0075)")), 'σ' => Some(("Greek sigma", "Latin 's' (U+0073)")), 'ς' => Some(("Greek final sigma", "Latin 's' (U+0073)")), 'χ' => Some(("Greek chi", "Latin 'x' (U+0078)")), 'ψ' => Some(("Greek psi", "Latin 'y' (U+0079)")),
'а' => Some(("Cyrillic a", "Latin 'a' (U+0061)")), 'е' => Some(("Cyrillic e", "Latin 'e' (U+0065)")), 'о' => Some(("Cyrillic o", "Latin 'o' (U+006F)")), 'р' => Some(("Cyrillic r", "Latin 'p' (U+0070)")), 'с' => Some(("Cyrillic s", "Latin 's' (U+0073)")), 'у' => Some(("Cyrillic u", "Latin 'u' (U+0075)")), 'х' => Some(("Cyrillic h", "Latin 'h' (U+0068)")), 'н' => Some(("Cyrillic n", "Latin 'n' (U+006E)")), 'в' => Some(("Cyrillic v", "Latin 'b' (U+0062)")), 'м' => Some(("Cyrillic m", "Latin 'm' (U+006D)")),
'ɪ' => Some(("Latin Small Capital I", "Latin 'i' (U+0069)")), 'ᴛ' => Some(("Latin Small Capital T", "Latin 't' (U+0074)")),
_ => None,
}
}
fn is_cyrillic(c: char) -> bool {
matches!(c as u32, 0x0400..=0x04FF)
}
fn is_greek(c: char) -> bool {
matches!(c as u32, 0x0370..=0x03FF)
}
fn is_latin(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c as u32, 0x0100..=0x024F)
}
fn is_bidi_override(c: char) -> bool {
matches!(
c as u32,
0x202A | 0x202B | 0x202C | 0x202D | 0x202E | 0x2066 | 0x2067 | 0x2068 | 0x2069 )
}
fn analyze_string(s: &str) -> (bool, bool, bool) {
let mut has_homoglyphs = false;
let mut has_bidi = false;
let mut has_mixed_scripts = false;
let mut has_latin = false;
let mut has_cyrillic = false;
let mut has_greek = false;
for c in s.chars() {
if get_homoglyph_info(c).is_some() {
has_homoglyphs = true;
}
if is_bidi_override(c) {
has_bidi = true;
}
if is_latin(c) {
has_latin = true;
}
if is_cyrillic(c) {
has_cyrillic = true;
}
if is_greek(c) {
has_greek = true;
}
}
if (has_greek || has_cyrillic) && has_latin {
has_mixed_scripts = true;
}
(has_homoglyphs, has_bidi, has_mixed_scripts)
}
fn scan_file(file: &Path, findings: &mut Vec<Finding>) -> Result<(), Box<dyn std::error::Error>> {
let content = read_file_limited(file)?;
let is_frontmatter_file = file
.file_name()
.and_then(|n| n.to_str())
.map(|n| n == "SKILL.md" || n == "AGENT.md")
.unwrap_or(false);
if is_frontmatter_file {
scan_frontmatter(&content, file, findings);
}
scan_markdown_body(&content, file, findings);
Ok(())
}
fn scan_frontmatter(content: &str, file: &Path, findings: &mut Vec<Finding>) {
let mut lines = content.lines().enumerate();
let Some((_, first)) = lines.next() else {
return;
};
if first.trim() != "---" {
return;
}
for (idx, line) in lines {
let line_num = idx + 1;
if line.trim() == "---" {
break;
}
if line.trim().starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once(':') {
let key = key.trim().to_lowercase();
let value = value.trim();
if value.is_empty() {
continue;
}
if matches!(key.as_str(), "name" | "description" | "compatibility") {
if is_suppressed_inline(line) {
continue;
}
check_script_issues(
value, file, line_num, line, findings, true, );
}
}
}
}
fn scan_markdown_body(content: &str, file: &Path, findings: &mut Vec<Finding>) {
let mut in_code_block = false;
for (idx, line) in content.lines().enumerate() {
let line_num = idx + 1;
if line.trim().starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if in_code_block || is_suppressed_inline(line) {
continue;
}
if line.trim() == "---" {
continue;
}
check_script_issues(line, file, line_num, line, findings, false);
}
}
fn check_script_issues(
text: &str,
file: &Path,
line_num: usize,
full_line: &str,
findings: &mut Vec<Finding>,
is_frontmatter: bool,
) {
let (has_homoglyphs, has_bidi, has_mixed_scripts) = analyze_string(text);
if has_bidi {
findings.push(Finding {
rule_id: "script/SM2-bidi-override".to_string(),
message: "Bidirectional text-direction override mark detected (potential RTL attack)"
.to_string(),
severity: Severity::Error,
file: Some(file.to_path_buf()),
line: Some(line_num),
column: None,
scanner: "script_mixing".to_string(),
snippet: Some(make_snippet(full_line)),
suppressed: false,
suppression_reason: None,
remediation: Some(
"Remove bidirectional override characters. These are rarely needed in legitimate \
skill content and are a common vector for prompt-injection attacks."
.to_string(),
),
});
}
if has_homoglyphs {
let (fake_name, real_char) = get_homoglyph_info(
text.chars()
.find(|c| get_homoglyph_info(*c).is_some())
.unwrap(),
)
.unwrap();
findings.push(Finding {
rule_id: "script/SM1-homoglyph".to_string(),
message: format!(
"Homoglyph detected: {} masquerading as {}",
fake_name, real_char
),
severity: Severity::Warning,
file: Some(file.to_path_buf()),
line: Some(line_num),
column: None,
scanner: "script_mixing".to_string(),
snippet: Some(make_snippet(full_line)),
suppressed: false,
suppression_reason: None,
remediation: Some(
"Replace the character with its ASCII equivalent. Homoglyphs are often used \
to bypass filters or create visually-deceptive prompts."
.to_string(),
),
});
}
if has_mixed_scripts && is_frontmatter {
findings.push(Finding {
rule_id: "script/SM3-mixed-scripts".to_string(),
message: "Mixed scripts detected in frontmatter field (potential obfuscation attack)"
.to_string(),
severity: Severity::Warning,
file: Some(file.to_path_buf()),
line: Some(line_num),
column: None,
scanner: "script_mixing".to_string(),
snippet: Some(make_snippet(full_line)),
suppressed: false,
suppression_reason: None,
remediation: Some(
"Skill identifiers (name, compatibility) should use only ASCII Latin characters. \
Replace non-Latin characters with their ASCII equivalents."
.to_string(),
),
});
}
}
fn make_snippet(line: &str) -> String {
let trimmed = line.trim();
if trimmed.len() > 120 {
let cut = trimmed
.char_indices()
.nth(117)
.map(|(i, _)| i)
.unwrap_or(trimmed.len());
format!("{}...", &trimmed[..cut])
} else {
trimmed.to_string()
}
}
pub struct ScriptMixingScanner;
impl Scanner for ScriptMixingScanner {
fn name(&self) -> &'static str {
"script_mixing"
}
fn description(&self) -> &'static str {
"Script-mixing scanner — detects homoglyphs and bidirectional text attacks"
}
fn is_available(&self) -> bool {
true
}
fn scan(&self, path: &Path, _config: &Config) -> ScanResult {
let start = Instant::now();
let mut findings = Vec::new();
let mut candidates = Vec::new();
for entry in walkdir::WalkDir::new(path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.map(|n| n == "SKILL.md" || n == "AGENT.md")
.unwrap_or(false)
})
{
candidates.push(entry.path().to_path_buf());
}
for entry in walkdir::WalkDir::new(path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.and_then(|ext| ext.to_str())
.map(|e| e == "md" || e == "markdown")
.unwrap_or(false)
})
{
let p = entry.path().to_path_buf();
if !candidates.contains(&p) {
candidates.push(p);
}
}
let files_scanned = candidates.len();
for file in candidates {
if scan_file(&file, &mut findings).is_err() {
continue;
}
}
ScanResult {
scanner_name: self.name().to_string(),
findings,
files_scanned,
skipped: false,
skip_reason: None,
error: None,
duration_ms: start.elapsed().as_millis() as u64,
scanner_score: None,
scanner_grade: None,
}
}
}
pub fn rules() -> Vec<RuleInfo> {
vec![
RuleInfo {
id: "script/SM1-homoglyph",
severity: "warning",
scanner: "script_mixing",
message: "Visually-confusing homoglyph substitution (e.g., Greek ο as Latin o)",
remediation: "Replace the character with its ASCII equivalent. Homoglyphs are often \
used to bypass filters or create visually-deceptive prompts.",
},
RuleInfo {
id: "script/SM2-bidi-override",
severity: "error",
scanner: "script_mixing",
message: "Bidirectional text-direction override marks (RTL-override, etc.)",
remediation: "Remove bidirectional override characters. These are rarely needed in \
legitimate skill content and are a common vector for prompt-injection \
attacks.",
},
RuleInfo {
id: "script/SM3-mixed-scripts",
severity: "warning",
scanner: "script_mixing",
message: "Mixed Unicode scripts in frontmatter identifiers",
remediation: "Skill identifiers should use only ASCII Latin characters. Replace \
non-Latin characters with their ASCII equivalents.",
},
]
}