1use serde::{Deserialize, Serialize};
65
66use crate::ast::{
67 AssignmentValue, Command, CommandArgument, CommandNameInner, List, Pipeline,
68 PrimaryExpression, Program, RedirectedStatement, Statement, StringPart,
69};
70use crate::wrappers::{peel_simple_command_loose, PeeledCommand};
71
72#[derive(
88 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
89)]
90#[serde(rename_all = "snake_case")]
91pub enum DangerTier {
92 Cosmetic,
94 Read,
96 ScopedWrite,
99 CrossWrite,
102 External,
105 Destructive,
109 ArbitraryExec,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase")]
122pub struct TierClassification {
123 pub tier: DangerTier,
124 pub reason: String,
129 pub payload_url: Option<String>,
134}
135
136pub fn classify_tier(program: &Program) -> TierClassification {
144 let mut acc: Option<TierClassification> = None;
145 for stmt in &program.statements {
146 let c = classify_statement(stmt);
147 acc = Some(match acc.take() {
148 None => c,
149 Some(prev) if c.tier > prev.tier => c,
150 Some(prev) => prev,
151 });
152 }
153 acc.unwrap_or_else(|| TierClassification {
154 tier: DangerTier::Read,
155 reason: "empty program".to_string(),
156 payload_url: None,
157 })
158}
159
160fn classify_statement(stmt: &Statement) -> TierClassification {
165 match stmt {
166 Statement::Command(cmd) => classify_command(cmd),
167 Statement::Pipeline(p) => classify_pipeline(p),
168 Statement::List(l) => classify_list(l),
169 Statement::Redirected(r) => classify_redirected(r),
170 Statement::Negated(n) => classify_statement(&n.inner),
171 Statement::CompoundStatement(cs) => classify_stmt_list(&cs.statements, "compound block"),
172 Statement::Subshell(s) => classify_stmt_list(&s.statements, "subshell"),
173 Statement::If(i) => {
174 let mut best = classify_stmt_list(&i.body, "if body");
175 for elif in &i.elif_clauses {
176 let c = classify_stmt_list(&elif.body, "elif body");
177 best = max_class(best, c);
178 }
179 if let Some(else_cl) = &i.else_clause {
180 best = max_class(best, classify_stmt_list(&else_cl.body, "else body"));
181 }
182 best
183 }
184 Statement::While(w) => classify_loop_body(&w.body),
185 Statement::For(f) => classify_loop_body(&f.body),
186 Statement::CStyleFor(c) => classify_loop_body(&c.body),
187 Statement::Case(c) => {
188 let mut best = read("case statement");
189 for item in &c.items {
190 best = max_class(best, classify_stmt_list(&item.body, "case branch"));
191 }
192 best
193 }
194 Statement::FunctionDefinition(_) => read("function definition"),
198 Statement::Declaration(_) => read("declaration"),
199 Statement::Unset(_) => read("unset"),
200 Statement::Test(_) => read("test expression"),
201 Statement::VariableAssignment(a) => classify_assignment_value(&a.value),
202 Statement::VariableAssignments(va) => {
203 let mut best = read("variable assignment");
204 for a in &va.assignments {
205 best = max_class(best, classify_assignment_value(&a.value));
206 }
207 best
208 }
209 }
210}
211
212fn classify_assignment_value(value: &AssignmentValue) -> TierClassification {
213 if let AssignmentValue::Primary(PrimaryExpression::CommandSubstitution(cs)) = value {
215 return classify_stmt_list(&cs.body, "command substitution");
216 }
217 read("variable assignment")
218}
219
220fn classify_stmt_list(stmts: &[Statement], label: &str) -> TierClassification {
221 let mut acc: Option<TierClassification> = None;
222 for s in stmts {
223 let c = classify_statement(s);
224 acc = Some(match acc.take() {
225 None => c,
226 Some(prev) => max_class(prev, c),
227 });
228 }
229 acc.unwrap_or_else(|| read(label))
230}
231
232fn classify_loop_body(body: &crate::ast::LoopBody) -> TierClassification {
233 let stmts = match body {
234 crate::ast::LoopBody::Compound(cs) => &cs.statements,
235 crate::ast::LoopBody::DoGroup(dg) => &dg.statements,
236 };
237 classify_stmt_list(stmts, "loop body")
238}
239
240fn classify_list(list: &List) -> TierClassification {
241 max_class(classify_statement(&list.left), classify_statement(&list.right))
244}
245
246fn classify_redirected(r: &RedirectedStatement) -> TierClassification {
247 match &r.body {
248 Some(b) => classify_statement(b),
249 None => read("redirect with no body"),
250 }
251}
252
253fn classify_pipeline(pipeline: &Pipeline) -> TierClassification {
261 let mut basenames: Vec<Option<String>> = Vec::with_capacity(pipeline.stages.len());
265 let mut fetched_urls: Vec<Option<String>> = Vec::with_capacity(pipeline.stages.len());
266 let mut per_stage_class: Vec<TierClassification> = Vec::with_capacity(pipeline.stages.len());
267 for stage in &pipeline.stages {
268 let peeled = stage_peel(stage);
269 let basename = peeled.as_ref().map(|p| p.primary.to_lowercase());
270 let fetched_url = peeled
271 .as_ref()
272 .filter(|p| is_fetcher(&p.primary))
273 .and_then(first_url_arg);
274 basenames.push(basename);
275 fetched_urls.push(fetched_url);
276 per_stage_class.push(classify_statement(stage));
277 }
278
279 let mut fetcher_url: Option<String> = None;
282 let mut fetcher_idx: Option<usize> = None;
283 for (i, basename) in basenames.iter().enumerate() {
284 if let Some(name) = basename {
285 if is_fetcher(name) {
286 fetcher_idx = Some(i);
287 if fetcher_url.is_none() {
288 fetcher_url = fetched_urls[i].clone();
289 }
290 }
291 }
292 }
293 if let Some(fi) = fetcher_idx {
294 for j in (fi + 1)..basenames.len() {
295 if let Some(name) = &basenames[j] {
296 if is_interpreter(name) {
297 return TierClassification {
298 tier: DangerTier::ArbitraryExec,
299 reason: format!("{} piped to {}", basenames[fi].as_deref().unwrap_or("?"), name),
300 payload_url: fetcher_url,
301 };
302 }
303 }
304 }
305 }
306
307 per_stage_class
309 .into_iter()
310 .reduce(max_class)
311 .unwrap_or_else(|| read("empty pipeline"))
312}
313
314fn stage_peel(stmt: &Statement) -> Option<PeeledCommand> {
315 let cmd = match stmt {
316 Statement::Command(c) => c,
317 Statement::Redirected(r) => match r.body.as_deref() {
318 Some(Statement::Command(c)) => c,
319 _ => return None,
320 },
321 _ => return None,
322 };
323 peel_simple_command_loose(cmd)
324}
325
326fn classify_command(cmd: &Command) -> TierClassification {
331 let lenient_basename = cmd_name_text_lenient(cmd)
336 .map(|s| basename_of(&s).to_ascii_lowercase());
337
338 if let Some(basename) = lenient_basename.as_deref() {
339 if basename == "sudo" {
341 return arbitrary("sudo escalates the entire program", None);
342 }
343 if basename == "doas" {
344 return arbitrary("doas escalates privilege", None);
345 }
346 if basename == "eval" && !cmd.arguments.is_empty() {
348 return arbitrary("eval runs computed code", None);
349 }
350 if is_interpreter(basename) {
352 if let Some(c) = classify_nested_fetch_exec(basename, &cmd.arguments) {
353 return c;
354 }
355 }
356 }
357
358 let Some(peeled) = peel_simple_command_loose(cmd) else {
362 return match lenient_basename {
363 Some(name) => scoped(&format!("opaque args to `{name}`")),
364 None => scoped("opaque command shape"),
365 };
366 };
367 let primary = peeled.primary.to_lowercase();
368 let basename = basename_of(&primary);
369 let args = &peeled.args;
370
371 if let Some(c) = classify_source_dot(basename, args) {
373 return c;
374 }
375
376 if let Some(c) = classify_interpreter_flag(basename, args) {
378 return c;
379 }
380
381 if let Some(c) = classify_package_runner(basename, args) {
383 return c;
384 }
385
386 classify_by_program(basename, args)
388}
389
390fn classify_source_dot(basename: &str, args: &[String]) -> Option<TierClassification> {
391 if (basename == "source" || basename == ".") && !args.is_empty() {
392 return Some(class(
393 DangerTier::CrossWrite,
394 "source runs the sourced file's commands",
395 None,
396 ));
397 }
398 None
399}
400
401fn cmd_name_text_lenient(cmd: &Command) -> Option<String> {
405 match &cmd.name.inner {
406 CommandNameInner::Primary(p) => primary_text_lenient(p),
407 CommandNameInner::Concatenation(c) => {
408 let s: String = c.parts.iter().filter_map(primary_text_lenient).collect();
409 if s.is_empty() { None } else { Some(s) }
410 }
411 }
412}
413
414fn primary_text_lenient(p: &PrimaryExpression) -> Option<String> {
415 match p {
416 PrimaryExpression::Word(w) => Some(w.text.clone()),
417 PrimaryExpression::RawString(r) => Some(r.text.trim_matches('\'').to_owned()),
418 PrimaryExpression::StringNode(s) => {
419 let text: String = s.parts.iter().filter_map(|part| match part {
420 StringPart::Content(c) => Some(c.text.clone()),
421 _ => None,
422 }).collect();
423 if text.is_empty() { None } else { Some(text) }
424 }
425 PrimaryExpression::AnsiCString(a) => Some(a.text.clone()),
426 _ => None,
427 }
428}
429
430fn classify_interpreter_flag(basename: &str, args: &[String]) -> Option<TierClassification> {
432 let interp_flag = match basename {
433 "node" | "deno" | "bun" => Some("-e"),
434 "python" | "python3" | "python2" => Some("-c"),
435 "ruby" => Some("-e"),
436 "perl" => Some("-e"),
437 "php" => Some("-r"),
438 _ => None,
439 }?;
440 let mut i = 0;
442 while i < args.len() {
443 let a = &args[i];
444 if a == interp_flag {
445 if let Some(body) = args.get(i + 1) {
447 if !body.trim().is_empty() {
449 return Some(arbitrary(
450 &format!("{basename} {interp_flag} runs an inline script"),
451 None,
452 ));
453 }
454 }
455 return None;
456 }
457 if a.starts_with(interp_flag) && a.len() > interp_flag.len() {
459 return Some(arbitrary(
460 &format!("{basename} {interp_flag} runs an inline script"),
461 None,
462 ));
463 }
464 i += 1;
465 }
466 None
467}
468
469fn classify_nested_fetch_exec(
471 basename: &str,
472 args: &[CommandArgument],
473) -> Option<TierClassification> {
474 let mut url: Option<String> = None;
475 if any_arg_runs_fetcher(args, &mut url) {
476 return Some(arbitrary(
477 &format!("{basename} runs code fetched in a substitution"),
478 url,
479 ));
480 }
481 None
482}
483
484fn any_arg_runs_fetcher(args: &[CommandArgument], url: &mut Option<String>) -> bool {
485 for a in args {
486 if arg_runs_fetcher(a, url) {
487 return true;
488 }
489 }
490 false
491}
492
493fn arg_runs_fetcher(arg: &CommandArgument, url: &mut Option<String>) -> bool {
494 match arg {
495 CommandArgument::Primary(p) => primary_runs_fetcher(p, url),
496 CommandArgument::Concatenation(c) => c.parts.iter().any(|p| primary_runs_fetcher(p, url)),
497 CommandArgument::Regex(_) | CommandArgument::Operator { .. } => false,
498 }
499}
500
501fn primary_runs_fetcher(p: &PrimaryExpression, url: &mut Option<String>) -> bool {
502 match p {
503 PrimaryExpression::ProcessSubstitution(ps) => stmts_run_fetcher(&ps.body, url),
504 PrimaryExpression::CommandSubstitution(cs) => stmts_run_fetcher(&cs.body, url),
505 PrimaryExpression::StringNode(s) => string_parts_run_fetcher(&s.parts, url),
506 PrimaryExpression::TranslatedString(s) => string_parts_run_fetcher(&s.parts, url),
507 _ => false,
508 }
509}
510
511fn string_parts_run_fetcher(parts: &[StringPart], url: &mut Option<String>) -> bool {
512 parts.iter().any(|part| match part {
513 StringPart::CommandSubstitution(cs) => stmts_run_fetcher(&cs.body, url),
514 _ => false,
515 })
516}
517
518fn stmts_run_fetcher(stmts: &[Statement], url: &mut Option<String>) -> bool {
519 for s in stmts {
520 if stmt_runs_fetcher(s, url) {
521 return true;
522 }
523 }
524 false
525}
526
527fn stmt_runs_fetcher(stmt: &Statement, url: &mut Option<String>) -> bool {
528 match stmt {
529 Statement::Command(c) => {
530 if let Some(peeled) = peel_simple_command_loose(c) {
531 if is_fetcher(&peeled.primary) {
532 if url.is_none() {
533 *url = first_url_arg(&peeled);
534 }
535 return true;
536 }
537 }
538 false
539 }
540 Statement::Pipeline(p) => p.stages.iter().any(|s| stmt_runs_fetcher(s, url)),
541 Statement::List(l) => stmt_runs_fetcher(&l.left, url) || stmt_runs_fetcher(&l.right, url),
542 Statement::Redirected(r) => r.body.as_deref().is_some_and(|b| stmt_runs_fetcher(b, url)),
543 Statement::Negated(n) => stmt_runs_fetcher(&n.inner, url),
544 Statement::CompoundStatement(cs) => stmts_run_fetcher(&cs.statements, url),
545 Statement::Subshell(s) => stmts_run_fetcher(&s.statements, url),
546 _ => false,
547 }
548}
549
550fn classify_package_runner(basename: &str, args: &[String]) -> Option<TierClassification> {
552 match basename {
553 "npx" => {
554 let pkg = first_non_flag(args)?;
555 if !is_pinned_package(pkg) {
556 return Some(arbitrary(
557 "npx fetches and runs an unpinned npm package",
558 None,
559 ));
560 }
561 }
562 "uvx" => {
563 let pkg = first_non_flag(args)?;
564 if !is_pinned_package(pkg) {
565 return Some(arbitrary(
566 "uvx fetches and runs an unpinned python package",
567 None,
568 ));
569 }
570 }
571 "pipx" => {
572 if args.first().map(String::as_str) == Some("run") {
573 let pkg = args.iter().skip(1).find(|a| !a.starts_with('-'))?;
574 if !is_pinned_package(pkg) {
575 return Some(arbitrary(
576 "pipx run fetches and runs an unpinned python package",
577 None,
578 ));
579 }
580 }
581 }
582 _ => {}
583 }
584 None
585}
586
587fn is_pinned_package(spec: &str) -> bool {
592 if let Some((_, rest)) = spec.split_once('@') {
594 if !spec.starts_with('@') {
596 return rest.starts_with(|c: char| c.is_ascii_digit());
597 }
598 if let Some((_, version)) = rest.split_once('@') {
600 return version.starts_with(|c: char| c.is_ascii_digit());
601 }
602 }
603 spec.contains("==") || spec.contains(">=") || spec.contains("~=")
605}
606
607fn classify_by_program(basename: &str, args: &[String]) -> TierClassification {
612 let first = args.first().map(String::as_str).unwrap_or("");
613
614 match basename {
615 "ls" | "grep" | "rg" | "ag" | "ack" | "find" | "fd" | "cat" | "bat"
617 | "head" | "tail" | "less" | "more" | "wc" | "file" | "stat" | "du"
618 | "df" | "tree" | "awk" | "sed" | "jq" | "yq" | "which" | "whereis"
619 | "pwd" | "echo" | "printf" | "fzf" | "ps" | "top" | "htop" | "lsof"
620 | "diff" | "true" | "false" | "test" | "[" => {
621 if basename == "find" && args.iter().any(|a| a == "-delete") {
623 return class(
624 DangerTier::CrossWrite,
625 "find -delete removes matched files",
626 None,
627 );
628 }
629 if basename == "sed" && sed_has_in_place(args) {
631 return class(
632 DangerTier::ScopedWrite,
633 "sed -i edits files in place",
634 None,
635 );
636 }
637 if basename == "awk" {
639 if let Some(c) = classify_awk(args) {
640 return c;
641 }
642 }
643 read(basename)
644 }
645
646 "git" => classify_git(args),
648
649 "gh" => classify_gh(args),
651
652 "rm" | "rmdir" => classify_rm(args),
654 "mv" | "cp" => {
655 if any_dest_destructive(args) {
656 destructive("write to system or dotfile path")
657 } else {
658 scoped(basename)
659 }
660 }
661 "dd" | "mkfs" | "shred" | "fdisk" | "parted" => destructive(basename),
662 "chmod" | "chown" => {
663 if any_dest_destructive(args) {
664 destructive(&format!("{basename} on system or dotfile path"))
665 } else {
666 scoped(basename)
667 }
668 }
669 "crontab" | "launchctl" | "systemctl" | "service" => destructive(basename),
670
671 "curl" | "wget" => classify_fetcher_alone(basename, args),
673
674 "apt" | "apt-get" | "yum" | "dnf" | "pacman" | "zypper" => destructive(basename),
676 "brew" => {
677 if matches!(first, "install" | "uninstall" | "upgrade" | "cask") {
678 destructive(&format!("brew {first}"))
679 } else {
680 read("brew")
681 }
682 }
683 "npm" | "bun" | "pnpm" | "yarn" => {
684 match first {
685 "publish" => external(&format!("{basename} publish")),
686 "install" | "i" | "add" | "remove" | "uninstall" => scoped(&format!("{basename} {first}")),
687 _ => scoped(basename),
688 }
689 }
690 "cargo" => match first {
691 "publish" => external("cargo publish"),
692 "install" | "uninstall" => scoped(&format!("cargo {first}")),
693 _ => scoped("cargo"),
694 },
695
696 "ssh" => external("ssh runs a remote command"),
698 "scp" | "rsync" => external(basename),
699 "kubectl" => match first {
700 "delete" | "apply" | "create" | "patch" | "replace" | "edit" => {
701 external(&format!("kubectl {first}"))
702 }
703 _ => read("kubectl"),
704 },
705 "docker" | "podman" | "nerdctl" => match first {
706 "push" => external(&format!("{basename} push")),
707 "rm" | "rmi" | "kill" | "stop" | "system" => scoped(&format!("{basename} {first}")),
708 "run" | "exec" => scoped(&format!("{basename} {first}")),
709 _ => read(basename),
710 },
711
712 "export" | "alias" | "unalias" | "set" | "shopt" | "ulimit"
714 | "cd" | "pushd" | "popd" | "history" => read(basename),
715
716 "yah" => scoped("yah"),
719
720 _ => scoped(&format!("unknown command `{basename}`")),
721 }
722}
723
724fn classify_git(args: &[String]) -> TierClassification {
725 let sub = args.first().map(String::as_str).unwrap_or("");
726 match sub {
727 "status" | "log" | "diff" | "show" | "branch" | "tag"
729 | "ls-files" | "ls-tree" | "reflog" | "rev-parse" | "config"
730 | "describe" | "blame" | "shortlog" | "stash" => read(&format!("git {sub}")),
731
732 "push" => {
734 if args.iter().any(|a| a == "--force" || a == "-f" || a == "--force-with-lease") {
739 return class(DangerTier::External, "git push --force", None);
740 }
741 external("git push")
742 }
743 "fetch" | "pull" | "clone" => external(&format!("git {sub}")),
744
745 "reset" => {
747 if args.iter().any(|a| a == "--hard") {
748 return class(DangerTier::CrossWrite, "git reset --hard", None);
749 }
750 scoped("git reset")
751 }
752 "rebase" | "merge" | "cherry-pick" | "revert" | "filter-branch" | "filter-repo" => {
753 class(DangerTier::CrossWrite, &format!("git {sub}"), None)
754 }
755 "checkout" => {
756 if args.iter().any(|a| a == "--") {
757 class(DangerTier::CrossWrite, "git checkout -- (discards changes)", None)
758 } else {
759 scoped("git checkout")
760 }
761 }
762 "clean" => {
763 if args.iter().any(|a| a == "-fd" || a == "-fdx" || a == "-f") {
764 class(DangerTier::CrossWrite, "git clean -f removes untracked files", None)
765 } else {
766 scoped("git clean")
767 }
768 }
769
770 "remote" => {
773 let action = args.get(1).map(String::as_str).unwrap_or("");
774 if action == "add" || action == "set-url" {
775 destructive(&format!("git remote {action}"))
776 } else {
777 read(&format!("git remote {action}"))
778 }
779 }
780
781 "commit" | "add" | "rm" | "mv" | "apply" | "init" => scoped(&format!("git {sub}")),
784
785 _ => scoped(&format!("git {sub}")),
786 }
787}
788
789fn classify_gh(args: &[String]) -> TierClassification {
790 let obj = args.first().map(String::as_str).unwrap_or("");
791 let action = args.get(1).map(String::as_str).unwrap_or("");
792 match (obj, action) {
793 ("pr", "create") | ("pr", "merge") | ("pr", "close") | ("pr", "reopen")
794 | ("pr", "comment") | ("pr", "review") | ("pr", "edit")
795 | ("issue", "create") | ("issue", "close") | ("issue", "comment") | ("issue", "edit")
796 | ("release", "create") | ("release", "delete") | ("release", "edit")
797 | ("repo", "create") | ("repo", "delete") | ("repo", "edit")
798 | ("api", _) | ("workflow", "run") | ("workflow", "enable") | ("workflow", "disable")
799 | ("run", "cancel") | ("run", "rerun") => external(&format!("gh {obj} {action}").trim().to_string().as_str()),
800 _ => read(&format!("gh {obj}")),
801 }
802}
803
804fn classify_rm(args: &[String]) -> TierClassification {
805 let mut destructive_path = false;
806 let mut has_recursive = false;
807 let mut has_force = false;
808 let mut targets: Vec<&str> = Vec::new();
809 for a in args {
810 if a.starts_with('-') {
811 if a.contains('r') || a.contains('R') {
812 has_recursive = true;
813 }
814 if a.contains('f') {
815 has_force = true;
816 }
817 continue;
818 }
819 targets.push(a.as_str());
820 if is_destructive_path(a) {
821 destructive_path = true;
822 }
823 }
824 if destructive_path {
825 return destructive("rm of a system or dotfile path");
826 }
827 if has_recursive && has_force {
830 for t in &targets {
831 if *t == "/" || *t == "/*" {
832 return destructive("rm -rf of filesystem root");
833 }
834 }
835 }
836 scoped("rm")
837}
838
839fn classify_fetcher_alone(basename: &str, args: &[String]) -> TierClassification {
840 let mut write_method = false;
841 let mut i = 0;
842 while i < args.len() {
843 let a = &args[i];
844 if a == "-X" || a == "--request" {
845 if let Some(next) = args.get(i + 1) {
846 if is_http_write_method(next) {
847 write_method = true;
848 }
849 }
850 } else if let Some(rest) = a.strip_prefix("-X") {
851 if is_http_write_method(rest) {
852 write_method = true;
853 }
854 } else if let Some(rest) = a.strip_prefix("--request=") {
855 if is_http_write_method(rest) {
856 write_method = true;
857 }
858 }
859 i += 1;
860 }
861 if write_method {
862 return external(&format!("{basename} POST/PUT/DELETE/PATCH"));
863 }
864 if basename == "wget" {
867 return scoped("wget downloads a file");
868 }
869 if basename == "curl" {
870 if args.iter().any(|a| a == "-O" || a == "-o" || a.starts_with("--output")) {
871 return scoped("curl writes a file");
872 }
873 return read("curl");
874 }
875 read(basename)
876}
877
878fn is_http_write_method(s: &str) -> bool {
879 matches!(s, "POST" | "PUT" | "DELETE" | "PATCH")
880}
881
882fn classify_awk(args: &[String]) -> Option<TierClassification> {
888 let mut i = 0;
889 while i < args.len() {
890 let a = &args[i];
891 if a == "-f" || a == "--file" {
894 return Some(class(
895 DangerTier::ScopedWrite,
896 "awk -f reads a script file we can't inspect",
897 None,
898 ));
899 }
900 if (a.starts_with("-f") && a.len() > 2 && !a.starts_with("--"))
901 || a.starts_with("--file=")
902 {
903 return Some(class(
904 DangerTier::ScopedWrite,
905 "awk -f reads a script file we can't inspect",
906 None,
907 ));
908 }
909 if a == "-F" || a == "-v" {
911 i += 2;
912 continue;
913 }
914 if a.starts_with('-') && a != "-" {
916 i += 1;
917 continue;
918 }
919 if let Some(reason) = awk_script_writes_files(a) {
921 return Some(class(DangerTier::ScopedWrite, reason, None));
922 }
923 return None;
924 }
925 None
926}
927
928fn awk_script_writes_files(script: &str) -> Option<&'static str> {
932 if awk_has_system_call(script) {
933 return Some("awk script calls system()");
934 }
935 if awk_has_quoted_redirect(script) {
936 return Some("awk script redirects output to a file");
937 }
938 None
939}
940
941fn awk_has_system_call(script: &str) -> bool {
942 let bytes = script.as_bytes();
943 let needle = b"system";
944 if bytes.len() < needle.len() + 1 {
945 return false;
946 }
947 let mut i = 0;
948 while i + needle.len() <= bytes.len() {
949 if &bytes[i..i + needle.len()] == needle {
950 let mut j = i + needle.len();
951 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
952 j += 1;
953 }
954 if j < bytes.len() && bytes[j] == b'(' {
955 return true;
956 }
957 }
958 i += 1;
959 }
960 false
961}
962
963fn awk_has_quoted_redirect(script: &str) -> bool {
964 let bytes = script.as_bytes();
965 let mut i = 0;
966 while i < bytes.len() {
967 if bytes[i] == b'>' {
968 let mut j = i + 1;
969 if j < bytes.len() && bytes[j] == b'>' {
970 j += 1;
971 }
972 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
973 j += 1;
974 }
975 if j < bytes.len() && (bytes[j] == b'"' || bytes[j] == b'\'') {
976 return true;
977 }
978 i = j.max(i + 1);
979 } else {
980 i += 1;
981 }
982 }
983 false
984}
985
986fn sed_has_in_place(args: &[String]) -> bool {
990 args.iter().any(|a| {
991 if a == "-i" || a == "--in-place" {
992 return true;
993 }
994 if a.starts_with("--in-place=") {
995 return true;
996 }
997 a.starts_with("-i") && !a.starts_with("--") && a.len() > 2
999 })
1000}
1001
1002fn any_dest_destructive(args: &[String]) -> bool {
1003 args.iter().any(|a| !a.starts_with('-') && is_destructive_path(a))
1004}
1005
1006fn is_destructive_path(p: &str) -> bool {
1010 const DESTRUCTIVE_DOTFILE_PATTERNS: &[&str] = &[
1011 ".zshrc", ".bashrc", ".bash_profile", ".profile",
1012 ".zprofile", ".zshenv", ".bash_logout",
1013 ".ssh", ".aws", ".gnupg", ".docker", ".kube",
1014 ".config/git", ".gitconfig",
1015 ];
1016 const DESTRUCTIVE_SYSTEM_PREFIXES: &[&str] = &[
1017 "/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64",
1018 "/var", "/dev", "/proc", "/sys", "/boot", "/root",
1019 "/run", "/snap", "/Library", "/System",
1020 ];
1021 if p.starts_with('~') || p.starts_with("$HOME") {
1022 let tail = p.trim_start_matches('~').trim_start_matches("$HOME");
1023 let tail = tail.trim_start_matches('/');
1024 for pat in DESTRUCTIVE_DOTFILE_PATTERNS {
1025 if tail == *pat || tail.starts_with(&format!("{pat}/")) {
1026 return true;
1027 }
1028 }
1029 return false;
1030 }
1031 for prefix in DESTRUCTIVE_SYSTEM_PREFIXES {
1032 if p == *prefix || p.starts_with(&format!("{prefix}/")) {
1033 return true;
1034 }
1035 }
1036 false
1037}
1038
1039fn basename_of(primary: &str) -> &str {
1044 primary.rsplit('/').next().unwrap_or(primary)
1045}
1046
1047fn is_fetcher(name: &str) -> bool {
1048 let b = basename_of(name).to_ascii_lowercase();
1049 matches!(b.as_str(), "curl" | "wget" | "fetch" | "http" | "httpie")
1050}
1051
1052fn is_interpreter(name: &str) -> bool {
1053 let b = basename_of(name).to_ascii_lowercase();
1054 matches!(
1055 b.as_str(),
1056 "bash" | "sh" | "zsh" | "fish" | "dash" | "ksh" | "tcsh" | "csh"
1057 | "python" | "python2" | "python3" | "ruby" | "perl" | "node"
1058 | "deno" | "lua" | "php" | "Rscript"
1059 )
1060}
1061
1062fn first_non_flag(args: &[String]) -> Option<&str> {
1063 args.iter().map(String::as_str).find(|a| !a.starts_with('-'))
1064}
1065
1066fn first_url_arg(peeled: &PeeledCommand) -> Option<String> {
1067 peeled.args.iter().find(|a| looks_like_url(a)).cloned()
1068}
1069
1070fn looks_like_url(s: &str) -> bool {
1071 s.starts_with("http://") || s.starts_with("https://") || s.starts_with("ftp://")
1072}
1073
1074fn max_class(a: TierClassification, b: TierClassification) -> TierClassification {
1075 if b.tier > a.tier { b } else { a }
1076}
1077
1078fn class(tier: DangerTier, reason: &str, payload_url: Option<String>) -> TierClassification {
1079 TierClassification {
1080 tier,
1081 reason: reason.to_string(),
1082 payload_url,
1083 }
1084}
1085
1086fn read(reason: &str) -> TierClassification {
1087 class(DangerTier::Read, reason, None)
1088}
1089
1090fn scoped(reason: &str) -> TierClassification {
1091 class(DangerTier::ScopedWrite, reason, None)
1092}
1093
1094fn external(reason: &str) -> TierClassification {
1095 class(DangerTier::External, reason, None)
1096}
1097
1098fn destructive(reason: &str) -> TierClassification {
1099 class(DangerTier::Destructive, reason, None)
1100}
1101
1102fn arbitrary(reason: &str, payload_url: Option<String>) -> TierClassification {
1103 class(DangerTier::ArbitraryExec, reason, payload_url)
1104}
1105
1106#[cfg(test)]
1111mod tests {
1112 use super::*;
1113 use crate::parse_to_ast;
1114
1115 fn classify(src: &str) -> TierClassification {
1116 let prog = parse_to_ast(src).expect("parse failed");
1117 classify_tier(&prog)
1118 }
1119
1120 fn tier_of(src: &str) -> DangerTier {
1121 classify(src).tier
1122 }
1123
1124 #[test]
1127 fn curl_piped_to_bash_is_arbitrary_exec() {
1128 let c = classify("curl https://get.example.com/install.sh | bash");
1129 assert_eq!(c.tier, DangerTier::ArbitraryExec);
1130 assert_eq!(c.payload_url.as_deref(), Some("https://get.example.com/install.sh"));
1131 assert!(c.reason.contains("curl"));
1132 assert!(c.reason.contains("bash"));
1133 }
1134
1135 #[test]
1136 fn wget_piped_to_sh_is_arbitrary_exec() {
1137 let c = classify("wget -q -O- https://example.com/x.sh | sh");
1138 assert_eq!(c.tier, DangerTier::ArbitraryExec);
1139 }
1140
1141 #[test]
1142 fn curl_piped_to_python_is_arbitrary_exec() {
1143 let c = classify("curl https://example.com/install.py | python3");
1144 assert_eq!(c.tier, DangerTier::ArbitraryExec);
1145 assert_eq!(c.payload_url.as_deref(), Some("https://example.com/install.py"));
1146 }
1147
1148 #[test]
1149 fn bash_process_sub_curl_is_arbitrary_exec() {
1150 let c = classify("bash <(curl https://example.com/install.sh)");
1151 assert_eq!(c.tier, DangerTier::ArbitraryExec);
1152 assert_eq!(c.payload_url.as_deref(), Some("https://example.com/install.sh"));
1154 }
1155
1156 #[test]
1157 fn sh_dash_c_command_sub_curl_is_arbitrary_exec() {
1158 let c = classify(r#"sh -c "$(curl https://example.com/install.sh)""#);
1159 assert_eq!(c.tier, DangerTier::ArbitraryExec);
1160 assert_eq!(c.payload_url.as_deref(), Some("https://example.com/install.sh"));
1161 }
1162
1163 #[test]
1164 fn node_dash_e_is_arbitrary_exec() {
1165 assert_eq!(
1166 tier_of("node -e 'console.log(1)'"),
1167 DangerTier::ArbitraryExec,
1168 );
1169 }
1170
1171 #[test]
1172 fn python_dash_c_is_arbitrary_exec() {
1173 assert_eq!(
1174 tier_of("python3 -c 'import os; os.system(\"id\")'"),
1175 DangerTier::ArbitraryExec,
1176 );
1177 }
1178
1179 #[test]
1180 fn perl_dash_e_is_arbitrary_exec() {
1181 assert_eq!(tier_of("perl -e 'print 1'"), DangerTier::ArbitraryExec);
1182 }
1183
1184 #[test]
1185 fn npx_unpinned_is_arbitrary_exec() {
1186 assert_eq!(tier_of("npx create-react-app myapp"), DangerTier::ArbitraryExec);
1187 }
1188
1189 #[test]
1190 fn npx_pinned_is_not_arbitrary_exec() {
1191 assert_ne!(tier_of("npx create-react-app@5.0.1 myapp"), DangerTier::ArbitraryExec);
1193 }
1194
1195 #[test]
1196 fn pipx_run_unpinned_is_arbitrary_exec() {
1197 assert_eq!(tier_of("pipx run black ."), DangerTier::ArbitraryExec);
1198 }
1199
1200 #[test]
1201 fn sudo_anything_is_arbitrary_exec() {
1202 assert_eq!(tier_of("sudo apt install foo"), DangerTier::ArbitraryExec);
1203 assert_eq!(tier_of("sudo ls /root"), DangerTier::ArbitraryExec);
1204 }
1205
1206 #[test]
1207 fn eval_with_body_is_arbitrary_exec() {
1208 assert_eq!(tier_of(r#"eval "$cmd""#), DangerTier::ArbitraryExec);
1209 }
1210
1211 #[test]
1214 fn rm_dotfile_is_destructive() {
1215 assert_eq!(tier_of("rm ~/.zshrc"), DangerTier::Destructive);
1216 }
1217
1218 #[test]
1219 fn write_to_etc_is_destructive() {
1220 assert_eq!(tier_of("cp config /etc/hosts"), DangerTier::Destructive);
1221 }
1222
1223 #[test]
1224 fn crontab_is_destructive() {
1225 assert_eq!(tier_of("crontab -e"), DangerTier::Destructive);
1226 }
1227
1228 #[test]
1229 fn apt_is_destructive() {
1230 assert_eq!(tier_of("apt install foo"), DangerTier::Destructive);
1231 }
1232
1233 #[test]
1234 fn rm_rf_root_is_destructive() {
1235 assert_eq!(tier_of("rm -rf /"), DangerTier::Destructive);
1236 }
1237
1238 #[test]
1241 fn git_push_is_external() {
1242 assert_eq!(tier_of("git push origin main"), DangerTier::External);
1243 }
1244
1245 #[test]
1246 fn git_push_force_is_external() {
1247 assert_eq!(tier_of("git push --force origin main"), DangerTier::External);
1250 }
1251
1252 #[test]
1253 fn gh_pr_create_is_external() {
1254 assert_eq!(tier_of("gh pr create --title foo --body bar"), DangerTier::External);
1255 }
1256
1257 #[test]
1258 fn curl_post_is_external() {
1259 assert_eq!(
1260 tier_of("curl -X POST https://api.example.com/items -d '{}'"),
1261 DangerTier::External,
1262 );
1263 }
1264
1265 #[test]
1266 fn ssh_is_external() {
1267 assert_eq!(tier_of("ssh host uptime"), DangerTier::External);
1268 }
1269
1270 #[test]
1273 fn git_reset_hard_is_cross_write() {
1274 assert_eq!(tier_of("git reset --hard HEAD~1"), DangerTier::CrossWrite);
1275 }
1276
1277 #[test]
1278 fn git_rebase_is_cross_write() {
1279 assert_eq!(tier_of("git rebase main"), DangerTier::CrossWrite);
1280 }
1281
1282 #[test]
1283 fn find_delete_is_cross_write() {
1284 assert_eq!(tier_of("find . -name '*.tmp' -delete"), DangerTier::CrossWrite);
1285 }
1286
1287 #[test]
1290 fn rm_named_path_is_scoped() {
1291 assert_eq!(tier_of("rm build/output.o"), DangerTier::ScopedWrite);
1292 }
1293
1294 #[test]
1295 fn cargo_build_is_scoped() {
1296 assert_eq!(tier_of("cargo build --release"), DangerTier::ScopedWrite);
1297 }
1298
1299 #[test]
1300 fn git_commit_is_scoped() {
1301 assert_eq!(tier_of("git commit -m 'fix'"), DangerTier::ScopedWrite);
1302 }
1303
1304 #[test]
1305 fn unknown_command_defaults_to_scoped() {
1306 assert_eq!(tier_of("myscript --foo"), DangerTier::ScopedWrite);
1307 }
1308
1309 #[test]
1312 fn sed_without_i_is_read() {
1313 assert_eq!(tier_of("sed -n '1,5p' file.txt"), DangerTier::Read);
1314 assert_eq!(tier_of("sed 's/foo/bar/' file.txt"), DangerTier::Read);
1315 }
1316
1317 #[test]
1318 fn sed_dash_i_is_scoped_write() {
1319 let c = classify("sed -i 's/foo/bar/' file.txt");
1320 assert_eq!(c.tier, DangerTier::ScopedWrite);
1321 assert!(c.reason.contains("sed -i"));
1322 }
1323
1324 #[test]
1325 fn sed_dash_i_with_backup_suffix_is_scoped_write() {
1326 assert_eq!(
1328 tier_of("sed -i.bak 's/foo/bar/' file.txt"),
1329 DangerTier::ScopedWrite,
1330 );
1331 assert_eq!(
1334 tier_of("sed -i '.orig' 's/foo/bar/' file.txt"),
1335 DangerTier::ScopedWrite,
1336 );
1337 }
1338
1339 #[test]
1340 fn sed_long_in_place_is_scoped_write() {
1341 assert_eq!(
1342 tier_of("sed --in-place 's/foo/bar/' file.txt"),
1343 DangerTier::ScopedWrite,
1344 );
1345 assert_eq!(
1346 tier_of("sed --in-place=.bak 's/foo/bar/' file.txt"),
1347 DangerTier::ScopedWrite,
1348 );
1349 }
1350
1351 #[test]
1354 fn awk_print_pattern_is_read() {
1355 assert_eq!(tier_of("awk '{print}' file.txt"), DangerTier::Read);
1356 assert_eq!(tier_of("awk -F: '{print $1}' /etc/passwd"), DangerTier::Read);
1357 }
1358
1359 #[test]
1360 fn awk_with_system_call_is_scoped_write() {
1361 let c = classify(r#"awk 'BEGIN { system("rm x") }'"#);
1362 assert_eq!(c.tier, DangerTier::ScopedWrite);
1363 assert!(c.reason.contains("system"));
1364 }
1365
1366 #[test]
1367 fn awk_with_print_redirect_is_scoped_write() {
1368 let c = classify(r#"awk '{ print $1 > "out.txt" }' file.txt"#);
1369 assert_eq!(c.tier, DangerTier::ScopedWrite);
1370 assert!(c.reason.contains("redirect") || c.reason.contains("file"));
1371 }
1372
1373 #[test]
1374 fn awk_numeric_comparison_stays_read() {
1375 assert_eq!(
1377 tier_of("awk '$1 > 10 {print}' data.txt"),
1378 DangerTier::Read,
1379 );
1380 }
1381
1382 #[test]
1383 fn awk_with_dash_f_script_file_escalates() {
1384 assert_eq!(
1386 tier_of("awk -f script.awk data.txt"),
1387 DangerTier::ScopedWrite,
1388 );
1389 }
1390
1391 #[test]
1394 fn grep_is_read() {
1395 assert_eq!(tier_of("grep pattern file.txt"), DangerTier::Read);
1396 }
1397
1398 #[test]
1399 fn rg_is_read() {
1400 assert_eq!(tier_of("rg pattern src/"), DangerTier::Read);
1401 }
1402
1403 #[test]
1404 fn ls_is_read() {
1405 assert_eq!(tier_of("ls -la"), DangerTier::Read);
1406 }
1407
1408 #[test]
1409 fn cat_is_read() {
1410 assert_eq!(tier_of("cat README.md"), DangerTier::Read);
1411 }
1412
1413 #[test]
1414 fn echo_is_read() {
1415 assert_eq!(tier_of("echo hello"), DangerTier::Read);
1416 }
1417
1418 #[test]
1419 fn plain_curl_is_read() {
1420 assert_eq!(tier_of("curl https://example.com"), DangerTier::Read);
1422 }
1423
1424 #[test]
1427 fn list_takes_max() {
1428 assert_eq!(
1430 tier_of("grep foo file && rm -rf ~/.zshrc"),
1431 DangerTier::Destructive,
1432 );
1433 }
1434
1435 #[test]
1436 fn pipeline_falls_back_to_max_when_not_fetcher_interp() {
1437 assert_eq!(tier_of("cat file | grep pattern"), DangerTier::Read);
1439 }
1440
1441 #[test]
1442 fn wrapper_peels() {
1443 assert_eq!(tier_of("timeout 30 git push origin main"), DangerTier::External);
1445 }
1446
1447 #[test]
1448 fn empty_program_is_read() {
1449 assert_eq!(tier_of(""), DangerTier::Read);
1450 }
1451
1452 #[test]
1453 fn assignment_with_cmd_sub_runs_inner() {
1454 assert_eq!(
1456 tier_of("out=$(curl https://example.com/x.sh | bash)"),
1457 DangerTier::ArbitraryExec,
1458 );
1459 }
1460
1461 #[test]
1462 fn cd_then_destructive_takes_max() {
1463 assert_eq!(
1465 tier_of("cd /tmp && curl https://example.com/x.sh | bash"),
1466 DangerTier::ArbitraryExec,
1467 );
1468 }
1469
1470 #[test]
1473 fn reason_is_set_for_every_tier() {
1474 for src in [
1475 "ls",
1476 "git commit -m x",
1477 "git reset --hard",
1478 "git push",
1479 "rm ~/.zshrc",
1480 "curl https://x | bash",
1481 ] {
1482 let c = classify(src);
1483 assert!(!c.reason.is_empty(), "empty reason for `{src}`");
1484 }
1485 }
1486
1487 #[test]
1488 fn payload_url_only_set_for_fetched_exec() {
1489 assert!(classify("ls").payload_url.is_none());
1490 assert!(classify("git push").payload_url.is_none());
1491 assert!(classify("sudo ls").payload_url.is_none());
1492 assert!(classify("curl https://x | bash").payload_url.is_some());
1493 }
1494
1495 #[test]
1498 fn tier_order_matches_severity() {
1499 use DangerTier::*;
1500 assert!(Read < ScopedWrite);
1501 assert!(ScopedWrite < CrossWrite);
1502 assert!(CrossWrite < External);
1503 assert!(External < Destructive);
1504 assert!(Destructive < ArbitraryExec);
1505 assert!(Cosmetic < Read);
1506 }
1507
1508 #[test]
1509 fn tier_serde_round_trip() {
1510 for tier in [
1511 DangerTier::Cosmetic,
1512 DangerTier::Read,
1513 DangerTier::ScopedWrite,
1514 DangerTier::CrossWrite,
1515 DangerTier::External,
1516 DangerTier::Destructive,
1517 DangerTier::ArbitraryExec,
1518 ] {
1519 let json = serde_json::to_string(&tier).expect("ser");
1520 let back: DangerTier = serde_json::from_str(&json).expect("de");
1521 assert_eq!(back, tier);
1522 }
1523 }
1524
1525 #[test]
1526 fn classification_serde_round_trip() {
1527 let c = classify("curl https://example.com/x.sh | bash");
1528 let json = serde_json::to_string(&c).expect("ser");
1529 let back: TierClassification = serde_json::from_str(&json).expect("de");
1530 assert_eq!(back, c);
1531 }
1532}