fix(GATE-005): harden helper trusted-input — argv separator + UUID v4 request_id
- apt build_plan: add POSIX '--' before user-derived name=version tokens - dnf build_plan: add POSIX '--' before user-derived name-version token - validate_mint_request: enforce canonical UUID v4 on request_id at intake - RAF components/04: document '--' separator in pipeline step 7 - RAF security/05: document hardened argv contract + UUID v4 validation - RAF flows/03: add stale banner (5 deviations from current code) 14 tests pass. Deferred: artifact_path staging root, name/version charset regex (both need design decisions — see task file).
This commit is contained in:
parent
4e08deef8c
commit
35732a63fd
5 changed files with 94 additions and 17 deletions
|
|
@ -28,7 +28,7 @@ The agent invokes it via `sudo systemd-run --pipe --property=ProtectSystem=no`
|
|||
4. **Signature** — `verify_signature()` checks the token's Ed25519 signature over the signed message (which embeds the closure hash) against the keyring, by `key_id`.
|
||||
5. **Artifact hashes** — `verify_artifacts()` SHA-256s every artifact the token authorizes. A mismatch anywhere is a denial.
|
||||
6. **Replay check** — `replay_check_and_record()`: token IDs are recorded in local state; a token executes once.
|
||||
7. **Plan + execute** — `build_plan()` translates the token into the exact package-manager commands; `execute_plan()` runs them. No interpretation, no substitution.
|
||||
7. **Plan + execute** — `build_plan()` translates the token into the exact package-manager commands; `execute_plan()` runs them. No interpretation, no substitution. A POSIX `--` (end-of-options) separator precedes all user-derived values (package names, versions) to prevent option injection (GATE-005).
|
||||
8. **Receipt** — `emit_result()` writes a `PolicyResult` the agent reports back; the server reconciles it into lifecycle state.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,3 +1,27 @@
|
|||
> **⚠ STALE — known deviations from current code (flagged 2026-06-16 by Vanguard).**
|
||||
> This flow doc describes code that no longer exists. It will be rewritten for v0.3.0
|
||||
> when the Windows mutation helper (SEC-030) lands. Until then, treat the below as
|
||||
> historical reference only — **not design of record**.
|
||||
>
|
||||
> **Known deviations:**
|
||||
> 1. `Params: {"version": "latest"}` — server now resolves to `serverVersion.AgentVersion`
|
||||
> before sending (CRITICAL-009 fix). `"latest"` rendered as `vlatest` and broke the gate.
|
||||
> 2. File paths are wrong. `agent/internal/orchestrator/update_handler.go` does not exist.
|
||||
> Actual handler: `agent/internal/handlers/agent_update.go`.
|
||||
> 3. **Two execution paths now exist** (not one "atomic swap"):
|
||||
> - **Linux:** capability-token helper owns the swap (`installAgentViaHelper`). Agent holds
|
||||
> no sudo for cp/chmod/restart.
|
||||
> - **Non-Linux (Windows):** in-process binary swap + detached `sc` restart fallback.
|
||||
> No capability token. Tracked as doctrine gap in SEC-030.
|
||||
> 4. **Watchdog/rollback removed.** `runUpdateWatchdog()` and `rollbackUpdate()` were
|
||||
> intentionally deleted — "could not survive systemd's SIGTERM and has been removed.
|
||||
> Server-side reconcileAgentUpdates closes the command." The code samples below show
|
||||
> functions that do not exist.
|
||||
> 5. Capability-token authorization is not mentioned at all — it is now the load-bearing
|
||||
> authorization for Linux self-upgrade.
|
||||
|
||||
---
|
||||
|
||||
# Agent Self-Upgrade Flow
|
||||
|
||||
**7-step agent upgrade with rollback and verification.**
|
||||
|
|
|
|||
|
|
@ -165,8 +165,9 @@ The token extends the existing Ed25519 infrastructure rather than introducing ne
|
|||
- **Executor (`helper/`, privileged, Rust).** Verify validity window → resolve trusted key by
|
||||
`key_id` from a local pinned keyring → reconstruct `signed_message` → Ed25519 verify →
|
||||
verify each artifact's sha256 → replay-guard on `token_id` → exec exactly one operation via
|
||||
argv (no shell, env stripped) → structured result + exit code. Fail-closed on every error
|
||||
path. Auditable in one sitting.
|
||||
argv (no shell, env stripped, POSIX `--` separator before user values) → structured result
|
||||
+ exit code. Fail-closed on every error path. `request_id` validated as canonical UUID v4
|
||||
at intake (GATE-005). Auditable in one sitting.
|
||||
- **Kernel layer (where applicable).** Linux eBPF / Windows WDAC / macOS ESF deny
|
||||
package-manager execution except via the trusted executor. Defense-in-depth.
|
||||
|
||||
|
|
|
|||
2
helper/Cargo.lock
generated
2
helper/Cargo.lock
generated
|
|
@ -209,7 +209,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "redflag-helper"
|
||||
version = "0.2.8"
|
||||
version = "0.2.9"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
|
|
|
|||
|
|
@ -232,6 +232,35 @@ fn key_id_for(pubkey: &[u8]) -> String {
|
|||
hex::encode(&digest[..16])
|
||||
}
|
||||
|
||||
// Canonical UUID v4: 8-4-4-4-12 lowercase hex with version (4) and variant
|
||||
// (8/9/a/b) nibbles. Used to constrain request_id so the journal dedup
|
||||
// needle is always an exact-match target, never a substring of another ID.
|
||||
fn is_valid_uuid_v4(s: &str) -> bool {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() != 36 {
|
||||
return false;
|
||||
}
|
||||
// Hyphens at fixed positions
|
||||
if bytes[8] != b'-' || bytes[13] != b'-' || bytes[18] != b'-' || bytes[23] != b'-' {
|
||||
return false;
|
||||
}
|
||||
// All non-hyphen positions must be lowercase hex digits
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
if i == 8 || i == 13 || i == 18 || i == 23 {
|
||||
continue;
|
||||
}
|
||||
if !b.is_ascii_hexdigit() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Version nibble at position 14 must be '4'
|
||||
if bytes[14] != b'4' {
|
||||
return false;
|
||||
}
|
||||
// Variant nibble at position 19 must be 8, 9, a, or b
|
||||
matches!(bytes[19], b'8' | b'9' | b'a' | b'b')
|
||||
}
|
||||
|
||||
// SEC-021: the helper defends its own trust inputs instead of relying on the
|
||||
// installer having set permissions correctly. A trusted file or directory must
|
||||
// be owned by the required uid, must not be writable by group or other, and
|
||||
|
|
@ -505,6 +534,9 @@ fn build_plan(token: &CapabilityToken) -> Result<Vec<(String, Vec<String>)>, Den
|
|||
let plan = match token.package_type.as_str() {
|
||||
"apt" => {
|
||||
let mut args = vec!["install".into(), "-y".into(), "--no-install-recommends".into()];
|
||||
// End-of-options marker: everything after is positional, so a
|
||||
// crafted package name starting with '-' cannot inject apt flags.
|
||||
args.push("--".into());
|
||||
for e in c {
|
||||
args.push(format!("{}={}", e.name, e.version));
|
||||
}
|
||||
|
|
@ -512,6 +544,9 @@ fn build_plan(token: &CapabilityToken) -> Result<Vec<(String, Vec<String>)>, Den
|
|||
}
|
||||
"dnf" => {
|
||||
let mut args = vec!["install".into(), "-y".into()];
|
||||
// End-of-options marker: a crafted name-version string starting
|
||||
// with '-' cannot inject dnf flags (e.g. --skip-broken).
|
||||
args.push("--".into());
|
||||
for e in c {
|
||||
args.push(format!("{}-{}", e.name, e.version));
|
||||
}
|
||||
|
|
@ -1335,8 +1370,16 @@ fn validate_mint_request(req: &MintRequest, now: i64) -> Result<(), Denial> {
|
|||
format!("package_type={}", req.package_type),
|
||||
));
|
||||
}
|
||||
if req.request_id.trim().is_empty() {
|
||||
return Err(Denial::new(EXIT_BAD_TOKEN, "mint_request_id_empty", ""));
|
||||
// request_id must be a canonical UUID v4 — this prevents the journal
|
||||
// substring-containment dedup check from matching across different IDs
|
||||
// (e.g. "req-1" matching inside "req-10") and blocks characters that
|
||||
// could interfere with the JSON needle pattern.
|
||||
if !is_valid_uuid_v4(&req.request_id) {
|
||||
return Err(Denial::new(
|
||||
EXIT_BAD_TOKEN,
|
||||
"mint_request_id_not_uuid_v4",
|
||||
"request_id must be a canonical UUID v4 (8-4-4-4-12 lowercase hex)",
|
||||
));
|
||||
}
|
||||
|
||||
// Bind to this host's independently-read identity, same as execute mode.
|
||||
|
|
@ -1894,13 +1937,22 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
// Valid UUID v4 strings for test request_ids.
|
||||
const UUID_1: &str = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const UUID_2: &str = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const UUID_3: &str = "550e8400-e29b-41d4-a716-446655440002";
|
||||
const UUID_4: &str = "550e8400-e29b-41d4-a716-446655440003";
|
||||
const UUID_5: &str = "550e8400-e29b-41d4-a716-446655440004";
|
||||
const UUID_6: &str = "550e8400-e29b-41d4-a716-446655440005";
|
||||
const UUID_7: &str = "550e8400-e29b-41d4-a716-446655440006";
|
||||
|
||||
// The full loop: mint a token, then verify it with the exact same functions
|
||||
// the execute path uses. If this passes, a minted token is executable.
|
||||
#[test]
|
||||
fn mint_round_trips_through_execute_verification() {
|
||||
let fx = mint_fixture("roundtrip");
|
||||
let now = now_unix();
|
||||
let token = run_mint(&good_request("req-1", now), &fx.paths, now).expect("mint should succeed");
|
||||
let token = run_mint(&good_request(UUID_1, now), &fx.paths, now).expect("mint should succeed");
|
||||
|
||||
assert_eq!(token.version, SUPPORTED_TOKEN_VERSION);
|
||||
assert_eq!(token.key_id, key_id_for(fx.verifying_key.as_bytes()));
|
||||
|
|
@ -1921,7 +1973,7 @@ mod tests {
|
|||
|
||||
// Journaled before emission.
|
||||
let journal = fs::read_to_string(&fx.paths.journal_path).unwrap();
|
||||
assert!(journal.contains("\"request_id\":\"req-1\""));
|
||||
assert!(journal.contains(&format!("\"request_id\":\"{}\"", UUID_1)));
|
||||
assert!(journal.contains(&token.token_id));
|
||||
}
|
||||
|
||||
|
|
@ -1929,7 +1981,7 @@ mod tests {
|
|||
fn mint_rejects_stale_evidence() {
|
||||
let fx = mint_fixture("stale");
|
||||
let now = now_unix();
|
||||
let mut req = good_request("req-stale", now);
|
||||
let mut req = good_request(UUID_2, now);
|
||||
req.gate_evidence.resolved_at = now - MINT_EVIDENCE_MAX_AGE_SECS - 1;
|
||||
let d = run_mint(&req, &fx.paths, now).unwrap_err();
|
||||
assert_eq!(d.code, EXIT_MINT_STALE);
|
||||
|
|
@ -1939,7 +1991,7 @@ mod tests {
|
|||
fn mint_rejects_future_dated_evidence() {
|
||||
let fx = mint_fixture("future");
|
||||
let now = now_unix();
|
||||
let mut req = good_request("req-future", now);
|
||||
let mut req = good_request(UUID_3, now);
|
||||
req.gate_evidence.resolved_at = now + MINT_CLOCK_SKEW_SECS + 30;
|
||||
let d = run_mint(&req, &fx.paths, now).unwrap_err();
|
||||
assert_eq!(d.code, EXIT_MINT_STALE);
|
||||
|
|
@ -1949,7 +2001,7 @@ mod tests {
|
|||
fn mint_vulnerable_requires_override_reason() {
|
||||
let fx = mint_fixture("vuln");
|
||||
let now = now_unix();
|
||||
let mut req = good_request("req-vuln", now);
|
||||
let mut req = good_request(UUID_4, now);
|
||||
req.gate_evidence.osv_status = "vulnerable".into();
|
||||
req.gate_evidence.osv_vuln_count = 2;
|
||||
let d = run_mint(&req, &fx.paths, now).unwrap_err();
|
||||
|
|
@ -1966,8 +2018,8 @@ mod tests {
|
|||
fn mint_duplicate_request_id_denied() {
|
||||
let fx = mint_fixture("dup");
|
||||
let now = now_unix();
|
||||
run_mint(&good_request("req-dup", now), &fx.paths, now).expect("first mint");
|
||||
let d = run_mint(&good_request("req-dup", now), &fx.paths, now).unwrap_err();
|
||||
run_mint(&good_request(UUID_5, now), &fx.paths, now).expect("first mint");
|
||||
let d = run_mint(&good_request(UUID_5, now), &fx.paths, now).unwrap_err();
|
||||
assert_eq!(d.code, EXIT_MINT_DUPLICATE);
|
||||
}
|
||||
|
||||
|
|
@ -1976,7 +2028,7 @@ mod tests {
|
|||
let fx = mint_fixture("perms");
|
||||
let now = now_unix();
|
||||
fs::set_permissions(&fx.paths.key_path, fs::Permissions::from_mode(0o644)).unwrap();
|
||||
let d = run_mint(&good_request("req-perm", now), &fx.paths, now).unwrap_err();
|
||||
let d = run_mint(&good_request(UUID_6, now), &fx.paths, now).unwrap_err();
|
||||
assert_eq!(d.code, EXIT_MINT_KEY);
|
||||
}
|
||||
|
||||
|
|
@ -1985,15 +2037,15 @@ mod tests {
|
|||
let fx = mint_fixture("shape");
|
||||
let now = now_unix();
|
||||
|
||||
let mut req = good_request("req-self", now);
|
||||
let mut req = good_request(UUID_7, now);
|
||||
req.package_type = AGENT_SELF_PACKAGE_TYPE.to_string();
|
||||
assert_eq!(run_mint(&req, &fx.paths, now).unwrap_err().code, EXIT_UNSUPPORTED_OP);
|
||||
|
||||
let mut req = good_request("req-badsha", now);
|
||||
let mut req = good_request(UUID_7, now);
|
||||
req.closure[0].sha256 = "deadbeef".into();
|
||||
assert_eq!(run_mint(&req, &fx.paths, now).unwrap_err().code, EXIT_ARTIFACT);
|
||||
|
||||
let mut req = good_request("req-remove", now);
|
||||
let mut req = good_request(UUID_7, now);
|
||||
req.operation = "remove".into();
|
||||
assert_eq!(run_mint(&req, &fx.paths, now).unwrap_err().code, EXIT_UNSUPPORTED_OP);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue