Skip to content

Commit f1fc87e

Browse files
mjamivjohntmyers
andauthored
fix(sandbox): trust exact declared private endpoints (#1560)
* fix(sandbox): trust exact declared private endpoints * fix(sandbox): preserve advisor endpoint provenance * fix(sandbox): repair advisor provenance lint failures --------- Co-authored-by: John Myers <9696606+johntmyers@users.noreply.github.com>
1 parent 7036dcf commit f1fc87e

12 files changed

Lines changed: 762 additions & 17 deletions

File tree

crates/openshell-policy/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,9 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy {
328328
allow_encoded_slash: e.allow_encoded_slash,
329329
websocket_credential_rewrite: e.websocket_credential_rewrite,
330330
request_body_credential_rewrite: e.request_body_credential_rewrite,
331+
// Advisor provenance is internal runtime state, not
332+
// a user-authored policy schema field.
333+
advisor_proposed: false,
331334
persisted_queries: e.persisted_queries,
332335
graphql_persisted_queries: e
333336
.graphql_persisted_queries

crates/openshell-policy/src/merge.rs

Lines changed: 168 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,7 @@ fn merge_endpoint(
537537
existing.allow_encoded_slash |= incoming.allow_encoded_slash;
538538
existing.websocket_credential_rewrite |= incoming.websocket_credential_rewrite;
539539
existing.request_body_credential_rewrite |= incoming.request_body_credential_rewrite;
540+
existing.advisor_proposed |= incoming.advisor_proposed;
540541
normalize_endpoint(existing);
541542
Ok(())
542543
}
@@ -723,6 +724,12 @@ fn expand_access_preset(protocol: &str, access: &str) -> Option<Vec<L7Rule>> {
723724
fn append_unique_binaries(existing: &mut Vec<NetworkBinary>, incoming: &[NetworkBinary]) {
724725
let mut seen: HashSet<String> = existing.iter().map(|binary| binary.path.clone()).collect();
725726
for binary in incoming {
727+
if let Some(existing_binary) = existing.iter_mut().find(|item| item.path == binary.path) {
728+
if !is_advisor_proposed_binary(binary) {
729+
mark_user_declared_binary(existing_binary);
730+
}
731+
continue;
732+
}
726733
if seen.insert(binary.path.clone()) {
727734
existing.push(binary.clone());
728735
}
@@ -778,8 +785,30 @@ fn dedup_strings(values: &mut Vec<String>) {
778785
}
779786

780787
fn dedup_binaries(values: &mut Vec<NetworkBinary>) {
781-
let mut seen = HashSet::new();
782-
values.retain(|binary| seen.insert(binary.path.clone()));
788+
let mut deduped: Vec<NetworkBinary> = Vec::with_capacity(values.len());
789+
for binary in std::mem::take(values) {
790+
if let Some(existing) = deduped.iter_mut().find(|item| item.path == binary.path) {
791+
if !is_advisor_proposed_binary(&binary) {
792+
mark_user_declared_binary(existing);
793+
}
794+
} else {
795+
deduped.push(binary);
796+
}
797+
}
798+
*values = deduped;
799+
}
800+
801+
fn is_advisor_proposed_binary(binary: &NetworkBinary) -> bool {
802+
#[allow(deprecated)]
803+
let advisor_proposed = binary.harness;
804+
advisor_proposed
805+
}
806+
807+
fn mark_user_declared_binary(binary: &mut NetworkBinary) {
808+
#[allow(deprecated)]
809+
{
810+
binary.harness = false;
811+
}
783812
}
784813

785814
fn dedup_l7_rules(values: &mut Vec<L7Rule>) {
@@ -878,6 +907,18 @@ mod tests {
878907
}
879908
}
880909

910+
fn advisor_binary(path: &str) -> NetworkBinary {
911+
let mut binary = NetworkBinary {
912+
path: path.to_string(),
913+
..Default::default()
914+
};
915+
#[allow(deprecated)]
916+
{
917+
binary.harness = true;
918+
}
919+
binary
920+
}
921+
881922
fn rest_rule(method: &str, path: &str) -> L7Rule {
882923
L7Rule {
883924
allow: Some(L7Allow {
@@ -949,6 +990,131 @@ mod tests {
949990
assert_eq!(rule.binaries.len(), 2);
950991
}
951992

993+
#[test]
994+
fn add_rule_user_binary_clears_advisor_marker_for_same_path() {
995+
let mut policy = restrictive_default_policy();
996+
policy.network_policies.insert(
997+
"existing".to_string(),
998+
NetworkPolicyRule {
999+
name: "existing".to_string(),
1000+
endpoints: vec![endpoint("api.github.com", 443)],
1001+
binaries: vec![advisor_binary("/usr/bin/curl")],
1002+
},
1003+
);
1004+
1005+
let incoming = NetworkPolicyRule {
1006+
name: "incoming".to_string(),
1007+
endpoints: vec![endpoint("api.github.com", 443)],
1008+
binaries: vec![NetworkBinary {
1009+
path: "/usr/bin/curl".to_string(),
1010+
..Default::default()
1011+
}],
1012+
};
1013+
1014+
let result = merge_policy(
1015+
policy,
1016+
&[PolicyMergeOp::AddRule {
1017+
rule_name: "existing".to_string(),
1018+
rule: incoming,
1019+
}],
1020+
)
1021+
.expect("merge should succeed");
1022+
1023+
let rule = &result.policy.network_policies["existing"];
1024+
assert_eq!(rule.binaries.len(), 1);
1025+
#[allow(deprecated)]
1026+
{
1027+
assert!(!rule.binaries[0].harness);
1028+
}
1029+
}
1030+
1031+
#[test]
1032+
fn add_rule_duplicate_binaries_prefer_user_declared_marker() {
1033+
let incoming = NetworkPolicyRule {
1034+
name: "incoming".to_string(),
1035+
endpoints: vec![endpoint("api.github.com", 443)],
1036+
binaries: vec![
1037+
advisor_binary("/usr/bin/curl"),
1038+
NetworkBinary {
1039+
path: "/usr/bin/curl".to_string(),
1040+
..Default::default()
1041+
},
1042+
],
1043+
};
1044+
1045+
let result = merge_policy(
1046+
restrictive_default_policy(),
1047+
&[PolicyMergeOp::AddRule {
1048+
rule_name: "github".to_string(),
1049+
rule: incoming,
1050+
}],
1051+
)
1052+
.expect("merge should succeed");
1053+
1054+
let rule = &result.policy.network_policies["github"];
1055+
assert_eq!(rule.binaries.len(), 1);
1056+
#[allow(deprecated)]
1057+
{
1058+
assert!(!rule.binaries[0].harness);
1059+
}
1060+
}
1061+
1062+
#[test]
1063+
fn add_rule_preserves_advisor_endpoint_marker_when_binary_is_deduped() {
1064+
let mut policy = restrictive_default_policy();
1065+
policy.network_policies.insert(
1066+
"app-api".to_string(),
1067+
NetworkPolicyRule {
1068+
name: "app-api".to_string(),
1069+
endpoints: vec![endpoint("api.example.com", 443)],
1070+
binaries: vec![NetworkBinary {
1071+
path: "/usr/bin/python".to_string(),
1072+
..Default::default()
1073+
}],
1074+
},
1075+
);
1076+
1077+
let incoming = NetworkPolicyRule {
1078+
name: "app-api".to_string(),
1079+
endpoints: vec![NetworkEndpoint {
1080+
host: "internal-admin.local".to_string(),
1081+
port: 443,
1082+
ports: vec![443],
1083+
advisor_proposed: true,
1084+
..Default::default()
1085+
}],
1086+
binaries: vec![advisor_binary("/usr/bin/python")],
1087+
};
1088+
1089+
let result = merge_policy(
1090+
policy,
1091+
&[PolicyMergeOp::AddRule {
1092+
rule_name: "app-api".to_string(),
1093+
rule: incoming,
1094+
}],
1095+
)
1096+
.expect("merge should succeed");
1097+
1098+
let rule = &result.policy.network_policies["app-api"];
1099+
assert_eq!(rule.binaries.len(), 1, "binary should still dedupe");
1100+
#[allow(deprecated)]
1101+
{
1102+
assert!(
1103+
!rule.binaries[0].harness,
1104+
"existing user binary provenance should be retained"
1105+
);
1106+
}
1107+
let internal_endpoint = rule
1108+
.endpoints
1109+
.iter()
1110+
.find(|endpoint| endpoint.host == "internal-admin.local")
1111+
.expect("advisor endpoint should be appended");
1112+
assert!(
1113+
internal_endpoint.advisor_proposed,
1114+
"endpoint provenance must survive merge even when binary provenance is deduped"
1115+
);
1116+
}
1117+
9521118
#[test]
9531119
fn add_rule_merges_websocket_credential_rewrite_flag() {
9541120
let mut policy = restrictive_default_policy();

crates/openshell-providers/src/profiles.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,7 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint {
587587
allow_encoded_slash: endpoint.allow_encoded_slash,
588588
websocket_credential_rewrite: endpoint.websocket_credential_rewrite,
589589
request_body_credential_rewrite: endpoint.request_body_credential_rewrite,
590+
advisor_proposed: false,
590591
persisted_queries: endpoint.persisted_queries.clone(),
591592
graphql_persisted_queries: endpoint
592593
.graphql_persisted_queries

crates/openshell-sandbox/data/sandbox-policy.rego

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,32 @@ binary_allowed(policy, exec) if {
161161
glob.match(b.path, ["/"], p)
162162
}
163163

164+
user_declared_binary_allowed(policy, exec) if {
165+
some b
166+
b := policy.binaries[_]
167+
not object.get(b, "advisor_proposed", false)
168+
not contains(b.path, "*")
169+
b.path == exec.path
170+
}
171+
172+
user_declared_binary_allowed(policy, exec) if {
173+
some b
174+
b := policy.binaries[_]
175+
not object.get(b, "advisor_proposed", false)
176+
not contains(b.path, "*")
177+
ancestor := exec.ancestors[_]
178+
b.path == ancestor
179+
}
180+
181+
user_declared_binary_allowed(policy, exec) if {
182+
some b in policy.binaries
183+
not object.get(b, "advisor_proposed", false)
184+
contains(b.path, "*")
185+
all_paths := array.concat([exec.path], exec.ancestors)
186+
some p in all_paths
187+
glob.match(b.path, ["/"], p)
188+
}
189+
164190
# --- Network action (allow / deny) ---
165191
#
166192
# These rules are mutually exclusive by construction:
@@ -647,6 +673,22 @@ matched_endpoint_config := _matching_endpoint_configs[0] if {
647673
count(_matching_endpoint_configs) > 0
648674
}
649675

676+
_policy_has_exact_declared_endpoint(policy) if {
677+
some ep
678+
ep := policy.endpoints[_]
679+
not object.get(ep, "advisor_proposed", false)
680+
not contains(ep.host, "*")
681+
lower(ep.host) == lower(input.network.host)
682+
ep.ports[_] == input.network.port
683+
}
684+
685+
exact_declared_endpoint_host if {
686+
some pname
687+
policy := data.network_policies[pname]
688+
user_declared_binary_allowed(policy, input.exec)
689+
_policy_has_exact_declared_endpoint(policy)
690+
}
691+
650692
# Hosted endpoint: exact host match + port in ports list.
651693
endpoint_matches_request(ep, network) if {
652694
not contains(ep.host, "*")

crates/openshell-sandbox/src/mechanistic_mapper.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,24 +129,33 @@ pub fn generate_proposals(summaries: &[DenialSummary]) -> Vec<PolicyChunk> {
129129
protocol: "rest".to_string(),
130130
enforcement: "enforce".to_string(),
131131
rules: l7_rules,
132+
advisor_proposed: true,
132133
..Default::default()
133134
}
134135
} else {
135136
NetworkEndpoint {
136137
host: host.clone(),
137138
port: *port,
138139
ports: vec![*port],
140+
advisor_proposed: true,
139141
..Default::default()
140142
}
141143
};
142144

143145
let binaries: Vec<NetworkBinary> = if binary.is_empty() {
144146
vec![]
145147
} else {
146-
vec![NetworkBinary {
148+
let mut proposal_binary = NetworkBinary {
147149
path: binary.clone(),
148150
..Default::default()
149-
}]
151+
};
152+
// The deprecated harness bit is ignored by policy YAML, but OPA
153+
// maps it to advisor_proposed to preserve the SSRF two-step flow.
154+
#[allow(deprecated)]
155+
{
156+
proposal_binary.harness = true;
157+
}
158+
vec![proposal_binary]
150159
};
151160

152161
let proposed_rule = NetworkPolicyRule {
@@ -500,6 +509,10 @@ mod tests {
500509
assert_eq!(rule.endpoints[0].port, 443);
501510
assert_eq!(rule.binaries.len(), 1);
502511
assert_eq!(rule.binaries[0].path, "/usr/bin/curl");
512+
#[allow(deprecated)]
513+
{
514+
assert!(rule.binaries[0].harness);
515+
}
503516

504517
// No L7 fields when no samples provided.
505518
assert!(rule.endpoints[0].protocol.is_empty());

0 commit comments

Comments
 (0)