Skip to main content

purple_ssh/
snippet_impact.rs

1//! Static, execution-free blast-radius analysis of a snippet command, surfaced
2//! by the Snippets detail IMPACT card. A snippet is a single-line shell command
3//! run over SSH against a fleet of hosts; this judges what it does BEFORE a
4//! fleet run, so an operator sees the blast radius at a glance.
5//!
6//! Pipeline (the ShellCheck/explainshell model, hand-rolled with no parser
7//! crate): quote-aware tokenize -> split the line into per-command segments on
8//! shell operators (`| ; & && || ( )`) -> resolve each segment's command head
9//! (skip `VAR=val` assignments, unwrap `sudo`/`env`/`nice`/`timeout`/`xargs`/...
10//! wrappers) -> classify the head + its flags/sub-command via a curated
11//! capability table, plus structural signals (redirects, pipe-to-interpreter,
12//! command substitution). Quote-awareness means `echo "rm -rf"` and `grep '>'`
13//! are inert, which the old flat tokenizer got wrong.
14//!
15//! This is ADVISORY, not a sandbox: substitution recurses one level, exotic
16//! syntax (process substitution, here-docs, ANSI-C quoting) is best-effort, and
17//! an unknown command head yields an explicit "effect unclear" finding so a
18//! green "read-only" verdict is never emitted falsely.
19
20/// Worst-to-best severity of an impact finding. `Ord`-derived so the card
21/// verdict is simply `findings.map(severity).max()`.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum Severity {
24    /// No state change observed: read-only, safe to fan out.
25    ReadOnly,
26    /// Changes state on each host, but recoverably.
27    WritesState,
28    /// Runs as root, fetches and runs remote code, or stops services.
29    Elevated,
30    /// Irreversible data loss or a fleet-wide outage.
31    Critical,
32}
33
34/// What kind of effect a finding describes. Drives the callout wording in the
35/// UI (the prose lives in `messages`, not here).
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Category {
38    /// Recoverable destructive change (delete, overwrite, recursive perms).
39    Destructive,
40    /// Unrecoverable data loss (rm of a system path, dd/mkfs of a device,
41    /// `git reset --hard`, `find -delete`).
42    Irreversible,
43    /// Runs with elevated privilege (`sudo`/`su`/`doas`).
44    Privilege,
45    /// Fetches code from the network and runs it (`curl ... | sh`).
46    RemoteExec,
47    /// Stops, restarts or kills services/processes/containers.
48    Service,
49    /// Reboots or powers off the host (availability impact).
50    Availability,
51    /// Installs/removes/upgrades system packages.
52    Package,
53    /// Truncates or overwrites a file via a `>` redirect.
54    Redirect,
55    /// Reads a path that commonly holds secrets (keys, .env, shadow).
56    Secrets,
57    /// Command head is not in the capability table: effect unverified.
58    Unknown,
59}
60
61/// One impact signal derived from the command. `subject` is the specific
62/// trigger (verb, flag, target or path) the UI parameterises its wording with;
63/// it is NOT user-facing prose (that is built in `messages::snippet`).
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Finding {
66    pub severity: Severity,
67    pub category: Category,
68    pub subject: String,
69}
70
71impl Finding {
72    fn new(severity: Severity, category: Category, subject: impl Into<String>) -> Self {
73        Self {
74            severity,
75            category,
76            subject: subject.into(),
77        }
78    }
79}
80
81/// The full impact analysis of a command: an ordered, de-duplicated set of
82/// findings. No findings means the command is read-only.
83#[derive(Debug, Clone, Default, PartialEq, Eq)]
84pub struct CommandImpact {
85    pub findings: Vec<Finding>,
86}
87
88impl CommandImpact {
89    /// The headline verdict: the worst severity across all findings, or
90    /// [`Severity::ReadOnly`] when there are none.
91    pub fn verdict(&self) -> Severity {
92        self.findings
93            .iter()
94            .map(|f| f.severity)
95            .max()
96            .unwrap_or(Severity::ReadOnly)
97    }
98
99    /// Findings worst-severity first (stable within a severity), for the card's
100    /// callout list. The caller caps the count.
101    pub fn callouts(&self) -> Vec<&Finding> {
102        let mut v: Vec<&Finding> = self.findings.iter().collect();
103        v.sort_by_key(|f| std::cmp::Reverse(f.severity));
104        v
105    }
106
107    /// True when the command runs with elevated privilege.
108    pub fn uses_sudo(&self) -> bool {
109        self.findings
110            .iter()
111            .any(|f| f.category == Category::Privilege)
112    }
113
114    /// True when the command needs a careful read before a fleet run: anything
115    /// above a plain state write.
116    pub fn is_elevated_or_destructive(&self) -> bool {
117        self.verdict() >= Severity::Elevated
118    }
119}
120
121// =========================================================================
122// Capability table: which command heads / flags / sub-commands escalate.
123// =========================================================================
124
125/// Command heads with no state-changing effect. A head in this set produces no
126/// finding; a head NOT here and not in the rule table is `Unknown` (never a
127/// silent all-clear). Kept deliberately conservative.
128const READ_ONLY_HEADS: &[&str] = &[
129    "ls",
130    "ll",
131    "pwd",
132    "cd",
133    "echo",
134    "printf",
135    "grep",
136    "egrep",
137    "fgrep",
138    "rg",
139    "ag",
140    "awk",
141    "cut",
142    "sort",
143    "uniq",
144    "wc",
145    "tr",
146    "column",
147    "true",
148    "false",
149    "test",
150    "stat",
151    "file",
152    "locate",
153    "which",
154    "type",
155    "whereis",
156    "readlink",
157    "realpath",
158    "basename",
159    "dirname",
160    "du",
161    "df",
162    "free",
163    "uptime",
164    "uname",
165    "hostname",
166    "hostnamectl",
167    "whoami",
168    "id",
169    "groups",
170    "w",
171    "who",
172    "last",
173    "date",
174    "cal",
175    "printenv",
176    "set",
177    "ps",
178    "pgrep",
179    "pstree",
180    "top",
181    "htop",
182    "vmstat",
183    "iostat",
184    "mpstat",
185    "lscpu",
186    "lsblk",
187    "lsusb",
188    "lspci",
189    "lsof",
190    "ip",
191    "ifconfig",
192    "ss",
193    "netstat",
194    "ping",
195    "ping6",
196    "traceroute",
197    "dig",
198    "nslookup",
199    "host",
200    "getent",
201    "curl",
202    "wget",
203    "nc",
204    "telnet",
205    "openssl",
206    "md5sum",
207    "sha256sum",
208    "sha1sum",
209    "cksum",
210    "sensors",
211    "dmesg",
212    "journalctl",
213    // Read / pipe-common transformers and dumpers (their output goes via a
214    // redirect, which is classified separately).
215    "pg_dump",
216    "pg_dumpall",
217    "mysqldump",
218    "gzip",
219    "gunzip",
220    "zcat",
221    "gzcat",
222    "xz",
223    "bzip2",
224    "base64",
225    "jq",
226    "yq",
227    "nl",
228    "tac",
229];
230
231/// Wrapper commands that run another command; the real head is what follows.
232/// `sudo`/`su`/`doas` additionally flag a privilege finding for the segment.
233const WRAPPERS: &[&str] = &[
234    "sudo", "doas", "su", "env", "nice", "ionice", "nohup", "timeout", "stdbuf", "setsid", "time",
235    "command", "builtin", "exec", "xargs",
236];
237
238/// Interpreters that run code from stdin (the downstream side of `curl | sh`).
239const INTERPRETERS: &[&str] = &[
240    "sh", "bash", "dash", "zsh", "ksh", "fish", "python", "python2", "python3", "perl", "ruby",
241    "node", "php", "lua", "tclsh",
242];
243
244/// Commands that fetch from the network (the upstream side of `curl | sh`).
245const FETCHERS: &[&str] = &["curl", "wget", "fetch", "http", "https"];
246
247/// Specific path markers for files that commonly hold secrets. Deliberately
248/// narrow (no loose "secret"/"token"/"private" substrings) so an ordinary log
249/// like `/var/log/private-api.log` is not mistaken for a key store.
250const SECRET_PATTERNS: &[&str] = &[
251    "id_rsa",
252    "id_ed25519",
253    "id_ecdsa",
254    "id_dsa",
255    ".pem",
256    ".key",
257    ".pfx",
258    ".p12",
259    "/etc/shadow",
260    "/etc/gshadow",
261    "/.ssh/",
262    "authorized_keys",
263    ".env",
264    ".aws/credentials",
265    ".npmrc",
266    ".netrc",
267];
268
269/// System paths whose recursive deletion is unrecoverable host damage.
270const SYSTEM_PATHS: &[&str] = &[
271    "/", "/*", "/bin", "/sbin", "/usr", "/etc", "/var", "/lib", "/lib64", "/boot", "/opt", "/root",
272    "/home", "/srv", "/dev", "/proc", "/sys",
273];
274
275// =========================================================================
276// Quote-aware segmenter
277// =========================================================================
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280enum RedirKind {
281    /// `>` truncate / clobber.
282    Truncate,
283    /// `>>` append.
284    Append,
285    /// `<`, `2>`, `&>`, `2>&1`, here-docs: input or fd plumbing (benign).
286    Other,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq)]
290struct Redirect {
291    kind: RedirKind,
292    target: String,
293}
294
295/// One command in the pipeline: quote-stripped words, redirects, any inner
296/// command substitutions, and whether it follows a `|`.
297#[derive(Debug, Clone, Default, PartialEq, Eq)]
298struct Segment {
299    words: Vec<String>,
300    redirects: Vec<Redirect>,
301    substitutions: Vec<String>,
302    after_pipe: bool,
303}
304
305/// Capture a balanced `( ... )` group. `start` is the index of the opening `(`.
306/// Returns the inner text and the index just past the matching `)`.
307fn capture_balanced(chars: &[char], start: usize) -> (String, usize) {
308    let mut depth = 0i32;
309    let mut inner = String::new();
310    let mut i = start;
311    while i < chars.len() {
312        match chars[i] {
313            '(' => {
314                depth += 1;
315                if depth == 1 {
316                    i += 1;
317                    continue;
318                }
319            }
320            ')' => {
321                depth -= 1;
322                if depth == 0 {
323                    return (inner, i + 1);
324                }
325            }
326            _ => {}
327        }
328        inner.push(chars[i]);
329        i += 1;
330    }
331    (inner, i)
332}
333
334/// Capture a backtick `` `...` `` substitution. `start` is the opening backtick.
335fn capture_backtick(chars: &[char], start: usize) -> (String, usize) {
336    let mut inner = String::new();
337    let mut i = start + 1;
338    while i < chars.len() && chars[i] != '`' {
339        inner.push(chars[i]);
340        i += 1;
341    }
342    (inner, (i + 1).min(chars.len()))
343}
344
345/// Classify a redirect operator at `chars[i]` (`>` or `<`). Returns the kind and
346/// how many chars the operator spans.
347fn redirect_kind(chars: &[char], i: usize) -> (RedirKind, usize) {
348    let next = chars.get(i + 1).copied();
349    if chars[i] == '>' {
350        match next {
351            Some('>') => (RedirKind::Append, 2),
352            Some('|') => (RedirKind::Truncate, 2),
353            Some('&') => (RedirKind::Other, 2), // >& fd dup
354            _ => (RedirKind::Truncate, 1),
355        }
356    } else {
357        match next {
358            Some('<') | Some('&') => (RedirKind::Other, 2),
359            _ => (RedirKind::Other, 1),
360        }
361    }
362}
363
364/// Read a redirect target word starting at `i`, handling quotes and `$(...)`
365/// so a timestamped path like `/backups/x-$(date +%F).gz` is one target, not a
366/// spurious subshell split. Returns the target text and the index past it.
367fn read_target(chars: &[char], mut i: usize) -> (String, usize) {
368    let n = chars.len();
369    let mut target = String::new();
370    while i < n && !chars[i].is_whitespace() && !matches!(chars[i], '|' | '&' | ';' | '>' | '<') {
371        match chars[i] {
372            '\'' | '"' => {
373                let q = chars[i];
374                i += 1;
375                while i < n && chars[i] != q {
376                    target.push(chars[i]);
377                    i += 1;
378                }
379                i += 1;
380            }
381            '$' if chars.get(i + 1) == Some(&'(') => {
382                let (inner, ni) = capture_balanced(chars, i + 1);
383                target.push_str("$(");
384                target.push_str(&inner);
385                target.push(')');
386                i = ni;
387            }
388            '(' | ')' => break,
389            c => {
390                target.push(c);
391                i += 1;
392            }
393        }
394    }
395    (target, i)
396}
397
398/// Quote-aware split of a single-line command into per-command segments. Only
399/// UNQUOTED operators split or redirect; quoted text and quoted operators are
400/// inert, which is what kills the old tokenizer's `echo "rm -rf"` / `grep '>'`
401/// false positives.
402fn segment(command: &str) -> Vec<Segment> {
403    let chars: Vec<char> = command.chars().collect();
404    let n = chars.len();
405    let mut segments: Vec<Segment> = Vec::new();
406    let mut cur = Segment::default();
407    let mut word = String::new();
408    let mut next_after_pipe = false;
409    let mut i = 0usize;
410
411    macro_rules! flush_word {
412        () => {
413            if !word.is_empty() {
414                cur.words.push(std::mem::take(&mut word));
415            }
416        };
417    }
418    macro_rules! flush_segment {
419        ($after:expr) => {{
420            flush_word!();
421            cur.after_pipe = next_after_pipe;
422            if !cur.words.is_empty() || !cur.redirects.is_empty() || !cur.substitutions.is_empty() {
423                segments.push(std::mem::take(&mut cur));
424            } else {
425                cur = Segment::default();
426            }
427            next_after_pipe = $after;
428        }};
429    }
430
431    while i < n {
432        let c = chars[i];
433        match c {
434            '\\' => {
435                if i + 1 < n {
436                    word.push(chars[i + 1]);
437                    i += 2;
438                } else {
439                    i += 1;
440                }
441            }
442            '\'' => {
443                i += 1;
444                while i < n && chars[i] != '\'' {
445                    word.push(chars[i]);
446                    i += 1;
447                }
448                i += 1;
449            }
450            '"' => {
451                i += 1;
452                while i < n && chars[i] != '"' {
453                    if chars[i] == '\\' && i + 1 < n {
454                        word.push(chars[i + 1]);
455                        i += 2;
456                        continue;
457                    }
458                    if chars[i] == '$' && i + 1 < n && chars[i + 1] == '(' {
459                        let (inner, ni) = capture_balanced(&chars, i + 1);
460                        cur.substitutions.push(inner);
461                        i = ni;
462                        continue;
463                    }
464                    if chars[i] == '`' {
465                        let (inner, ni) = capture_backtick(&chars, i);
466                        cur.substitutions.push(inner);
467                        i = ni;
468                        continue;
469                    }
470                    word.push(chars[i]);
471                    i += 1;
472                }
473                i += 1;
474            }
475            '$' if i + 1 < n && chars[i + 1] == '(' => {
476                let (inner, ni) = capture_balanced(&chars, i + 1);
477                cur.substitutions.push(inner);
478                i = ni;
479            }
480            '`' => {
481                let (inner, ni) = capture_backtick(&chars, i);
482                cur.substitutions.push(inner);
483                i = ni;
484            }
485            c if c.is_whitespace() => {
486                flush_word!();
487                i += 1;
488            }
489            '|' => {
490                if chars.get(i + 1) == Some(&'|') {
491                    flush_segment!(false);
492                    i += 2;
493                } else {
494                    flush_segment!(true);
495                    i += 1;
496                }
497            }
498            '&' => {
499                if chars.get(i + 1) == Some(&'&') {
500                    flush_segment!(false);
501                    i += 2;
502                } else if chars.get(i + 1) == Some(&'>') {
503                    // `&>` / `&>>`: redirect stdout+stderr (benign for impact).
504                    flush_word!();
505                    let span = if chars.get(i + 2) == Some(&'>') { 3 } else { 2 };
506                    i += span;
507                    while i < n && chars[i].is_whitespace() {
508                        i += 1;
509                    }
510                    let (target, ni) = read_target(&chars, i);
511                    i = ni;
512                    cur.redirects.push(Redirect {
513                        kind: RedirKind::Other,
514                        target,
515                    });
516                } else {
517                    flush_segment!(false);
518                    i += 1;
519                }
520            }
521            ';' => {
522                flush_segment!(false);
523                i += 1;
524            }
525            '(' | ')' => {
526                flush_segment!(false);
527                i += 1;
528            }
529            '>' | '<' => {
530                // A lone leading fd digit (the `2` in `2>`) is part of the
531                // operator, not an operand.
532                if !word.is_empty() && word.chars().all(|c| c.is_ascii_digit()) {
533                    word.clear();
534                } else {
535                    flush_word!();
536                }
537                let (kind, span) = redirect_kind(&chars, i);
538                i += span;
539                while i < n && chars[i].is_whitespace() {
540                    i += 1;
541                }
542                let (target, ni) = read_target(&chars, i);
543                i = ni;
544                cur.redirects.push(Redirect { kind, target });
545            }
546            _ => {
547                word.push(c);
548                i += 1;
549            }
550        }
551    }
552    // Final flush of the trailing segment (no operator follows it).
553    if !word.is_empty() {
554        cur.words.push(word);
555    }
556    cur.after_pipe = next_after_pipe;
557    if !cur.words.is_empty() || !cur.redirects.is_empty() || !cur.substitutions.is_empty() {
558        segments.push(cur);
559    }
560    segments
561}
562
563// =========================================================================
564// Classifier: segments -> findings
565// =========================================================================
566
567/// Basename of a command head (`/usr/bin/rm` -> `rm`).
568fn basename(s: &str) -> &str {
569    s.rsplit('/').next().unwrap_or(s)
570}
571
572/// True for a `VAR=val` leading assignment (identifier before `=`, no `/`).
573fn is_assignment(w: &str) -> bool {
574    match w.split_once('=') {
575        Some((k, _)) => {
576            !k.is_empty()
577                && !k.contains('/')
578                && k.chars().all(|c| c.is_alphanumeric() || c == '_')
579                && k.chars()
580                    .next()
581                    .is_some_and(|c| c.is_alphabetic() || c == '_')
582        }
583        None => false,
584    }
585}
586
587/// How many of a wrapper's own option arguments to skip before its target
588/// command (best-effort).
589fn skip_wrapper_args(wrapper: &str, rest: &[String]) -> usize {
590    let mut k = 0;
591    match wrapper {
592        "env" => {
593            while k < rest.len() && (is_assignment(&rest[k]) || rest[k].starts_with('-')) {
594                k += 1;
595            }
596        }
597        "timeout" => {
598            while k < rest.len() && rest[k].starts_with('-') {
599                k += 1;
600            }
601            if k < rest.len() {
602                k += 1; // the duration
603            }
604        }
605        "nice" | "ionice" => {
606            while k < rest.len() && rest[k].starts_with('-') {
607                let takes_val = matches!(rest[k].as_str(), "-n" | "-c" | "-p");
608                k += 1;
609                if takes_val && k < rest.len() {
610                    k += 1;
611                }
612            }
613        }
614        "sudo" | "doas" => {
615            while k < rest.len() && rest[k].starts_with('-') {
616                let takes_val = matches!(
617                    rest[k].as_str(),
618                    "-u" | "-g" | "-p" | "-C" | "-h" | "-r" | "-t" | "-U"
619                );
620                k += 1;
621                if takes_val && k < rest.len() {
622                    k += 1;
623                }
624            }
625        }
626        _ => {
627            while k < rest.len() && rest[k].starts_with('-') {
628                k += 1;
629            }
630        }
631    }
632    k
633}
634
635/// Resolve a segment's real command head: skip leading `VAR=val`, unwrap
636/// wrappers. Returns `(head, args, privileged)` where `privileged` is set when a
637/// `sudo`/`su`/`doas` wrapper was unwrapped.
638fn resolve_head(seg: &Segment) -> Option<(String, Vec<String>, bool)> {
639    let words = &seg.words;
640    let mut idx = 0;
641    let mut privileged = false;
642    loop {
643        while idx < words.len() && is_assignment(&words[idx]) {
644            idx += 1;
645        }
646        let w = words.get(idx)?;
647        if WRAPPERS.contains(&w.as_str()) {
648            if matches!(w.as_str(), "sudo" | "su" | "doas") {
649                privileged = true;
650            }
651            idx += 1;
652            idx += skip_wrapper_args(w, words.get(idx..).unwrap_or(&[]));
653            continue;
654        }
655        if w.starts_with('-') {
656            // First token is a flag (e.g. the real command was a substitution):
657            // no resolvable head.
658            return None;
659        }
660        let args = words.get(idx + 1..).unwrap_or(&[]).to_vec();
661        return Some((w.clone(), args, privileged));
662    }
663}
664
665/// Non-flag positional arguments.
666fn positionals(args: &[String]) -> Vec<&String> {
667    args.iter().filter(|a| !a.starts_with('-')).collect()
668}
669
670/// First positional argument (the sub-command for git/docker/systemctl/...).
671fn first_subcommand(args: &[String]) -> Option<&str> {
672    args.iter()
673        .find(|a| !a.starts_with('-'))
674        .map(|s| s.as_str())
675}
676
677/// True when any short-flag bundle contains one of `wanted`, or a matching long
678/// flag is present.
679fn has_flag(args: &[String], wanted: &[char], long: &[&str]) -> bool {
680    args.iter().any(|a| {
681        (a.starts_with('-')
682            && !a.starts_with("--")
683            && a.chars().skip(1).any(|c| wanted.contains(&c)))
684            || long.contains(&a.as_str())
685    })
686}
687
688fn is_system_path(p: &str) -> bool {
689    if p == "/" || p == "/*" {
690        return true;
691    }
692    let t = p.trim_end_matches('/');
693    SYSTEM_PATHS.contains(&t) || p.contains("/*")
694}
695
696fn looks_unbounded(p: &str) -> bool {
697    p.contains('*') || p.contains('?') || p.starts_with('$') || p.contains("/$")
698}
699
700fn is_secret_path(p: &str) -> bool {
701    let lower = p.to_lowercase();
702    SECRET_PATTERNS.iter().any(|s| lower.contains(s))
703}
704
705/// A raw block device or any non-stdio `/dev` node: writing to it is destructive.
706fn is_block_device(p: &str) -> bool {
707    p.starts_with("/dev/") && !is_devnull(p)
708}
709
710/// System control file where ANY write (truncate or append) is high-impact:
711/// editing it changes privilege, accounts, scheduling or boot/network config.
712fn is_control_file(p: &str) -> bool {
713    const CONTROL: &[&str] = &[
714        "/etc/sudoers",
715        "/etc/passwd",
716        "/etc/shadow",
717        "/etc/gshadow",
718        "/etc/group",
719        "/etc/crontab",
720        "/etc/fstab",
721        "/etc/hosts",
722        "/etc/resolv.conf",
723        "/etc/environment",
724    ];
725    CONTROL.contains(&p)
726        || p.contains("authorized_keys")
727        || p.starts_with("/etc/sudoers.d/")
728        || p.starts_with("/etc/cron.")
729        || p.starts_with("/etc/systemd/")
730}
731
732/// Absolute path at or under a system root: recursive deletion or a config write
733/// here is unrecoverable host damage, not a local edit.
734fn is_under_system_path(p: &str) -> bool {
735    const ROOTS: &[&str] = &[
736        "/etc/",
737        "/var/lib/",
738        "/boot/",
739        "/usr/",
740        "/lib/",
741        "/lib64/",
742        "/bin/",
743        "/sbin/",
744        "/root",
745        "/sys/",
746        "/proc/",
747    ];
748    is_system_path(p) || ROOTS.iter().any(|r| p.starts_with(r))
749}
750
751/// The user's home directory: `rm -rf ~` wipes everything on each host.
752fn is_home(p: &str) -> bool {
753    p == "~" || p == "$HOME" || p.starts_with("~/") || p.starts_with("$HOME/")
754}
755
756/// Classify all segments into findings (pipe-to-interpreter detection, then a
757/// per-segment pass, plus one level of substitution recursion).
758fn classify(segments: &[Segment], depth: usize) -> Vec<Finding> {
759    let mut findings = Vec::new();
760
761    // Remote-fetch-and-execute: an interpreter head on a piped segment, when any
762    // segment in the line fetches from the network.
763    let heads: Vec<Option<(String, bool)>> = segments
764        .iter()
765        .map(|s| resolve_head(s).map(|(h, _, p)| (basename(&h).to_string(), p)))
766        .collect();
767    // Remote-fetch-and-execute: a fetcher anywhere, then an interpreter head in
768    // a LATER segment. Covers both the piped form (`curl | sh`) and the two-step
769    // form (`curl ... -o /tmp/x && bash /tmp/x`, `wget ...; sh ...`).
770    let first_fetcher = heads.iter().position(|h| {
771        h.as_ref()
772            .is_some_and(|(n, _)| FETCHERS.contains(&n.as_str()))
773    });
774    if let Some(fi) = first_fetcher {
775        for (idx, h) in heads.iter().enumerate() {
776            if idx <= fi {
777                continue;
778            }
779            if let Some((name, priv_)) = h {
780                if INTERPRETERS.contains(&name.as_str()) {
781                    let sev = if *priv_ {
782                        Severity::Critical
783                    } else {
784                        Severity::Elevated
785                    };
786                    findings.push(Finding::new(sev, Category::RemoteExec, "curl|sh"));
787                }
788            }
789        }
790    }
791
792    for seg in segments {
793        classify_segment(seg, &mut findings, depth);
794        if depth == 0 {
795            for inner in &seg.substitutions {
796                if !inner.trim().is_empty() {
797                    findings.extend(classify(&segment(inner), depth + 1));
798                }
799            }
800        }
801    }
802    findings
803}
804
805/// Per-segment classification: redirects, then the command head + flags.
806fn classify_segment(seg: &Segment, findings: &mut Vec<Finding>, depth: usize) {
807    for r in &seg.redirects {
808        classify_redirect(r, findings);
809    }
810    if let Some((head, args, privileged)) = resolve_head(seg) {
811        classify_head(basename(&head), &args, privileged, findings, depth);
812    }
813}
814
815/// A redirect's impact: truncating a real file is a write; truncating a device
816/// or writing (even appending) to a system control file is far worse.
817fn classify_redirect(r: &Redirect, findings: &mut Vec<Finding>) {
818    let t = &r.target;
819    if t.is_empty() || is_devnull(t) {
820        return;
821    }
822    match r.kind {
823        RedirKind::Truncate => {
824            if is_block_device(t) {
825                findings.push(Finding::new(
826                    Severity::Critical,
827                    Category::Irreversible,
828                    t.clone(),
829                ));
830            } else if is_control_file(t) || is_under_system_path(t) {
831                findings.push(Finding::new(
832                    Severity::Elevated,
833                    Category::Redirect,
834                    t.clone(),
835                ));
836            } else {
837                findings.push(Finding::new(
838                    Severity::WritesState,
839                    Category::Redirect,
840                    t.clone(),
841                ));
842            }
843        }
844        // Append is benign for ordinary files (a log line) but high-impact for a
845        // control file (e.g. `>> /etc/sudoers`, `>> ~/.ssh/authorized_keys`).
846        RedirKind::Append => {
847            if is_control_file(t) {
848                findings.push(Finding::new(
849                    Severity::Elevated,
850                    Category::Redirect,
851                    t.clone(),
852                ));
853            }
854        }
855        RedirKind::Other => {}
856    }
857}
858
859fn is_devnull(t: &str) -> bool {
860    matches!(t, "/dev/null" | "/dev/stdout" | "/dev/stderr")
861}
862
863/// The curated capability table: maps a command head + its flags/sub-command to
864/// impact findings. The heart of the analysis. Read-only forms (e.g. `git
865/// status`, `systemctl status`) intentionally emit nothing; an unknown head
866/// emits an `Unknown` finding so a green verdict is never falsely shown.
867//
868// One `match head` is the readable single source of truth for the rule table.
869// Splitting it across helper functions to dodge the line cap would scatter the
870// curated rules and make coverage harder to audit, so the allow is intentional.
871#[allow(clippy::too_many_lines)]
872fn classify_head(
873    head: &str,
874    args: &[String],
875    privileged: bool,
876    findings: &mut Vec<Finding>,
877    depth: usize,
878) {
879    if privileged {
880        findings.push(Finding::new(
881            Severity::Elevated,
882            Category::Privilege,
883            "sudo",
884        ));
885    }
886
887    // Filesystem-image utilities (mkfs, mkfs.ext4, ...).
888    if head == "mkfs" || head.starts_with("mkfs.") {
889        findings.push(Finding::new(
890            Severity::Critical,
891            Category::Irreversible,
892            "mkfs",
893        ));
894        return;
895    }
896
897    match head {
898        "rm" => {
899            let recursive = has_flag(args, &['r', 'f', 'R'], &["--recursive", "--force"]);
900            let ps = positionals(args);
901            // Irreversible host damage: a top-level system root, the home dir, an
902            // unbounded glob/variable target, or any recursive delete under a
903            // system root (/etc/letsencrypt, /var/lib/mysql, ...).
904            let critical = ps.iter().any(|p| {
905                is_system_path(p)
906                    || is_home(p)
907                    || (recursive && (looks_unbounded(p) || is_under_system_path(p)))
908            });
909            // Recursive delete of an absolute location is worse than a local one.
910            let absolute = ps.iter().any(|p| p.starts_with('/'));
911            if critical {
912                findings.push(Finding::new(
913                    Severity::Critical,
914                    Category::Irreversible,
915                    "rm",
916                ));
917            } else if recursive && absolute {
918                findings.push(Finding::new(
919                    Severity::Elevated,
920                    Category::Destructive,
921                    "rm -rf",
922                ));
923            } else {
924                let s = if recursive { "rm -rf" } else { "rm" };
925                findings.push(Finding::new(
926                    Severity::WritesState,
927                    Category::Destructive,
928                    s,
929                ));
930            }
931        }
932        "rmdir" | "unlink" => {
933            findings.push(Finding::new(
934                Severity::WritesState,
935                Category::Destructive,
936                head,
937            ));
938        }
939        "shred" | "wipefs" | "fdisk" | "sgdisk" | "parted" | "mkswap" => {
940            findings.push(Finding::new(
941                Severity::Critical,
942                Category::Irreversible,
943                head,
944            ));
945        }
946        "dd" => {
947            if let Some(of) = args.iter().find_map(|a| a.strip_prefix("of=")) {
948                if of.starts_with("/dev/") {
949                    findings.push(Finding::new(
950                        Severity::Critical,
951                        Category::Irreversible,
952                        "dd",
953                    ));
954                } else {
955                    findings.push(Finding::new(
956                        Severity::WritesState,
957                        Category::Destructive,
958                        "dd",
959                    ));
960                }
961            }
962        }
963        "truncate" => {
964            findings.push(Finding::new(
965                Severity::WritesState,
966                Category::Destructive,
967                "truncate",
968            ));
969        }
970        "find" => {
971            if args.iter().any(|a| a == "-delete") {
972                findings.push(Finding::new(
973                    Severity::WritesState,
974                    Category::Destructive,
975                    "find -delete",
976                ));
977            }
978            // Classify the command that -exec actually runs (the token after
979            // -exec, until `;`/`+`), not any "rm" anywhere in the args. So
980            // `find -exec chmod 644 {} ;` is destructive but `find -name rm
981            // -exec ls {} ;` is read-only.
982            if let Some(pos) = args
983                .iter()
984                .position(|a| matches!(a.as_str(), "-exec" | "-execdir" | "-ok" | "-okdir"))
985            {
986                let rest = args.get(pos + 1..).unwrap_or(&[]);
987                let end = rest
988                    .iter()
989                    .position(|a| a == ";" || a == "+" || a == "\\;")
990                    .unwrap_or(rest.len());
991                if let Some(cmd) = rest.get(..end).and_then(<[String]>::first) {
992                    classify_head(
993                        basename(cmd),
994                        rest.get(1..end).unwrap_or(&[]),
995                        false,
996                        findings,
997                        depth,
998                    );
999                }
1000            }
1001        }
1002        "sed" => {
1003            if args.iter().any(|a| {
1004                a == "-i"
1005                    || a.starts_with("--in-place")
1006                    || (a.starts_with("-i") && a.len() > 2 && !a.starts_with("--"))
1007            }) {
1008                findings.push(Finding::new(
1009                    Severity::WritesState,
1010                    Category::Destructive,
1011                    "sed -i",
1012                ));
1013            }
1014        }
1015        "tee" => {
1016            // Writing to a system/control path (e.g. `... | sudo tee /etc/...`)
1017            // is far worse than teeing to a local file.
1018            if positionals(args)
1019                .iter()
1020                .any(|p| is_control_file(p) || is_under_system_path(p) || is_block_device(p))
1021            {
1022                findings.push(Finding::new(Severity::Elevated, Category::Redirect, "tee"));
1023            } else {
1024                findings.push(Finding::new(
1025                    Severity::WritesState,
1026                    Category::Redirect,
1027                    "tee",
1028                ));
1029            }
1030        }
1031        "mv" | "cp" | "install" | "ln" => {
1032            findings.push(Finding::new(
1033                Severity::WritesState,
1034                Category::Destructive,
1035                head,
1036            ));
1037        }
1038        "chmod" => {
1039            let recursive = has_flag(args, &['R'], &["--recursive"]);
1040            let world = positionals(args)
1041                .iter()
1042                .any(|p| p.contains("777") || p.contains("o+w") || p.contains("a+w"));
1043            let s = if world {
1044                "chmod 777"
1045            } else if recursive {
1046                "chmod -R"
1047            } else {
1048                "chmod"
1049            };
1050            findings.push(Finding::new(
1051                Severity::WritesState,
1052                Category::Destructive,
1053                s,
1054            ));
1055        }
1056        "chown" | "chgrp" => {
1057            let recursive = has_flag(args, &['R'], &["--recursive"]);
1058            let s = if recursive {
1059                format!("{head} -R")
1060            } else {
1061                head.to_string()
1062            };
1063            findings.push(Finding::new(
1064                Severity::WritesState,
1065                Category::Destructive,
1066                s,
1067            ));
1068        }
1069        "systemctl" => match first_subcommand(args) {
1070            Some("reboot") | Some("poweroff") | Some("halt") => {
1071                findings.push(Finding::new(
1072                    Severity::Critical,
1073                    Category::Availability,
1074                    "systemctl",
1075                ));
1076            }
1077            Some(s @ ("stop" | "restart" | "kill" | "disable" | "mask" | "isolate")) => {
1078                findings.push(Finding::new(
1079                    Severity::Elevated,
1080                    Category::Service,
1081                    format!("systemctl {s}"),
1082                ));
1083            }
1084            Some(s @ ("start" | "enable" | "reload" | "daemon-reload" | "set-default")) => {
1085                findings.push(Finding::new(
1086                    Severity::WritesState,
1087                    Category::Service,
1088                    format!("systemctl {s}"),
1089                ));
1090            }
1091            _ => {}
1092        },
1093        "service" => {
1094            if positionals(args)
1095                .iter()
1096                .any(|p| matches!(p.as_str(), "stop" | "restart" | "reload"))
1097            {
1098                findings.push(Finding::new(
1099                    Severity::Elevated,
1100                    Category::Service,
1101                    "service",
1102                ));
1103            }
1104        }
1105        "kill" | "pkill" | "killall" => {
1106            // `kill -0` (liveness probe) and `kill -l` (list signals) send no
1107            // signal: read-only.
1108            if !args.iter().any(|a| a == "-0" || a == "-l" || a == "-L") {
1109                findings.push(Finding::new(Severity::Elevated, Category::Service, head));
1110            }
1111        }
1112        "reboot" | "shutdown" | "halt" | "poweroff" | "init" => {
1113            findings.push(Finding::new(
1114                Severity::Critical,
1115                Category::Availability,
1116                head,
1117            ));
1118        }
1119        "docker" | "podman" => {
1120            // Resolve up to the two leading sub-commands so nested forms
1121            // (`docker compose down`, `docker container rm`, `docker system
1122            // prune`) are classified, not just top-level ones.
1123            let ps = positionals(args);
1124            let s1 = ps.first().map(|s| s.as_str());
1125            let s2 = ps.get(1).map(|s| s.as_str());
1126            match (s1, s2) {
1127                (Some("system"), Some("prune")) | (Some("volume"), Some("rm" | "prune")) => {
1128                    findings.push(Finding::new(
1129                        Severity::Elevated,
1130                        Category::Destructive,
1131                        format!("{head} {} prune", s1.unwrap_or("")),
1132                    ));
1133                }
1134                (Some("rm" | "rmi"), _)
1135                | (Some("prune"), _)
1136                | (Some("container" | "image" | "network"), Some("rm" | "prune")) => {
1137                    findings.push(Finding::new(
1138                        Severity::WritesState,
1139                        Category::Destructive,
1140                        format!("{head} rm"),
1141                    ));
1142                }
1143                (Some("compose"), Some(s @ ("down" | "stop" | "kill" | "restart")))
1144                | (Some(s @ ("stop" | "kill" | "down" | "restart")), _) => {
1145                    findings.push(Finding::new(
1146                        Severity::Elevated,
1147                        Category::Service,
1148                        format!("{head} {s}"),
1149                    ));
1150                }
1151                (Some("compose"), Some(s @ ("up" | "start"))) => {
1152                    findings.push(Finding::new(
1153                        Severity::WritesState,
1154                        Category::Service,
1155                        format!("{head} compose {s}"),
1156                    ));
1157                }
1158                _ => {}
1159            }
1160        }
1161        "kubectl" => match first_subcommand(args) {
1162            Some("delete") => {
1163                findings.push(Finding::new(
1164                    Severity::Critical,
1165                    Category::Destructive,
1166                    "kubectl delete",
1167                ));
1168            }
1169            Some("drain") => {
1170                findings.push(Finding::new(
1171                    Severity::Elevated,
1172                    Category::Service,
1173                    "kubectl drain",
1174                ));
1175            }
1176            Some(
1177                s @ ("apply" | "create" | "patch" | "replace" | "scale" | "cordon" | "uncordon"),
1178            ) => {
1179                findings.push(Finding::new(
1180                    Severity::WritesState,
1181                    Category::Service,
1182                    format!("kubectl {s}"),
1183                ));
1184            }
1185            _ => {}
1186        },
1187        "git" => match first_subcommand(args) {
1188            Some("reset") if args.iter().any(|a| a == "--hard") => {
1189                findings.push(Finding::new(
1190                    Severity::WritesState,
1191                    Category::Irreversible,
1192                    "git reset --hard",
1193                ));
1194            }
1195            Some("clean") if has_flag(args, &['f'], &["--force"]) => {
1196                findings.push(Finding::new(
1197                    Severity::WritesState,
1198                    Category::Irreversible,
1199                    "git clean -f",
1200                ));
1201            }
1202            // Exact `--force`/`-f` only: `--force-with-lease` is the safe variant
1203            // that refuses to clobber unseen remote work.
1204            Some("push") if args.iter().any(|a| a == "--force" || a == "-f") => {
1205                findings.push(Finding::new(
1206                    Severity::Elevated,
1207                    Category::Destructive,
1208                    "git push --force",
1209                ));
1210            }
1211            Some(s @ ("checkout" | "restore")) if has_flag(args, &['f'], &["--force"]) => {
1212                findings.push(Finding::new(
1213                    Severity::WritesState,
1214                    Category::Destructive,
1215                    format!("git {s} --force"),
1216                ));
1217            }
1218            Some("rm") => {
1219                findings.push(Finding::new(
1220                    Severity::WritesState,
1221                    Category::Destructive,
1222                    "git rm",
1223                ));
1224            }
1225            // status/log/diff/fetch/add/commit/pull/merge/rebase/stash/tag and a
1226            // non-forced checkout are normal low-risk workflow: no finding.
1227            _ => {}
1228        },
1229        "apt" | "apt-get" | "dnf" | "yum" | "zypper" => match first_subcommand(args) {
1230            Some(s @ ("remove" | "purge" | "autoremove" | "erase")) => {
1231                findings.push(Finding::new(
1232                    Severity::Elevated,
1233                    Category::Package,
1234                    format!("{head} {s}"),
1235                ));
1236            }
1237            Some(
1238                s @ ("install" | "upgrade" | "update" | "dist-upgrade" | "full-upgrade"
1239                | "reinstall"),
1240            ) => {
1241                findings.push(Finding::new(
1242                    Severity::WritesState,
1243                    Category::Package,
1244                    format!("{head} {s}"),
1245                ));
1246            }
1247            _ => {}
1248        },
1249        "pacman" => {
1250            // Operation is the leading char of the first short bundle. -Ss/-Si/
1251            // -Sl/-Sp/-Sg (search/info) and -Q* (query) are read-only; only -R
1252            // removes and a bare -S/-U installs.
1253            let bundle = args
1254                .iter()
1255                .find(|a| a.starts_with('-') && !a.starts_with("--"))
1256                .map(String::as_str)
1257                .unwrap_or("");
1258            let op = bundle.chars().nth(1);
1259            let mods: String = bundle.chars().skip(2).collect();
1260            let read = matches!(op, Some('Q' | 'F' | 'T'))
1261                || (op == Some('S')
1262                    && mods
1263                        .chars()
1264                        .any(|c| matches!(c, 's' | 'i' | 'l' | 'p' | 'g')));
1265            if read {
1266                // read-only
1267            } else if op == Some('R') {
1268                findings.push(Finding::new(
1269                    Severity::Elevated,
1270                    Category::Package,
1271                    "pacman -R",
1272                ));
1273            } else if matches!(op, Some('S' | 'U')) {
1274                findings.push(Finding::new(
1275                    Severity::WritesState,
1276                    Category::Package,
1277                    "pacman -S",
1278                ));
1279            }
1280        }
1281        "apk" => match first_subcommand(args) {
1282            Some("del") => findings.push(Finding::new(
1283                Severity::Elevated,
1284                Category::Package,
1285                "apk del",
1286            )),
1287            Some("add") | Some("upgrade") => {
1288                findings.push(Finding::new(
1289                    Severity::WritesState,
1290                    Category::Package,
1291                    "apk add",
1292                ));
1293            }
1294            _ => {}
1295        },
1296        "pip" | "pip3" | "npm" | "gem" | "cargo" | "snap" | "flatpak" | "brew" => {
1297            if positionals(args).iter().any(|p| {
1298                matches!(
1299                    p.as_str(),
1300                    "install" | "uninstall" | "remove" | "upgrade" | "update"
1301                )
1302            }) {
1303                findings.push(Finding::new(Severity::WritesState, Category::Package, head));
1304            }
1305        }
1306        "rsync" => {
1307            if args
1308                .iter()
1309                .any(|a| a == "--delete" || a.starts_with("--delete"))
1310            {
1311                findings.push(Finding::new(
1312                    Severity::WritesState,
1313                    Category::Destructive,
1314                    "rsync --delete",
1315                ));
1316            }
1317        }
1318        "crontab" => {
1319            if has_flag(args, &['r'], &[]) {
1320                findings.push(Finding::new(
1321                    Severity::WritesState,
1322                    Category::Destructive,
1323                    "crontab -r",
1324                ));
1325            }
1326        }
1327        "userdel" | "groupdel" => {
1328            findings.push(Finding::new(Severity::Elevated, Category::Privilege, head));
1329        }
1330        "useradd" | "usermod" | "groupadd" | "groupmod" | "passwd" | "chpasswd" => {
1331            findings.push(Finding::new(
1332                Severity::WritesState,
1333                Category::Privilege,
1334                head,
1335            ));
1336        }
1337        "iptables" | "ip6tables" | "nft" | "ufw" => {
1338            // Listing rules (-L, -nvL, `list`, `list ruleset`, `status`) is
1339            // read-only. Only mutating subforms flag.
1340            let flush = has_flag(args, &['F'], &["--flush"])
1341                || positionals(args)
1342                    .iter()
1343                    .any(|p| matches!(p.as_str(), "flush" | "reset"));
1344            let mutates = has_flag(
1345                args,
1346                &['A', 'I', 'D', 'R', 'X', 'N', 'P', 'Z'],
1347                &[
1348                    "--append",
1349                    "--insert",
1350                    "--delete",
1351                    "--replace",
1352                    "--new-chain",
1353                    "--policy",
1354                ],
1355            ) || positionals(args).iter().any(|p| {
1356                matches!(
1357                    p.as_str(),
1358                    "add"
1359                        | "insert"
1360                        | "delete"
1361                        | "replace"
1362                        | "create"
1363                        | "enable"
1364                        | "disable"
1365                        | "allow"
1366                        | "deny"
1367                        | "reject"
1368                        | "limit"
1369                )
1370            });
1371            if flush {
1372                findings.push(Finding::new(
1373                    Severity::Elevated,
1374                    Category::Service,
1375                    format!("{head} flush"),
1376                ));
1377            } else if mutates {
1378                findings.push(Finding::new(Severity::WritesState, Category::Service, head));
1379            }
1380        }
1381        "mount" | "umount" | "swapoff" | "swapon" => {
1382            findings.push(Finding::new(Severity::WritesState, Category::Service, head));
1383        }
1384        "tar" => {
1385            // Extraction overwrites files; create/list are read-ish (the archive
1386            // file write, if any, is covered by the redirect/output).
1387            if has_flag(args, &['x'], &["--extract", "--get"]) {
1388                findings.push(Finding::new(
1389                    Severity::WritesState,
1390                    Category::Destructive,
1391                    "tar -x",
1392                ));
1393            }
1394        }
1395        "sh" | "bash" | "dash" | "zsh" | "ksh" | "fish" | "python" | "python2" | "python3"
1396        | "perl" | "ruby" | "node" | "php" | "lua" => {
1397            // `-c <payload>` / perl-ruby `-e <payload>` runs an inline command
1398            // string: recurse one level so `bash -c "rm -rf /"` surfaces the rm.
1399            let payload = args
1400                .iter()
1401                .position(|a| a == "-c" || a == "-e")
1402                .and_then(|i| args.get(i + 1));
1403            if let Some(p) = payload {
1404                if depth == 0 {
1405                    findings.extend(classify(&segment(p), depth + 1));
1406                }
1407            } else if positionals(args).iter().any(|p| !p.is_empty()) {
1408                // Running a local script file: effect not verified.
1409                findings.push(Finding::new(Severity::WritesState, Category::Unknown, head));
1410            }
1411            // A bare interpreter reading stdin is handled by pipe detection.
1412        }
1413        "cat" | "head" | "tail" | "less" | "more" | "bat" | "xxd" | "hexdump" | "strings" => {
1414            if positionals(args).iter().any(|p| is_secret_path(p)) {
1415                findings.push(Finding::new(Severity::WritesState, Category::Secrets, head));
1416            }
1417        }
1418        _ => {
1419            if !READ_ONLY_HEADS.contains(&head) && !head.is_empty() {
1420                findings.push(Finding::new(Severity::WritesState, Category::Unknown, head));
1421            }
1422        }
1423    }
1424}
1425
1426/// Analyse `command` for the Snippets IMPACT card. Pure and deterministic; runs
1427/// when the detail panel renders the selected snippet.
1428pub fn analyze_command(command: &str) -> CommandImpact {
1429    let segments = segment(command);
1430    let mut findings = classify(&segments, 0);
1431    dedup(&mut findings);
1432    CommandImpact { findings }
1433}
1434
1435/// Remove duplicate findings (same severity + category + subject), preserving
1436/// first-seen order. Findings are few, so the O(n^2) scan is fine.
1437fn dedup(findings: &mut Vec<Finding>) {
1438    let mut seen: Vec<Finding> = Vec::new();
1439    findings.retain(|f| {
1440        if seen.contains(f) {
1441            false
1442        } else {
1443            seen.push(f.clone());
1444            true
1445        }
1446    });
1447}
1448
1449#[cfg(test)]
1450#[path = "snippet_impact_tests.rs"]
1451mod tests;
1452
1453#[cfg(test)]
1454mod _sudoers_trace_probe {
1455    use super::*;
1456    #[test]
1457    fn probe_sudoers_append() {
1458        let cmd = "echo 'attacker ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers";
1459        let segs = segment(cmd);
1460        eprintln!("SEGMENTS={:#?}", segs);
1461        let r = analyze_command(cmd);
1462        eprintln!("SUDOERS_VERDICT={:?}", r.verdict());
1463        eprintln!("SUDOERS_FINDINGS={:?}", r.findings);
1464    }
1465}
1466
1467#[cfg(test)]
1468mod _find_exec_chmod_trace_probe {
1469    use super::*;
1470    #[test]
1471    fn probe_find_exec_chmod() {
1472        let cmds = [
1473            r#"find / -path '/proc' -prune -o -exec chmod 777 {} +"#,
1474            r#"find / -exec sh -c 'rm -rf "$1"' _ {} \;"#,
1475            r#"find . -exec mv {} /dev/null \;"#,
1476        ];
1477        for cmd in cmds {
1478            let segs = segment(cmd);
1479            eprintln!("CMD={:?}", cmd);
1480            for (i, s) in segs.iter().enumerate() {
1481                eprintln!(
1482                    "  SEG[{}] after_pipe={} words={:?} redirects_len={} subs={:?}",
1483                    i,
1484                    s.after_pipe,
1485                    s.words,
1486                    s.redirects.len(),
1487                    s.substitutions
1488                );
1489            }
1490            let r = analyze_command(cmd);
1491            eprintln!("  VERDICT={:?}", r.verdict());
1492            eprintln!("  FINDINGS={:?}", r.findings);
1493        }
1494    }
1495}
1496#[cfg(test)]
1497mod _case_probe {
1498    use super::*;
1499    #[test]
1500    fn probe_sudo_bash_c() {
1501        for cmd in ["sudo bash -c 'rm -rf /'", "sudo sh -c 'rm -rf /'"] {
1502            let segs = segment(cmd);
1503            let r = analyze_command(cmd);
1504            eprintln!("CMD={cmd:?}");
1505            for (i, s) in segs.iter().enumerate() {
1506                eprintln!(
1507                    "  SEG[{}] after_pipe={} words={:?} redirects_len={} subs={:?}",
1508                    i,
1509                    s.after_pipe,
1510                    s.words,
1511                    s.redirects.len(),
1512                    s.substitutions
1513                );
1514            }
1515            eprintln!("  VERDICT={:?}", r.verdict());
1516            eprintln!("  FINDINGS={:?}", r.findings);
1517        }
1518    }
1519}