Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-06-17 22:07:36 -04:00
commit 35732a63fd
5 changed files with 94 additions and 17 deletions

View file

@ -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.
---

View file

@ -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.**

View file

@ -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
View file

@ -209,7 +209,7 @@ dependencies = [
[[package]]
name = "redflag-helper"
version = "0.2.8"
version = "0.2.9"
dependencies = [
"ed25519-dalek",
"hex",

View file

@ -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);
}