use std::time::Instant;
use crate::extract::{self, ScanContext};
use crate::normalize;
use crate::policy::Policy;
use crate::tokenize::ShellType;
use crate::verdict::{Finding, Timings, Verdict};
fn extract_raw_path_from_url(raw: &str) -> Option<String> {
if let Some(idx) = raw.find("://") {
let after = &raw[idx + 3..];
if let Some(slash_idx) = after.find('/') {
let path_start = &after[slash_idx..];
let end = path_start.find(['?', '#']).unwrap_or(path_start.len());
return Some(path_start[..end].to_string());
}
}
None
}
pub struct AnalysisContext {
pub input: String,
pub shell: ShellType,
pub scan_context: ScanContext,
pub raw_bytes: Option<Vec<u8>>,
pub interactive: bool,
pub cwd: Option<String>,
}
pub fn analyze(ctx: &AnalysisContext) -> Verdict {
let start = Instant::now();
let tier0_start = Instant::now();
let bypass_requested = std::env::var("TIRITH").ok().as_deref() == Some("0");
let tier0_ms = tier0_start.elapsed().as_secs_f64() * 1000.0;
let tier1_start = Instant::now();
let byte_scan_triggered = if ctx.scan_context == ScanContext::Paste {
if let Some(ref bytes) = ctx.raw_bytes {
let scan = extract::scan_bytes(bytes);
scan.has_ansi_escapes
|| scan.has_control_chars
|| scan.has_bidi_controls
|| scan.has_zero_width
|| scan.has_invalid_utf8
} else {
false
}
} else {
false
};
let regex_triggered = extract::tier1_scan(&ctx.input, ctx.scan_context);
let exec_bidi_triggered = if ctx.scan_context == ScanContext::Exec {
let scan = extract::scan_bytes(ctx.input.as_bytes());
scan.has_bidi_controls || scan.has_zero_width
} else {
false
};
let tier1_ms = tier1_start.elapsed().as_secs_f64() * 1000.0;
if !byte_scan_triggered && !regex_triggered && !exec_bidi_triggered {
let total_ms = start.elapsed().as_secs_f64() * 1000.0;
return Verdict::allow_fast(
1,
Timings {
tier0_ms,
tier1_ms,
tier2_ms: None,
tier3_ms: None,
total_ms,
},
);
}
let tier2_start = Instant::now();
if bypass_requested {
let policy = Policy::discover_partial(ctx.cwd.as_deref());
let allow_bypass = if ctx.interactive {
policy.allow_bypass_env
} else {
policy.allow_bypass_env_noninteractive
};
if allow_bypass {
let tier2_ms = tier2_start.elapsed().as_secs_f64() * 1000.0;
let total_ms = start.elapsed().as_secs_f64() * 1000.0;
let mut verdict = Verdict::allow_fast(
2,
Timings {
tier0_ms,
tier1_ms,
tier2_ms: Some(tier2_ms),
tier3_ms: None,
total_ms,
},
);
verdict.bypass_requested = true;
verdict.bypass_honored = true;
verdict.interactive_detected = ctx.interactive;
verdict.policy_path_used = policy.path.clone();
crate::audit::log_verdict(&verdict, &ctx.input, None, None);
return verdict;
}
}
let mut policy = Policy::discover(ctx.cwd.as_deref());
policy.load_user_lists();
policy.load_org_lists(ctx.cwd.as_deref());
let tier2_ms = tier2_start.elapsed().as_secs_f64() * 1000.0;
let tier3_start = Instant::now();
let mut findings = Vec::new();
if ctx.scan_context == ScanContext::Paste {
if let Some(ref bytes) = ctx.raw_bytes {
let byte_findings = crate::rules::terminal::check_bytes(bytes);
findings.extend(byte_findings);
}
let multiline_findings = crate::rules::terminal::check_hidden_multiline(&ctx.input);
findings.extend(multiline_findings);
}
if ctx.scan_context == ScanContext::Exec {
let byte_input = ctx.input.as_bytes();
let scan = extract::scan_bytes(byte_input);
if scan.has_bidi_controls || scan.has_zero_width {
let byte_findings = crate::rules::terminal::check_bytes(byte_input);
findings.extend(byte_findings.into_iter().filter(|f| {
matches!(
f.rule_id,
crate::verdict::RuleId::BidiControls | crate::verdict::RuleId::ZeroWidthChars
)
}));
}
}
let extracted = extract::extract_urls(&ctx.input, ctx.shell);
for url_info in &extracted {
let raw_path = extract_raw_path_from_url(&url_info.raw);
let normalized_path = url_info.parsed.path().map(normalize::normalize_path);
let hostname_findings = crate::rules::hostname::check(&url_info.parsed, &policy);
findings.extend(hostname_findings);
let path_findings = crate::rules::path::check(
&url_info.parsed,
normalized_path.as_ref(),
raw_path.as_deref(),
);
findings.extend(path_findings);
let transport_findings =
crate::rules::transport::check(&url_info.parsed, url_info.in_sink_context);
findings.extend(transport_findings);
let ecosystem_findings = crate::rules::ecosystem::check(&url_info.parsed);
findings.extend(ecosystem_findings);
}
let command_findings = crate::rules::command::check(&ctx.input, ctx.shell);
findings.extend(command_findings);
let env_findings = crate::rules::environment::check(&crate::rules::environment::RealEnv);
findings.extend(env_findings);
for finding in &mut findings {
if let Some(override_sev) = policy.severity_override(&finding.rule_id) {
finding.severity = override_sev;
}
}
for url_info in &extracted {
if policy.is_blocklisted(&url_info.raw) {
findings.push(Finding {
rule_id: crate::verdict::RuleId::PolicyBlocklisted,
severity: crate::verdict::Severity::Critical,
title: "URL matches blocklist".to_string(),
description: format!("URL '{}' matches a blocklist pattern", url_info.raw),
evidence: vec![crate::verdict::Evidence::Url {
raw: url_info.raw.clone(),
}],
});
}
}
if !policy.allowlist.is_empty() {
let blocklisted_urls: Vec<String> = extracted
.iter()
.filter(|u| policy.is_blocklisted(&u.raw))
.map(|u| u.raw.clone())
.collect();
findings.retain(|f| {
let url_in_evidence = f.evidence.iter().find_map(|e| {
if let crate::verdict::Evidence::Url { raw } = e {
Some(raw.clone())
} else {
None
}
});
match url_in_evidence {
Some(ref url) => {
blocklisted_urls.contains(url) || !policy.is_allowlisted(url)
}
None => true, }
});
}
let tier3_ms = tier3_start.elapsed().as_secs_f64() * 1000.0;
let total_ms = start.elapsed().as_secs_f64() * 1000.0;
let mut verdict = Verdict::from_findings(
findings,
3,
Timings {
tier0_ms,
tier1_ms,
tier2_ms: Some(tier2_ms),
tier3_ms: Some(tier3_ms),
total_ms,
},
);
verdict.bypass_requested = bypass_requested;
verdict.interactive_detected = ctx.interactive;
verdict.policy_path_used = policy.path.clone();
verdict.urls_extracted_count = Some(extracted.len());
verdict
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exec_bidi_without_url() {
let input = format!("echo hello{}world", '\u{202E}');
let ctx = AnalysisContext {
input,
shell: ShellType::Posix,
scan_context: ScanContext::Exec,
raw_bytes: None,
interactive: true,
cwd: None,
};
let verdict = analyze(&ctx);
assert!(
verdict.tier_reached >= 3,
"bidi in exec should reach tier 3, got tier {}",
verdict.tier_reached
);
assert!(
verdict
.findings
.iter()
.any(|f| matches!(f.rule_id, crate::verdict::RuleId::BidiControls)),
"should detect bidi controls in exec context"
);
}
}