1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
//! A caller value in a positional slot must not be readable as an option.
//!
//! `safety::reject_option_like` has existed since the `tar
//! --use-compress-program` finding. What kept going wrong was not the helper but
//! its coverage: it was called at three of eight `sqlite3` spawn sites, then at
//! none of the fourteen `curl` sites, then at none of the seventeen `git` sites.
//! Each time it was found by reading, and each time the reading had to be
//! repeated because nothing recorded the answer.
//!
//! This records it. The rule is scoped to the tools where a leading `-` buys
//! **code execution**, because that is a claim that can be checked rather than
//! asserted:
//!
//! | tool | the option |
//! |-----------------|-----------------------------------------------|
//! | `git` | `--upload-pack`, `--exec-path`, `-c <cfg>` |
//! | `ssh`/`scp`/`sftp` | `-o ProxyCommand=…`, `-S <program>` |
//! | `tar` | `--use-compress-program=…` |
//! | `zip`/`unzip` | `-TT <cmd>` |
//! | `curl` | `-K <file>` — a config that can set `output` |
//! | `wget` | `--use-askpass=…` |
//! | `openssl` | `-engine <so>` |
//! | `find` | `-exec … ;` |
//!
//! Tools outside that set are out of scope on purpose. A leading `-` reaching
//! `ps` or `uname` is a bad argument, not a foothold, and a rule that flagged
//! every one of the 372 sites carrying a value would be ignored rather than
//! obeyed. Narrow and enforced beats broad and waived.
//!
//! The allowlist below is the other half, and its entries are *decisions*: a slot
//! that is an option **by contract** (`git reset --hard`), or one where the value
//! is consumed as another option's argument and a leading `-` is legitimate data
//! (a commit message, a password, a `find -name` pattern). Guarding those would
//! break correct calls with no workaround, which is the failure mode
//! `reject_option_like`'s own doc warns about.
use std::collections::BTreeSet;
/// Tools where a caller-controlled leading `-` reaches an option that can run a
/// program. This list may only grow.
const RISKY_TOOLS: &[&str] = &[
"git", "ssh", "scp", "sftp", "rsync", "tar", "zip", "unzip", "find", "wget", "curl", "openssl",
"sqlite3",
];
/// Spawn sites that pass a caller value to a risky tool without
/// `reject_option_like`, each because guarding it would be wrong.
///
/// This list may only shrink, and every entry names a slot, not a builtin, so
/// "it was already on the list" cannot cover a second unguarded argument added
/// later.
const ALLOWED: &[(&str, &str)] = &[
(
"bi_git_status",
"the value is `.current_dir()`, not an argv slot — nothing parses it as an option",
),
(
"bi_git_diff",
"the value is `.current_dir()`, not an argv slot",
),
(
"bi_git_diff_staged",
"the value is `.current_dir()`, not an argv slot",
),
(
"bi_git_commit",
"the message is the argument of `-m`, which consumes it; messages legitimately start with '-'",
),
(
"bi_git_reset",
"the mode IS an option by contract — it defaults to `--mixed` and callers pass `--hard`",
),
(
"bi_session_checkpoint",
"the name is the argument of `git stash push -m`, which consumes it",
),
(
"bi_search_files",
"the pattern is the argument of `find -name`, which consumes it; glob patterns may start with '-'",
),
(
"bi_search_by_size",
"the size IS an option-shaped value by contract — it defaults to `+1M` and `-1M` is the documented spelling for 'smaller than'",
),
(
"bi_crypto_password_hash",
"a password may legitimately start with '-', and `openssl passwd` has no option that runs a program",
),
(
"bi_crypto_hmac",
"an HMAC key is opaque bytes and may start with '-'; it is the argument of `-hmac`, which consumes it",
),
(
"bi_git_clean",
"`flag` is a local bound to one of two source literals (\"-n\"/\"-f\") from a bool argument, not caller text — the one place 'bare identifier' over-approximates",
),
];
fn source() -> String {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/builtins.rs");
std::fs::read_to_string(path).expect("src/builtins.rs is readable")
}
/// Every spawn of a risky tool, as (tool, enclosing fn, body-so-far, arg window).
fn risky_spawn_sites(src: &str) -> Vec<(String, String, String, String)> {
let lines: Vec<&str> = src.lines().collect();
let mut fn_starts: Vec<(usize, String)> = Vec::new();
for (i, l) in lines.iter().enumerate() {
if l.starts_with("fn ") || l.starts_with("pub fn ") {
let name = l
.trim_start_matches("pub ")
.trim_start_matches("fn ")
.split('(')
.next()
.unwrap_or("")
.to_string();
fn_starts.push((i, name));
}
}
let enclosing = |i: usize| -> (usize, String) {
let mut best = (0usize, "<top>".to_string());
for (j, n) in &fn_starts {
if *j <= i {
best = (*j, n.clone());
} else {
break;
}
}
best
};
let mut out = Vec::new();
for (i, line) in lines.iter().enumerate() {
let t = line.trim();
if t.starts_with("//") || t.starts_with('*') {
continue;
}
let Some(tool) = RISKY_TOOLS
.iter()
.find(|p| t.contains(&format!("Command::new(\"{p}\")")))
else {
continue;
};
let (start, name) = enclosing(i);
let window = lines[i..(i + 10).min(lines.len())].join("\n");
out.push((tool.to_string(), name, lines[start..=i].join("\n"), window));
}
out
}
/// Does this spawn hand the tool an argument whose **first character** the
/// caller controls?
///
/// That is the precise question, and getting it wrong in the loose direction was
/// this file's first draft: it looked for `&` anywhere in a ten-line window and
/// reported eighteen sites, seventeen of them literals such as `["stash",
/// "list"]` followed two lines later by `&output.stdout`. A rule that cries wolf
/// on `git tag -l` gets an allowlist entry per false positive, and then it is a
/// list of excuses rather than a control.
///
/// So: only a *bare identifier* argument counts — `&host`, `archive`,
/// `&branch.clone()`. Everything else cannot begin with a caller's `-`:
///
/// * a string literal is written in the source;
/// * `&format!("-{}", n)` produces a constant prefix — the `-` is the tool's own
/// syntax, written here, and `n` cannot reach the front of the string;
/// * `&bits.to_string()` is a number.
///
/// This is deliberately a lower bound: an expression like
/// `&path.display().to_string()` would slip through. It catches the shape that
/// actually occurred every time — a caller's string handed straight over.
fn passes_a_value(window: &str) -> bool {
// Only the spawn statement itself, up to its terminating `;`. The window
// extends further so the enclosing-fn walk has context; the argument list
// does not.
let stmt = window.split_once(";\n").map(|(a, _)| a).unwrap_or(window);
let stmt = stmt.split_once(";\r\n").map(|(a, _)| a).unwrap_or(stmt);
let mut args: Vec<String> = Vec::new();
let mut rest = stmt;
while let Some(at) = rest.find(".arg") {
let after = &rest[at..];
let Some(open) = after.find('(') else { break };
let mut depth = 0i32;
let mut end = None;
for (k, c) in after[open..].char_indices() {
match c {
'(' | '[' => depth += 1,
')' | ']' => {
depth -= 1;
if depth == 0 {
end = Some(open + k);
break;
}
}
_ => {}
}
}
let Some(end) = end else { break };
let inner = after[open + 1..end].trim().trim_start_matches('[').trim();
for part in inner.split(',') {
let p = part.trim();
if !p.is_empty() {
args.push(p.to_string());
}
}
rest = &after[end..];
}
args.iter().any(|a| {
let a = a.trim().trim_matches(|c| c == '[' || c == ']').trim();
let a = a.trim_start_matches('&').trim();
let a = a.strip_suffix(".clone()").unwrap_or(a);
!a.is_empty()
&& a.chars()
.next()
.is_some_and(|c| c.is_alphabetic() || c == '_')
&& a.chars().all(|c| c.is_alphanumeric() || c == '_')
})
}
#[test]
fn every_risky_spawn_guards_its_positional_values() {
let src = source();
let sites = risky_spawn_sites(&src);
assert!(
sites.len() >= 50,
"only {} risky spawn sites parsed; the scanner has drifted and this test is \
checking almost nothing",
sites.len()
);
let mut offenders: BTreeSet<String> = BTreeSet::new();
let mut checked = 0usize;
for (tool, name, body, window) in &sites {
if !passes_a_value(window) {
continue;
}
checked += 1;
// `guard_network` calls `reject_option_like` on the URL, so a site that
// goes through it is covered by the one door rather than at the site.
if body.contains("reject_option_like") || body.contains("guard_network") {
continue;
}
if ALLOWED.iter().any(|(n, _)| n == name) {
continue;
}
offenders.insert(format!(" {tool:8} {name}"));
}
assert!(
checked >= 25,
"only {checked} risky spawn sites carry a value; the value test has drifted"
);
assert!(
offenders.is_empty(),
"{} spawn site(s) hand a caller value to a tool that can be made to run a \
program through an option, without `safety::reject_option_like`.\n\n\
A leading `-` in a path, host or ref is parsed as an option: \
`ssh -oProxyCommand=…`, `git --upload-pack=…`, `tar \
--use-compress-program=…`, `curl -K<config>`.\n\n\
Guard it, or — if the slot is an option by contract, or the value is \
consumed as another option's argument — add the function to ALLOWED in \
this file *with the reason*.\n\n{}",
offenders.len(),
offenders.into_iter().collect::<Vec<_>>().join("\n")
);
}
#[test]
fn the_scanner_finds_the_tools_it_claims_to() {
// A check on the checker. If the walk breaks, the rule above passes by
// finding nothing.
let sites = risky_spawn_sites(&source());
let tools: BTreeSet<&str> = sites.iter().map(|(t, _, _, _)| t.as_str()).collect();
// `sftp` was on this list until the dead-code pass: all three `sftp_*`
// implementations were unreachable and were deleted, taking three of this
// session's guard sites with them. Worth recording rather than quietly
// editing — some of the option-injection hardening was applied to code no
// caller could reach, which is a fair description of about a tenth of it.
for expected in ["git", "ssh", "scp", "tar", "unzip", "curl", "sqlite3"] {
assert!(
tools.contains(expected),
"{expected} spawn sites should have been found, got {tools:?}"
);
}
let names: BTreeSet<&str> = sites.iter().map(|(_, n, _, _)| n.as_str()).collect();
for expected in ["bi_git_checkout", "bi_ssh_exec", "bi_tar_extract"] {
assert!(
names.contains(expected),
"{expected} should be among the parsed sites"
);
}
}
#[test]
fn the_value_test_tells_a_literal_from_a_caller_value() {
assert!(passes_a_value(r#".args(["checkout", &branch])"#));
assert!(passes_a_value(r#"cmd.arg(&host).arg(&command);"#));
assert!(!passes_a_value(r#".args(["stash", "pop"])"#));
assert!(!passes_a_value(r#".args(["fetch"]).output()?;"#));
// The seventeen false positives the loose version produced, in one line
// each. A constant prefix means the caller cannot reach the front of the
// string, so the leading `-` is the tool's syntax rather than their input.
assert!(!passes_a_value(
r#".args(["log", &format!("-{}", count), "--oneline"])"#
));
assert!(!passes_a_value(
r#".args([".", "-name", &format!("*.{}", ext)])"#
));
assert!(!passes_a_value(r#".args(["genrsa", &bits.to_string()])"#));
// And the shape that made it cry wolf: a literal argv, then a borrow of
// the *result* two lines down.
assert!(!passes_a_value(
" .args([\"tag\", \"-l\"])
.output()?;
let t = String::from_utf8_lossy(&output.stdout);"
));
}
#[test]
fn the_allowlist_has_a_reason_for_every_entry_and_no_duplicates() {
let mut seen = BTreeSet::new();
for (name, reason) in ALLOWED {
assert!(
!reason.trim().is_empty(),
"{name} is allowed without a reason; the reason is the point"
);
assert!(
reason.len() > 25,
"{name}'s reason is too short to be one: {reason:?}"
);
assert!(seen.insert(*name), "{name} is listed twice");
}
assert!(
ALLOWED.len() <= 11,
"the option-injection allowlist has grown to {}; it may only shrink",
ALLOWED.len()
);
}
#[test]
fn every_allowlisted_function_still_exists() {
// An allowlist entry naming a function that no longer exists is a claim about
// nothing: it reads as a considered exception, and it exempts a name the
// compiler can no longer check. Three stale `SELF_GUARDED` entries survived
// exactly that way -- `curl_exec`, `lxc_exec` and `nohup_run` were listed as
// guarding themselves after their implementations had become unreachable.
//
// The list may only shrink, and this is what makes shrinking mandatory when
// the thing it describes is deleted.
let src = source();
let missing: Vec<&str> = ALLOWED
.iter()
.map(|(n, _)| *n)
// Two conventions in play: this file's allowlist holds *builtin* names
// (`apply`), the option-injection and shell-spawn ratchets hold *function*
// names (`bi_apply`). The first version of this check tested only one form
// and reported four live functions as missing. Accept either.
.filter(|n| !src.contains(&format!("fn {n}(")) && !src.contains(&format!("fn bi_{n}(")))
.collect();
assert!(
missing.is_empty(),
"these allowlist entries name functions that no longer exist: {missing:?}
Remove them -- an exception for something that is gone is not an exception."
);
}