kernel/discard/
forecast.rs1use crate::tool::ExecArm;
11
12pub(crate) const SUSPECT_SNIPPETS_TEXT: [&str; 5] =
16 ["rm ", "rmdir", "-delete", "git reset --hard", "git clean"];
17
18pub(crate) const SUSPECT_SNIPPETS_PYTHON: [&str; 3] = ["os.remove", "shutil.rmtree", "os.unlink"];
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum DiscardForecast {
23 Clear,
24 Suspected { pattern: String },
25}
26
27fn program_basename(path: &str) -> &str {
28 let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
29 name.strip_suffix(".exe").unwrap_or(name)
30}
31
32pub fn forecast(arm: &ExecArm) -> DiscardForecast {
36 match arm {
37 ExecArm::Program { path, args } => {
38 let base = program_basename(path);
39 if matches!(base, "rm" | "rmdir" | "del") {
40 return DiscardForecast::Suspected {
41 pattern: base.to_owned(),
42 };
43 }
44 let joined = args.join(" ");
45 if base == "git" && (joined.contains("reset --hard") || joined.contains("clean")) {
46 return DiscardForecast::Suspected {
47 pattern: format!("git {joined}"),
48 };
49 }
50 if base == "find" && args.iter().any(|a| a == "-delete") {
51 return DiscardForecast::Suspected {
52 pattern: "find -delete".to_owned(),
53 };
54 }
55 DiscardForecast::Clear
56 }
57 ExecArm::Python { code } => {
58 for pattern in SUSPECT_SNIPPETS_TEXT
59 .iter()
60 .chain(SUSPECT_SNIPPETS_PYTHON.iter())
61 {
62 if code.contains(pattern) {
63 return DiscardForecast::Suspected {
64 pattern: (*pattern).to_owned(),
65 };
66 }
67 }
68 if code.contains("open(") && (code.contains("'w'") || code.contains("\"w\"")) {
69 return DiscardForecast::Suspected {
70 pattern: "open(..., 'w')".to_owned(),
71 };
72 }
73 DiscardForecast::Clear
74 }
75 ExecArm::Shell { text } => {
76 for pattern in SUSPECT_SNIPPETS_TEXT.iter() {
77 if text.contains(pattern) {
78 return DiscardForecast::Suspected {
79 pattern: (*pattern).to_owned(),
80 };
81 }
82 }
83 if text.contains('>') && !text.contains(">>") {
84 return DiscardForecast::Suspected {
85 pattern: "> truncation".to_owned(),
86 };
87 }
88 DiscardForecast::Clear
89 }
90 }
91}
92
93#[cfg(test)]
94#[allow(
95 clippy::unwrap_used,
96 clippy::expect_used,
97 clippy::panic,
98 clippy::indexing_slicing,
99 reason = "test code"
100)]
101mod tests {
102 use super::super::request::DiscardRequest;
103 use super::super::request::{Discard, Restoration};
104 use super::super::verdict::{DiscardVerdict, decide};
105 use super::*;
106 use crate::address::Address;
107 use crate::budget::ByteLen;
108 use crate::locator::Locator;
109 use crate::taint::TaintSet;
110 use crate::taint::TaintSource;
111 use proptest::prelude::*;
112 fn addr(raw: &str) -> Address {
113 Address::parse(raw).unwrap()
114 }
115 fn tracked() -> Restoration {
116 Restoration::Tracked(Locator::parse(&format!("file:b/x.md@{}", "ab".repeat(20))).unwrap())
117 }
118 #[test]
119 fn program_forecast_reads_path_and_args_whole() {
120 let rm = ExecArm::Program {
121 path: "/usr/bin/rm".into(),
122 args: vec!["-rf".into(), "docs".into()],
123 };
124 assert!(matches!(forecast(&rm), DiscardForecast::Suspected { .. }));
125 let git_ok = ExecArm::Program {
126 path: "git".into(),
127 args: vec!["status".into()],
128 };
129 assert_eq!(forecast(&git_ok), DiscardForecast::Clear);
130 let git_hard = ExecArm::Program {
131 path: "git".into(),
132 args: vec!["reset".into(), "--hard".into()],
133 };
134 assert!(matches!(
135 forecast(&git_hard),
136 DiscardForecast::Suspected { .. }
137 ));
138 let find_delete = ExecArm::Program {
139 path: "find.exe".into(),
140 args: vec![".".into(), "-delete".into()],
141 };
142 assert!(matches!(
143 forecast(&find_delete),
144 DiscardForecast::Suspected { .. }
145 ));
146 }
147
148 #[test]
149 fn text_arms_are_conservative() {
150 let py = ExecArm::Python {
151 code: "import shutil\nshutil.rmtree('build')".into(),
152 };
153 assert!(matches!(forecast(&py), DiscardForecast::Suspected { .. }));
154 let py_write = ExecArm::Python {
155 code: "open('x.txt', 'w').write('hi')".into(),
156 };
157 assert!(matches!(
158 forecast(&py_write),
159 DiscardForecast::Suspected { .. }
160 ));
161 let py_read = ExecArm::Python {
162 code: "print(open('x.txt').read())".into(),
163 };
164 assert_eq!(forecast(&py_read), DiscardForecast::Clear);
165 let sh_trunc = ExecArm::Shell {
166 text: "echo hi > file.txt".into(),
167 };
168 assert!(matches!(
169 forecast(&sh_trunc),
170 DiscardForecast::Suspected { .. }
171 ));
172 let sh_append = ExecArm::Shell {
173 text: "echo hi >> log.txt".into(),
174 };
175 assert_eq!(forecast(&sh_append), DiscardForecast::Clear);
176 }
177
178 proptest! {
179 #[test]
181 fn allow_implies_every_guard_passed(files in 1usize..24, bytes in any::<u64>(),
182 tainted in any::<bool>()) {
183 let paths: Vec<Address> = (0..files).map(|i| addr(&format!("b/f{i}"))).collect();
184 let taint = if tainted {
185 TaintSet::of(TaintSource::new("web:x").unwrap())
186 } else {
187 TaintSet::empty()
188 };
189 let discard = Discard::new(paths, tracked(), taint, ByteLen::new(bytes)).unwrap();
190 let verdict = decide(&DiscardRequest::Planned(discard));
191 if verdict == DiscardVerdict::Allow {
192 prop_assert!(!tainted, "a delete asked for from outside is refused");
193 }
194 }
195 }
196}