use crate::policy::{literal_allowed, reason_class_matches, scope_matches, scope_matches_permitted,
ParsedPolicy, PolicyRule};
use candor_report::GateViolation;
use std::collections::{BTreeSet, HashMap};
pub fn net_classes_of<E: AsRef<str> + Ord>(
q: &str,
hostsacc: &HashMap<String, BTreeSet<String>>,
incompleteacc: &HashMap<String, BTreeSet<E>>,
partners: &BTreeSet<String>,
) -> Vec<String> {
let mut classes: BTreeSet<String> = hostsacc
.get(q)
.into_iter()
.flatten()
.map(|h| crate::net_dest_class(h, partners).to_string())
.collect();
let masked = incompleteacc.get(q).is_some_and(|s| s.iter().any(|e| e.as_ref() == "Net"));
let no_hosts = hostsacc.get(q).map(|s| s.is_empty()).unwrap_or(true);
if masked || no_hosts {
classes.insert("unknown-host".to_string());
}
classes.into_iter().collect()
}
impl<'a, E: AsRef<str> + Ord> GateInput<'a, E> {
pub fn disp<'x>(&'x self, k: &'x str) -> &'x str {
self.display.get(k).map(|v| v.as_str()).unwrap_or(k)
}
pub fn unit<'x>(&'x self, k: &'x str) -> &'x str {
self.hash.get(k).map(|v| v.as_str()).unwrap_or("")
}
}
pub struct GateInput<'a, E: AsRef<str> + Ord> {
pub all: &'a [String],
pub display: &'a std::collections::HashMap<String, String>,
pub hash: &'a std::collections::HashMap<String, String>,
pub inferred: &'a HashMap<String, BTreeSet<E>>,
pub calls: &'a HashMap<String, BTreeSet<String>>,
pub hosts: &'a HashMap<String, BTreeSet<String>>,
pub cmds: &'a HashMap<String, BTreeSet<String>>,
pub paths: &'a HashMap<String, BTreeSet<String>>,
pub tables: &'a HashMap<String, BTreeSet<String>>,
pub surface_incomplete: &'a HashMap<String, BTreeSet<E>>,
pub reason_classes: &'a HashMap<String, BTreeSet<String>>,
pub net_classes: &'a HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Withheld {
pub rule: String,
pub func: String,
pub filter: &'static str,
}
#[derive(Debug, Default)]
pub struct GateOutcome {
pub violations: Vec<GateViolation>,
pub withheld: Vec<Withheld>,
pub zero_match: Vec<String>,
pub multi_match: Vec<(String, Vec<String>)>,
}
pub struct RuleHits<'a> {
pub hits: Vec<&'a str>,
pub withheld: Vec<&'static str>,
}
pub fn rule_hits<'a>(
r: &PolicyRule,
effects: &[&'a str],
reason_classes: Option<&BTreeSet<String>>,
net_classes: &[String],
) -> RuleHits<'a> {
let mut withheld: Vec<&'static str> = Vec::new();
let mut hits: Vec<&str> = if r.effects.is_empty() {
effects.iter().copied().filter(|e| *e != "Unknown").collect()
} else {
effects.iter().copied().filter(|e| r.effects.contains(e)).collect()
};
if hits.contains(&"Unknown") && !r.unknown_classes.is_empty() {
let want: BTreeSet<&str> = r.unknown_classes.iter().map(|c| c.token()).collect();
let classes = reason_classes;
let determinable = classes.is_some_and(|cs| !cs.is_empty());
if !determinable {
hits.retain(|e| *e != "Unknown");
withheld.push("Unknown");
} else if !reason_class_matches(classes, &want) {
hits.retain(|e| *e != "Unknown");
}
}
if hits.contains(&"Net") && !r.net_classes.is_empty() {
let fn_net = net_classes;
if fn_net.is_empty() {
hits.retain(|e| *e != "Net");
withheld.push("Net");
} else if !fn_net.iter().any(|c| r.net_classes.contains(c)) {
hits.retain(|e| *e != "Net");
}
}
RuleHits { hits, withheld }
}
pub fn gate<E: AsRef<str> + Ord>(p: &ParsedPolicy, gi: &GateInput<E>) -> GateOutcome {
let empty: BTreeSet<E> = BTreeSet::new();
let no_classes: Vec<String> = Vec::new();
let mut out = Vec::new();
let mut withheld: Vec<Withheld> = Vec::new();
let mut seen_fn: std::collections::HashSet<&str> = std::collections::HashSet::new();
for q in gi.all {
let disp_q = gi.disp(q);
if !seen_fn.insert(q.as_str()) {
continue;
}
let inf = gi.inferred.get(q).unwrap_or(&empty);
let effs: Vec<&str> = inf.iter().map(AsRef::as_ref).collect();
for r in &p.rules {
if let Some(s) = &r.scope {
if !scope_matches(gi.disp(q), s) {
continue;
}
}
let RuleHits { hits, withheld: wh } = rule_hits(
r,
&effs,
gi.reason_classes.get(q),
gi.net_classes.get(q).map(Vec::as_slice).unwrap_or(&no_classes),
);
for filter in wh {
withheld.push(Withheld { rule: r.raw.clone(), func: gi.disp(q).to_string(), filter });
}
if !hits.is_empty() {
let reason_class = if hits.contains(&"Unknown") {
gi.reason_classes.get(q).map(|cs| cs.iter().cloned().collect()).unwrap_or_default()
} else {
Vec::new()
};
let net_class = if hits.contains(&"Net") {
gi.net_classes.get(q).cloned().unwrap_or_default()
} else {
Vec::new()
};
out.push(GateViolation {
rule: "AS-EFF-006".into(),
func: gi.disp(q).to_string(),
hash: gi.unit(q).to_string(),
effects: hits.iter().map(|s| s.to_string()).collect(),
detail: format!("`{disp_q}` performs {{ {} }}, forbidden by policy: `{}`", hits.join(", "), r.raw),
reason_class,
net_class,
});
}
}
for r in &p.allow_rules {
if let Some(s) = &r.scope {
if !scope_matches(gi.disp(q), s) {
continue;
}
}
if !inf.iter().any(|e| e.as_ref() == r.effect) {
continue;
}
let lits = match r.effect {
"Net" | "Llm" => gi.hosts.get(q),
"Exec" => gi.cmds.get(q),
"Db" => gi.tables.get(q),
_ => gi.paths.get(q),
};
let inc_key = if r.effect == "Llm" { "Net" } else { r.effect };
let surface_incomplete =
gi.surface_incomplete.get(q).is_some_and(|s| s.iter().any(|e| e.as_ref() == inc_key));
match lits {
Some(ls) if !ls.is_empty() && !surface_incomplete => {
let bad: Vec<&str> =
ls.iter().filter(|l| !literal_allowed(r.effect, l, &r.literals)).map(String::as_str).collect();
if !bad.is_empty() {
out.push(GateViolation {
rule: "AS-EFF-008".into(),
func: gi.disp(q).to_string(),
hash: gi.unit(q).to_string(), effects: vec![r.effect.to_string()],
detail: format!("`{disp_q}` reaches {{ {} }} outside the allowlist: `{}`", bad.join(", "), r.raw),
..Default::default()
});
}
}
_ => out.push(GateViolation {
rule: "AS-EFF-008".into(),
func: gi.disp(q).to_string(),
hash: gi.unit(q).to_string(), effects: vec![r.effect.to_string()],
detail: format!("`{disp_q}` performs {} with no visible literal — the surface cannot be certified: `{}`", r.effect, r.raw),
..Default::default()
}),
}
}
for r in &p.layer_rules {
if !scope_matches(gi.disp(q), &r.from) {
continue;
}
let mut seen: BTreeSet<&str> = BTreeSet::new();
let mut stack: Vec<&str> =
gi.calls.get(q).map(|cs| cs.iter().map(String::as_str).collect()).unwrap_or_default();
let mut hit: Option<&str> = None;
while let Some(n) = stack.pop() {
if !seen.insert(n) {
continue;
}
if scope_matches(gi.disp(n), &r.to) {
hit = Some(n);
break;
}
if let Some(cs) = gi.calls.get(n) {
stack.extend(cs.iter().map(String::as_str));
}
}
if let Some(h) = hit {
out.push(GateViolation {
rule: "AS-EFF-009".into(),
func: gi.disp(q).to_string(),
hash: gi.unit(q).to_string(), effects: Vec::new(), detail: format!("`{disp_q}` reaches into a forbidden layer (via `{h}`): `{}`", r.raw),
..Default::default()
});
}
}
for r in &p.only_rules {
if !scope_matches(gi.disp(q), &r.from) {
continue;
}
let mut seen: BTreeSet<&str> = BTreeSet::new();
let mut stack: Vec<&str> =
gi.calls.get(q).map(|cs| cs.iter().map(String::as_str).collect()).unwrap_or_default();
let mut hit: Option<&str> = None;
while let Some(n) = stack.pop() {
if !seen.insert(n) {
continue;
}
if r.to.iter().any(|t| scope_matches_permitted(gi.disp(n), t)) {
continue; }
if !scope_matches(gi.disp(n), &r.from) {
hit = Some(n);
break;
}
if let Some(cs) = gi.calls.get(n) {
stack.extend(cs.iter().map(String::as_str));
}
}
if let Some(h) = hit {
out.push(GateViolation {
rule: "AS-EFF-011".into(),
func: gi.disp(q).to_string(),
hash: gi.unit(q).to_string(), effects: Vec::new(),
detail: format!(
"`{disp_q}` reaches `{h}`, which this permission rule does not permit: `{}`",
r.raw
),
..Default::default()
});
}
}
}
out.sort_by(|a, b| {
(a.rule.as_str(), a.detail.as_str(), a.hash.as_str())
.cmp(&(b.rule.as_str(), b.detail.as_str(), b.hash.as_str()))
});
withheld.sort_by(|a, b| (&a.rule, &a.func).cmp(&(&b.rule, &b.func)));
withheld.dedup();
let mut zero: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for r in &p.rules {
if r.scope.is_some() {
zero.entry(r.raw.as_str()).or_insert(0);
}
}
for r in &p.layer_rules {
zero.entry(r.raw.as_str()).or_insert(0);
}
for r in &p.only_rules {
zero.entry(r.raw.as_str()).or_insert(0);
}
if !zero.is_empty() {
let mut names: std::collections::BTreeSet<&str> =
gi.all.iter().map(|q| q.as_str()).collect();
names.extend(gi.calls.keys().map(String::as_str));
for n in names {
for r in &p.rules {
if let Some(s) = &r.scope {
if scope_matches(gi.disp(n), s) {
*zero.entry(r.raw.as_str()).or_insert(0) += 1;
}
}
}
for r in &p.layer_rules {
if scope_matches(gi.disp(n), &r.from) || scope_matches(gi.disp(n), &r.to) {
*zero.entry(r.raw.as_str()).or_insert(0) += 1;
}
}
for r in &p.only_rules {
if scope_matches(gi.disp(n), &r.from) {
*zero.entry(r.raw.as_str()).or_insert(0) += 1;
}
}
}
}
let zero_match: Vec<String> = zero
.iter()
.filter(|(_, c)| **c == 0)
.map(|(raw, _)| raw.to_string())
.collect();
let mut multi_match: Vec<(String, Vec<String>)> = Vec::new();
for r in &p.rules {
let Some(sc) = &r.scope else { continue };
if sc.contains("::") {
continue; }
if zero.get(r.raw.as_str()).copied().unwrap_or(0) < 2 {
continue;
}
let mut names: std::collections::BTreeSet<&str> =
gi.all.iter().map(|q| q.as_str()).collect();
names.extend(gi.calls.keys().map(String::as_str));
let bound: Vec<String> = names
.into_iter()
.filter(|n| scope_matches(gi.disp(n), sc))
.map(|n| n.to_string())
.collect();
if bound.len() > 1 {
multi_match.push((r.raw.clone(), bound));
}
}
multi_match.sort();
multi_match.dedup();
GateOutcome { violations: out, withheld, zero_match, multi_match }
}