Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ All paths are relative to `crates/openshell-sandbox/src/`.
| `l7/inference.rs` | Inference API pattern detection (`detect_inference_pattern()`), HTTP request/response parsing and formatting for intercepted inference connections |
| `l7/tls.rs` | Ephemeral CA generation (`SandboxCa`), per-hostname leaf cert cache (`CertCache`), TLS termination/connection helpers |
| `l7/relay.rs` | Protocol-aware bidirectional relay with per-request OPA evaluation |
| `l7/rest.rs` | HTTP/1.1 request/response parsing, body framing (Content-Length, chunked), deny response generation |
| `l7/rest.rs` | HTTP/1.1 request/response parsing, body framing, deny response generation, and **custom header injection** |
| `l7/provider.rs` | `L7Provider` trait and `L7Request`/`BodyLength` types |

## Startup and Orchestration
Expand Down Expand Up @@ -281,7 +281,9 @@ The proxy calls `evaluate_network_action()` (not `evaluate_network()`) as its ma

### L7 endpoint config query

After L4 allows a connection, `query_endpoint_config(input)` evaluates `data.openshell.sandbox.matched_endpoint_config` to get the full endpoint object. If the endpoint has a `protocol` field, `l7::parse_l7_config()` extracts the L7 config for protocol-aware inspection.
After L4 allows a connection, `query_endpoint_config(input)` evaluates `data.openshell.sandbox.matched_endpoint_config` to get the full endpoint object. If the endpoint has a `protocol` field, `l7::parse_l7_config()` extracts the L7 config (including any `external_resolver` settings) for protocol-aware inspection.

If an `external_resolver` is present, `l7::relay::resolve_external_secret()` is called to fetch a dynamic secret from an external API using a configurable HTTP method and JSON body template. This secret is then passed to the L7 REST relay and injected into the outbound request's `Authorization: Bearer` header.

### Engine cloning for L7

Expand Down
13 changes: 13 additions & 0 deletions architecture/security-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,19 @@ Each endpoint defines a network destination and, optionally, L7 inspection behav
| `access` | `string` | `""` | Shorthand preset for common L7 rule sets. Mutually exclusive with `rules`. |
| `rules` | `L7Rule[]` | `[]` | Explicit L7 allow rules. Mutually exclusive with `access`. |
| `allowed_ips` | `string[]` | `[]` | IP allowlist for SSRF override. See [Private IP Access via `allowed_ips`](#private-ip-access-via-allowed_ips). |
| `external_resolver` | `ExternalResolver`| `None` | Configuration for dynamic secret resolution via external API. Only for `protocol: rest`. |

#### `ExternalResolver`

Defines an external API to call for fetching secrets (e.g., API keys) before forwarding a request.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `url` | `string` | Yes | The URL of the external resolver service. |
| `method` | `string` | No | HTTP method to use (e.g., `POST`, `GET`). Default: `GET`. |
| `header` | `string` | No | HTTP header name to inject the secret into. Default: `Authorization`. |
| `body_template` | `string` | No | JSON body template with placeholders: `{{.SandboxID}}`, `{{.Host}}`, `{{.Port}}`, `{{.Binary}}`. |
| `response_path` | `string` | No | Dot-separated response path for extracting the secret (e.g., `data.token`). If omitted, fallback keys `secret`, `token`, `key` are used. |

#### `NetworkBinary`

Expand Down
57 changes: 57 additions & 0 deletions crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,22 @@ struct NetworkEndpointDef {
rules: Vec<L7RuleDef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
allowed_ips: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
external_resolver: Option<ExternalResolverDef>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ExternalResolverDef {
url: String,
#[serde(default)]
method: String,
#[serde(default)]
body_template: String,
#[serde(default)]
header: String,
#[serde(default)]
response_path: String,
}

fn is_zero(v: &u32) -> bool {
Expand Down Expand Up @@ -180,6 +196,15 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy {
})
.collect(),
allowed_ips: e.allowed_ips,
external_resolver: e.external_resolver.map(|er| {
openshell_core::proto::ExternalResolver {
url: er.url,
method: er.method,
body_template: er.body_template,
header: er.header,
response_path: er.response_path,
}
}),
}
})
.collect(),
Expand Down Expand Up @@ -280,6 +305,15 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
})
.collect(),
allowed_ips: e.allowed_ips.clone(),
external_resolver: e.external_resolver.as_ref().map(|er| {
ExternalResolverDef {
url: er.url.clone(),
method: er.method.clone(),
body_template: er.body_template.clone(),
header: er.header.clone(),
response_path: er.response_path.clone(),
}
}),
}
})
.collect(),
Expand Down Expand Up @@ -1117,4 +1151,27 @@ network_policies:
proto2.network_policies["test"].endpoints[0].host
);
}

#[test]
fn parse_external_resolver() {
let yaml = r#"
version: 1
network_policies:
test:
name: test
endpoints:
- { host: "api.openai.com", port: 443, protocol: "rest", external_resolver: { url: "http://host.openshell.internal:5000/v1/resolve-secret", method: "POST", header: "X-OpenShell-Token", response_path: "data.token", body_template: '{"foo": "bar"}' } }
binaries:
- { path: /usr/bin/curl }
"#;
let policy = parse_sandbox_policy(yaml).expect("should parse");
let ep = &policy.network_policies["test"].endpoints[0];
assert_eq!(ep.host, "api.openai.com");
let er = ep.external_resolver.as_ref().expect("external_resolver missing");
assert_eq!(er.url, "http://host.openshell.internal:5000/v1/resolve-secret");
assert_eq!(er.method, "POST");
assert_eq!(er.header, "X-OpenShell-Token");
assert_eq!(er.response_path, "data.token");
assert_eq!(er.body_template, "{\"foo\": \"bar\"}");
}
}
1 change: 1 addition & 0 deletions crates/openshell-sandbox/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ rcgen = { workspace = true }
webpki-roots = { workspace = true }

# HTTP
reqwest = { workspace = true }
bytes = { workspace = true }

# IP network / CIDR parsing
Expand Down
17 changes: 17 additions & 0 deletions crates/openshell-sandbox/data/sandbox-policy.rego
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,20 @@ endpoint_has_extended_config(ep) if {
endpoint_has_extended_config(ep) if {
count(object.get(ep, "allowed_ips", [])) > 0
}

# --- External secret resolution (queried per-request within a tunnel) ---
# Returns the resolver metadata (url, method, body_template) if the request
# matches a policy that specifies an external_resolver.

default external_resolver = undefined

external_resolver := resolver if {
some name
policy := data.network_policies[name]
endpoint_allowed(policy, input.network)
binary_allowed(policy, input.exec)
some ep
ep := policy.endpoints[_]
endpoint_matches_request(ep, input.network)
resolver := ep.external_resolver
}
36 changes: 36 additions & 0 deletions crates/openshell-sandbox/src/l7/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
//! doing a raw `copy_bidirectional`. Each request within the tunnel is parsed,
//! evaluated against OPA policy, and either forwarded or denied.

use tracing::info;

pub mod inference;
pub mod provider;
pub mod relay;
Expand Down Expand Up @@ -51,12 +53,23 @@ pub enum EnforcementMode {
Enforce,
}

/// External secret resolver configuration.
#[derive(Debug, Clone)]
pub struct ExternalResolverConfig {
pub url: String,
pub method: String,
pub body_template: String,
pub header: String,
pub response_path: String,
}

/// L7 configuration for an endpoint, extracted from policy data.
#[derive(Debug, Clone)]
pub struct L7EndpointConfig {
pub protocol: L7Protocol,
pub tls: TlsMode,
pub enforcement: EnforcementMode,
pub external_resolver: Option<ExternalResolverConfig>,
}

/// Result of an L7 policy decision for a single request.
Expand Down Expand Up @@ -89,15 +102,38 @@ pub fn parse_l7_config(val: &regorus::Value) -> Option<L7EndpointConfig> {
_ => TlsMode::Passthrough,
};

info!(val = ?val, "Parsing L7 config from OPA value");
let enforcement = match get_object_str(val, "enforcement").as_deref() {
Some("enforce") => EnforcementMode::Enforce,
_ => EnforcementMode::Audit,
};

let external_resolver = (|| {
let obj = val.as_object().ok()?;
let found = obj.get(&regorus::Value::from("external_resolver")).is_some();
info!(has_external_resolver = found, "Checking for external_resolver in OPA object");
let v = obj.get(&regorus::Value::from("external_resolver"))?;
let url = get_object_str(v, "url")?;
let method = get_object_str(v, "method")?;
let body_template = get_object_str(v, "body_template").unwrap_or_default();
let header = get_object_str(v, "header").unwrap_or_else(|| "Authorization".to_string());
let response_path = get_object_str(v, "response_path").unwrap_or_default();
let config = ExternalResolverConfig {
url,
method,
body_template,
header,
response_path,
};
info!(config = ?config, "Parsed external resolver config from OPA");
Some(config)
})();

Some(L7EndpointConfig {
protocol,
tls,
enforcement,
external_resolver,
})
}

Expand Down
109 changes: 109 additions & 0 deletions crates/openshell-sandbox/src/l7/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ pub struct L7EvalContext {
pub cmdline_paths: Vec<String>,
/// Supervisor-only placeholder resolver for outbound headers.
pub(crate) secret_resolver: Option<Arc<SecretResolver>>,
/// Sandbox ID for external secret resolution.
pub sandbox_id: Option<String>,
}

/// Run protocol-aware L7 inspection on a tunnel.
Expand Down Expand Up @@ -133,12 +135,34 @@ where
);

if allowed || config.enforcement == EnforcementMode::Audit {
let external_secret = if let Some(resolver) = &config.external_resolver {
match resolve_external_secret(resolver, ctx.sandbox_id.as_deref().unwrap_or("-"), ctx).await {
Ok(secret) => {
Some(secret)
}
Err(e) => {
warn!(error = %e, "External secret resolution failed");
None
}
}
} else {
None
};

let external_header = config
.external_resolver
.as_ref()
.map(|r| r.header.as_str())
.unwrap_or("");

// Forward request to upstream and relay response
let reusable = crate::l7::rest::relay_http_request_with_resolver(
&req,
client,
upstream,
ctx.secret_resolver.as_deref(),
external_secret.as_deref(),
external_header,
)
.await?;
if !reusable {
Expand Down Expand Up @@ -229,3 +253,88 @@ fn evaluate_l7_request(

Ok((allowed, reason))
}

async fn resolve_external_secret(
resolver: &crate::l7::ExternalResolverConfig,
sandbox_id: &str,
ctx: &L7EvalContext,
) -> Result<String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| miette::miette!("failed to build reqwest client: {e}"))?;

let body = resolver
.body_template
.replace("{{.SandboxID}}", sandbox_id)
.replace("{{.Host}}", &ctx.host)
.replace("{{.Port}}", &ctx.port.to_string())
.replace("{{.Binary}}", &ctx.binary_path);

let method = resolver.method.to_uppercase();

let mut builder = match method.as_str() {
"POST" => client.post(&resolver.url),
"PUT" => client.put(&resolver.url),
_ => client.get(&resolver.url),
};

if !body.is_empty() {
builder = builder
.header("Content-Type", "application/json")
.body(body);
}

let resp = builder
.send()
.await
.map_err(|e| miette::miette!("external resolver request failed: {e}"))?;

if !resp.status().is_success() {
return Err(miette::miette!(
"external resolver returned error {}: {}",
resp.status(),
resp.text().await.unwrap_or_default()
));
}

let json: serde_json::Value = resp
.json()
.await
.map_err(|e| miette::miette!("failed to parse resolver response: {e}"))?;

if !resolver.response_path.is_empty() {
let value = extract_json_value_by_path(&json, &resolver.response_path).ok_or_else(|| {
miette::miette!(
"external resolver response missing value at response_path '{}'",
resolver.response_path
)
})?;

let secret = value.as_str().ok_or_else(|| {
miette::miette!(
"external resolver value at response_path '{}' must be a string",
resolver.response_path
)
})?;

return Ok(secret.to_string());
}

let secret = json
.get("secret")
.or_else(|| json.get("token"))
.or_else(|| json.get("key"))
.and_then(|v| v.as_str())
.ok_or_else(|| {
miette::miette!("external resolver response missing 'secret', 'token', or 'key' field")
})?;

Ok(secret.to_string())
}

fn extract_json_value_by_path<'a>(root: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
path.split('.')
.filter(|segment| !segment.is_empty())
.try_fold(root, |current, segment| current.get(segment))
}
Loading
Loading