fix(helper): close pacman custody gaps
This commit is contained in:
parent
d089435c2f
commit
3ecec1f547
4 changed files with 212 additions and 7 deletions
1
helper/Cargo.lock
generated
1
helper/Cargo.lock
generated
|
|
@ -213,6 +213,7 @@ version = "0.2.9"
|
|||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ serde_json = "1.0"
|
|||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
subtle = "2"
|
||||
libc = "0.2"
|
||||
|
||||
[[bin]]
|
||||
name = "redflag-helper"
|
||||
|
|
|
|||
|
|
@ -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, ¤t)? {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue