feat(security): move secret env out of podman inspect and Quadlet unit files
Secret env used to merge into manifest.app.environment, landing in
'podman inspect' Config.Env on the API backend and — worse — as
plaintext Environment= lines in Quadlet unit files on disk. Now:
- expand_and_partition_env (container crate, pure + tested) expands
${KEY} placeholders and splits env into plain entries and
secret-bearing pairs. Plain entries that interpolate a secret
(btcpay's Password=${BTCPAY_DB_PASS} connection strings) are
tainted and travel as secrets too. Secret values themselves are
never expanded (a generated value containing '${' passes verbatim).
- values register as podman secrets: stdin (never argv/tempfile),
--replace, content-hash label to skip no-op rewrites; a per-app hash
cache in the orchestrator makes steady-state reconciles free of
podman secret calls. Registration goes through the runtime trait
(default no-op keeps mocks/docker inert).
- containers reference secrets by name: secret_env map in the libpod
create spec, Secret=<name>,type=env,target=<KEY> in Quadlet units.
Verified empirically on fleet podman 5.4.2: value absent from
inspect Config.Env, runtime injection works rootless.
- rotation detection: io.archipelago.secret-env-hash container label
(API) / the changed unit bytes (Quadlet). Pre-upgrade containers
lack the label, so every secret-bearing app recreates ONCE on the
first reconcile after deploy — deliberate, it scrubs the plaintext
secrets out of existing container configs. Data dirs untouched.
- docker dev fallback keeps plain -e injection (no secret store);
podman secrets persist across uninstall, matching the
preserve-credentials invariant (reinstall re-registers by hash).
In-container /proc/<pid>/environ is unchanged — env remains the
app-compat contract; the closed leaks are inspect output and unit
files on disk.
Tests: archipelago-container 61/61 (3 new: taint partition, verbatim
secrets, hash order-independence), archipelago container:: 160/160
(fedimint install test now asserts the secret arrives as a ref, not
env; quadlet render test asserts Secret=/Label= lines). NEEDS the
on-node gate re-run before the item counts as verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
eed830e1ee
commit
4665e497d7
@@ -142,6 +142,14 @@ pub struct QuadletUnit {
|
||||
// companion's rendered bytes are unchanged from before this PR.
|
||||
pub ports: Vec<(u16, u16, String)>,
|
||||
pub environment: Vec<String>,
|
||||
/// Secret-backed env: (env_key, podman secret name). Rendered as
|
||||
/// `Secret=<name>,type=env,target=<key>` so the VALUE never lands in
|
||||
/// this unit file on disk — only a reference to the podman secret
|
||||
/// store. The orchestrator registers the secrets before writing units.
|
||||
pub secret_env: Vec<(String, String)>,
|
||||
/// Container labels (`Label=k=v`). Carries the secret-env content hash
|
||||
/// for rotation-drift detection.
|
||||
pub labels: Vec<(String, String)>,
|
||||
pub devices: Vec<String>,
|
||||
pub add_hosts: Vec<(String, String)>,
|
||||
pub network_aliases: Vec<String>,
|
||||
@@ -247,6 +255,12 @@ impl QuadletUnit {
|
||||
// accepts that form on a single Environment= line per pair.
|
||||
let _ = writeln!(s, "Environment={}", quote_environment(env));
|
||||
}
|
||||
for (key, secret_name) in &self.secret_env {
|
||||
let _ = writeln!(s, "Secret={secret_name},type=env,target={key}");
|
||||
}
|
||||
for (k, v) in &self.labels {
|
||||
let _ = writeln!(s, "Label={k}={v}");
|
||||
}
|
||||
for dev in &self.devices {
|
||||
let _ = writeln!(s, "AddDevice={dev}");
|
||||
}
|
||||
@@ -415,6 +429,23 @@ impl QuadletUnit {
|
||||
.map(|p| (p.host, p.container, p.protocol.clone()))
|
||||
.collect(),
|
||||
environment: app.environment.clone(),
|
||||
secret_env: app
|
||||
.container
|
||||
.secret_env_refs
|
||||
.iter()
|
||||
.map(|r| (r.env_key.clone(), r.secret_name.clone()))
|
||||
.collect(),
|
||||
labels: app
|
||||
.container
|
||||
.secret_env_hash
|
||||
.iter()
|
||||
.map(|h| {
|
||||
(
|
||||
archipelago_container::manifest::SECRET_ENV_HASH_LABEL.to_string(),
|
||||
h.clone(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
devices: app.devices.clone(),
|
||||
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
|
||||
// Container always answers to its own name; manifest extras add the
|
||||
@@ -847,6 +878,24 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn render_emits_secret_env_by_reference_never_value() {
|
||||
let u = QuadletUnit {
|
||||
name: "t".into(),
|
||||
description: "t".into(),
|
||||
image: "img".into(),
|
||||
secret_env: vec![("DB_PASS".into(), "archy-env-app-db_pass".into())],
|
||||
labels: vec![("io.archipelago.secret-env-hash".into(), "abc123".into())],
|
||||
..QuadletUnit::default()
|
||||
};
|
||||
let s = u.render();
|
||||
assert!(s.contains("Secret=archy-env-app-db_pass,type=env,target=DB_PASS"));
|
||||
assert!(s.contains("Label=io.archipelago.secret-env-hash=abc123"));
|
||||
// the secret VALUE never had a path into this unit — but guard the
|
||||
// env channel anyway: no Environment= line may mention the key
|
||||
assert!(!s.contains("Environment=DB_PASS"));
|
||||
}
|
||||
|
||||
fn sample_unit() -> QuadletUnit {
|
||||
QuadletUnit {
|
||||
name: "archy-bitcoin-ui".into(),
|
||||
|
||||
Reference in New Issue
Block a user