Generate draft `PolicyChunk` proposals from denial summaries. Groups denials by `(host, port, binary)`, then for each group generates a `PolicyChunk` with a `NetworkPolicyRule` allowing that endpoint for that single binary. This produces one proposal per binary so each `(sandbox_id, host, port, binary)` maps to exactly one DB row. Proposals never include `allowed_ips`. If the user applies a prop
(summaries: &[DenialSummary])
| 57 | /// |
| 58 | /// Returns an empty vec if there are no actionable denials. |
| 59 | pub fn generate_proposals(summaries: &[DenialSummary]) -> Vec<PolicyChunk> { |
| 60 | // Group denials by (host, port, binary). |
| 61 | let mut groups: HashMap<(String, u32, String), Vec<&DenialSummary>> = HashMap::new(); |
| 62 | |
| 63 | for summary in summaries { |
| 64 | let binary_key = if summary.binary.is_empty() { |
| 65 | String::new() |
| 66 | } else { |
| 67 | summary.binary.clone() |
| 68 | }; |
| 69 | groups |
| 70 | .entry((summary.host.clone(), summary.port, binary_key)) |
| 71 | .or_default() |
| 72 | .push(summary); |
| 73 | } |
| 74 | |
| 75 | let mut proposals = Vec::new(); |
| 76 | |
| 77 | for ((host, port, binary), denials) in &groups { |
| 78 | let rule_name = generate_rule_name(host, *port); |
| 79 | |
| 80 | let mut total_count: u32 = 0; |
| 81 | let mut first_seen_ms: i64 = i64::MAX; |
| 82 | let mut last_seen_ms: i64 = 0; |
| 83 | let mut is_ssrf = false; |
| 84 | |
| 85 | for denial in denials { |
| 86 | total_count += denial.count; |
| 87 | first_seen_ms = first_seen_ms.min(denial.first_seen_ms); |
| 88 | last_seen_ms = last_seen_ms.max(denial.last_seen_ms); |
| 89 | if denial.denial_stage == "ssrf" { |
| 90 | is_ssrf = true; |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | // Collect L7 request samples across all denials in this group. |
| 95 | let mut l7_methods: HashMap<(String, String), u32> = HashMap::new(); |
| 96 | let mut has_l7 = false; |
| 97 | for denial in denials { |
| 98 | if denial.l7_inspection_active || !denial.l7_request_samples.is_empty() { |
| 99 | has_l7 = true; |
| 100 | } |
| 101 | for sample in &denial.l7_request_samples { |
| 102 | *l7_methods |
| 103 | .entry((sample.method.clone(), sample.path.clone())) |
| 104 | .or_insert(0) += sample.count; |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | // Skip proposals for always-blocked destinations (loopback, |
| 109 | // link-local, unspecified, and known metadata hostnames). These would |
| 110 | // be denied at runtime regardless of policy, producing an infinite |
| 111 | // proposal loop in the TUI. |
| 112 | if is_always_blocked_destination(host) { |
| 113 | tracing::info!( |
| 114 | host, |
| 115 | port, |
| 116 | "Skipped proposal for always-blocked destination \ |