Skip to content

Commit 4907459

Browse files
authored
Add additional sandboxing to compose and k8s backends (#78)
1 parent 25b079f commit 4907459

File tree

11 files changed

+612
-44
lines changed

11 files changed

+612
-44
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,11 @@ Direct output only supports components that use `program.path`.
139139
- Linux: `bwrap` and `slirp4netns`
140140
- macOS: `/usr/bin/sandbox-exec`
141141

142+
Current enforcement notes:
143+
- Direct/native on Linux has the strongest capability mediation today: Amber runs each component behind a sidecar/router, isolates sidecar networking, joins the component into that namespace, shapes the filesystem with curated read-only mounts plus explicit writable storage, and drops all Linux capabilities for Amber-owned sidecars.
144+
- Docker Compose and Kubernetes now default generated containers to non-escalating privilege settings, run Amber-owned internal routers/provisioners non-root where their images already guarantee it, make those internal root filesystems read-only where possible, and reject external slot targets that resolve to loopback or link-local IPs.
145+
- Docker Compose and Kubernetes do not yet transparently redirect all arbitrary container egress through the router. Amber strongly mediates declared capability paths, but shared pod/service networking still means generic outbound traffic is not yet fully non-bypassable on those backends.
146+
142147
### 3c) Generate VM output and run
143148

144149
```sh

cli/src/main.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -954,6 +954,8 @@ struct ProcessSpec {
954954
env: BTreeMap<String, String>,
955955
work_dir: PathBuf,
956956
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
957+
drop_all_caps: bool,
958+
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
957959
read_only_mounts: Vec<ReadOnlyMount>,
958960
writable_dirs: Vec<PathBuf>,
959961
bind_dirs: Vec<PathBuf>,
@@ -1180,6 +1182,7 @@ async fn run_direct_init(args: RunDirectInitArgs) -> Result<()> {
11801182
args: Vec::new(),
11811183
env,
11821184
work_dir,
1185+
drop_all_caps: true,
11831186
read_only_mounts: vec![ReadOnlyMount {
11841187
source: runtime_root.join("mesh"),
11851188
dest: runtime_root.join("mesh"),
@@ -1231,6 +1234,7 @@ async fn run_direct_init(args: RunDirectInitArgs) -> Result<()> {
12311234
args: Vec::new(),
12321235
env,
12331236
work_dir,
1237+
drop_all_caps: true,
12341238
read_only_mounts: vec![ReadOnlyMount {
12351239
source: runtime_root.join("mesh"),
12361240
dest: runtime_root.join("mesh"),
@@ -1771,6 +1775,7 @@ fn component_program_spec(
17711775
args,
17721776
env: env.clone(),
17731777
work_dir,
1778+
drop_all_caps: false,
17741779
read_only_mounts,
17751780
writable_dirs,
17761781
bind_dirs: Vec::new(),
@@ -1820,6 +1825,7 @@ fn component_program_spec(
18201825
args: vec!["run".to_string()],
18211826
env,
18221827
work_dir,
1828+
drop_all_caps: false,
18231829
read_only_mounts,
18241830
writable_dirs,
18251831
bind_dirs: Vec::new(),
@@ -2981,6 +2987,10 @@ impl DirectSandbox {
29812987
"--chdir".to_string(),
29822988
spec.work_dir.display().to_string(),
29832989
];
2990+
if spec.drop_all_caps {
2991+
args.push("--cap-drop".to_string());
2992+
args.push("ALL".to_string());
2993+
}
29842994
if matches!(spec.network, ProcessNetwork::Isolated) {
29852995
args.push("--unshare-net".to_string());
29862996
}
@@ -4847,6 +4857,7 @@ mod tests {
48474857
args: vec!["ok".to_string()],
48484858
env: BTreeMap::new(),
48494859
work_dir: PathBuf::from("/tmp/amber-work"),
4860+
drop_all_caps: false,
48504861
read_only_mounts: Vec::new(),
48514862
writable_dirs: Vec::new(),
48524863
bind_dirs: Vec::new(),
@@ -4985,6 +4996,27 @@ mod tests {
49854996
);
49864997
}
49874998

4999+
#[cfg(target_os = "linux")]
5000+
#[test]
5001+
fn bubblewrap_can_drop_all_caps_for_internal_processes() {
5002+
let mut sandbox = DirectSandbox::Bubblewrap {
5003+
binary: PathBuf::from("/usr/bin/bwrap"),
5004+
};
5005+
let spec = ProcessSpec {
5006+
drop_all_caps: true,
5007+
..linux_test_process_spec()
5008+
};
5009+
5010+
let (_, args) = sandbox
5011+
.wrap_command(&spec)
5012+
.expect("command should be wrapped");
5013+
assert!(
5014+
args.windows(2)
5015+
.any(|window| window[0] == "--cap-drop" && window[1] == "ALL"),
5016+
"bubblewrap args should drop all caps when requested: {args:?}"
5017+
);
5018+
}
5019+
49885020
#[cfg(target_os = "linux")]
49895021
#[test]
49905022
fn bubblewrap_uses_curated_linux_mounts() {

compiler/src/targets/mesh/docker_compose/mod.rs

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ struct Service {
184184
image: String,
185185
#[serde(skip_serializing_if = "Option::is_none")]
186186
user: Option<String>,
187+
#[serde(skip_serializing_if = "Option::is_none")]
188+
read_only: Option<bool>,
187189
#[serde(default, skip_serializing_if = "Vec::is_empty")]
188190
cap_add: Vec<String>,
189191
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -504,6 +506,8 @@ fn render_docker_compose_inner(scenario: &Scenario) -> DcResult<DockerComposeArt
504506
.volumes
505507
.push(format!("{HELPER_VOLUME_NAME}:{HELPER_BIN_DIR}"));
506508
helper_init.restart = Some("no".to_string());
509+
apply_default_service_hardening(&mut helper_init);
510+
apply_internal_service_rootfs_hardening(&mut helper_init);
507511
compose
508512
.services
509513
.insert(HELPER_INIT_SERVICE.to_string(), helper_init);
@@ -516,10 +520,7 @@ fn render_docker_compose_inner(scenario: &Scenario) -> DcResult<DockerComposeArt
516520

517521
let mut provisioner_service = Service::new(images.provisioner.clone());
518522
provisioner_service.user = Some("0:0".to_string());
519-
provisioner_service.cap_drop.push("ALL".to_string());
520-
provisioner_service
521-
.security_opt
522-
.push("no-new-privileges:true".to_string());
523+
apply_default_service_hardening(&mut provisioner_service);
523524
provisioner_service.environment = Some(Environment::List(vec![format!(
524525
"AMBER_MESH_PROVISION_PLAN_PATH={PROVISIONER_PLAN_PATH}"
525526
)]));
@@ -583,6 +584,7 @@ fn render_docker_compose_inner(scenario: &Scenario) -> DcResult<DockerComposeArt
583584
"${{AMBER_DOCKER_CONTAINER_LOGS_DIR:-{DOCKER_CONTAINER_LOGS_DIR}}}:\
584585
{DOCKER_CONTAINER_LOGS_DIR}:ro"
585586
));
587+
apply_default_service_hardening(&mut otelcol_service);
586588
compose
587589
.services
588590
.insert(OTELCOL_SERVICE_NAME.to_string(), otelcol_service);
@@ -625,6 +627,7 @@ fn render_docker_compose_inner(scenario: &Scenario) -> DcResult<DockerComposeArt
625627
));
626628
push_router_observability_env(&mut env_entries);
627629
let mut router_service = Service::new(images.router.clone());
630+
router_service.user = Some(format!("{ROUTER_RUNTIME_UID}:{ROUTER_RUNTIME_GID}"));
628631
router_service.environment = Some(Environment::List(env_entries));
629632
router_service
630633
.extra_hosts
@@ -667,6 +670,8 @@ fn render_docker_compose_inner(scenario: &Scenario) -> DcResult<DockerComposeArt
667670
),
668671
],
669672
);
673+
apply_default_service_hardening(&mut router_service);
674+
apply_internal_service_rootfs_hardening(&mut router_service);
670675

671676
compose
672677
.services
@@ -678,6 +683,7 @@ fn render_docker_compose_inner(scenario: &Scenario) -> DcResult<DockerComposeArt
678683
let svc = names.get(id).unwrap();
679684

680685
let mut sidecar_service = Service::new(images.router.clone());
686+
sidecar_service.user = Some(format!("{ROUTER_RUNTIME_UID}:{ROUTER_RUNTIME_GID}"));
681687
let mut sidecar_env_entries = vec![
682688
format!("AMBER_ROUTER_CONFIG_PATH={}", mesh_config_path()),
683689
format!("AMBER_ROUTER_IDENTITY_PATH={}", mesh_identity_path()),
@@ -702,6 +708,8 @@ fn render_docker_compose_inner(scenario: &Scenario) -> DcResult<DockerComposeArt
702708
"service_completed_successfully",
703709
)],
704710
);
711+
apply_default_service_hardening(&mut sidecar_service);
712+
apply_internal_service_rootfs_hardening(&mut sidecar_service);
705713
compose
706714
.services
707715
.insert(svc.sidecar.clone(), sidecar_service);
@@ -845,6 +853,7 @@ fn render_docker_compose_inner(scenario: &Scenario) -> DcResult<DockerComposeArt
845853
}
846854
if Some(*id) == docker_gateway_component {
847855
configure_injected_docker_gateway_service(&mut program_service, s, *id, svc)?;
856+
apply_internal_service_rootfs_hardening(&mut program_service);
848857
}
849858
for storage_mount in storage_mounts {
850859
program_service.volumes.push(format!(
@@ -858,6 +867,7 @@ fn render_docker_compose_inner(scenario: &Scenario) -> DcResult<DockerComposeArt
858867
&label,
859868
&compose_component_service_name(&label),
860869
);
870+
apply_default_service_hardening(&mut program_service);
861871

862872
compose
863873
.services
@@ -1012,6 +1022,25 @@ service:
10121022
)
10131023
}
10141024

1025+
fn apply_default_service_hardening(service: &mut Service) {
1026+
if !service.cap_drop.iter().any(|cap| cap == "ALL") {
1027+
service.cap_drop.push("ALL".to_string());
1028+
}
1029+
if !service
1030+
.security_opt
1031+
.iter()
1032+
.any(|opt| opt == "no-new-privileges:true")
1033+
{
1034+
service
1035+
.security_opt
1036+
.push("no-new-privileges:true".to_string());
1037+
}
1038+
}
1039+
1040+
fn apply_internal_service_rootfs_hardening(service: &mut Service) {
1041+
service.read_only = Some(true);
1042+
}
1043+
10151044
fn push_router_observability_env(env_entries: &mut Vec<String>) {
10161045
env_entries.push(format!(
10171046
"{SCENARIO_RUN_ID_ENV}=${{{COMPOSE_PROJECT_NAME_ENV}:-default}}"

compiler/src/targets/mesh/docker_compose/tests.rs

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,21 @@ fn env_value(service: &super::Service, key: &str) -> Option<String> {
376376
}
377377
}
378378

379+
fn assert_service_hardened(service: &super::Service, yaml: &str) {
380+
assert!(service.cap_drop.iter().any(|cap| cap == "ALL"), "{yaml}");
381+
assert!(
382+
service
383+
.security_opt
384+
.iter()
385+
.any(|opt| opt == "no-new-privileges:true"),
386+
"{yaml}"
387+
);
388+
}
389+
390+
fn assert_internal_service_rootfs_hardened(service: &super::Service, yaml: &str) {
391+
assert_eq!(service.read_only, Some(true), "{yaml}");
392+
}
393+
379394
fn injected_docker_gateway_service(compose: &super::DockerComposeFile) -> (&str, &super::Service) {
380395
compose
381396
.services
@@ -414,7 +429,7 @@ fn assert_depends_on(service: &super::Service, name: &str, condition: &str) {
414429
}
415430

416431
fn storage_scenario(version: &str, initial_state: &str) -> Scenario {
417-
let provide_http =
432+
let provide_http: ProvideDecl =
418433
serde_json::from_value(json!({ "kind": "http", "endpoint": "http" })).unwrap();
419434

420435
let root = Component {
@@ -447,7 +462,7 @@ fn storage_scenario(version: &str, initial_state: &str) -> Scenario {
447462
}),
448463
)),
449464
slots: BTreeMap::new(),
450-
provides: BTreeMap::from([("http".to_string(), provide_http)]),
465+
provides: BTreeMap::from([("http".to_string(), provide_http.clone())]),
451466
resources: BTreeMap::from([("state".to_string(), storage_resource_decl(None))]),
452467
metadata: None,
453468
children: Vec::new(),
@@ -457,7 +472,14 @@ fn storage_scenario(version: &str, initial_state: &str) -> Scenario {
457472
root: ComponentId(0),
458473
components: vec![Some(root)],
459474
bindings: Vec::new(),
460-
exports: Vec::new(),
475+
exports: vec![ScenarioExport {
476+
name: "http".to_string(),
477+
capability: provide_http.decl.clone(),
478+
from: ProvideRef {
479+
component: ComponentId(0),
480+
name: "http".to_string(),
481+
},
482+
}],
461483
}
462484
}
463485

@@ -864,6 +886,7 @@ fn docker_compose_emits_gateway_for_framework_docker_binding() {
864886
gateway.network_mode.as_deref(),
865887
Some(expected_network_mode.as_str()),
866888
);
889+
assert_internal_service_rootfs_hardened(gateway, yaml.as_ref());
867890

868891
let gateway_config = env_value(gateway, super::DOCKER_GATEWAY_CONFIG_ENV)
869892
.expect("gateway config env should be present");
@@ -962,6 +985,10 @@ fn docker_compose_emits_framework_docker_mount_proxy_wiring() {
962985
super::HELPER_INIT_SERVICE,
963986
"service_completed_successfully",
964987
);
988+
assert_internal_service_rootfs_hardened(
989+
service(&compose, super::HELPER_INIT_SERVICE),
990+
yaml.as_ref(),
991+
);
965992
assert_depends_on(program_service, gateway_name, "service_started");
966993

967994
let mount_proxy_b64 = env_value(program_service, super::DOCKER_MOUNT_PROXY_SPEC_ENV)
@@ -1109,6 +1136,20 @@ fn compose_emits_sidecars_and_programs_and_slot_urls() {
11091136
assert!(compose.services.contains_key("c2-client-net"), "{yaml}");
11101137
assert!(compose.services.contains_key("c2-client"), "{yaml}");
11111138

1139+
for name in [
1140+
"amber-provisioner",
1141+
"amber-otelcol",
1142+
"c1-server-net",
1143+
"c1-server",
1144+
"c2-client-net",
1145+
"c2-client",
1146+
] {
1147+
assert_service_hardened(service(&compose, name), yaml.as_ref());
1148+
}
1149+
for name in ["c1-server-net", "c2-client-net"] {
1150+
assert_internal_service_rootfs_hardened(service(&compose, name), yaml.as_ref());
1151+
}
1152+
11121153
// Program uses sidecar netns.
11131154
assert_eq!(
11141155
service(&compose, "c2-client").network_mode.as_deref(),
@@ -1626,6 +1667,12 @@ fn compose_routes_external_slots_through_router() {
16261667
assert!(compose.services.contains_key("amber-router"));
16271668
let router_service = service(&compose, "amber-router");
16281669
assert!(env_value(router_service, "AMBER_EXTERNAL_SLOT_API_URL").is_some());
1670+
assert_eq!(
1671+
router_service.user.as_deref(),
1672+
Some("65532:65532"),
1673+
"{yaml}"
1674+
);
1675+
assert_internal_service_rootfs_hardened(router_service, yaml.as_ref());
16291676
assert!(
16301677
router_service.ports.is_empty(),
16311678
"slot-only scenarios should not publish the router mesh port on the host"

0 commit comments

Comments
 (0)