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
//! The path denylist has to block the files it names and nothing else.
//!
//! It did neither. `validate_safe_path` matched each blocked pattern with
//! `path.to_lowercase().contains(&pattern.to_lowercase())`, and that single
//! line failed in both directions simultaneously:
//!
//! **Nothing it was written to protect was protected.** The entries `*.key`,
//! `*.pem`, `*.p12` and `*.pfx` were compared as literal text. No filename
//! contains the characters `*.pem`, so `cat("server.pem")` returned the
//! private key. Four of the thirteen patterns then in the list -- the four aimed
//! squarely at credential exfiltration by an agent -- had no effect at all.
//!
//! **Ordinary source files were unreadable.** `SAM`, `SYSTEM`, `SECURITY` and
//! `SOFTWARE` name Windows registry hives, but as substrings they matched any
//! path containing those letters:
//!
//! ```text
//! cat("src/security.rs") error: cat: path validation failed
//! cat("filesystem.rs") error: cat: path validation failed
//! cat("samples/data.txt") error: cat: path validation failed
//! ```
//!
//! This repository has a `samples/` directory, so the shell could not read its
//! own examples. It was found by asking the shell to count the lines in each
//! file of `src/` -- thirty-three files succeeded and the thirty-fourth,
//! `security.rs`, did not.
//!
//! A denylist that is wrong in the permissive direction is a security defect
//! and one that is wrong in the restrictive direction is a usability defect;
//! the same line of code produced both, which is why this file asserts both.
use iron_shell::security::validate_read_path;
use std::fs;
use std::path::{Path, PathBuf};
fn sandbox(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ae-blocked-{name}-{}", std::process::id()));
fs::create_dir_all(&dir).expect("create sandbox");
dir
}
fn write(dir: &Path, name: &str, body: &str) -> String {
let p = dir.join(name);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).expect("create parent");
}
fs::write(&p, body).expect("write fixture");
p.to_string_lossy().into_owned()
}
// ── the hole: credentials the denylist named and did not block ──────────
#[test]
fn key_material_is_refused_by_extension() {
let dir = sandbox("keys");
for name in ["server.pem", "id.key", "bundle.p12", "cert.pfx"] {
let path = write(&dir, name, "-----BEGIN PRIVATE KEY-----\n");
let err = validate_read_path(&path).expect_err(&format!(
"{name} must be refused: it is what the denylist is for"
));
assert!(
err.to_string().contains("blocked pattern"),
"{name} was refused for the wrong reason: {err}"
);
}
}
#[test]
fn the_extension_match_is_case_insensitive_and_anchored() {
let dir = sandbox("case");
let upper = write(&dir, "SERVER.PEM", "x");
assert!(
validate_read_path(&upper).is_err(),
"SERVER.PEM must be refused"
);
// `*.pem` blocks the extension, not the letters. A file that merely
// mentions pem in its name is ordinary.
let ok = write(&dir, "pemberton-notes.txt", "x");
assert!(
validate_read_path(&ok).is_ok(),
"pemberton-notes.txt is not key material"
);
}
// ── the breakage: ordinary files the denylist blocked ───────────────────
#[test]
fn source_files_named_after_a_registry_hive_are_readable() {
let dir = sandbox("src");
for name in [
"security.rs", // "SECURITY"
"filesystem.rs", // "SYSTEM"
"system_info.rs", // "SYSTEM"
"software_list.rs", // "SOFTWARE"
"samples/data.txt", // "SAM"
"sample.json", // "SAM"
] {
let path = write(&dir, name, "ordinary content\n");
assert!(
validate_read_path(&path).is_ok(),
"{name} was refused; substring matching on hive names is back"
);
}
}
// ── what must still be blocked ──────────────────────────────────────────
#[test]
fn literal_sensitive_paths_are_still_refused() {
for path in ["/etc/passwd", "/etc/shadow", "/etc/sudoers"] {
assert!(
validate_read_path(path).is_err(),
"{path} must remain blocked"
);
}
}
#[test]
fn a_separator_pattern_matches_on_a_component_boundary() {
let dir = sandbox("sep");
// `.ssh/id_rsa` must block the real thing...
let real = write(&dir, ".ssh/id_rsa", "key");
assert!(
validate_read_path(&real).is_err(),
".ssh/id_rsa must be refused"
);
// ...and not a file that merely contains those letters run together.
let note = write(&dir, "notes-about-ssh-id_rsa.md", "prose");
assert!(
validate_read_path(¬e).is_ok(),
"a document discussing id_rsa is not id_rsa"
);
}
#[test]
fn a_bare_hive_name_is_refused_as_a_whole_component() {
let dir = sandbox("hive");
let hive = write(&dir, "SAM", "hive bytes");
assert!(
validate_read_path(&hive).is_err(),
"a file named exactly SAM is a registry hive and must stay blocked"
);
}
// ── non-vacuity ─────────────────────────────────────────────────────────
#[test]
fn non_vacuity_the_validator_is_reachable_and_discriminating() {
// If `validate_read_path` accepted everything, half of this file passes
// trivially; if it rejected everything, the other half does.
let dir = sandbox("vac");
let ok = write(&dir, "plain.txt", "hello");
assert!(
validate_read_path(&ok).is_ok(),
"an ordinary file must validate"
);
assert!(
validate_read_path("/etc/shadow").is_err(),
"a blocked path must not validate"
);
assert!(
validate_read_path("").is_err(),
"the validator is not running at all"
);
}