Skip to content
Open
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
6 changes: 3 additions & 3 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -962,7 +962,7 @@ flowchart LR
| `EnforcementMode` | `Audit`, `Enforce` | What to do on L7 deny (log-only vs block) |
| `L7EndpointConfig` | `{ protocol, tls, enforcement }` | Per-endpoint L7 configuration |
| `L7Decision` | `{ allowed, reason, matched_rule }` | Result of L7 evaluation |
| `L7RequestInfo` | `{ action, target }` | HTTP method + path for policy evaluation |
| `L7RequestInfo` | `{ action, target, query_params }` | HTTP method, path, and decoded query multimap for policy evaluation |

### Access presets

Expand Down Expand Up @@ -1041,7 +1041,7 @@ This enables credential injection on all HTTPS endpoints automatically, without

Implements `L7Provider` for HTTP/1.1:

- **`parse_request()`**: Reads up to 16 KiB of headers, parses the request line (method, path), determines body framing from `Content-Length` or `Transfer-Encoding: chunked` headers. Returns `L7Request` with raw header bytes (may include overflow body bytes).
- **`parse_request()`**: Reads up to 16 KiB of headers, parses the request line (method, path), decodes query parameters into a multimap, determines body framing from `Content-Length` or `Transfer-Encoding: chunked` headers. Returns `L7Request` with raw header bytes (may include overflow body bytes).

- **`relay()`**: Forwards request headers and body to upstream (handling Content-Length, chunked, and no-body cases), then reads and relays the full response back to the client.

Expand All @@ -1054,7 +1054,7 @@ Implements `L7Provider` for HTTP/1.1:
`relay_with_inspection()` in `crates/openshell-sandbox/src/l7/relay.rs` is the main relay loop:

1. Parse one HTTP request from client via the provider
2. Build L7 input JSON with `request.method`, `request.path`, plus the CONNECT-level context (host, port, binary, ancestors, cmdline)
2. Build L7 input JSON with `request.method`, `request.path`, `request.query_params`, plus the CONNECT-level context (host, port, binary, ancestors, cmdline)
3. Evaluate `data.openshell.sandbox.allow_request` and `data.openshell.sandbox.request_deny_reason`
4. Log the L7 decision (tagged `L7_REQUEST`)
5. If allowed (or audit mode): relay request to upstream and response back to client, then loop
Expand Down
8 changes: 7 additions & 1 deletion architecture/security-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,9 +461,14 @@ rules:
- allow:
method: GET
path: "/repos/**"
query:
per_page: "1*"
- allow:
method: POST
path: "/repos/*/issues"
query:
labels:
any: ["bug*", "p1*"]
```

#### `L7Allow`
Expand All @@ -473,8 +478,9 @@ rules:
| `method` | `string` | HTTP method: `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `PATCH`, `OPTIONS`, or `*` (any). Case-insensitive matching. |
| `path` | `string` | URL path glob pattern: `**` matches everything, otherwise `glob.match` with `/` delimiter. |
| `command` | `string` | SQL command: `SELECT`, `INSERT`, `UPDATE`, `DELETE`, or `*` (any). Case-insensitive matching. For `protocol: sql` endpoints. |
| `query` | `map` | Optional REST query rules keyed by decoded query param name. Value is either a glob string (for example, `tag: "foo-*"`) or `{ any: ["foo-*", "bar-*"] }`. |

Method and command fields use `*` as wildcard for "any". Path patterns use `**` for "match everything" and standard glob patterns with `/` as a delimiter otherwise. See `sandbox-policy.rego` -- `method_matches()`, `path_matches()`, `command_matches()`.
Method and command fields use `*` as wildcard for "any". Path patterns use `**` for "match everything" and standard glob patterns with `/` as a delimiter otherwise. Query matching is case-sensitive and evaluates decoded values; when duplicate keys are present in the request, every value for that key must match the configured matcher. See `sandbox-policy.rego` -- `method_matches()`, `path_matches()`, `command_matches()`, `query_params_match()`.

#### Access Presets

Expand Down
94 changes: 92 additions & 2 deletions crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ use std::path::Path;

use miette::{IntoDiagnostic, Result, WrapErr};
use openshell_core::proto::{
FilesystemPolicy, L7Allow, L7Rule, LandlockPolicy, NetworkBinary, NetworkEndpoint,
NetworkPolicyRule, ProcessPolicy, SandboxPolicy,
FilesystemPolicy, L7Allow, L7QueryMatcher, L7Rule, LandlockPolicy, NetworkBinary,
NetworkEndpoint, NetworkPolicyRule, ProcessPolicy, SandboxPolicy,
};
use serde::{Deserialize, Serialize};

Expand Down Expand Up @@ -120,6 +120,22 @@ struct L7AllowDef {
path: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
command: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
query: BTreeMap<String, QueryMatcherDef>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
enum QueryMatcherDef {
Glob(String),
Any(QueryAnyDef),
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct QueryAnyDef {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
any: Vec<String>,
}

#[derive(Debug, Serialize, Deserialize)]
Expand Down Expand Up @@ -176,6 +192,23 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy {
method: r.allow.method,
path: r.allow.path,
command: r.allow.command,
query: r
.allow
.query
.into_iter()
.map(|(key, matcher)| {
let proto = match matcher {
QueryMatcherDef::Glob(glob) => {
L7QueryMatcher { glob, any: vec![] }
}
QueryMatcherDef::Any(any) => L7QueryMatcher {
glob: String::new(),
any: any.any,
},
};
(key, proto)
})
.collect(),
}),
})
.collect(),
Expand Down Expand Up @@ -275,6 +308,20 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
method: a.method,
path: a.path,
command: a.command,
query: a
.query
.into_iter()
.map(|(key, matcher)| {
let yaml_matcher = if !matcher.any.is_empty() {
QueryMatcherDef::Any(QueryAnyDef {
any: matcher.any,
})
} else {
QueryMatcherDef::Glob(matcher.glob)
};
(key, yaml_matcher)
})
.collect(),
},
}
})
Expand Down Expand Up @@ -754,6 +801,49 @@ network_policies:
assert_eq!(rule.binaries[0].path, "/usr/bin/curl");
}

#[test]
fn parse_l7_query_matchers_and_round_trip() {
let yaml = r#"
version: 1
network_policies:
query_test:
name: query_test
endpoints:
- host: api.example.com
port: 8080
protocol: rest
rules:
- allow:
method: GET
path: /download
query:
slug: "my-*"
tag:
any: ["foo-*", "bar-*"]
binaries:
- path: /usr/bin/curl
"#;
let proto = parse_sandbox_policy(yaml).expect("parse failed");
let allow = proto.network_policies["query_test"].endpoints[0].rules[0]
.allow
.as_ref()
.expect("allow");
assert_eq!(allow.query["slug"].glob, "my-*");
assert_eq!(allow.query["slug"].any, Vec::<String>::new());
assert_eq!(allow.query["tag"].any, vec!["foo-*", "bar-*"]);
assert!(allow.query["tag"].glob.is_empty());

let yaml_out = serialize_sandbox_policy(&proto).expect("serialize failed");
let proto_round_trip = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
let allow_round_trip = proto_round_trip.network_policies["query_test"].endpoints[0].rules
[0]
.allow
.as_ref()
.expect("allow");
assert_eq!(allow_round_trip.query["slug"].glob, "my-*");
assert_eq!(allow_round_trip.query["tag"].any, vec!["foo-*", "bar-*"]);
}

#[test]
fn parse_rejects_unknown_fields() {
let yaml = "version: 1\nbogus_field: true\n";
Expand Down
50 changes: 50 additions & 0 deletions crates/openshell-sandbox/data/sandbox-policy.rego
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ request_allowed_for_endpoint(request, endpoint) if {
rule.allow.method
method_matches(request.method, rule.allow.method)
path_matches(request.path, rule.allow.path)
query_params_match(request, rule)
}

# --- L7 rule matching: SQL command ---
Expand Down Expand Up @@ -235,6 +236,55 @@ path_matches(actual, pattern) if {
glob.match(pattern, ["/"], actual)
}

# Query matching:
# - If no query rules are configured, allow any query params.
# - For configured keys, all request values for that key must match.
# - Matcher shape supports either `glob` or `any`.
query_params_match(request, rule) if {
query_rules := object.get(rule.allow, "query", {})
not query_mismatch(request, query_rules)
}

query_mismatch(request, query_rules) if {
some key
matcher := query_rules[key]
not query_key_matches(request, key, matcher)
}

query_key_matches(request, key, matcher) if {
request_query := object.get(request, "query_params", {})
values := object.get(request_query, key, null)
values != null
count(values) > 0
not query_value_mismatch(values, matcher)
}

query_value_mismatch(values, matcher) if {
some i
value := values[i]
not query_value_matches(value, matcher)
}

query_value_matches(value, matcher) if {
is_string(matcher)
glob.match(matcher, [], value)
}

query_value_matches(value, matcher) if {
is_object(matcher)
glob_pattern := object.get(matcher, "glob", "")
glob_pattern != ""
glob.match(glob_pattern, [], value)
}

query_value_matches(value, matcher) if {
is_object(matcher)
any_patterns := object.get(matcher, "any", [])
count(any_patterns) > 0
some i
glob.match(any_patterns[i], [], value)
}

# SQL command matching: "*" matches any; otherwise case-insensitive.
command_matches(_, "*") if true

Expand Down
Loading
Loading