Watch
1
0
Fork
You've already forked RedFlag
0

fix(helper): close pacman custody gaps

This commit is contained in:
Fimeg 2026-08-31 21:09:07 -04:00
commit 3ecec1f547
4 changed files with 212 additions and 7 deletions

1
helper/Cargo.lock generated
View file

@ -213,6 +213,7 @@ version = "0.2.9"
dependencies = [
"ed25519-dalek",
"hex",
"libc",
"serde",
"serde_json",
"sha2",

View file

@ -12,6 +12,7 @@ serde_json = "1.0"
sha2 = "0.10"
hex = "0.4"
subtle = "2"
libc = "0.2"
[[bin]]
name = "redflag-helper"

View file

@ -72,6 +72,8 @@ const DEFAULT_MUTATION_REPLAY_DIR: &str = "/var/lib/redflag/helper/consumed-auth
const DEFAULT_MUTATION_STAGING_DIR: &str = "/var/lib/redflag/helper/mutations";
#[cfg(unix)]
const PACMAN_BINARY: &str = "/usr/bin/pacman";
#[cfg(unix)]
const VERCMP_BINARY: &str = "/usr/bin/vercmp";
const AGENT_ID_FILES: &[&str] = &["/etc/redflag/agent_id", "/var/lib/redflag/agent_id"];
// Agent self-upgrade (package_type "agent-self"). The agent drops the downloaded
@ -2545,7 +2547,52 @@ fn stage_pacman_actions_as(
.and_then(|name| name.to_str())
.unwrap_or("package.pkg.tar.zst");
let destination = operation_dir.join(format!("{:04}-{}", index, source_name));
if let Err(e) = fs::copy(&action.source_path, &destination) {
let mut source_options = fs::OpenOptions::new();
source_options.read(true).custom_flags(libc::O_NOFOLLOW);
let mut source = match source_options.open(&action.source_path) {
Ok(file) => file,
Err(e) => {
let _ = fs::remove_dir_all(&operation_dir);
return Err(Denial::new(
EXIT_ARTIFACT,
"pacman_artifact_source_open_failed",
format!("{}: {}", action.source_path.display(), e),
));
}
};
match source.metadata() {
Ok(metadata) if metadata.is_file() => {}
Ok(_) => {
let _ = fs::remove_dir_all(&operation_dir);
return Err(Denial::new(
EXIT_ARTIFACT,
"pacman_artifact_source_not_regular",
action.source_path.display().to_string(),
));
}
Err(e) => {
let _ = fs::remove_dir_all(&operation_dir);
return Err(Denial::new(
EXIT_ARTIFACT,
"pacman_artifact_source_metadata_failed",
format!("{}: {}", action.source_path.display(), e),
));
}
}
let mut destination_options = fs::OpenOptions::new();
destination_options.write(true).create_new(true).mode(0o600);
let mut destination_file = match destination_options.open(&destination) {
Ok(file) => file,
Err(e) => {
let _ = fs::remove_dir_all(&operation_dir);
return Err(Denial::new(
EXIT_ARTIFACT,
"pacman_artifact_stage_create_failed",
format!("{}: {}", destination.display(), e),
));
}
};
if let Err(e) = std::io::copy(&mut source, &mut destination_file) {
let _ = fs::remove_dir_all(&operation_dir);
return Err(Denial::new(
EXIT_ARTIFACT,
@ -2558,14 +2605,15 @@ fn stage_pacman_actions_as(
),
));
}
if let Err(e) = fs::set_permissions(&destination, fs::Permissions::from_mode(0o600)) {
if let Err(e) = destination_file.sync_all() {
let _ = fs::remove_dir_all(&operation_dir);
return Err(Denial::new(
EXIT_INTERNAL,
"pacman_artifact_stage_chmod_failed",
"pacman_artifact_stage_sync_failed",
format!("{}: {}", destination.display(), e),
));
}
drop(destination_file);
if let Err(d) = validate_trusted_path_as(&destination, required_uid) {
let _ = fs::remove_dir_all(&operation_dir);
return Err(d);
@ -2659,6 +2707,109 @@ fn verify_pacman_archive_identity(action: &PacmanStagedAction) -> Result<(), Den
validate_pacman_archive_identity_output(action, &output.stdout)
}
#[cfg(unix)]
fn parse_pacman_local_version(name: &str, output: &[u8]) -> Result<String, Denial> {
let found = String::from_utf8_lossy(output);
let mut fields = found.split_ascii_whitespace();
let found_name = fields.next().unwrap_or("");
let found_version = fields.next().unwrap_or("");
if found_name != name || found_version.is_empty() || fields.next().is_some() {
return Err(Denial::new(
EXIT_INTERNAL,
"pacman_local_identity_invalid",
format!("expected={} found={}", name, found.trim()),
));
}
Ok(found_version.to_string())
}
#[cfg(unix)]
fn current_pacman_version(name: &str) -> Result<Option<String>, Denial> {
let output = Command::new(PACMAN_BINARY)
.args(["-Q", "--", name])
.env_clear()
.env(
"PATH",
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
)
.env("LC_ALL", "C")
.output()
.map_err(|e| {
Denial::new(
EXIT_EXEC_FAILED,
"pacman_local_query_spawn_failed",
e.to_string(),
)
})?;
if output.status.success() {
return parse_pacman_local_version(name, &output.stdout).map(Some);
}
if output.status.code() == Some(1) && output.stdout.is_empty() {
return Ok(None);
}
Err(Denial::new(
EXIT_EXEC_FAILED,
"pacman_local_query_failed",
format!("name={} exit={:?}", name, output.status.code()),
))
}
#[cfg(unix)]
fn compare_pacman_versions(candidate: &str, current: &str) -> Result<i32, Denial> {
let output = Command::new(VERCMP_BINARY)
.args([candidate, current])
.env_clear()
.env("LC_ALL", "C")
.output()
.map_err(|e| {
Denial::new(
EXIT_EXEC_FAILED,
"pacman_vercmp_spawn_failed",
e.to_string(),
)
})?;
if !output.status.success() {
return Err(Denial::new(
EXIT_EXEC_FAILED,
"pacman_vercmp_failed",
format!("exit={:?}", output.status.code()),
));
}
parse_pacman_vercmp_output(&output.stdout)
}
#[cfg(unix)]
fn parse_pacman_vercmp_output(output: &[u8]) -> Result<i32, Denial> {
String::from_utf8_lossy(output)
.trim()
.parse::<i32>()
.map_err(|_| {
Denial::new(
EXIT_INTERNAL,
"pacman_vercmp_output_invalid",
"non-integer output",
)
})
}
#[cfg(unix)]
fn enforce_forward_only_pacman_action(action: &PacmanStagedAction) -> Result<(), Denial> {
let Some(current) = current_pacman_version(&action.name)? else {
return Ok(());
};
match compare_pacman_versions(&action.version, &current)? {
value if value >= 0 => Ok(()),
_ => Err(Denial::new(
EXIT_AUTHORIZATION_DENIED,
"pacman_downgrade_refused",
format!(
"identity={} current={} target={}",
action.name, current, action.version
),
)),
}
}
#[cfg(unix)]
fn claim_mutation_authorization_as(
authorization_id: &str,
@ -2725,7 +2876,7 @@ fn claim_mutation_authorization_as(
#[cfg(unix)]
fn execute_pacman_actions(actions: &[PacmanStagedAction]) -> Result<(), Denial> {
let mut command = Command::new(PACMAN_BINARY);
command.args(["-U", "--noconfirm", "--needed", "--"]);
command.args(["-U", "--noconfirm", "--"]);
for action in actions {
command.arg(&action.path);
}
@ -2831,6 +2982,28 @@ mod pacman_envelope_tests {
let _ = fs::remove_dir_all(&root);
}
#[test]
fn pacman_staging_refuses_symlink_sources() {
let root = fixture_dir("symlink");
let target = root.join("target.pkg.tar.zst");
let source = root.join("acl-2.3.2-1-x86_64.pkg.tar.zst");
fs::write(&target, b"signed package bytes").unwrap();
std::os::unix::fs::symlink(&target, &source).unwrap();
let expected = compute_file_sha256(&target).unwrap();
let actions = parse_pacman_actions(&manifest_for(&source, &expected, "acl@2.3.2-1"))
.expect("payload shape may name an existing path before custody opens it");
let staging = root.join("staging");
let uid = fs::symlink_metadata(&root).unwrap().uid();
let denial = stage_pacman_actions_as(&actions, &staging, TEST_AUTHORIZATION_ID, uid)
.expect_err("custody must not follow an agent-controlled symlink");
assert_eq!(denial.code, EXIT_ARTIFACT);
assert_eq!(denial.reason, "pacman_artifact_source_open_failed");
assert!(!staging.join(TEST_AUTHORIZATION_ID).exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn mutation_authorization_claim_is_atomic_and_one_shot() {
let root = fixture_dir("replay");
@ -2862,6 +3035,27 @@ mod pacman_envelope_tests {
.expect_err("different archive version must fail closed");
assert_eq!(denial.reason, "pacman_archive_identity_mismatch");
}
#[test]
fn pacman_local_identity_output_is_exact() {
assert_eq!(
parse_pacman_local_version("linux", b"linux 1:6.19.14-1\n").unwrap(),
"1:6.19.14-1"
);
let denial = parse_pacman_local_version("linux", b"other 1:6.19.14-1\n")
.expect_err("a different installed package must fail closed");
assert_eq!(denial.reason, "pacman_local_identity_invalid");
}
#[test]
fn pacman_vercmp_output_is_an_integer() {
assert_eq!(parse_pacman_vercmp_output(b"1\n").unwrap(), 1);
assert_eq!(parse_pacman_vercmp_output(b"0\n").unwrap(), 0);
assert_eq!(parse_pacman_vercmp_output(b"-1\n").unwrap(), -1);
let denial = parse_pacman_vercmp_output(b"older\n")
.expect_err("non-integer vercmp output must fail closed");
assert_eq!(denial.reason, "pacman_vercmp_output_invalid");
}
}
// Ordered so each refusal gets its own exit code instead of one opaque
@ -3159,6 +3353,10 @@ fn execute_verified_envelope(envelope: &MutationEnvelope) -> Result<u32, (Denial
let _ = fs::remove_dir_all(&operation_dir);
return Err((denial, index as u32));
}
if let Err(denial) = enforce_forward_only_pacman_action(action) {
let _ = fs::remove_dir_all(&operation_dir);
return Err((denial, index as u32 + 1));
}
}
if let Err(denial) = claim_mutation_authorization_as(

View file

@ -222,9 +222,14 @@ replay state.
`redflag-helper execute-envelope --envelope-file <path> [--receipt-file <path>]` admits the
first migrated backend: pacman `install` and `upgrade` over exact cached package archives.
The helper copies every signed cache path into root-owned custody, verifies the signed
SHA-256 and archive `name@version`, then atomically consumes `authorization_id` before one
fixed `pacman -U` invocation. A receipt records the joined operation, manifest,
The helper opens every signed cache path without following a final symlink, copies it into
a root-owned `0700` operation directory, and verifies the signed SHA-256 and archive
`name@version`. It then queries installed state with a cleared environment and pacman's
`vercmp`; any resolved action older than its installed version fails closed. Absent and
equal packages remain valid closure members because format 1 does not distinguish requested
roots from resolved dependencies. Only then does it atomically consume
`authorization_id` before one fixed `pacman -U` invocation without `--needed`, so exit zero
cannot conceal an already-satisfied no-op. A receipt records the joined operation, manifest,
authorization, verified-action count, and outcome.
Every other backend still fails closed with `backend_not_migrated`. The Agent request,