Skip to content

Commit d2a126d

Browse files
hyperpolymathclaude
andcommitted
proofs(lean4) + tests: A-12 path-traversal containment
Closes frontier item A-12 from docs/PROOF-OPEN-FRONTIER.adoc: prove that ShellState::resolve_path always returns a PathBuf within the sandbox root, regardless of `..` / `.` / normal components in the input. Regression-guard for the 2026-02-12 CVE-class audit fix (resolve_path("../../etc/passwd") previously escaped the sandbox). Lean 4 — proofs/lean4/PathTraversal.lean * Models the Rust resolve_path loop as Component → applyComponent. * Proves applyComponent_preserves_root_prefix per component. * Lifts to normalizeRaw_within_root via foldl invariant. * Headline: path_traversal_containment. * Corollary: normalizePath_eq_normalizeRaw (final clamp never fires). * Wired into lakefile.lean as lean_lib PathTraversal. * Verified: lean PathTraversal.lean exit 0. Rust — impl/rust-cli/tests/security_tests.rs * property_resolve_path_stays_within_sandbox — proptest with mixed ../ + ./ + normal components, optional leading /. * property_resolve_path_parent_dir_heavy_stays_within_sandbox — 10:1 weighted toward `..` to specifically target the audit failure mode. Each runs 256 random cases (proptest default). * All 17 security tests pass (existing 15 + 2 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2e8e1d5 commit d2a126d

3 files changed

Lines changed: 273 additions & 0 deletions

File tree

impl/rust-cli/tests/security_tests.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
mod fixtures;
1313

1414
use fixtures::tempfile::TempDir;
15+
use proptest::prelude::*;
1516
use std::fs;
1617
use vsh::parser::parse_command;
1718
use vsh::state::ShellState;
@@ -114,6 +115,93 @@ fn security_path_traversal_protection() {
114115
}
115116
}
116117

118+
// ============================================================
119+
// Path traversal containment — property test for frontier A-12
120+
// ============================================================
121+
//
122+
// The headline theorem `PathTraversal.path_traversal_containment` in
123+
// `proofs/lean4/PathTraversal.lean` proves that the Rust normaliser's
124+
// loop-body invariant holds for arbitrary component sequences. This
125+
// property test is the Rust-side validation that the implementation
126+
// matches the Lean model: regardless of how many `..` / `.` / normal
127+
// components appear (or in what order), `resolve_path` always returns
128+
// a `PathBuf` that starts with the sandbox root.
129+
//
130+
// Regression-guard for the 2026-02-12 CVE-class audit fix
131+
// (`resolve_path("../../etc/passwd")` previously escaped the sandbox).
132+
133+
/// Generate a single path component as a string fragment.
134+
/// Mix of `..`, `.`, and short normal names to exercise every branch
135+
/// of the normaliser loop.
136+
fn component_strategy() -> impl Strategy<Value = String> {
137+
prop_oneof![
138+
Just("..".to_string()),
139+
Just(".".to_string()),
140+
"[a-zA-Z0-9_-]{1,8}".prop_map(|s| s),
141+
]
142+
}
143+
144+
/// Build a path string from a sequence of components, with optional
145+
/// leading slash to exercise the absolute-path strip.
146+
fn path_strategy() -> impl Strategy<Value = String> {
147+
(any::<bool>(), prop::collection::vec(component_strategy(), 0..20))
148+
.prop_map(|(absolute, parts)| {
149+
let joined = parts.join("/");
150+
if absolute {
151+
format!("/{}", joined)
152+
} else {
153+
joined
154+
}
155+
})
156+
}
157+
158+
proptest! {
159+
/// `resolve_path(p)` is always a descendant of the sandbox root, for
160+
/// any input `p` (any mix of `..`, `.`, normal components, with or
161+
/// without leading slash). Mirrors `PathTraversal.path_traversal_containment`.
162+
#[test]
163+
fn property_resolve_path_stays_within_sandbox(path in path_strategy()) {
164+
let temp = TempDir::new().unwrap();
165+
let state = ShellState::new(temp.path().to_str().unwrap()).unwrap();
166+
let resolved = state.resolve_path(&path);
167+
168+
// The sandbox root as the implementation sees it. We compare
169+
// path-prefix (not canonical-realpath) because the resolved
170+
// path may not physically exist; the Lean theorem is about
171+
// structural prefix, not filesystem realpath.
172+
let root = temp.path();
173+
prop_assert!(
174+
resolved.starts_with(root),
175+
"resolve_path({:?}) returned {:?}, which does not start with sandbox root {:?}",
176+
path, resolved, root
177+
);
178+
}
179+
180+
/// Stronger variant: paths heavily stocked with `..` (10× more
181+
/// likely than normal components) still stay contained. Targets
182+
/// the specific 2026-02-12 audit failure mode.
183+
#[test]
184+
fn property_resolve_path_parent_dir_heavy_stays_within_sandbox(
185+
path in prop::collection::vec(
186+
prop_oneof![
187+
10 => Just("..".to_string()),
188+
1 => "[a-z]{1,4}".prop_map(|s| s),
189+
],
190+
1..30,
191+
).prop_map(|parts| parts.join("/"))
192+
) {
193+
let temp = TempDir::new().unwrap();
194+
let state = ShellState::new(temp.path().to_str().unwrap()).unwrap();
195+
let resolved = state.resolve_path(&path);
196+
let root = temp.path();
197+
prop_assert!(
198+
resolved.starts_with(root),
199+
"..-heavy path {:?} escaped sandbox: resolved={:?}, root={:?}",
200+
path, resolved, root
201+
);
202+
}
203+
}
204+
117205
#[test]
118206
fn security_absolute_path_handling() {
119207
let temp = TempDir::new().unwrap();

proofs/lean4/PathTraversal.lean

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/- SPDX-License-Identifier: MPL-2.0
2+
Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
4+
Valence Shell — Path-traversal containment proof (frontier item A-12)
5+
6+
Formalises the path-normalisation discipline implemented by
7+
`vsh::state::ShellState::resolve_path` in `impl/rust-cli/src/state.rs`
8+
and proves that the result is always within the sandbox root,
9+
regardless of `..` components in the input.
10+
11+
The 2026-02-12 audit caught a real CVE-class bug here
12+
(`resolve_path("../../etc/passwd")` escaped the sandbox before the
13+
`normalized.starts_with(root)` check + `..`-pop guard landed). This
14+
module is the regression-guard.
15+
16+
Companion property test:
17+
`impl/rust-cli/tests/security_tests.rs::property_resolve_path_stays_within_sandbox`
18+
-/
19+
20+
namespace PathTraversal
21+
22+
-- Path representation matches `FilesystemModel.Path` (List String of
23+
-- components). Kept local to avoid coupling: this module is about
24+
-- containment of a path-string normalisation, not about the
25+
-- filesystem semantics.
26+
abbrev Path := List String
27+
28+
-- A raw path component as parsed from a user-supplied string.
29+
-- Mirrors `std::path::Component` in Rust (the three variants
30+
-- `resolve_path` switches on; we elide RootDir/Prefix since the
31+
-- Rust code strips them before normalisation).
32+
inductive Component where
33+
| parentDir : Component
34+
| curDir : Component
35+
| normal : String → Component
36+
deriving DecidableEq, Repr
37+
38+
-- Step the normaliser by one component. Matches the Rust loop body:
39+
-- `..` → pop if normalized strictly extends root (still within root
40+
-- AND not at root); silently clamp at root otherwise
41+
-- `.` → skip
42+
-- name → append
43+
def applyComponent (root normalized : Path) (c : Component) : Path :=
44+
match c with
45+
| Component.parentDir =>
46+
if root <+: normalized ∧ normalized ≠ root then
47+
normalized.dropLast
48+
else
49+
normalized
50+
| Component.curDir => normalized
51+
| Component.normal s => normalized ++ [s]
52+
53+
-- Drive the normaliser over a list of components, starting from
54+
-- `root` as the initial accumulator (matches the Rust implementation,
55+
-- which starts `normalized` empty and pre-joins with root).
56+
def normalizeRaw (root : Path) (raw : List Component) : Path :=
57+
raw.foldl (applyComponent root) root
58+
59+
-- The final safety check (Rust line 589-593 in `state.rs`): if the
60+
-- accumulated result somehow does not start with root, clamp to root.
61+
-- This is defensive; the foldl invariant we prove below means the
62+
-- check never fires, but the clamp is what makes the theorem closed-
63+
-- form regardless of `applyComponent` correctness.
64+
def normalizePath (root : Path) (raw : List Component) : Path :=
65+
let n := normalizeRaw root raw
66+
if root <+: n then n else root
67+
68+
-- ---------------------------------------------------------------------
69+
-- Helper lemmas
70+
-- ---------------------------------------------------------------------
71+
72+
-- Appending on the right preserves prefix (`xs <+: ys → xs <+: ys ++ zs`).
73+
-- This is the key compositional step used by the `Component.normal`
74+
-- case. Uses `List.prefix_append` (every list is a prefix of itself
75+
-- appended) plus transitivity of `<+:`.
76+
theorem isPrefix_append_right
77+
(xs ys zs : Path) (h : xs <+: ys) :
78+
xs <+: ys ++ zs :=
79+
h.trans (List.prefix_append ys zs)
80+
81+
-- Dropping the last element preserves a prefix, provided the prefix
82+
-- itself wasn't the whole list (otherwise dropping erases an element
83+
-- of the prefix and breaks containment).
84+
theorem isPrefix_dropLast
85+
(xs ys : Path)
86+
(hp : xs <+: ys) (hne : ys ≠ xs) :
87+
xs <+: ys.dropLast := by
88+
-- Obtain the witness suffix: ys = xs ++ tail
89+
obtain ⟨tail, htail⟩ := hp
90+
-- tail must be non-empty (if it were [], then ys = xs ++ [] = xs,
91+
-- contradicting hne).
92+
cases tail with
93+
| nil =>
94+
exfalso; apply hne
95+
simpa using htail.symm
96+
| cons t ts =>
97+
-- ys = xs ++ (t :: ts); dropLast strips the trailing element.
98+
-- ys.dropLast = (xs ++ (t :: ts)).dropLast = xs ++ (t :: ts).dropLast
99+
-- (since t :: ts is non-empty), so xs is still a prefix.
100+
refine ⟨(t :: ts).dropLast, ?_⟩
101+
subst htail
102+
rw [List.dropLast_append_of_ne_nil _ (List.cons_ne_nil t ts)]
103+
104+
-- ---------------------------------------------------------------------
105+
-- Per-step invariant
106+
-- ---------------------------------------------------------------------
107+
108+
-- Applying one component preserves the root-prefix invariant.
109+
theorem applyComponent_preserves_root_prefix
110+
(root normalized : Path) (c : Component)
111+
(h : root <+: normalized) :
112+
root <+: applyComponent root normalized c := by
113+
cases c with
114+
| curDir =>
115+
unfold applyComponent
116+
exact h
117+
| normal s =>
118+
unfold applyComponent
119+
exact isPrefix_append_right root normalized [s] h
120+
| parentDir =>
121+
unfold applyComponent
122+
by_cases hcond : root <+: normalized ∧ normalized ≠ root
123+
· simp [hcond]
124+
obtain ⟨hpre, hne⟩ := hcond
125+
exact isPrefix_dropLast root normalized hpre hne
126+
· simp [hcond]; exact h
127+
128+
-- ---------------------------------------------------------------------
129+
-- foldl invariant
130+
-- ---------------------------------------------------------------------
131+
132+
-- foldl preserves the root-prefix invariant for any initial accumulator
133+
-- that already starts with root.
134+
theorem normalizeRaw_acc_preserves_root_prefix
135+
(root : Path) :
136+
∀ (raw : List Component) (acc : Path),
137+
root <+: acc →
138+
root <+: raw.foldl (applyComponent root) acc := by
139+
intro raw
140+
induction raw with
141+
| nil =>
142+
intro acc h; exact h
143+
| cons c rest ih =>
144+
intro acc h
145+
simp only [List.foldl]
146+
apply ih
147+
exact applyComponent_preserves_root_prefix root acc c h
148+
149+
-- The intermediate result is always within root.
150+
theorem normalizeRaw_within_root (root : Path) (raw : List Component) :
151+
root <+: normalizeRaw root raw := by
152+
unfold normalizeRaw
153+
exact normalizeRaw_acc_preserves_root_prefix root raw root (List.prefix_refl root)
154+
155+
-- ---------------------------------------------------------------------
156+
-- Headline theorem (frontier A-12)
157+
-- ---------------------------------------------------------------------
158+
159+
-- Path-traversal containment: `normalizePath root raw` is always a
160+
-- descendant of `root`, regardless of `..` / `.` / normal components
161+
-- in `raw`. This is the regression-guard for the 2026-02-12 CVE-class
162+
-- audit fix.
163+
theorem path_traversal_containment
164+
(root : Path) (raw : List Component) :
165+
root <+: normalizePath root raw := by
166+
unfold normalizePath
167+
by_cases h : root <+: normalizeRaw root raw
168+
· simp [h]
169+
· simp [h]
170+
171+
-- Corollary: the final clamp never fires (the unfolded definition
172+
-- always lands on `normalizeRaw root raw`, never on the fallback
173+
-- `root`). Useful as a regression-guard against changes to
174+
-- `applyComponent` that would silently break the invariant.
175+
theorem normalizePath_eq_normalizeRaw
176+
(root : Path) (raw : List Component) :
177+
normalizePath root raw = normalizeRaw root raw := by
178+
unfold normalizePath
179+
have h : root <+: normalizeRaw root raw := normalizeRaw_within_root root raw
180+
simp [h]
181+
182+
end PathTraversal

proofs/lean4/lakefile.lean

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,6 @@ lean_lib PermissionOperations where
4848

4949
lean_lib CrashConsistency where
5050
srcDir := "."
51+
52+
lean_lib PathTraversal where
53+
srcDir := "."

0 commit comments

Comments
 (0)