Skip to content

Commit aead95b

Browse files
authored
fix(policy): propose rules for unknown DNS hosts (#3707)
* fix(policy): propose rules for unknown DNS hosts Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * docs(policy): clarify synthetic DNS use across protocols Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * fix(policy): harden unknown-host DNS observations - Emit the policy_dns_ineligible denial for every unknown name and report observation staging failures as DNS failure events. - Refuse unknown names during fail-closed quarantine and after the observation budget, now a quarter of each address family's pool. - Pin transparent TCP to the mapping of the deciding policy generation so a reload between DNS and authorization fails closed. - Stop Docker workloads from inheriting host DNS search domains, which let the first expanded short name claim an observation address. - Share mechanistic draft polling in conformance, register new-hostname-proposal in the installed suite, and update docs. Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * test(policy): build policy DNS proxy tests on every target The proxy tests name PolicyEndpointId, which proxy.rs imported only on Linux, so the macOS test build failed. Import it for test builds too. Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * fix(policy): name DNS queries and mapped hosts in OCSF denials DNS denial and failure events attached port 53 to the queried name, which read as a connection to that host. They now carry only the name. Transparent TCP denials for a policy DNS address show the mapped hostname and keep the synthetic address in dst_endpoint.ip. Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> --------- Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
1 parent c93fd94 commit aead95b

17 files changed

Lines changed: 1471 additions & 215 deletions

File tree

‎CI.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ Manually dispatch `Integration Tests` on the candidate branch with an
5050
[{"environment":"ubuntu-docker-rootful","installer":"binaries","testsuite":"policy-advisor"}]
5151
```
5252

53-
This runs the `mechanistic-proposal` and `policy-local` conformance tests in the
54-
installed-artifact suite. The artifact run must contain the candidate CLI and
53+
This runs the `mechanistic-proposal`, `new-hostname-proposal`, and
54+
`policy-local` conformance tests in the installed-artifact suite. The artifact run must contain the candidate CLI and
5555
gateway binaries and runtime images. This manual run does not replace the
5656
required PR E2E gate.
5757

‎architecture/sandbox.md‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -269,8 +269,9 @@ Input mediation, DNS/TCP authorization, and outer-fence enforcement are
269269
identical in both modes. The selected mode is emitted in the sandbox
270270
qualification output (`seccomp_listener_mode`).
271271

272-
DNS uses an exact sandbox-local resolver at `127.0.0.53:53`. The driver sets the
273-
nameserver and permits an unprivileged bind to port 53. UDP and TCP DNS requests
272+
DNS uses an exact sandbox-local resolver at `127.0.0.53:53`. The driver sets that
273+
nameserver without search domains, so names reach policy DNS as the workload
274+
wrote them, and permits an unprivileged bind to port 53. UDP and TCP DNS requests
274275
are forwarded through the supervisor, which applies hostname-based DNS policy.
275276
The Podman driver supplies that resolver configuration as a driver-owned,
276277
read-only secret mounted at `/etc/resolv.conf`; the workload remains on
@@ -323,10 +324,12 @@ preserves this lifetime rule; persistent responses remain eligible for reuse.
323324
An explicit `protocol: tcp` endpoint with a valid DNS hostname opts into native
324325
DNS and transparent TCP when the selected runtime advertises that substrate.
325326
Hostless `allowed_ips` and literal-IP selectors remain available only to the
326-
legacy explicit-proxy path when `protocol` is omitted. The shared supervisor
327-
answers only eligible DNS names, returns an epoch-scoped synthetic address, and
327+
legacy explicit-proxy path when `protocol` is omitted. For an eligible DNS
328+
name, the shared supervisor returns an epoch-scoped synthetic address and
328329
publishes the expiring name, endpoint, ports, policy generation, and validated
329-
real addresses as one correlation. A connection to that synthetic address is
330+
real addresses as one correlation. A name absent from policy receives at most a
331+
contract-free observation address for policy advisor proposals; see
332+
[Security Policy](security-policy.md). A connection to that synthetic address is
330333
captured before the bypass fence, mapped back to its workload process, authorized
331334
through the same egress pipeline, and dialed only through the pinned addresses.
332335
Omitted protocol endpoints retain explicit-proxy behavior.

‎architecture/security-policy.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,19 @@ because it changes the effective access model for every sandbox on the gateway.
282282
The policy advisor pipeline turns observed denials into draft policy
283283
recommendations. There are two proposers (sandbox-side mechanistic mapper,
284284
agent-authored via `policy.local`); the gateway is the single referee.
285+
For a new DNS name absent from policy, the supervisor can
286+
publish a bounded, short-lived synthetic observation mapping without querying
287+
an upstream resolver. It carries only the name to the subsequent TCP mediation
288+
step, which supplies the destination port and verified process identity for a
289+
denial summary. Observation mappings have no endpoint contracts or real pinned
290+
addresses, cannot authorize a relay, and become stale on policy generation
291+
change. Transparent TCP re-acquires the mapping at the generation that
292+
authorized the connection, so a reload between DNS and authorization fails
293+
closed instead of resolving the name again. Every unknown name still produces a
294+
DNS denial event. Reserved names, fail-closed quarantine, and an exhausted
295+
observation budget (a quarter of each address family's synthetic pool) keep the
296+
plain DNS refusal. This mechanistic observation path does not depend on the
297+
agent-authored proposal setting.
285298
When enabled, L7 `policy_denied` responses include both structured
286299
`next_steps` and a short `agent_guidance` string so generic agents can continue
287300
through the proposal loop instead of treating the denial as terminal.

‎crates/openshell-conformance/src/lib.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ use tokio::time::sleep;
2424
use self::executor::{CliExecutionError, CliExecutor, ProcessCli};
2525

2626
pub use scenarios::{
27-
MECHANISTIC_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, SANDBOX_LIFECYCLE_SCENARIO,
28-
SMOKE_SCENARIO,
27+
MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO,
28+
SANDBOX_LIFECYCLE_SCENARIO, SMOKE_SCENARIO,
2929
};
3030

3131
/// An installed conformance scenario.
@@ -48,6 +48,7 @@ const SCENARIOS: &[Scenario] = &[
4848
SMOKE_SCENARIO,
4949
SANDBOX_LIFECYCLE_SCENARIO,
5050
MECHANISTIC_PROPOSAL_SCENARIO,
51+
NEW_HOSTNAME_PROPOSAL_SCENARIO,
5152
POLICY_LOCAL_SCENARIO,
5253
];
5354

‎crates/openshell-conformance/src/scenarios/mod.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ mod policy_behavior;
77
mod sandbox_lifecycle;
88
mod smoke;
99

10-
pub use policy_behavior::{MECHANISTIC_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO};
10+
pub use policy_behavior::{
11+
MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO,
12+
};
1113
pub use sandbox_lifecycle::SANDBOX_LIFECYCLE_SCENARIO;
1214
pub use smoke::SMOKE_SCENARIO;

‎crates/openshell-conformance/src/scenarios/policy_behavior.rs‎

Lines changed: 158 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,21 @@ pub const MECHANISTIC_PROPOSAL_SCENARIO: Scenario = Scenario {
3737
run: run_mechanistic_proposal,
3838
};
3939

40+
pub const NEW_HOSTNAME_PROPOSAL_SCENARIO: Scenario = Scenario {
41+
name: "new-hostname-proposal",
42+
description: "Turn a denied TCP open to a hostname absent from policy into a scoped draft.",
43+
run: run_new_hostname_proposal,
44+
};
45+
46+
const EMPTY_NETWORK_POLICY: &[u8] = br"version: 1
47+
filesystem_policy:
48+
include_workdir: true
49+
read_only: [/usr, /bin, /lib, /lib64, /proc, /dev/urandom, /app, /etc, /var/log]
50+
read_write: [/sandbox, /tmp, /dev/null]
51+
landlock: { compatibility: best_effort }
52+
network_policies: {}
53+
";
54+
4055
fn run_policy_local(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> {
4156
Box::pin(async move {
4257
let name = format!("ct-{}-pl", runner.id());
@@ -279,16 +294,7 @@ fn run_mechanistic_proposal(runner: &mut OpenShellRunner) -> ScenarioFuture<'_>
279294
Box::pin(async move {
280295
let mut policy = NamedTempFile::new().map_err(|error| error.to_string())?;
281296
policy
282-
.write_all(
283-
br"version: 1
284-
filesystem_policy:
285-
include_workdir: true
286-
read_only: [/usr, /bin, /lib, /lib64, /proc, /dev/urandom, /app, /etc, /var/log]
287-
read_write: [/sandbox, /tmp, /dev/null]
288-
landlock: { compatibility: best_effort }
289-
network_policies: {}
290-
",
291-
)
297+
.write_all(EMPTY_NETWORK_POLICY)
292298
.map_err(|error| error.to_string())?;
293299
let policy_path = policy
294300
.path()
@@ -345,60 +351,139 @@ network_policies: {}
345351
return Err(probe.failure_diagnostic("TCP open is denied before any upstream dial"));
346352
}
347353

348-
let started = Instant::now();
349-
loop {
350-
let draft = runner
351-
.step("mechanistic-draft")
352-
.description("a single scoped mechanistic draft appears")
353-
.with_timeout(COMMAND_TIMEOUT)
354-
.run(&["rule", "get", &name])
355-
.await
356-
.map_err(|error| error.to_string())?;
357-
if !draft.success() {
358-
if started.elapsed() >= PROPOSAL_TIMEOUT {
359-
return Err(draft.failure_diagnostic("the reviewer inbox is readable"));
360-
}
361-
sleep(POLL_INTERVAL).await;
362-
continue;
354+
await_mechanistic_draft(
355+
runner,
356+
&name,
357+
&ExpectedDraft {
358+
rule: "allow_1_1_1_1_443",
359+
endpoint: "1.1.1.1:443",
360+
binary: &binary,
361+
},
362+
probe.stderr(),
363+
)
364+
.await
365+
})
366+
}
367+
368+
fn run_new_hostname_proposal(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> {
369+
Box::pin(async move {
370+
let mut policy = NamedTempFile::new().map_err(|error| error.to_string())?;
371+
policy
372+
.write_all(EMPTY_NETWORK_POLICY)
373+
.map_err(|error| error.to_string())?;
374+
let policy_path = policy
375+
.path()
376+
.to_str()
377+
.ok_or("temporary policy path is not UTF-8")?;
378+
let name = format!("ct-{}-nh", runner.id());
379+
create_sandbox(runner, &name, Some(policy_path)).await?;
380+
let binary = sandbox_bash_path(runner, &name).await?;
381+
382+
let probe = runner
383+
.step("denied-new-hostname")
384+
.description("Bash cannot connect to pypi.org:80 before approval")
385+
.with_timeout(COMMAND_TIMEOUT)
386+
.run(&[
387+
"sandbox",
388+
"exec",
389+
"--name",
390+
&name,
391+
"--no-tty",
392+
"--",
393+
"bash",
394+
"-c",
395+
"if exec 3<>/dev/tcp/pypi.org/80; then echo UNEXPECTED_ALLOWED; exit 1; else echo DENIED; fi",
396+
])
397+
.await
398+
.map_err(|error| error.to_string())?;
399+
probe.require_success()?;
400+
if !probe.stdout().lines().any(|line| line == "DENIED") {
401+
return Err(probe.failure_diagnostic("new hostname stays denied before approval"));
402+
}
403+
404+
await_mechanistic_draft(
405+
runner,
406+
&name,
407+
&ExpectedDraft {
408+
rule: "allow_pypi_org_80",
409+
endpoint: "pypi.org:80",
410+
binary: &binary,
411+
},
412+
probe.stderr(),
413+
)
414+
.await
415+
})
416+
}
417+
418+
/// The single L4 mechanistic draft expected for one denied endpoint.
419+
struct ExpectedDraft<'a> {
420+
rule: &'a str,
421+
endpoint: &'a str,
422+
binary: &'a str,
423+
}
424+
425+
/// Poll the reviewer inbox until a draft appears, tolerating transient read
426+
/// failures, then require that it is the single expected draft.
427+
async fn await_mechanistic_draft(
428+
runner: &OpenShellRunner,
429+
sandbox: &str,
430+
expected: &ExpectedDraft<'_>,
431+
probe_stderr: &str,
432+
) -> Result<(), String> {
433+
let started = Instant::now();
434+
loop {
435+
let draft = runner
436+
.step("mechanistic-draft")
437+
.description("a single scoped mechanistic draft appears")
438+
.with_timeout(COMMAND_TIMEOUT)
439+
.run(&["rule", "get", sandbox])
440+
.await
441+
.map_err(|error| error.to_string())?;
442+
if !draft.success() {
443+
if started.elapsed() >= PROPOSAL_TIMEOUT {
444+
return Err(draft.failure_diagnostic("the reviewer inbox is readable"));
363445
}
364-
if !draft.stdout().contains("Chunk:") {
365-
if started.elapsed() >= PROPOSAL_TIMEOUT {
366-
return Err(draft.failure_diagnostic(&format!(
367-
"one mechanistic draft for 1.1.1.1:443 and {binary}; probe stderr:\n{}",
368-
probe.stderr()
369-
)));
370-
}
371-
sleep(POLL_INTERVAL).await;
372-
continue;
446+
sleep(POLL_INTERVAL).await;
447+
continue;
448+
}
449+
if !draft.stdout().contains("Chunk:") {
450+
if started.elapsed() >= PROPOSAL_TIMEOUT {
451+
return Err(draft.failure_diagnostic(&format!(
452+
"one mechanistic draft for {} and {}; probe stderr:\n{probe_stderr}",
453+
expected.endpoint, expected.binary
454+
)));
373455
}
374-
assert_mechanistic_draft(draft.stdout(), &binary)
375-
.map_err(|error| draft.failure_diagnostic(&error))?;
376-
return Ok(());
456+
sleep(POLL_INTERVAL).await;
457+
continue;
377458
}
378-
})
459+
return assert_mechanistic_draft(draft.stdout(), expected)
460+
.map_err(|error| draft.failure_diagnostic(&error));
461+
}
379462
}
380463

381-
fn assert_mechanistic_draft(output: &str, binary: &str) -> Result<(), String> {
464+
fn assert_mechanistic_draft(output: &str, expected: &ExpectedDraft<'_>) -> Result<(), String> {
382465
let fields = output.lines().map(str::trim).collect::<Vec<_>>();
383466
let field = |name: &str| {
384467
fields
385468
.iter()
386469
.find_map(|line| line.strip_prefix(name).map(str::trim))
387470
};
471+
let endpoints = format!("{} [L4]", expected.endpoint);
388472
if fields
389473
.iter()
390474
.filter(|line| line.starts_with("Chunk:"))
391475
.count()
392476
!= 1
393477
|| !matches!(field("Status:"), Some("pending" | "approved"))
394-
|| field("Rule:") != Some("allow_1_1_1_1_443")
395-
|| field("Binary:") != Some(binary)
396-
|| field("Binaries:") != Some(binary)
397-
|| field("Endpoints:") != Some("1.1.1.1:443 [L4]")
398-
|| !field("Rationale:").is_some_and(|value| value.contains("1.1.1.1:443"))
478+
|| field("Rule:") != Some(expected.rule)
479+
|| field("Binary:") != Some(expected.binary)
480+
|| field("Binaries:") != Some(expected.binary)
481+
|| field("Endpoints:") != Some(endpoints.as_str())
482+
|| !field("Rationale:").is_some_and(|value| value.contains(expected.endpoint))
399483
{
400484
return Err(format!(
401-
"expected one pending or approved L4 mechanistic draft scoped to {binary} and 1.1.1.1:443"
485+
"expected one pending or approved L4 mechanistic draft scoped to {} and {}",
486+
expected.binary, expected.endpoint
402487
));
403488
}
404489
Ok(())
@@ -462,11 +547,36 @@ async fn create_sandbox(
462547

463548
#[cfg(test)]
464549
mod tests {
465-
use super::assert_mechanistic_draft;
550+
use super::{ExpectedDraft, assert_mechanistic_draft};
551+
552+
const EXPECTED: ExpectedDraft<'static> = ExpectedDraft {
553+
rule: "allow_pypi_org_80",
554+
endpoint: "pypi.org:80",
555+
binary: "/usr/bin/bash",
556+
};
557+
558+
fn draft(binary: &str) -> String {
559+
format!(
560+
"Chunk: id\nStatus: pending\nRule: allow_pypi_org_80\nBinary: {binary}\nRationale: Allow {binary} to connect to pypi.org:80 (HTTP).\nEndpoints: pypi.org:80 [L4]\nBinaries: {binary}\n"
561+
)
562+
}
563+
564+
#[test]
565+
fn draft_assertion_accepts_a_hostname_scoped_draft() {
566+
assert!(assert_mechanistic_draft(&draft("/usr/bin/bash"), &EXPECTED).is_ok());
567+
}
466568

467569
#[test]
468570
fn draft_assertion_rejects_unrelated_binary() {
469-
let draft = "Chunk: id\nStatus: pending\nRule: allow_1_1_1_1_443\nBinary: /usr/bin/sh\nRationale: Allow sh to connect to 1.1.1.1:443.\nEndpoints: 1.1.1.1:443 [L4]\nBinaries: /usr/bin/sh\n";
470-
assert!(assert_mechanistic_draft(draft, "/usr/bin/bash").is_err());
571+
assert!(assert_mechanistic_draft(&draft("/usr/bin/sh"), &EXPECTED).is_err());
572+
}
573+
574+
#[test]
575+
fn draft_assertion_rejects_fields_spread_across_drafts() {
576+
let drafts = format!(
577+
"{}Chunk: other\nStatus: pending\nRule: allow_1_1_1_1_443\n",
578+
draft("/usr/bin/bash")
579+
);
580+
assert!(assert_mechanistic_draft(&drafts, &EXPECTED).is_err());
471581
}
472582
}

‎crates/openshell-driver-docker/src/lib.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5796,6 +5796,10 @@ fn build_container_create_body_for_image(
57965796
}),
57975797
network_mode: Some("none".to_string()),
57985798
dns: Some(vec!["127.0.0.53".to_string()]),
5799+
// Docker otherwise copies the host's search domains. Like the
5800+
// Podman, Kubernetes, and VM resolvers, send names to policy DNS
5801+
// exactly as written so short names never expand to host domains.
5802+
dns_search: Some(vec![".".to_string()]),
57995803
tmpfs: Some(HashMap::from([(
58005804
openshell_sandbox_backend::SUPERVISOR_CA_RUNTIME_DIR.to_string(),
58015805
format!(

‎crates/openshell-driver-docker/src/tests.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,6 +1278,7 @@ fn container_creation_uses_inspected_immutable_image() {
12781278
);
12791279
assert_eq!(host.network_mode.as_deref(), Some("none"));
12801280
assert_eq!(host.dns, Some(vec!["127.0.0.53".to_string()]));
1281+
assert_eq!(host.dns_search, Some(vec![".".to_string()]));
12811282
}
12821283

12831284
#[test]
@@ -2714,6 +2715,11 @@ fn build_container_create_body_disables_docker_networking() {
27142715
);
27152716
assert_eq!(host_config.extra_hosts, None);
27162717
assert_eq!(host_config.dns, Some(vec!["127.0.0.53".to_string()]));
2718+
assert_eq!(
2719+
host_config.dns_search,
2720+
Some(vec![".".to_string()]),
2721+
"host search domains must not expand workload names before policy DNS"
2722+
);
27172723
}
27182724

27192725
#[test]

0 commit comments

Comments
 (0)