Skip to content

Commit 14ab2c1

Browse files
jamesadevineCopilot
andcommitted
fix(safeoutputs): surface metadata errors and allow symlink-removal patches
Two review fixes for the symlink-exfiltration PR: 1. push_file_change_skipping_symlinks: split the catch-all arm so IO errors from symlink_metadata (e.g. permissions denied) are surfaced via warn! rather than silently swallowed. Non-file, non-symlink entries (directories, fifos, sockets) are still silently skipped. 2. validate_patch_paths: only reject patch lines that INTRODUCE a symlink (new file mode 120000, new mode 120000). Patches that convert a symlink into a regular file (old mode 120000 + new mode 100644) or delete an existing symlink (deleted file mode 120000) are now allowed — they are legitimate cleanup operations and produce a symlink-free worktree, so they pose no exfiltration risk. Adds two positive tests covering symlink→file conversion and symlink deletion; existing negative tests still pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 26c32ec commit 14ab2c1

1 file changed

Lines changed: 58 additions & 7 deletions

File tree

src/safeoutputs/create_pull_request.rs

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1814,7 +1814,15 @@ async fn push_file_change_skipping_symlinks(
18141814
file_path
18151815
);
18161816
}
1817-
_ => {}
1817+
Ok(_) => {
1818+
// Not a regular file (e.g. directory, fifo, socket) — silently skip.
1819+
}
1820+
Err(e) => {
1821+
warn!(
1822+
"Failed to read metadata for {}: {} — skipping",
1823+
file_path, e
1824+
);
1825+
}
18181826
}
18191827
Ok(())
18201828
}
@@ -1905,14 +1913,23 @@ fn validate_patch_paths(patch_content: &str) -> anyhow::Result<()> {
19051913
let path = line.splitn(3, ' ').nth(2).unwrap_or("").trim_matches('"');
19061914
validate_single_path(path)?;
19071915
} else if line.starts_with("new file mode 120000")
1908-
|| line.starts_with("old mode 120000")
19091916
|| line.starts_with("new mode 120000")
19101917
{
1911-
// Reject symlink entries (git mode 120000 = symbolic link).
1912-
// Symlinks in a patch could be used to make Stage 3 follow them to
1913-
// arbitrary filesystem paths (e.g. /proc/self/environ) when collecting
1914-
// file changes to upload to ADO.
1915-
anyhow::bail!("Patch contains a symlink entry (mode 120000), which is not allowed");
1918+
// Reject patch lines that INTRODUCE a symlink (git mode 120000).
1919+
// Either of these lines means the resulting tree contains a symlink:
1920+
// - "new file mode 120000" — a freshly added symlink
1921+
// - "new mode 120000" — an existing file converted to a symlink
1922+
// A symlink in the worktree could make Stage 3 follow it to arbitrary
1923+
// filesystem paths (e.g. /proc/self/environ) when collecting file
1924+
// changes to upload to ADO.
1925+
//
1926+
// We deliberately do NOT reject "old mode 120000" on its own: a patch
1927+
// with "old mode 120000" + "new mode 100644" converts a symlink into a
1928+
// regular file, which is a legitimate cleanup operation and produces a
1929+
// safe worktree.
1930+
anyhow::bail!(
1931+
"Patch introduces a symlink (mode 120000), which is not allowed"
1932+
);
19161933
}
19171934
}
19181935
Ok(())
@@ -2297,6 +2314,40 @@ index 0000000..abcdefg
22972314
);
22982315
}
22992316

2317+
#[test]
2318+
fn test_validate_patch_paths_symlink_to_file_allowed() {
2319+
// Converting a symlink into a regular file is a legitimate cleanup
2320+
// operation: the resulting worktree contains no symlinks, so there is
2321+
// no exfiltration risk. The "old mode 120000" line on its own must not
2322+
// cause rejection.
2323+
let patch = "diff --git a/file.txt b/file.txt\n\
2324+
old mode 120000\n\
2325+
new mode 100644\n\
2326+
--- a/file.txt\n\
2327+
+++ b/file.txt\n\
2328+
@@ -1 +1 @@\n\
2329+
-/etc/passwd\n\
2330+
+hello world\n";
2331+
assert!(
2332+
validate_patch_paths(patch).is_ok(),
2333+
"patch converting symlink → regular file should be allowed"
2334+
);
2335+
}
2336+
2337+
#[test]
2338+
fn test_validate_patch_paths_symlink_deletion_allowed() {
2339+
// Deleting an existing symlink is also legitimate cleanup — no symlink
2340+
// remains in the worktree afterwards.
2341+
let patch = "diff --git a/link b/link\n\
2342+
deleted file mode 120000\n\
2343+
--- a/link\n\
2344+
+++ /dev/null\n";
2345+
assert!(
2346+
validate_patch_paths(patch).is_ok(),
2347+
"patch deleting an existing symlink should be allowed"
2348+
);
2349+
}
2350+
23002351
#[test]
23012352
fn test_validate_single_path_valid() {
23022353
assert!(validate_single_path("src/main.rs").is_ok());

0 commit comments

Comments
 (0)