use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use super::model::{
CAPTURE, CATCH_ALL, PostureManifest, RouteEntry, RouteKey, escape_field, escape_list,
hex_digest, is_open, normalize_captures,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Widening,
Neutral,
Narrowing,
}
impl Severity {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Widening => "widening",
Self::Neutral => "neutral",
Self::Narrowing => "narrowing",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub kind: &'static str,
pub severity: Severity,
pub method: String,
pub path: String,
pub before: String,
pub after: String,
pub fingerprint: String,
pub detail: String,
}
impl Finding {
#[must_use]
pub fn canonical(&self) -> String {
format!(
"{}\t{}\t{}\t{}",
self.kind,
escape_field(&self.method),
escape_field(&normalize_captures(&self.path)),
escape_field(&self.fingerprint)
)
}
}
#[must_use]
pub fn diff(base: &PostureManifest, head: &PostureManifest) -> Vec<Finding> {
let mut findings = Vec::new();
diff_build(base, head, &mut findings);
diff_routes(base, head, &mut findings);
diff_authorization_policies(base, head, &mut findings);
diff_csrf(base, head, &mut findings);
diff_headers(base, head, &mut findings);
diff_mtls(base, head, &mut findings);
bind_to_effective_posture(&mut findings, base, head);
findings.sort_by(|a, b| {
a.severity
.cmp(&b.severity)
.then_with(|| a.path.cmp(&b.path))
.then_with(|| a.method.cmp(&b.method))
.then_with(|| a.kind.cmp(b.kind))
.then_with(|| a.fingerprint.cmp(&b.fingerprint))
});
findings
}
fn bind_to_effective_posture(
findings: &mut [Finding],
base: &PostureManifest,
head: &PostureManifest,
) {
let routes = route_index(head);
let csrf = csrf_index(head);
let bindings = authz_index(head);
let mtls = mtls_index(head);
let base_keys = route_keys(base);
let head_keys = route_keys(head);
for finding in findings
.iter_mut()
.filter(|f| f.severity == Severity::Widening)
{
let key = (normalize_captures(&finding.path), finding.method.clone());
let posture = match effective_posture(&key, &routes, &csrf, &bindings, &mtls) {
Some(own) => own,
None if base_keys.contains(&key) => {
let mut inherited: Vec<String> = takers((&key.0, &key.1), &base_keys, &head_keys)
.iter()
.filter_map(|taker| {
effective_posture(taker, &routes, &csrf, &bindings, &mtls).map(|posture| {
escape_list(&[taker.1.clone(), taker.0.clone(), posture])
})
})
.collect();
inherited.sort();
if inherited.is_empty() {
String::new()
} else {
escape_list(&inherited)
}
}
None => String::new(),
};
if !posture.is_empty() {
finding.fingerprint = escape_list(&[finding.fingerprint.clone(), posture]);
}
}
}
fn effective_posture(
key: &RouteKey,
routes: &BTreeMap<RouteKey, RouteEntry>,
csrf: &BTreeMap<RouteKey, (bool, bool, String)>,
bindings: &BTreeMap<AuthzKey, String>,
mtls: &BTreeSet<RouteKey>,
) -> Option<String> {
let entry = routes.get(key)?;
let enforced = csrf
.get(key)
.map_or(
"absent",
|(enforced, ..)| {
if *enforced { "csrf" } else { "no-csrf" }
},
)
.to_owned();
let mut parts = vec![
posture_fingerprint(entry),
enforced,
escape_list(&checks_at(key, bindings)),
];
if mtls.contains(key) {
parts.push("mtls".to_owned());
}
Some(escape_list(&parts))
}
pub(super) fn mtls_index(m: &PostureManifest) -> BTreeSet<RouteKey> {
m.dimensions
.mtls
.entries
.iter()
.filter(|e| e.mtls_required)
.map(super::model::MtlsEntry::key)
.collect()
}
fn postures_of(
keys: &[RouteKey],
routes: &BTreeMap<RouteKey, RouteEntry>,
csrf: &BTreeMap<RouteKey, (bool, bool, String)>,
bindings: &BTreeMap<AuthzKey, String>,
mtls: &BTreeSet<RouteKey>,
) -> String {
let mut each: Vec<String> = keys
.iter()
.filter_map(|key| {
effective_posture(key, routes, csrf, bindings, mtls)
.map(|posture| escape_list(&[key.1.clone(), key.0.clone(), posture]))
})
.collect();
each.sort();
escape_list(&each)
}
pub(super) fn checks_at(key: &RouteKey, bindings: &BTreeMap<AuthzKey, String>) -> Vec<String> {
let mut checks: Vec<String> = bindings
.keys()
.filter(|(path, method, _, _)| (path, method) == (&key.0, &key.1))
.map(|(_, _, action, resource)| format!("{action} on {resource}"))
.collect();
checks.sort();
checks.dedup();
checks
}
#[must_use]
pub fn widening(findings: &[Finding]) -> Vec<&Finding> {
findings
.iter()
.filter(|f| f.severity == Severity::Widening)
.collect()
}
fn diff_build(base: &PostureManifest, head: &PostureManifest, out: &mut Vec<Finding>) {
if base.build == head.build {
return;
}
out.push(Finding {
kind: "build_changed",
severity: Severity::Widening,
method: "*".to_owned(),
path: "*".to_owned(),
before: base.build.label(),
after: head.build.label(),
fingerprint: format!(
"build:{}",
escape_list(&[base.build.canonical(), head.build.canonical()])
),
detail: "the manifests come from different Cargo builds, and a diff across \
builds can hide a widening; audit both with the build you deploy"
.to_owned(),
});
}
pub(super) fn route_index(m: &PostureManifest) -> BTreeMap<RouteKey, RouteEntry> {
m.dimensions
.routes
.entries
.iter()
.map(|entry| (entry.key(), entry.clone()))
.collect()
}
fn diff_routes(base: &PostureManifest, head: &PostureManifest, out: &mut Vec<Finding>) {
let before = route_index(base);
let after = route_index(head);
let (keys_before, keys_after) = (route_keys(base), route_keys(head));
for (key, entry) in &after {
let Some(previous) = before.get(key) else {
let open = is_open(&entry.classification);
out.push(Finding {
kind: if open {
"route_added_open"
} else {
"route_added_gated"
},
severity: if open {
Severity::Widening
} else {
Severity::Neutral
},
method: entry.method.clone(),
path: entry.path.clone(),
before: "absent".to_owned(),
after: entry.posture_label(),
fingerprint: format!("added:{}", entry.classification),
detail: if open {
format!(
"new route reachable without a proven guard ({})",
entry.posture_label()
)
} else {
"new guarded route".to_owned()
},
});
if !open {
report_displacement(key, entry, &before, (&keys_before, &keys_after), out);
}
continue;
};
compare_route(previous, entry, out);
}
for (key, entry) in &before {
if !after.contains_key(key) {
out.push(Finding {
kind: "route_removed",
severity: Severity::Narrowing,
method: entry.method.clone(),
path: entry.path.clone(),
before: entry.posture_label(),
after: "absent".to_owned(),
fingerprint: "removed".to_owned(),
detail: "route no longer mounted".to_owned(),
});
report_shadow_exposure(key, entry, &after, (&keys_before, &keys_after), out);
}
}
report_path_exposure(base, head, &before, &after, out);
}
fn report_path_exposure(
base: &PostureManifest,
head: &PostureManifest,
before: &BTreeMap<RouteKey, RouteEntry>,
after: &BTreeMap<RouteKey, RouteEntry>,
out: &mut Vec<Finding>,
) {
let keys_before = route_keys(base);
let keys_after = route_keys(head);
let paths_after: BTreeSet<&String> = keys_after.iter().map(|(path, _)| path).collect();
let mut vanished: BTreeMap<&String, BTreeSet<String>> = BTreeMap::new();
for (path, method) in &keys_before {
if !paths_after.contains(path) {
vanished
.entry(path)
.or_default()
.extend(answered_methods(method, path, &keys_before));
}
}
for (path, served) in vanished {
let candidates: Vec<&RouteKey> = keys_after
.iter()
.filter(|(p, _)| takes_precedence(path, p))
.collect();
for key in undominated(path, &candidates) {
let Some(survivor) = after.get(key) else {
continue;
};
let open = is_open(&survivor.classification);
let (survivor_path, survivor_method) = key;
let newly: Vec<String> = answered_methods(survivor_method, survivor_path, &keys_after)
.difference(&served)
.cloned()
.collect();
if newly.is_empty() {
continue;
}
let written = before
.iter()
.find(|((p, _), _)| p == path)
.map_or_else(|| path.clone(), |(_, entry)| entry.path.clone());
out.push(Finding {
kind: "route_path_exposed",
severity: if open {
Severity::Widening
} else {
Severity::Neutral
},
method: newly.join(", "),
path: written.clone(),
before: "405 (no handler at this path)".to_owned(),
after: survivor.posture_label(),
fingerprint: format!(
"path-exposed:{}",
escape_list(&[
escape_list(&newly),
survivor.method.clone(),
survivor.path.clone(),
posture_fingerprint(survivor),
])
),
detail: format!(
"nothing is mounted at this path any more, so {} on it no longer stops at a \
405 — `{} {}` answers it now ({})",
newly.join(", "),
survivor.method,
survivor.path,
survivor.posture_label()
),
});
}
}
}
fn report_displacement(
(path, method): &RouteKey,
added: &RouteEntry,
before: &BTreeMap<RouteKey, RouteEntry>,
(keys_before, keys_after): (&BTreeSet<RouteKey>, &BTreeSet<RouteKey>),
out: &mut Vec<Finding>,
) {
for key in takers((path, method), keys_after, keys_before) {
let Some(displaced) = before.get(&key) else {
continue;
};
let mut probe = Vec::new();
compare_route(displaced, added, &mut probe);
if !probe.iter().any(|f| f.severity == Severity::Widening) {
continue;
}
out.push(Finding {
kind: "route_added_shadowing",
severity: Severity::Widening,
method: method.clone(),
path: added.path.clone(),
before: displaced.posture_label(),
after: added.posture_label(),
fingerprint: format!(
"added-shadowing:{}",
escape_list(&[
displaced.method.clone(),
displaced.path.clone(),
posture_fingerprint(displaced),
posture_fingerprint(added),
])
),
detail: format!(
"this route is more specific than `{} {}`, so it takes those requests over and \
admits callers that one refused ({} → {})",
displaced.method,
displaced.path,
displaced.posture_label(),
added.posture_label()
),
});
}
}
fn report_shadow_exposure(
(path, method): &RouteKey,
removed: &RouteEntry,
after: &BTreeMap<RouteKey, RouteEntry>,
(keys_before, keys_after): (&BTreeSet<RouteKey>, &BTreeSet<RouteKey>),
out: &mut Vec<Finding>,
) {
for key in takers((path, method), keys_before, keys_after) {
let Some(survivor) = after.get(&key) else {
continue;
};
let mut probe = Vec::new();
compare_route(removed, survivor, &mut probe);
if !probe.iter().any(|f| f.severity == Severity::Widening) {
continue;
}
out.push(Finding {
kind: "route_shadow_exposed",
severity: Severity::Widening,
method: method.clone(),
path: removed.path.clone(),
before: removed.posture_label(),
after: survivor.posture_label(),
fingerprint: format!(
"shadow-exposed:{}",
escape_list(&[
survivor.method.clone(),
survivor.path.clone(),
posture_fingerprint(survivor),
])
),
detail: format!(
"removing this route does not remove the URL: `{} {}` still matches it and \
admits callers this route refused ({} → {})",
survivor.method,
survivor.path,
removed.posture_label(),
survivor.posture_label()
),
});
}
}
fn posture_fingerprint(entry: &RouteEntry) -> String {
let roles: Vec<String> = entry.role_set().into_iter().collect();
let scopes: Vec<String> = entry.scope_set().into_iter().collect();
escape_list(&[
entry.classification.clone(),
escape_list(&roles),
escape_list(&scopes),
entry.policy.to_string(),
])
}
fn effective_methods(method: &str) -> BTreeSet<String> {
let declared = method.to_ascii_uppercase();
let mut answered = BTreeSet::new();
if declared == "GET" {
answered.insert("HEAD".to_owned());
}
answered.insert(if declared == "WS" {
"GET".to_owned()
} else {
declared
});
answered
}
fn answered_methods(method: &str, path: &str, routes: &BTreeSet<RouteKey>) -> BTreeSet<String> {
let mut answered = effective_methods(method);
if !method.eq_ignore_ascii_case("HEAD")
&& routes.contains(&(path.to_owned(), "HEAD".to_owned()))
{
answered.remove("HEAD");
}
answered
}
fn takers(
(path, method): (&str, &str),
subject_keys: &BTreeSet<RouteKey>,
other_keys: &BTreeSet<RouteKey>,
) -> Vec<RouteKey> {
let answered = answered_methods(method, path, subject_keys);
let same_path: Vec<RouteKey> = other_keys
.iter()
.filter(|(p, m)| p == path && !answered.is_disjoint(&answered_methods(m, p, other_keys)))
.cloned()
.collect();
if !same_path.is_empty() {
return same_path;
}
if other_keys.iter().any(|(p, _)| p == path) {
return Vec::new();
}
let candidates: Vec<&RouteKey> = other_keys
.iter()
.filter(|(p, _)| takes_precedence(path, p))
.collect();
undominated(path, &candidates)
.into_iter()
.filter(|(p, m)| !answered.is_disjoint(&answered_methods(m, p, other_keys)))
.cloned()
.collect()
}
fn route_keys(m: &PostureManifest) -> BTreeSet<RouteKey> {
m.dimensions
.routes
.entries
.iter()
.map(RouteEntry::key)
.collect()
}
fn undominated<'a>(subject: &str, takers: &[&'a RouteKey]) -> Vec<&'a RouteKey> {
takers
.iter()
.filter(|(path, _)| {
let Some(share) = intersect(subject, path) else {
return false;
};
!takers.iter().any(|(other, _)| {
other != path && takes_precedence(other, path) && covers(other, &share)
})
})
.copied()
.collect()
}
fn intersect(a: &str, b: &str) -> Option<String> {
let a: Vec<&str> = a.split('/').collect();
let b: Vec<&str> = b.split('/').collect();
let mut out: Vec<String> = Vec::new();
for i in 0..a.len().max(b.len()) {
match (a.get(i), b.get(i)) {
(Some(x), Some(_)) if x.contains(CATCH_ALL) => {
out.extend(b[i..].iter().map(|s| (*s).to_owned()));
return Some(out.join("/"));
}
(Some(_), Some(y)) if y.contains(CATCH_ALL) => {
out.extend(a[i..].iter().map(|s| (*s).to_owned()));
return Some(out.join("/"));
}
(Some(x), Some(y)) if segments_overlap(x, y) => {
let narrower = if specificity(x) <= specificity(y) {
x
} else {
y
};
out.push((*narrower).to_owned());
}
_ => return None,
}
}
Some(out.join("/"))
}
fn covers(pattern: &str, target: &str) -> bool {
let pattern: Vec<&str> = pattern.split('/').collect();
let target: Vec<&str> = target.split('/').collect();
for i in 0..pattern.len().max(target.len()) {
match (pattern.get(i), target.get(i)) {
(Some(p), Some(_)) if p.contains(CATCH_ALL) => return true,
(Some(p), Some(t)) => {
if p.contains(CAPTURE) {
if !segments_overlap(p, t) {
return false;
}
} else if p != t {
return false;
}
}
_ => return false,
}
}
true
}
fn takes_precedence(first: &str, second: &str) -> bool {
if !shapes_overlap(first, second) {
return false;
}
let first: Vec<&str> = first.split('/').collect();
let second: Vec<&str> = second.split('/').collect();
for i in 0..first.len().max(second.len()) {
let mine = first.get(i).map_or(u8::MAX, |s| specificity(s));
let theirs = second.get(i).map_or(u8::MAX, |s| specificity(s));
if mine != theirs {
return mine < theirs;
}
}
true
}
fn specificity(segment: &str) -> u8 {
if segment.contains(CATCH_ALL) {
2
} else {
u8::from(segment.contains(CAPTURE))
}
}
fn shapes_overlap(a: &str, b: &str) -> bool {
let a: Vec<&str> = a.split('/').collect();
let b: Vec<&str> = b.split('/').collect();
for i in 0..a.len().max(b.len()) {
match (a.get(i), b.get(i)) {
(Some(x), Some(y)) => {
if x.contains(CATCH_ALL) || y.contains(CATCH_ALL) {
return true;
}
if !segments_overlap(x, y) {
return false;
}
}
_ => return false,
}
}
true
}
fn segments_overlap(a: &str, b: &str) -> bool {
if !a.contains(CAPTURE) && !b.contains(CAPTURE) {
return a == b;
}
let (a_prefix, a_suffix) = literal_edges(a);
let (b_prefix, b_suffix) = literal_edges(b);
if !(a_prefix.starts_with(b_prefix) || b_prefix.starts_with(a_prefix)) {
return false;
}
if !(a_suffix.ends_with(b_suffix) || b_suffix.ends_with(a_suffix)) {
return false;
}
if !a.contains(CAPTURE) && min_match_len(b) > a.chars().count() {
return false;
}
if !b.contains(CAPTURE) && min_match_len(a) > b.chars().count() {
return false;
}
true
}
fn min_match_len(segment: &str) -> usize {
segment.chars().count()
}
fn literal_edges(segment: &str) -> (&str, &str) {
match (segment.find(CAPTURE), segment.rfind(CAPTURE)) {
(Some(first), Some(last)) => (&segment[..first], &segment[last + CAPTURE.len_utf8()..]),
_ => (segment, segment),
}
}
fn compare_route(before: &RouteEntry, after: &RouteEntry, out: &mut Vec<Finding>) {
let label_before = before.posture_label();
let label_after = after.posture_label();
if before.classification != after.classification {
let was_open = is_open(&before.classification);
let now_open = is_open(&after.classification);
let (kind, severity, detail) = match (was_open, now_open) {
(false, true) => (
"classification_downgraded",
Severity::Widening,
format!(
"guard removed: {} \u{2192} {}",
before.classification, after.classification
),
),
(true, false) => (
"classification_upgraded",
Severity::Narrowing,
format!(
"guard added: {} \u{2192} {}",
before.classification, after.classification
),
),
_ => (
"classification_changed",
Severity::Neutral,
format!(
"classification {} \u{2192} {}",
before.classification, after.classification
),
),
};
out.push(Finding {
kind,
severity,
method: after.method.clone(),
path: after.path.clone(),
before: label_before.clone(),
after: label_after.clone(),
fingerprint: format!("class:{}->{}", before.classification, after.classification),
detail,
});
if was_open != now_open {
return;
}
}
compare_guard_strength(before, after, &label_before, &label_after, out);
compare_roles(before, after, &label_before, &label_after, out);
compare_scopes(before, after, &label_before, &label_after, out);
if before.policy && !after.policy {
out.push(Finding {
kind: "policy_removed",
severity: Severity::Widening,
method: after.method.clone(),
path: after.path.clone(),
before: label_before,
after: label_after,
fingerprint: format!("policy-removed:{}", posture_fingerprint(after)),
detail: "record-level policy check removed".to_owned(),
});
} else if !before.policy && after.policy {
out.push(Finding {
kind: "policy_added",
severity: Severity::Narrowing,
method: after.method.clone(),
path: after.path.clone(),
before: label_before,
after: label_after,
fingerprint: "policy-added".to_owned(),
detail: "record-level policy check added".to_owned(),
});
}
}
fn compare_guard_strength(
before: &RouteEntry,
after: &RouteEntry,
label_before: &str,
label_after: &str,
out: &mut Vec<Finding>,
) {
if is_open(&after.classification) || before.is_declared() == after.is_declared() {
return;
}
let (kind, severity, fingerprint, detail) = if after.is_declared() {
(
"guard_declared",
Severity::Widening,
"guard:proven->declared",
"guard was proven, now only declared by a layer",
)
} else {
(
"guard_proven",
Severity::Narrowing,
"guard:declared->proven",
"guard was declared by a layer, now proven",
)
};
out.push(Finding {
kind,
severity,
method: after.method.clone(),
path: after.path.clone(),
before: label_before.to_owned(),
after: label_after.to_owned(),
fingerprint: fingerprint.to_owned(),
detail: detail.to_owned(),
});
}
fn compare_roles(
before: &RouteEntry,
after: &RouteEntry,
label_before: &str,
label_after: &str,
out: &mut Vec<Finding>,
) {
let was: BTreeSet<String> = before.role_set();
let now: BTreeSet<String> = after.role_set();
if was == now {
return;
}
let added: Vec<String> = now.difference(&was).cloned().collect();
let removed: Vec<String> = was.difference(&now).cloned().collect();
if !was.is_empty() && now.is_empty() {
out.push(Finding {
kind: "roles_cleared",
severity: Severity::Widening,
method: after.method.clone(),
path: after.path.clone(),
before: label_before.to_owned(),
after: label_after.to_owned(),
fingerprint: format!("roles-cleared:{}", escape_list(&removed)),
detail: format!(
"role requirement dropped ({}) — any authenticated session now passes",
removed.join(", ")
),
});
return;
}
if was.is_empty() {
out.push(Finding {
kind: "roles_narrowed",
severity: Severity::Narrowing,
method: after.method.clone(),
path: after.path.clone(),
before: label_before.to_owned(),
after: label_after.to_owned(),
fingerprint: format!("roles-first:{}", escape_list(&added)),
detail: format!(
"route now requires role{} {} — previously any authenticated session passed",
plural(added.len()),
added.join(", ")
),
});
return;
}
if !added.is_empty() {
out.push(Finding {
kind: "roles_widened",
severity: Severity::Widening,
method: after.method.clone(),
path: after.path.clone(),
before: label_before.to_owned(),
after: label_after.to_owned(),
fingerprint: format!("roles+{}", escape_list(&added)),
detail: format!(
"role{} {} now also admitted",
plural(added.len()),
added.join(", ")
),
});
}
if !removed.is_empty() {
out.push(Finding {
kind: "roles_narrowed",
severity: Severity::Narrowing,
method: after.method.clone(),
path: after.path.clone(),
before: label_before.to_owned(),
after: label_after.to_owned(),
fingerprint: format!("roles-{}", escape_list(&removed)),
detail: format!(
"role{} {} no longer admitted",
plural(removed.len()),
removed.join(", ")
),
});
}
}
fn compare_scopes(
before: &RouteEntry,
after: &RouteEntry,
label_before: &str,
label_after: &str,
out: &mut Vec<Finding>,
) {
let was: BTreeSet<String> = before.scope_set();
let now: BTreeSet<String> = after.scope_set();
if was == now {
return;
}
let added: Vec<String> = now.difference(&was).cloned().collect();
let removed: Vec<String> = was.difference(&now).cloned().collect();
let now_sorted: Vec<String> = now.iter().cloned().collect();
if !removed.is_empty() {
out.push(Finding {
kind: "scopes_widened",
severity: Severity::Widening,
method: after.method.clone(),
path: after.path.clone(),
before: label_before.to_owned(),
after: label_after.to_owned(),
fingerprint: format!(
"scopes-{}",
escape_list(&[escape_list(&removed), escape_list(&now_sorted)])
),
detail: format!(
"scope{} {} no longer required",
plural(removed.len()),
removed.join(", ")
),
});
}
if !added.is_empty() {
out.push(Finding {
kind: "scopes_narrowed",
severity: Severity::Narrowing,
method: after.method.clone(),
path: after.path.clone(),
before: label_before.to_owned(),
after: label_after.to_owned(),
fingerprint: format!("scopes+{}", escape_list(&added)),
detail: format!(
"scope{} {} now required",
plural(added.len()),
added.join(", ")
),
});
}
}
const fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
pub(super) type AuthzKey = (String, String, String, String);
pub(super) fn authz_index(m: &PostureManifest) -> BTreeMap<AuthzKey, String> {
m.dimensions
.authorization_policies
.entries
.iter()
.map(|e| {
(
(
normalize_captures(&e.path),
e.method.clone(),
e.action.clone(),
e.resource.clone(),
),
e.path.clone(),
)
})
.collect()
}
fn diff_authorization_policies(
base: &PostureManifest,
head: &PostureManifest,
out: &mut Vec<Finding>,
) {
let before = authz_index(base);
let after = authz_index(head);
let routes_before = route_keys(base);
let routes_after = route_keys(head);
let gained_on: BTreeMap<RouteKey, Vec<String>> =
after.keys().filter(|key| !before.contains_key(*key)).fold(
BTreeMap::new(),
|mut acc, (path, method, action, resource)| {
acc.entry((path.clone(), method.clone()))
.or_default()
.push(format!("{action} on {resource}"));
acc
},
);
for (key, written_as) in &before {
if after.contains_key(key) {
continue;
}
let (path, method, action, resource) = key;
let takers = takers((path, method), &routes_before, &routes_after);
if takers.is_empty() {
continue;
}
let fell_through = !routes_after.contains(&(path.clone(), method.clone()));
if fell_through
&& takers.iter().all(|(p, m)| {
after.contains_key(&(p.clone(), m.clone(), action.clone(), resource.clone()))
})
{
continue;
}
let mut detail =
format!("record-level authorization `{action}` on `{resource}` no longer checked");
if fell_through {
detail.push_str(
" (the route carrying it is gone, but the URL is not: another mounted route \
answers it now, without this check)",
);
}
if let Some(gained) = gained_on.get(&(path.clone(), method.clone())) {
let _ = write!(
detail,
" (the same route gained: {}; a renamed resource reads as a removal plus an \
addition, which a manifest cannot tell from a real one)",
gained.join(", ")
);
}
out.push(Finding {
kind: "authorization_binding_removed",
severity: Severity::Widening,
method: method.clone(),
path: written_as.clone(),
before: format!("authorize({action}, {resource})"),
after: "none".to_owned(),
fingerprint: format!(
"authz-removed:{}",
escape_list(&[
action.clone(),
resource.clone(),
checks_on((path, method), &after, &routes_after, &routes_before),
])
),
detail,
});
}
report_displaced_bindings(base, head, &before, &after, out);
for (key, written_as) in &after {
if before.contains_key(key) {
continue;
}
let (_, method, action, resource) = key;
out.push(Finding {
kind: "authorization_binding_added",
severity: Severity::Narrowing,
method: method.clone(),
path: written_as.clone(),
before: "none".to_owned(),
after: format!("authorize({action}, {resource})"),
fingerprint: format!(
"authz-added:{}:{}",
escape_field(action),
escape_field(resource)
),
detail: format!("record-level authorization `{action}` on `{resource}` now checked"),
});
}
}
fn report_csrf_loss(
lost: BTreeMap<RouteKey, (String, bool, Vec<RouteKey>)>,
head_routes: &BTreeMap<RouteKey, RouteEntry>,
head_csrf: &BTreeMap<RouteKey, (bool, bool, String)>,
head_bindings: &BTreeMap<AuthzKey, String>,
head_mtls: &BTreeSet<RouteKey>,
collapse: bool,
out: &mut Vec<Finding>,
) {
if collapse {
let routes: Vec<String> = lost
.iter()
.map(|((_, method), (written_as, ..))| format!("{method} {written_as}"))
.collect();
let guarded_keys: Vec<String> = lost
.iter()
.map(|((path, method), (_, _, inheritors))| {
escape_list(&[
method.clone(),
path.clone(),
postures_of(inheritors, head_routes, head_csrf, head_bindings, head_mtls),
])
})
.collect();
out.push(Finding {
kind: "csrf_disabled",
severity: Severity::Widening,
method: "*".to_owned(),
path: "*".to_owned(),
before: "csrf enforced".to_owned(),
after: format!("csrf not enforced on {} routes", routes.len()),
fingerprint: format!(
"csrf-disabled:{}",
&hex_digest(escape_list(&guarded_keys).as_bytes())[..16]
),
detail: format!(
"CSRF enforcement lost on all {} mutating routes: {}",
routes.len(),
routes.join(", ")
),
});
} else {
for (key, (path, exempt, inheritors)) in lost {
let guard = postures_of(
&inheritors,
head_routes,
head_csrf,
head_bindings,
head_mtls,
);
let (_, method) = key;
out.push(Finding {
kind: "csrf_enforcement_removed",
severity: Severity::Widening,
method,
path,
before: "csrf enforced".to_owned(),
after: "csrf not enforced".to_owned(),
fingerprint: format!("csrf-removed:{guard}"),
detail: if exempt {
"CSRF enforcement lost: this route now matches a configured exempt prefix"
.to_owned()
} else {
"CSRF enforcement lost".to_owned()
},
});
}
}
}
fn exempts(prefix: &str, path: &str) -> bool {
if path == prefix {
return true;
}
path.strip_prefix(prefix)
.is_some_and(|rest| prefix.ends_with('/') || rest.starts_with('/'))
}
fn exempt_servers<'a>(prefix: &str, routes: &[&'a RouteKey]) -> Vec<&'a RouteKey> {
let base = prefix.strip_suffix('/').unwrap_or(prefix);
let below = format!("{base}/{CATCH_ALL}");
let subjects = if prefix.ends_with('/') {
vec![below]
} else {
vec![base.to_owned(), below]
};
subjects
.iter()
.flat_map(|subject| {
let overlapping: Vec<&RouteKey> = routes
.iter()
.filter(|(path, _)| intersect(path, subject).is_some())
.copied()
.collect();
undominated(subject, &overlapping)
})
.collect()
}
fn diff_csrf_exemptions(base: &PostureManifest, head: &PostureManifest, out: &mut Vec<Finding>) {
let before: BTreeSet<&String> = base.dimensions.csrf.exempt_paths.iter().collect();
let after: BTreeSet<&String> = head.dimensions.csrf.exempt_paths.iter().collect();
let added: Vec<String> = after
.iter()
.filter(|p| !before.iter().any(|old| exempts(old, p)))
.map(|p| (*p).clone())
.collect();
let removed: Vec<String> = before
.iter()
.filter(|p| !after.iter().any(|new| exempts(new, p)))
.map(|p| (*p).clone())
.collect();
if !added.is_empty() {
let routes = route_index(head);
let csrf = csrf_index(head);
let bindings = authz_index(head);
let mtls = mtls_index(head);
let mounted: Vec<&RouteKey> = routes.keys().collect();
let served: BTreeSet<&RouteKey> = added
.iter()
.flat_map(|prefix| exempt_servers(prefix, &mounted))
.collect();
let mut guards: Vec<String> = served
.into_iter()
.filter_map(|key| {
effective_posture(key, &routes, &csrf, &bindings, &mtls)
.map(|posture| escape_list(&[key.1.clone(), key.0.clone(), posture]))
})
.collect();
guards.sort();
out.push(Finding {
kind: "csrf_exemption_added",
severity: Severity::Widening,
method: "*".to_owned(),
path: "*".to_owned(),
before: "not exempt".to_owned(),
after: added.join(", "),
fingerprint: format!(
"csrf-exempt-added:{}",
escape_list(&[escape_list(&added), escape_list(&guards)])
),
detail: format!(
"CSRF validation is now skipped for {}, which the per-route rows cannot show: \
the audit matches a prefix against a route template, the runtime against the \
request path",
added.join(", ")
),
});
}
if !removed.is_empty() {
out.push(Finding {
kind: "csrf_exemption_removed",
severity: Severity::Narrowing,
method: "*".to_owned(),
path: "*".to_owned(),
before: removed.join(", "),
after: "not exempt".to_owned(),
fingerprint: format!("csrf-exempt-removed:{}", escape_list(&removed)),
detail: format!(
"CSRF validation is enforced again for {}",
removed.join(", ")
),
});
}
}
fn checks_on(
(path, method): (&str, &str),
bindings: &BTreeMap<AuthzKey, String>,
routes: &BTreeSet<RouteKey>,
from_routes: &BTreeSet<RouteKey>,
) -> String {
let mut per_route: Vec<String> = takers((path, method), from_routes, routes)
.into_iter()
.map(|(p, m)| {
let mut checks: Vec<String> = bindings
.keys()
.filter(|(bp, bm, _, _)| *bp == p && *bm == m)
.map(|(_, _, action, resource)| format!("{action} on {resource}"))
.collect();
checks.sort();
checks.dedup();
escape_list(&[m, p, escape_list(&checks)])
})
.collect();
per_route.sort();
escape_list(&per_route)
}
fn report_displaced_bindings(
base: &PostureManifest,
head: &PostureManifest,
before: &BTreeMap<AuthzKey, String>,
after: &BTreeMap<AuthzKey, String>,
out: &mut Vec<Finding>,
) {
let keys_before = route_keys(base);
let keys_after = route_keys(head);
let head_routes = route_index(head);
let mut reported: BTreeSet<(RouteKey, String, String)> = BTreeSet::new();
for added_key in keys_after.difference(&keys_before) {
let (added_path, added_method) = added_key;
for displaced_key in takers((added_path, added_method), &keys_after, &keys_before) {
let (displaced_path, displaced_method) = &displaced_key;
for ((path, method, action, resource), written_as) in before {
if path != displaced_path || method != displaced_method {
continue;
}
let carried = (
added_path.clone(),
added_method.clone(),
action.clone(),
resource.clone(),
);
if after.contains_key(&carried)
|| !reported.insert((added_key.clone(), action.clone(), resource.clone()))
{
continue;
}
let written_added = head_routes
.get(added_key)
.map_or_else(|| added_path.clone(), |r| r.path.clone());
out.push(Finding {
kind: "authorization_binding_displaced",
severity: Severity::Widening,
method: added_method.clone(),
path: written_added.clone(),
before: format!("authorize({action}, {resource})"),
after: "none".to_owned(),
fingerprint: format!(
"authz-displaced:{}",
escape_list(&[
written_as.clone(),
displaced_method.clone(),
action.clone(),
resource.clone(),
checks_on((added_path, added_method), after, &keys_after, &keys_before),
])
),
detail: format!(
"this route is more specific than `{displaced_method} {written_as}`, so it \
takes those requests over — and it does not perform that route's \
record-level `{action}` check on `{resource}`"
),
});
}
}
}
}
pub(super) fn csrf_index(m: &PostureManifest) -> BTreeMap<RouteKey, (bool, bool, String)> {
let mut index: BTreeMap<RouteKey, (bool, bool, String)> = BTreeMap::new();
for e in &m.dimensions.csrf.entries {
let key = (normalize_captures(&e.path), e.method.clone());
index
.entry(key)
.and_modify(|(enforced, exempt, _)| {
*enforced = *enforced && e.csrf_enforced;
*exempt = *exempt || e.exempt;
})
.or_insert_with(|| (e.csrf_enforced, e.exempt, e.path.clone()));
}
index
}
fn diff_csrf(base: &PostureManifest, head: &PostureManifest, out: &mut Vec<Finding>) {
diff_csrf_exemptions(base, head, out);
let head_routes = route_index(head);
let before = csrf_index(base);
let after = csrf_index(head);
let routes_before = route_keys(base);
let routes_after = route_keys(head);
let mut lost: BTreeMap<RouteKey, (String, bool, Vec<RouteKey>)> = BTreeMap::new();
let mut gained: Vec<(RouteKey, String)> = Vec::new();
for (key, (enforced_now, exempt_now, written_as)) in &after {
match before.get(key).map(|(enforced, ..)| *enforced) {
Some(true) if !enforced_now => {
lost.insert(
key.clone(),
(written_as.clone(), *exempt_now, vec![key.clone()]),
);
}
Some(false) if *enforced_now => gained.push((key.clone(), written_as.clone())),
_ => {}
}
}
for (key, (enforced_before, _, written_as)) in &before {
if !*enforced_before || after.contains_key(key) {
continue;
}
let (path, method) = key;
let inheritors: Vec<RouteKey> = takers((path, method), &routes_before, &routes_after)
.into_iter()
.filter(|taker| after.get(taker).is_none_or(|(enforced, ..)| !enforced))
.collect();
if !inheritors.is_empty() {
lost.entry(key.clone())
.or_insert_with(|| (written_as.clone(), false, inheritors));
}
}
let collapse = after.values().all(|(enforced, ..)| !enforced)
&& before.values().any(|entry| entry.0)
&& lost.len() > 1;
report_csrf_loss(
lost,
&head_routes,
&after,
&authz_index(head),
&mtls_index(head),
collapse,
out,
);
for ((_, method), path) in gained {
out.push(Finding {
kind: "csrf_enforcement_added",
severity: Severity::Narrowing,
method,
path,
before: "csrf not enforced".to_owned(),
after: "csrf enforced".to_owned(),
fingerprint: "csrf-added".to_owned(),
detail: "CSRF enforcement gained".to_owned(),
});
}
}
fn diff_mtls(base: &PostureManifest, head: &PostureManifest, out: &mut Vec<Finding>) {
diff_mtls_mode(base, head, out);
diff_mtls_required_paths(base, head, out);
let before = mtls_index(base);
let after = mtls_index(head);
let head_routes = route_index(head);
let head_csrf = csrf_index(head);
let head_bindings = authz_index(head);
for key in before.difference(&after) {
if !head_routes.contains_key(key) {
continue;
}
let (path, method) = key;
out.push(Finding {
kind: "mtls_requirement_removed",
severity: Severity::Widening,
method: method.clone(),
path: path.clone(),
before: "mtls required".to_owned(),
after: "mtls not required".to_owned(),
fingerprint: format!(
"mtls-removed:{}",
postures_of(
std::slice::from_ref(key),
&head_routes,
&head_csrf,
&head_bindings,
&after,
)
),
detail: "this route no longer requires a verified client certificate".to_owned(),
});
}
for key in after.difference(&before) {
let (path, method) = key;
out.push(Finding {
kind: "mtls_requirement_added",
severity: Severity::Narrowing,
method: method.clone(),
path: path.clone(),
before: "mtls not required".to_owned(),
after: "mtls required".to_owned(),
fingerprint: "mtls-added".to_owned(),
detail: "this route now requires a verified client certificate".to_owned(),
});
}
}
fn diff_mtls_required_paths(
base: &PostureManifest,
head: &PostureManifest,
out: &mut Vec<Finding>,
) {
if !base.dimensions.mtls.requests_certificate() {
return;
}
let head_requests = head.dimensions.mtls.requests_certificate();
let before: BTreeSet<&String> = base.dimensions.mtls.required_paths.iter().collect();
let after: BTreeSet<&String> = head.dimensions.mtls.required_paths.iter().collect();
let dropped: Vec<String> = before
.iter()
.filter(|p| !head_requests || !after.iter().any(|new| covers_prefix(new, p)))
.map(|p| (*p).clone())
.collect();
let added: Vec<String> = after
.iter()
.filter(|p| !before.iter().any(|old| covers_prefix(old, p)))
.map(|p| (*p).clone())
.collect();
if !dropped.is_empty() {
let mut surviving: Vec<String> = after.iter().map(|p| (*p).clone()).collect();
surviving.sort();
out.push(Finding {
kind: "mtls_required_path_removed",
severity: Severity::Widening,
method: "*".to_owned(),
path: "*".to_owned(),
before: dropped.join(", "),
after: if surviving.is_empty() {
"not required".to_owned()
} else {
format!("still required: {}", surviving.join(", "))
},
fingerprint: format!(
"mtls-required-path-removed:{}",
escape_list(&[
escape_list(&dropped),
escape_list(&added),
escape_list(&surviving),
])
),
detail: format!(
"a verified client certificate is no longer required for {}, which the \
per-route rows cannot show: the audit matches a prefix against a route \
template, the runtime against the request path",
dropped.join(", ")
),
});
}
if !added.is_empty() {
out.push(Finding {
kind: "mtls_required_path_added",
severity: Severity::Narrowing,
method: "*".to_owned(),
path: "*".to_owned(),
before: "not required".to_owned(),
after: added.join(", "),
fingerprint: format!("mtls-required-path-added:{}", escape_list(&added)),
detail: format!(
"a verified client certificate is now required for {}",
added.join(", ")
),
});
}
}
fn covers_prefix(outer: &str, inner: &str) -> bool {
let outer = outer.strip_suffix('/').unwrap_or(outer);
let inner = inner.strip_suffix('/').unwrap_or(inner);
inner == outer
|| inner
.strip_prefix(outer)
.is_some_and(|r| r.starts_with('/'))
}
fn diff_mtls_mode(base: &PostureManifest, head: &PostureManifest, out: &mut Vec<Finding>) {
let before = mtls_mode_rank(&base.dimensions.mtls.mode);
let after = mtls_mode_rank(&head.dimensions.mtls.mode);
if before == after {
return;
}
let label = |m: &str| {
if m.is_empty() {
"off".to_owned()
} else {
m.to_owned()
}
};
let (before_label, after_label) = (
label(&base.dimensions.mtls.mode),
label(&head.dimensions.mtls.mode),
);
let (kind, severity, detail) = if after < before {
(
"mtls_mode_weakened",
Severity::Widening,
format!(
"the listener's client-certificate mode weakened from `{before_label}` to \
`{after_label}`"
),
)
} else {
(
"mtls_mode_strengthened",
Severity::Narrowing,
format!(
"the listener's client-certificate mode strengthened from `{before_label}` to \
`{after_label}`"
),
)
};
out.push(Finding {
kind,
severity,
method: "*".to_owned(),
path: "*".to_owned(),
before: before_label,
after: after_label.clone(),
fingerprint: format!("mtls-mode:{after_label}"),
detail,
});
}
fn mtls_mode_rank(mode: &str) -> u8 {
match mode {
"required" => 2,
"optional" => 1,
_ => 0,
}
}
fn headers_index(m: &PostureManifest) -> BTreeMap<&str, (bool, &str)> {
m.dimensions
.security_headers
.entries
.iter()
.map(|e| (e.header.as_str(), (e.emitted, e.value.as_str())))
.collect()
}
fn diff_headers(base: &PostureManifest, head: &PostureManifest, out: &mut Vec<Finding>) {
let before = headers_index(base);
let after = headers_index(head);
let emitted: Vec<String> = after
.iter()
.filter(|(_, (emitted, _))| *emitted)
.map(|(header, (_, value))| escape_list(&[(*header).to_owned(), (*value).to_owned()]))
.collect();
let remaining = escape_list(&emitted);
let removed = |header: &str, value_before: &str| Finding {
kind: "security_header_removed",
severity: Severity::Widening,
method: "*".to_owned(),
path: header.to_owned(),
before: value_before.to_owned(),
after: "not emitted".to_owned(),
fingerprint: escape_list(&["header-removed".to_owned(), remaining.clone()]),
detail: format!("security header `{header}` is no longer emitted"),
};
for (header, (emitted_now, value_now)) in &after {
match before.get(header) {
None => {
if *emitted_now {
out.push(Finding {
kind: "security_header_added",
severity: Severity::Narrowing,
method: "*".to_owned(),
path: (*header).to_owned(),
before: "not emitted".to_owned(),
after: (*value_now).to_owned(),
fingerprint: "header-added".to_owned(),
detail: format!("security header `{header}` is now emitted"),
});
}
}
Some((emitted_before, value_before)) => {
if *emitted_before && !*emitted_now {
out.push(removed(header, value_before));
} else if !*emitted_before && *emitted_now {
out.push(Finding {
kind: "security_header_added",
severity: Severity::Narrowing,
method: "*".to_owned(),
path: (*header).to_owned(),
before: "not emitted".to_owned(),
after: (*value_now).to_owned(),
fingerprint: "header-added".to_owned(),
detail: format!("security header `{header}` is now emitted"),
});
} else if value_before != value_now {
out.push(Finding {
kind: "security_header_value_changed",
severity: Severity::Neutral,
method: "*".to_owned(),
path: (*header).to_owned(),
before: (*value_before).to_owned(),
after: (*value_now).to_owned(),
fingerprint: "header-value-changed".to_owned(),
detail: format!("security header `{header}` value changed — review by eye"),
});
}
}
}
}
for (header, (emitted_before, value_before)) in &before {
if *emitted_before && !after.contains_key(header) {
out.push(removed(header, value_before));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::posture::model::PostureManifest;
fn manifest(routes: &str, csrf: &str, headers: &str, authz: &str) -> PostureManifest {
let json = format!(
r#"{{"schema_version":3,"dimensions":{{
"routes":{{"provenance":"provable","source":"m","entries":[{routes}]}},
"csrf":{{"provenance":"declared","source":"c","exempt_paths":[],"entries":[{csrf}]}},
"security_headers":{{"provenance":"declared","source":"c","entries":[{headers}]}},
"authorization_policies":{{"provenance":"provable","source":"m","runtime_caveat":"x","entries":[{authz}]}}
}},"excluded":[]}}"#
);
PostureManifest::parse(&json, "test.json").expect("fixture parses")
}
fn routes_only(routes: &str) -> PostureManifest {
manifest(routes, "", "", "")
}
fn manifest_exempt(routes: &str, csrf: &str, exempt: &[&str]) -> PostureManifest {
let exempt = exempt
.iter()
.map(|p| format!("\"{p}\""))
.collect::<Vec<_>>()
.join(",");
let json = format!(
r#"{{"schema_version":3,"dimensions":{{
"routes":{{"provenance":"provable","source":"m","entries":[{routes}]}},
"csrf":{{"provenance":"declared","source":"c","exempt_paths":[{exempt}],"entries":[{csrf}]}},
"security_headers":{{"provenance":"declared","source":"c","entries":[]}},
"authorization_policies":{{"provenance":"provable","source":"m","runtime_caveat":"x","entries":[]}}
}},"excluded":[]}}"#
);
PostureManifest::parse(&json, "test.json").expect("fixture parses")
}
fn route(
path: &str,
method: &str,
classification: &str,
roles: &[&str],
scopes: &[&str],
policy: bool,
) -> String {
let roles = roles
.iter()
.map(|r| format!("\"{r}\""))
.collect::<Vec<_>>()
.join(",");
let scopes = scopes
.iter()
.map(|s| format!("\"{s}\""))
.collect::<Vec<_>>()
.join(",");
format!(
r#"{{"path":"{path}","method":"{method}","name":"h","classification":"{classification}",
"roles":[{roles}],"scopes":[{scopes}],"policy":{policy},"source":"user",
"location":"src/routes.rs:1","provenance":"provable"}}"#
)
}
fn kinds(findings: &[Finding]) -> Vec<&'static str> {
findings.iter().map(|f| f.kind).collect()
}
fn only(findings: Vec<Finding>) -> Finding {
assert_eq!(
findings.len(),
1,
"expected exactly one finding: {findings:#?}"
);
findings.into_iter().next().expect("one finding")
}
fn manifest_mtls(
routes: &str,
mode: &str,
required_paths: &[&str],
mtls: &str,
) -> PostureManifest {
let required = required_paths
.iter()
.map(|p| format!("\"{p}\""))
.collect::<Vec<_>>()
.join(",");
let json = format!(
r#"{{"schema_version":4,"dimensions":{{
"routes":{{"provenance":"provable","source":"m","entries":[{routes}]}},
"csrf":{{"provenance":"declared","source":"c","exempt_paths":[],"entries":[]}},
"security_headers":{{"provenance":"declared","source":"c","entries":[]}},
"authorization_policies":{{"provenance":"provable","source":"m","runtime_caveat":"x","entries":[]}},
"mtls":{{"provenance":"declared","source":"config:server.tls.client_auth",
"mode":"{mode}","required_paths":[{required}],"entries":[{mtls}]}}
}},"excluded":[]}}"#
);
PostureManifest::parse(&json, "test.json").expect("fixture parses")
}
fn mtls_entry(path: &str, method: &str, required: bool) -> String {
format!(r#"{{"path":"{path}","method":"{method}","mtls_required":{required}}}"#)
}
#[test]
fn a_route_dropping_its_mtls_requirement_is_a_widening_that_names_it() {
let routes = route("/internal/keys", "GET", "gated", &["admin"], &[], false);
let base = manifest_mtls(
&routes,
"optional",
&["/internal/"],
&mtls_entry("/internal/keys", "GET", true),
);
let head = manifest_mtls(
&routes,
"optional",
&[],
&mtls_entry("/internal/keys", "GET", false),
);
let findings = diff(&base, &head);
let finding = findings
.iter()
.find(|f| f.kind == "mtls_requirement_removed")
.unwrap_or_else(|| panic!("expected a per-route finding: {findings:#?}"));
assert_eq!(finding.severity, Severity::Widening);
assert_eq!(finding.path, "/internal/keys");
assert_eq!(finding.method, "GET");
assert!(kinds(&findings).contains(&"mtls_required_path_removed"));
}
#[test]
fn adding_an_mtls_requirement_is_narrowing() {
let routes = route("/internal/keys", "GET", "gated", &[], &[], false);
let base = manifest_mtls(
&routes,
"optional",
&[],
&mtls_entry("/internal/keys", "GET", false),
);
let head = manifest_mtls(
&routes,
"optional",
&["/internal/"],
&mtls_entry("/internal/keys", "GET", true),
);
let findings = diff(&base, &head);
let finding = findings
.iter()
.find(|f| f.kind == "mtls_requirement_added")
.unwrap_or_else(|| panic!("expected a per-route finding: {findings:#?}"));
assert_eq!(finding.severity, Severity::Narrowing);
assert!(kinds(&findings).contains(&"mtls_required_path_added"));
}
#[test]
fn weakening_the_listener_mode_is_a_widening() {
let routes = route("/a", "GET", "public", &[], &[], false);
let base = manifest_mtls(&routes, "required", &[], &mtls_entry("/a", "GET", false));
let head = manifest_mtls(&routes, "optional", &[], &mtls_entry("/a", "GET", false));
let finding = only(diff(&base, &head));
assert_eq!(finding.kind, "mtls_mode_weakened");
assert_eq!(finding.severity, Severity::Widening);
assert_eq!(finding.before, "required");
assert_eq!(finding.after, "optional");
}
#[test]
fn strengthening_the_listener_mode_is_narrowing() {
let routes = route("/a", "GET", "public", &[], &[], false);
let base = manifest_mtls(&routes, "optional", &[], &mtls_entry("/a", "GET", false));
let head = manifest_mtls(&routes, "required", &[], &mtls_entry("/a", "GET", false));
let finding = only(diff(&base, &head));
assert_eq!(finding.kind, "mtls_mode_strengthened");
assert_eq!(finding.severity, Severity::Narrowing);
}
#[test]
fn deleting_a_route_does_not_also_report_a_lost_mtls_requirement() {
let base = manifest_mtls(
&route("/internal/keys", "GET", "gated", &[], &[], false),
"optional",
&["/internal/"],
&mtls_entry("/internal/keys", "GET", true),
);
let head = manifest_mtls("", "optional", &["/internal/"], "");
assert!(
!kinds(&diff(&base, &head)).contains(&"mtls_requirement_removed"),
"a removed route must not raise a second, mTLS-shaped finding"
);
}
#[test]
fn dropping_a_required_prefix_that_matches_no_route_template_is_a_widening() {
let routes = route("/users/{id}", "GET", "gated", &[], &[], false);
let entry = mtls_entry("/users/{id}", "GET", false);
let base = manifest_mtls(&routes, "optional", &["/users/admin"], &entry);
let head = manifest_mtls(&routes, "optional", &[], &entry);
let finding = only(diff(&base, &head));
assert_eq!(finding.kind, "mtls_required_path_removed");
assert_eq!(finding.severity, Severity::Widening);
assert!(finding.before.contains("/users/admin"), "{finding:?}");
}
#[test]
fn narrowing_a_required_prefix_reports_only_the_coverage_lost() {
let routes = route("/a", "GET", "public", &[], &[], false);
let entry = mtls_entry("/a", "GET", false);
let widened = diff(
&manifest_mtls(&routes, "optional", &["/internal/keys"], &entry),
&manifest_mtls(&routes, "optional", &["/internal"], &entry),
);
assert!(
!kinds(&widened).contains(&"mtls_required_path_removed"),
"widening a prefix loses no coverage: {widened:#?}"
);
let narrowed = diff(
&manifest_mtls(&routes, "optional", &["/internal"], &entry),
&manifest_mtls(&routes, "optional", &["/internal/keys"], &entry),
);
assert!(kinds(&narrowed).contains(&"mtls_required_path_removed"));
}
#[test]
fn two_different_narrowings_of_one_prefix_do_not_share_a_fingerprint() {
let routes = route("/internal/{id}", "GET", "gated", &[], &[], false);
let entry = mtls_entry("/internal/{id}", "GET", false);
let base = manifest_mtls(&routes, "optional", &["/internal"], &entry);
let fingerprint = |head_prefix: &str| {
let head = manifest_mtls(&routes, "optional", &[head_prefix], &entry);
let found = diff(&base, &head)
.into_iter()
.find(|f| f.kind == "mtls_required_path_removed");
let Some(finding) = found else {
panic!("expected a removal finding for {head_prefix}");
};
finding.fingerprint
};
assert_ne!(
fingerprint("/internal/admin"),
fingerprint("/internal/public"),
"two narrowings protecting different URLs must not share an acknowledgment"
);
}
#[test]
fn a_trailing_slash_is_not_a_prefix_change() {
let routes = route("/a", "GET", "public", &[], &[], false);
let entry = mtls_entry("/a", "GET", false);
let findings = diff(
&manifest_mtls(&routes, "optional", &["/internal"], &entry),
&manifest_mtls(&routes, "optional", &["/internal/"], &entry),
);
assert!(findings.is_empty(), "{findings:#?}");
}
#[test]
fn turning_the_listener_off_does_not_double_report_every_prefix() {
let routes = route("/a", "GET", "public", &[], &[], false);
let entry = mtls_entry("/a", "GET", false);
let findings = diff(
&manifest_mtls(&routes, "required", &["/internal"], &entry),
&manifest_mtls(&routes, "off", &[], &entry),
);
let kinds = kinds(&findings);
assert!(kinds.contains(&"mtls_mode_weakened"), "{findings:#?}");
assert!(
kinds.contains(&"mtls_required_path_removed"),
"the prefixes it stopped honouring are still named: {findings:#?}"
);
}
#[test]
fn the_schema_bump_alone_is_not_a_posture_change() {
let routes = route("/a", "GET", "public", &[], &[], false);
let v3 = routes_only(&routes);
let v4 = manifest_mtls(&routes, "off", &[], &mtls_entry("/a", "GET", false));
assert!(
diff(&v3, &v4).is_empty(),
"reading a v3 manifest against a v4 one with client auth off must be a no-op: {:#?}",
diff(&v3, &v4)
);
assert_eq!(
v3.posture_digest(),
v4.posture_digest(),
"an app with no client auth must keep its posture digest across the bump"
);
}
#[test]
fn dropping_mtls_moves_the_fingerprint_of_a_coincident_widening() {
let locked = |classification: &str| {
manifest_mtls(
&route("/internal/keys", "GET", classification, &[], &[], false),
"optional",
&["/internal/"],
&mtls_entry("/internal/keys", "GET", true),
)
};
let unlocked = manifest_mtls(
&route("/internal/keys", "GET", "public", &[], &[], false),
"optional",
&[],
&mtls_entry("/internal/keys", "GET", false),
);
let with_mtls = diff(&locked("gated"), &locked("public"));
let without = diff(&locked("gated"), &unlocked);
let class_change = |findings: &[Finding]| {
findings
.iter()
.find(|f| f.kind == "classification_downgraded")
.map(|f| f.fingerprint.clone())
};
assert_ne!(
class_change(&with_mtls),
class_change(&without),
"an acknowledgment of the class change must not also cover losing mTLS"
);
}
#[test]
fn flipping_a_role_gated_route_to_public_is_a_widening_that_names_it() {
let base = routes_only(&route(
"/admin/users",
"GET",
"gated",
&["admin"],
&[],
false,
));
let head = routes_only(&route("/admin/users", "GET", "public", &[], &[], false));
let findings = diff(&base, &head);
let f = only(findings.clone());
assert_eq!(f.kind, "classification_downgraded");
assert_eq!(f.severity, Severity::Widening);
assert_eq!(f.path, "/admin/users");
assert_eq!(f.method, "GET");
assert!(f.before.contains("gated"), "{f:?}");
assert!(f.before.contains("admin"), "{f:?}");
assert_eq!(f.after, "public");
assert_eq!(widening(&findings).len(), 1);
}
#[test]
fn a_cosmetic_refactor_produces_no_finding() {
let base = routes_only(
r#"{"path":"/admin/users","method":"GET","name":"list_users","classification":"gated",
"roles":["admin"],"scopes":[],"policy":false,"source":"user",
"location":"src/routes/admin.rs:12","module":"routes::admin","provenance":"provable"}"#,
);
let head = routes_only(
r#"{"path":"/admin/users","method":"GET","name":"index","classification":"gated",
"roles":["admin"],"scopes":[],"policy":false,"source":"user",
"location":"src/routes/admin/users.rs:88","module":"routes::admin::users","provenance":"provable"}"#,
);
assert!(diff(&base, &head).is_empty(), "{:#?}", diff(&base, &head));
}
#[test]
fn an_identical_manifest_produces_no_finding() {
let m = manifest(
&route("/a", "GET", "gated", &["admin"], &[], false),
r#"{"path":"/a","method":"POST","csrf_enforced":true,"exempt":false}"#,
r#"{"header":"x_frame_options","value":"DENY","emitted":true}"#,
r#"{"path":"/a","method":"GET","name":"h","action":"read","resource":"Post","provenance":"provable"}"#,
);
let m2 = manifest(
&route("/a", "GET", "gated", &["admin"], &[], false),
r#"{"path":"/a","method":"POST","csrf_enforced":true,"exempt":false}"#,
r#"{"header":"x_frame_options","value":"DENY","emitted":true}"#,
r#"{"path":"/a","method":"GET","name":"h","action":"read","resource":"Post","provenance":"provable"}"#,
);
assert!(diff(&m, &m2).is_empty());
}
fn route_prov(path: &str, classification: &str, provenance: &str) -> String {
route(path, "GET", classification, &[], &[], false).replace(
r#""provenance":"provable""#,
&format!(r#""provenance":"{provenance}""#),
)
}
#[test]
fn a_proven_guard_that_becomes_declared_is_widening() {
let base = routes_only(&route_prov("/api/todos", "gated", "provable"));
let head = routes_only(&route_prov("/api/todos", "gated", "declared"));
let f = only(diff(&base, &head));
assert_eq!(f.kind, "guard_declared");
assert_eq!(f.severity, Severity::Widening);
assert_ne!(base.posture_digest(), head.posture_digest());
}
#[test]
fn a_declared_guard_that_becomes_proven_is_narrowing() {
let base = routes_only(&route_prov("/api/todos", "gated", "declared"));
let head = routes_only(&route_prov("/api/todos", "gated", "provable"));
let f = only(diff(&base, &head));
assert_eq!(f.kind, "guard_proven");
assert_eq!(f.severity, Severity::Narrowing);
}
#[test]
fn an_unchanged_declared_guard_is_not_a_finding() {
let m = routes_only(&route_prov("/api/todos", "gated", "declared"));
assert!(diff(&m, &m).is_empty());
}
#[test]
fn a_declared_guard_that_becomes_public_is_widening() {
let base = routes_only(&route_prov("/api/todos", "gated", "declared"));
let head = routes_only(&route_prov("/api/todos", "public", "provable"));
let f = only(diff(&base, &head));
assert_eq!(f.kind, "classification_downgraded");
assert_eq!(f.severity, Severity::Widening);
}
#[test]
fn a_provable_manifest_keeps_its_digest() {
let provable = routes_only(&route_prov("/a", "gated", "provable"));
let absent = routes_only(
&route_prov("/a", "gated", "provable").replace(r#","provenance":"provable""#, ""),
);
assert_eq!(provable.posture_digest(), absent.posture_digest());
}
#[test]
fn a_new_public_route_is_widening() {
let base = routes_only("");
let head = routes_only(&route("/signup", "POST", "public", &[], &[], false));
let f = only(diff(&base, &head));
assert_eq!(f.kind, "route_added_open");
assert_eq!(f.severity, Severity::Widening);
assert_eq!(f.before, "absent");
}
#[test]
fn a_new_unclassified_route_is_widening_too() {
let base = routes_only("");
let head = routes_only(&route("/oops", "POST", "unclassified", &[], &[], false));
assert_eq!(only(diff(&base, &head)).severity, Severity::Widening);
}
#[test]
fn a_new_gated_route_never_blocks() {
let base = routes_only("");
let head = routes_only(&route("/admin", "GET", "gated", &["admin"], &[], false));
let f = only(diff(&base, &head));
assert_eq!(f.kind, "route_added_gated");
assert_eq!(f.severity, Severity::Neutral);
assert!(widening(&diff(&base, &head)).is_empty());
}
#[test]
fn a_new_framework_route_never_blocks() {
let base = routes_only("");
let head = routes_only(&route(
"/actuator/health",
"GET",
"framework",
&[],
&[],
false,
));
assert_eq!(only(diff(&base, &head)).severity, Severity::Neutral);
}
#[test]
fn a_removed_route_is_narrowing() {
let base = routes_only(&route("/old", "GET", "public", &[], &[], false));
let head = routes_only("");
let f = only(diff(&base, &head));
assert_eq!(f.kind, "route_removed");
assert_eq!(f.severity, Severity::Narrowing);
}
#[test]
fn the_same_path_on_a_different_method_is_a_different_route() {
let base = routes_only(&route("/posts", "GET", "public", &[], &[], false));
let head = routes_only(&format!(
"{},{}",
route("/posts", "GET", "public", &[], &[], false),
route("/posts", "DELETE", "public", &[], &[], false)
));
let f = only(diff(&base, &head));
assert_eq!(f.method, "DELETE");
assert_eq!(f.severity, Severity::Widening);
}
#[test]
fn dropping_the_role_requirement_is_widening() {
let base = routes_only(&route("/admin", "GET", "gated", &["admin"], &[], false));
let head = routes_only(&route("/admin", "GET", "gated", &[], &[], false));
let f = only(diff(&base, &head));
assert_eq!(f.kind, "roles_cleared");
assert_eq!(f.severity, Severity::Widening);
assert!(f.detail.contains("admin"), "{f:?}");
}
#[test]
fn adding_a_role_is_widening_because_roles_are_or_ed() {
let base = routes_only(&route("/admin", "GET", "gated", &["admin"], &[], false));
let head = routes_only(&route(
"/admin",
"GET",
"gated",
&["admin", "editor"],
&[],
false,
));
let f = only(diff(&base, &head));
assert_eq!(f.kind, "roles_widened");
assert_eq!(f.severity, Severity::Widening);
assert!(f.detail.contains("editor"), "{f:?}");
}
#[test]
fn removing_one_of_several_roles_is_narrowing() {
let base = routes_only(&route(
"/admin",
"GET",
"gated",
&["admin", "editor"],
&[],
false,
));
let head = routes_only(&route("/admin", "GET", "gated", &["admin"], &[], false));
let f = only(diff(&base, &head));
assert_eq!(f.kind, "roles_narrowed");
assert_eq!(f.severity, Severity::Narrowing);
}
#[test]
fn reordering_roles_is_not_a_change() {
let base = routes_only(&route(
"/admin",
"GET",
"gated",
&["admin", "editor"],
&[],
false,
));
let head = routes_only(&route(
"/admin",
"GET",
"gated",
&["editor", "admin"],
&[],
false,
));
assert!(diff(&base, &head).is_empty());
}
#[test]
fn removing_a_scope_is_widening_because_scopes_are_and_ed() {
let base = routes_only(&route(
"/api",
"POST",
"gated",
&[],
&["posts:write", "admin"],
false,
));
let head = routes_only(&route(
"/api",
"POST",
"gated",
&[],
&["posts:write"],
false,
));
let f = only(diff(&base, &head));
assert_eq!(f.kind, "scopes_widened");
assert_eq!(f.severity, Severity::Widening);
assert!(f.detail.contains("admin"), "{f:?}");
}
#[test]
fn adding_a_scope_is_narrowing() {
let base = routes_only(&route(
"/api",
"POST",
"gated",
&[],
&["posts:write"],
false,
));
let head = routes_only(&route(
"/api",
"POST",
"gated",
&[],
&["posts:write", "admin"],
false,
));
assert_eq!(only(diff(&base, &head)).kind, "scopes_narrowed");
}
#[test]
fn removing_the_policy_check_is_widening() {
let base = routes_only(&route("/posts/1", "PUT", "gated", &["user"], &[], true));
let head = routes_only(&route("/posts/1", "PUT", "gated", &["user"], &[], false));
let f = only(diff(&base, &head));
assert_eq!(f.kind, "policy_removed");
assert_eq!(f.severity, Severity::Widening);
}
#[test]
fn adding_the_policy_check_is_narrowing() {
let base = routes_only(&route("/posts/1", "PUT", "gated", &["user"], &[], false));
let head = routes_only(&route("/posts/1", "PUT", "gated", &["user"], &[], true));
assert_eq!(only(diff(&base, &head)).severity, Severity::Narrowing);
}
#[test]
fn gating_a_public_route_is_narrowing() {
let base = routes_only(&route("/admin", "GET", "public", &[], &[], false));
let head = routes_only(&route("/admin", "GET", "gated", &["admin"], &[], false));
let f = only(diff(&base, &head));
assert_eq!(f.kind, "classification_upgraded");
assert_eq!(f.severity, Severity::Narrowing);
}
#[test]
fn a_route_that_loses_its_guard_reports_one_finding_not_three() {
let base = routes_only(&route(
"/admin",
"GET",
"gated",
&["admin", "editor"],
&["s"],
true,
));
let head = routes_only(&route("/admin", "GET", "public", &[], &[], false));
let findings = diff(&base, &head);
assert_eq!(kinds(&findings), vec!["classification_downgraded"]);
}
#[test]
fn removing_an_authorize_binding_is_widening() {
let r = route("/posts/1", "PUT", "gated", &["user"], &[], true);
let base = manifest(
&r,
"",
"",
r#"{"path":"/posts/1","method":"PUT","name":"h","action":"update","resource":"Post","provenance":"provable"}"#,
);
let head = manifest(&r, "", "", "");
let f = only(diff(&base, &head));
assert_eq!(f.kind, "authorization_binding_removed");
assert_eq!(f.severity, Severity::Widening);
assert!(f.detail.contains("update"), "{f:?}");
assert!(f.detail.contains("Post"), "{f:?}");
}
#[test]
fn adding_an_authorize_binding_is_narrowing() {
let r = route("/posts/1", "PUT", "gated", &["user"], &[], true);
let base = manifest(&r, "", "", "");
let head = manifest(
&r,
"",
"",
r#"{"path":"/posts/1","method":"PUT","name":"h","action":"update","resource":"Post","provenance":"provable"}"#,
);
assert_eq!(only(diff(&base, &head)).severity, Severity::Narrowing);
}
#[test]
fn a_binding_that_left_with_its_route_is_not_a_widening() {
let base = manifest(
&route("/posts/1", "DELETE", "gated", &["user"], &[], true),
"",
"",
r#"{"path":"/posts/1","method":"DELETE","name":"h","action":"destroy","resource":"Post","provenance":"provable"}"#,
);
let head = manifest("", "", "", "");
let findings = diff(&base, &head);
assert_eq!(kinds(&findings), vec!["route_removed"]);
assert!(widening(&findings).is_empty());
}
#[test]
fn losing_csrf_enforcement_on_a_route_is_widening() {
let base = manifest(
"",
r#"{"path":"/pay","method":"POST","csrf_enforced":true,"exempt":false}"#,
"",
"",
);
let head = manifest(
"",
r#"{"path":"/pay","method":"POST","csrf_enforced":false,"exempt":true}"#,
"",
"",
);
let f = only(diff(&base, &head));
assert_eq!(f.kind, "csrf_enforcement_removed");
assert_eq!(f.severity, Severity::Widening);
}
#[test]
fn gaining_csrf_enforcement_is_narrowing() {
let base = manifest(
"",
r#"{"path":"/pay","method":"POST","csrf_enforced":false,"exempt":true}"#,
"",
"",
);
let head = manifest(
"",
r#"{"path":"/pay","method":"POST","csrf_enforced":true,"exempt":false}"#,
"",
"",
);
assert_eq!(only(diff(&base, &head)).severity, Severity::Narrowing);
}
#[test]
fn disabling_csrf_everywhere_collapses_into_one_finding() {
let on = (0..5)
.map(|i| {
format!(r#"{{"path":"/r{i}","method":"POST","csrf_enforced":true,"exempt":false}}"#)
})
.collect::<Vec<_>>()
.join(",");
let off = (0..5)
.map(|i| {
format!(
r#"{{"path":"/r{i}","method":"POST","csrf_enforced":false,"exempt":false}}"#
)
})
.collect::<Vec<_>>()
.join(",");
let f = only(diff(
&manifest("", &on, "", ""),
&manifest("", &off, "", ""),
));
assert_eq!(f.kind, "csrf_disabled");
assert_eq!(f.severity, Severity::Widening);
assert!(f.detail.contains('5'), "{f:?}");
}
#[test]
fn dropping_a_security_header_is_widening() {
let base = manifest(
"",
"",
r#"{"header":"x_frame_options","value":"DENY","emitted":true}"#,
"",
);
let head = manifest(
"",
"",
r#"{"header":"x_frame_options","value":"","emitted":false}"#,
"",
);
let f = only(diff(&base, &head));
assert_eq!(f.kind, "security_header_removed");
assert_eq!(f.severity, Severity::Widening);
}
#[test]
fn adding_a_security_header_is_narrowing() {
let base = manifest(
"",
"",
r#"{"header":"referrer_policy","value":"","emitted":false}"#,
"",
);
let head = manifest(
"",
"",
r#"{"header":"referrer_policy","value":"no-referrer","emitted":true}"#,
"",
);
assert_eq!(only(diff(&base, &head)).severity, Severity::Narrowing);
}
#[test]
fn changing_a_security_header_value_is_neutral_not_widening() {
let base = manifest(
"",
"",
r#"{"header":"content_security_policy","value":"default-src 'self'","emitted":true}"#,
"",
);
let head = manifest(
"",
"",
r#"{"header":"content_security_policy","value":"default-src *","emitted":true}"#,
"",
);
let f = only(diff(&base, &head));
assert_eq!(f.kind, "security_header_value_changed");
assert_eq!(f.severity, Severity::Neutral);
assert!(widening(&diff(&base, &head)).is_empty());
}
#[test]
fn csrf_entries_that_disappear_from_the_manifest_are_still_widening() {
let r = route("/pay", "POST", "public", &[], &[], false);
let base = manifest(
&r,
r#"{"path":"/pay","method":"POST","csrf_enforced":true,"exempt":false}"#,
"",
"",
);
let head = manifest(&r, "", "", "");
let f = only(diff(&base, &head));
assert_eq!(f.severity, Severity::Widening, "{f:?}");
assert_eq!(f.path, "/pay");
}
#[test]
fn security_headers_that_disappear_from_the_manifest_are_still_widening() {
let base = manifest(
"",
"",
r#"{"header":"x_frame_options","value":"DENY","emitted":true}"#,
"",
);
let head = manifest("", "", "", "");
let f = only(diff(&base, &head));
assert_eq!(f.kind, "security_header_removed");
assert_eq!(f.severity, Severity::Widening);
}
#[test]
fn a_header_that_was_not_emitted_and_then_vanishes_is_not_a_finding() {
let base = manifest(
"",
"",
r#"{"header":"referrer_policy","value":"","emitted":false}"#,
"",
);
let head = manifest("", "", "", "");
assert!(diff(&base, &head).is_empty());
}
#[test]
fn a_csrf_entry_that_left_with_its_route_is_not_a_widening() {
let base = manifest(
&route("/pay", "POST", "public", &[], &[], false),
r#"{"path":"/pay","method":"POST","csrf_enforced":true,"exempt":false}"#,
"",
"",
);
let head = manifest("", "", "", "");
let findings = diff(&base, &head);
assert_eq!(kinds(&findings), vec!["route_removed"]);
}
#[test]
fn adding_the_first_role_to_an_unrestricted_gate_is_narrowing() {
let base = routes_only(&route("/admin", "GET", "gated", &[], &[], false));
let head = routes_only(&route("/admin", "GET", "gated", &["admin"], &[], false));
let f = only(diff(&base, &head));
assert_eq!(f.kind, "roles_narrowed");
assert_eq!(f.severity, Severity::Narrowing);
}
#[test]
fn a_crafted_path_cannot_forge_the_canonical_form_of_two_findings() {
let smuggled = Finding {
kind: "route_added_open",
severity: Severity::Widening,
method: "GET".to_owned(),
path: "/a\tabsent\tpublic\nroute_added_open\tGET\t/admin".to_owned(),
before: "absent".to_owned(),
after: "public".to_owned(),
fingerprint: "added:public".to_owned(),
detail: String::new(),
};
let plain_a = Finding {
path: "/a".to_owned(),
..smuggled.clone()
};
let plain_b = Finding {
path: "/admin".to_owned(),
..smuggled.clone()
};
assert_ne!(
smuggled.canonical(),
format!("{}\n{}", plain_a.canonical(), plain_b.canonical()),
"a tab or newline inside a field must not read as a field separator"
);
assert!(
!smuggled.canonical().contains('\n'),
"the canonical form of one finding is one line"
);
}
fn built(routes: &str, build: &str) -> PostureManifest {
let json = format!(
r#"{{"schema_version":5,"build":{build},"dimensions":{{
"routes":{{"provenance":"provable","source":"m","entries":[{routes}]}}
}},"excluded":[]}}"#
);
PostureManifest::parse(&json, "test.json").expect("fixture parses")
}
#[test]
fn a_changed_build_is_a_widening() {
let r = route("/a", "GET", "gated", &["admin"], &[], false);
let base = routes_only(&r);
let head = built(&r, r#"{"profile":"release","features":["embed-assets"]}"#);
let f = only(diff(&base, &head));
assert_eq!(f.kind, "build_changed");
assert_eq!(f.severity, Severity::Widening);
assert_eq!((f.method.as_str(), f.path.as_str()), ("*", "*"));
assert!(f.before.contains("dev"), "{f:?}");
assert!(
f.after.contains("release") && f.after.contains("embed-assets"),
"{f:?}"
);
}
#[test]
fn the_same_build_is_not_a_finding() {
let r = route("/a", "GET", "gated", &["admin"], &[], false);
let base = built(&r, r#"{"profile":"release","features":["b","a"]}"#);
let head = built(&r, r#"{"profile":"release","features":["a","b","a"]}"#);
assert!(diff(&base, &head).is_empty());
assert!(diff(&routes_only(&r), &built(&r, r#"{"profile":"dev"}"#)).is_empty());
}
#[test]
fn two_build_changes_do_not_share_a_fingerprint() {
let r = route("/a", "GET", "gated", &["admin"], &[], false);
let base = routes_only(&r);
let release = only(diff(&base, &built(&r, r#"{"profile":"release"}"#)));
let dist = only(diff(&base, &built(&r, r#"{"profile":"dist"}"#)));
assert_ne!(release.canonical(), dist.canonical());
}
#[test]
fn a_build_change_fingerprint_is_directed_and_stable() {
let r = route("/a", "GET", "gated", &["admin"], &[], false);
let dev = routes_only(&r);
let release = built(&r, r#"{"profile":"release","features":["a","b"]}"#);
let reordered = built(&r, r#"{"profile":"release","features":["b","a"]}"#);
let up = only(diff(&dev, &release));
let down = only(diff(&release, &dev));
assert_ne!(up.canonical(), down.canonical());
assert_eq!(up.canonical(), only(diff(&dev, &reordered)).canonical());
}
#[test]
fn renaming_a_capture_does_not_hide_a_loosened_guard() {
let base = routes_only(&route(
"/users/{id}",
"GET",
"gated",
&["admin"],
&[],
false,
));
let head = routes_only(&route("/users/{user_id}", "GET", "public", &[], &[], false));
let f = only(diff(&base, &head));
assert_eq!(f.kind, "classification_downgraded");
assert_eq!(f.severity, Severity::Widening);
}
#[test]
fn renaming_a_capture_alone_produces_no_finding() {
let base = routes_only(&route("/a/{id}", "GET", "gated", &["admin"], &[], false));
let head = routes_only(&route("/a/{ident}", "GET", "gated", &["admin"], &[], false));
assert!(diff(&base, &head).is_empty());
}
#[test]
fn a_segment_capture_and_a_wildcard_are_not_the_same_route() {
let base = routes_only(&route("/f/{name}", "GET", "gated", &["admin"], &[], false));
let head = routes_only(&route("/f/{*rest}", "GET", "public", &[], &[], false));
let findings = diff(&base, &head);
assert!(
findings.iter().any(|f| f.kind == "route_added_open"),
"the wildcard is a new, wider route, not an edit of the old one: {findings:?}"
);
}
#[test]
fn removing_a_route_that_a_public_one_still_covers_is_a_widening() {
let public_by_id = route("/users/{id}", "GET", "public", &[], &[], false);
let gated_me = route("/users/me", "GET", "gated", &["user"], &[], false);
let base = routes_only(&format!("{public_by_id},{gated_me}"));
let head = routes_only(&public_by_id);
let findings = diff(&base, &head);
let widening = widening(&findings);
assert_eq!(widening.len(), 1, "{findings:#?}");
assert_eq!(widening[0].kind, "route_shadow_exposed");
assert_eq!(widening[0].path, "/users/me");
assert!(
widening[0].detail.contains("/users/{id}"),
"the finding must name the route that now serves the URL: {:?}",
widening[0]
);
}
#[test]
fn removing_the_last_route_at_a_path_exposes_its_other_methods() {
let removed = route("/users/me", "GET", "gated", &["user"], &[], false);
let survivor = route("/users/{id}", "POST", "public", &[], &[], false);
let base = routes_only(&format!("{removed},{survivor}"));
let head = routes_only(&survivor);
let findings = diff(&base, &head);
let widening = widening(&findings);
assert_eq!(widening.len(), 1, "{findings:#?}");
assert_eq!(widening[0].kind, "route_path_exposed");
assert!(widening[0].method.contains("POST"), "{:?}", widening[0]);
}
#[test]
fn a_guarded_survivor_taking_a_vanished_path_is_not_a_widening() {
let removed = route("/users/me", "GET", "gated", &["user"], &[], false);
let survivor = route("/users/{id}", "POST", "gated", &["admin"], &[], false);
let base = routes_only(&format!("{removed},{survivor}"));
let head = routes_only(&survivor);
assert!(
widening(&diff(&base, &head)).is_empty(),
"{:#?}",
diff(&base, &head)
);
}
#[test]
fn a_taker_that_keeps_part_of_the_urls_is_not_dominated() {
let removed = route("/records/me/{id}", "GET", "gated", &["user"], &[], false);
let narrow = route(
"/records/{user}/private",
"GET",
"gated",
&["user"],
&[],
false,
);
let public_rest = route("/records/{user}/{id}", "GET", "public", &[], &[], false);
let base = routes_only(&format!("{removed},{narrow},{public_rest}"));
let head = routes_only(&format!("{narrow},{public_rest}"));
let findings = diff(&base, &head);
let widening = widening(&findings);
assert_eq!(widening.len(), 1, "{findings:#?}");
assert_eq!(widening[0].kind, "route_shadow_exposed");
assert!(
widening[0].detail.contains("/records/{user}/{id}"),
"the public route takes every URL the narrow one does not: {:?}",
widening[0]
);
}
#[test]
fn a_surviving_method_at_the_same_path_blocks_the_fallthrough() {
let removed = route("/users/me", "GET", "gated", &["user"], &[], false);
let same_path = route("/users/me", "POST", "gated", &["user"], &[], false);
let dynamic = route("/users/{id}", "GET", "public", &[], &[], false);
let base = routes_only(&format!("{removed},{same_path},{dynamic}"));
let head = routes_only(&format!("{same_path},{dynamic}"));
let findings = diff(&base, &head);
assert!(
widening(&findings).is_empty(),
"the path node still answers, with a 405: {findings:#?}"
);
}
#[test]
fn a_guarded_route_inheriting_a_vanished_path_is_annotated() {
let removed = route("/users/me", "GET", "gated", &["user"], &[], false);
let survivor = route("/users/{id}", "POST", "gated", &["admin"], &[], false);
let base = routes_only(&format!("{removed},{survivor}"));
let head = routes_only(&survivor);
let findings = diff(&base, &head);
assert!(widening(&findings).is_empty(), "{findings:#?}");
let exposed = findings
.iter()
.find(|f| f.kind == "route_path_exposed")
.expect("newly reachable, so it is reported");
assert_eq!(exposed.severity, Severity::Neutral);
}
#[test]
fn a_survivor_outranked_by_another_taker_is_not_the_fallthrough() {
let gated = |path: &str| route(path, "GET", "gated", &["user"], &[], false);
let removed = gated("/records/me/private");
let wins = gated("/records/me/{id}");
let public_loser = route("/records/{user}/private", "GET", "public", &[], &[], false);
let base = routes_only(&format!("{removed},{wins},{public_loser}"));
let head = routes_only(&format!("{wins},{public_loser}"));
assert!(
widening(&diff(&base, &head)).is_empty(),
"the public route never sees that URL: {:#?}",
diff(&base, &head)
);
}
#[test]
fn a_binding_only_some_takers_perform_is_still_a_widening() {
let gated = |path: &str| route(path, "GET", "gated", &["user"], &[], true);
let binding = |path: &str| {
format!(
r#"{{"path":"{path}","method":"GET","action":"read_self","resource":"Record"}}"#
)
};
let removed = gated("/records/me/private");
let wins = gated("/records/me/{id}");
let also_takes = gated("/records/{user}/private");
let base = manifest(
&format!("{removed},{wins},{also_takes}"),
"",
"",
&format!(
"{},{}",
binding("/records/me/private"),
binding("/records/{user}/private")
),
);
let head = manifest(
&format!("{wins},{also_takes}"),
"",
"",
&binding("/records/{user}/private"),
);
let findings = diff(&base, &head);
let widening = widening(&findings);
assert_eq!(widening.len(), 1, "{findings:#?}");
assert_eq!(widening[0].kind, "authorization_binding_removed");
}
#[test]
fn a_widening_acknowledgment_binds_to_the_whole_posture_at_head() {
let admin_read = route("/a", "POST", "gated", &["admin"], &["read"], false);
let roles_then = |scopes: &[&str]| {
let head = route("/a", "POST", "gated", &["admin", "editor"], scopes, false);
diff(&routes_only(&admin_read), &routes_only(&head))
.into_iter()
.find(|f| f.kind == "roles_widened")
.expect("admitting editor is a widening")
.canonical()
};
assert_ne!(
roles_then(&["read", "mfa"]),
roles_then(&["read"]),
"editor behind `mfa` is not editor without it"
);
let scopes_then = |roles: &[&str]| {
let head = route("/a", "POST", "gated", roles, &[], false);
diff(&routes_only(&admin_read), &routes_only(&head))
.into_iter()
.find(|f| f.kind == "scopes_widened")
.expect("losing `read` is a widening")
.canonical()
};
assert_ne!(
scopes_then(&["admin"]),
scopes_then(&["admin", "editor"]),
"dropping the scope for admins is not dropping it for editors too"
);
let added_with_csrf = |enforced: bool| {
let head = route("/new", "POST", "public", &[], &[], false);
let entry = format!(
r#"{{"path":"/new","method":"POST","csrf_enforced":{enforced},"exempt":false}}"#
);
diff(&routes_only(""), &manifest(&head, &entry, "", ""))
.into_iter()
.find(|f| f.kind == "route_added_open")
.expect("a new public route is a widening")
.canonical()
};
assert_ne!(
added_with_csrf(true),
added_with_csrf(false),
"a new public POST with CSRF is not the same endpoint without it"
);
}
#[test]
fn a_more_specific_node_returns_405_rather_than_yielding_to_a_catch_all() {
let removed = route("/users/me/42", "POST", "gated", &["user"], &[], false);
let specific = route("/users/me/{id}", "HEAD", "gated", &["user"], &[], false);
let catch_all = route("/users/{*rest}", "POST", "public", &[], &[], false);
let base = routes_only(&format!("{removed},{specific},{catch_all}"));
let head = routes_only(&format!("{specific},{catch_all}"));
let findings = diff(&base, &head);
assert!(
widening(&findings).is_empty(),
"the specific node answers that URL, with a 405: {findings:#?}"
);
}
#[test]
fn a_transferred_csrf_loss_fingerprints_the_route_that_inherits_it() {
let base = manifest(
&route("/x", "WS", "gated", &["admin"], &[], false),
r#"{"path":"/x","method":"WS","csrf_enforced":true,"exempt":false}"#,
"",
"",
);
let replaced_by = |scopes: &[&str]| {
diff(
&base,
&manifest(
&route("/x", "GET", "gated", &["admin"], scopes, false),
"",
"",
"",
),
)
.into_iter()
.find(|f| f.kind == "csrf_enforcement_removed")
.expect("the lost CSRF is a widening")
.canonical()
};
assert_ne!(replaced_by(&["mfa"]), replaced_by(&[]));
}
#[test]
fn csrf_lost_when_a_ws_route_becomes_a_get_is_still_a_widening() {
let base = manifest(
&route("/x", "WS", "gated", &["user"], &[], false),
r#"{"path":"/x","method":"WS","csrf_enforced":true,"exempt":false}"#,
"",
"",
);
let head = manifest(
&route("/x", "GET", "gated", &["user"], &[], false),
"",
"",
"",
);
let findings = diff(&base, &head);
let widening = widening(&findings);
assert_eq!(widening.len(), 1, "{findings:#?}");
assert_eq!(widening[0].kind, "csrf_enforcement_removed");
}
#[test]
fn a_csrf_acknowledgment_binds_to_what_still_guards_the_route() {
let entry = |enforced: bool| {
format!(
r#"{{"path":"/pay","method":"POST","csrf_enforced":{enforced},"exempt":false}}"#
)
};
let base = manifest(
&route("/pay", "POST", "gated", &["admin"], &[], false),
&entry(true),
"",
"",
);
let guarded_by = |scopes: &[&str]| {
diff(
&base,
&manifest(
&route("/pay", "POST", "gated", &["admin"], scopes, false),
&entry(false),
"",
"",
),
)
.into_iter()
.find(|f| f.kind == "csrf_enforcement_removed")
.expect("losing CSRF is a widening")
.canonical()
};
assert_ne!(guarded_by(&["mfa"]), guarded_by(&[]));
}
#[test]
fn a_policy_acknowledgment_binds_to_what_still_guards_the_route() {
let base = routes_only(&route("/a", "PUT", "gated", &["user"], &[], true));
let guarded_by = |scopes: &[&str]| {
diff(
&base,
&routes_only(&route("/a", "PUT", "gated", &["user"], scopes, false)),
)
.into_iter()
.find(|f| f.kind == "policy_removed")
.expect("losing the record check is a widening")
.canonical()
};
assert_ne!(guarded_by(&["mfa"]), guarded_by(&[]));
}
#[test]
fn renaming_a_capture_does_not_invalidate_an_acknowledgment() {
let widened = |path: &str| {
let base = routes_only(&route(path, "GET", "gated", &["admin"], &[], false));
let head = routes_only(&route(
path,
"GET",
"gated",
&["admin", "editor"],
&[],
false,
));
only(diff(&base, &head)).canonical()
};
assert_eq!(widened("/users/{id}"), widened("/users/{user_id}"));
}
#[test]
fn a_scope_acknowledgment_binds_to_the_scope_that_replaced_it() {
let base = routes_only(&route("/a", "GET", "gated", &["user"], &["read"], false));
let now_requiring = |scopes: &[&str]| {
diff(
&base,
&routes_only(&route("/a", "GET", "gated", &["user"], scopes, false)),
)
.into_iter()
.find(|f| f.kind == "scopes_widened")
.expect("losing `read` is a widening")
.canonical()
};
assert_ne!(
now_requiring(&["mfa"]),
now_requiring(&[]),
"acknowledging a swap to `mfa` must not authorize dropping it too"
);
}
#[test]
fn replacement_checks_come_only_from_the_route_that_serves_the_url() {
let me = route("/records/me", "GET", "gated", &["user"], &[], true);
let by_id = route("/records/{id}", "GET", "gated", &["user"], &[], true);
let binding = |path: &str, action: &str| {
format!(r#"{{"path":"{path}","method":"GET","action":"{action}","resource":"Record"}}"#)
};
let losing = format!(
"{},{}",
binding("/records/{id}", "read_any"),
binding("/records/{id}", "read_all")
);
let base = manifest(
&format!("{me},{by_id}"),
"",
"",
&format!("{},{losing}", binding("/records/me", "read_self")),
);
let swapped_to = |action: &str| {
diff(
&base,
&manifest(
&format!("{me},{by_id}"),
"",
"",
&format!("{},{losing}", binding("/records/me", action)),
),
)
.into_iter()
.find(|f| f.kind == "authorization_binding_removed" && f.path == "/records/me")
.expect("losing `read_self` is a widening")
.canonical()
};
assert_ne!(swapped_to("read_any"), swapped_to("read_all"));
}
#[test]
fn an_explicit_head_takes_the_fallback_from_a_get_at_the_same_path() {
let get = route("/x", "GET", "gated", &["admin"], &[], false);
let head_route = route("/x", "HEAD", "gated", &["editor"], &[], false);
let base = routes_only(&get);
let head = routes_only(&format!("{get},{head_route}"));
let findings = diff(&base, &head);
let widening = widening(&findings);
assert_eq!(widening.len(), 1, "{findings:#?}");
assert_eq!(widening[0].kind, "route_added_shadowing");
}
#[test]
fn removing_an_explicit_head_hands_it_to_the_get_at_the_same_path() {
let get = route("/x", "GET", "gated", &["editor"], &[], false);
let head_route = route("/x", "HEAD", "gated", &["admin"], &[], false);
let base = routes_only(&format!("{get},{head_route}"));
let head = routes_only(&get);
let findings = diff(&base, &head);
let widening = widening(&findings);
assert_eq!(widening.len(), 1, "{findings:#?}");
assert_eq!(widening[0].kind, "route_shadow_exposed");
}
#[test]
fn adding_a_method_to_an_existing_path_node_displaces_nothing() {
let existing = route("/users/me", "POST", "gated", &["user"], &[], false);
let dynamic = route("/users/{id}", "GET", "gated", &["admin"], &[], false);
let added = route("/users/me", "GET", "gated", &["editor"], &[], false);
let base = routes_only(&format!("{existing},{dynamic}"));
let head = routes_only(&format!("{existing},{dynamic},{added}"));
let findings = diff(&base, &head);
assert!(
widening(&findings).is_empty(),
"the node already owned that URL: {findings:#?}"
);
}
#[test]
fn a_binding_does_not_fall_through_a_surviving_path_node() {
let bound = route("/records/me", "GET", "gated", &["user"], &[], true);
let same_path = route("/records/me", "POST", "gated", &["user"], &[], true);
let dynamic = route("/records/{id}", "GET", "gated", &["user"], &[], true);
let binding =
r#"{"path":"/records/me","method":"GET","action":"read","resource":"Record"}"#;
let base = manifest(&format!("{bound},{same_path},{dynamic}"), "", "", binding);
let head = manifest(&format!("{same_path},{dynamic}"), "", "", "");
let findings = diff(&base, &head);
assert!(
widening(&findings).is_empty(),
"`GET /records/me` is not reachable at all now: {findings:#?}"
);
}
#[test]
fn an_exact_route_acknowledgment_binds_to_its_replacement_check() {
let by_id = route("/records/{id}", "GET", "gated", &["user"], &[], true);
let binding = |action: &str| {
format!(
r#"{{"path":"/records/{{id}}","method":"GET","action":"{action}","resource":"Record"}}"#
)
};
let base = manifest(&by_id, "", "", &binding("read_self"));
let replaced_by = |action: &str| {
diff(&base, &manifest(&by_id, "", "", &binding(action)))
.into_iter()
.find(|f| f.kind == "authorization_binding_removed")
.expect("losing `read_self` is a widening")
.canonical()
};
assert_ne!(replaced_by("read_any"), replaced_by("read_all"));
}
#[test]
fn a_displaced_binding_is_compared_against_the_prior_winner() {
let gated = |path: &str| route(path, "GET", "gated", &["user"], &[], true);
let binding = |path: &str, action: &str| {
format!(r#"{{"path":"{path}","method":"GET","action":"{action}","resource":"Record"}}"#)
};
let prior_owner = gated("/records/me/{id}");
let never_served = gated("/records/{user}/private");
let added = gated("/records/me/private");
let base = manifest(
&format!("{prior_owner},{never_served}"),
"",
"",
&format!(
"{},{}",
binding("/records/me/{id}", "read_self"),
binding("/records/{user}/private", "read_other")
),
);
let head = manifest(
&format!("{prior_owner},{never_served},{added}"),
"",
"",
&format!(
"{},{},{}",
binding("/records/me/{id}", "read_self"),
binding("/records/{user}/private", "read_other"),
binding("/records/me/private", "read_self")
),
);
let findings = diff(&base, &head);
assert!(
widening(&findings).is_empty(),
"`read_other` never applied to that URL: {findings:#?}"
);
}
#[test]
fn a_fallthrough_acknowledgment_binds_to_the_checks_that_replace_it() {
let me = route("/records/me", "GET", "gated", &["user"], &[], true);
let by_id = route("/records/{id}", "GET", "gated", &["user"], &[], true);
let binding = |path: &str, action: &str| {
format!(r#"{{"path":"{path}","method":"GET","action":"{action}","resource":"Record"}}"#)
};
let base = manifest(
&format!("{me},{by_id}"),
"",
"",
&format!(
"{},{}",
binding("/records/me", "read_self"),
binding("/records/{id}", "read_any")
),
);
let lost_to = |action: &str| {
diff(
&base,
&manifest(&by_id, "", "", &binding("/records/{id}", action)),
)
.into_iter()
.find(|f| f.kind == "authorization_binding_removed" && f.path == "/records/me")
.expect("the fall-through is a widening")
.canonical()
};
assert_ne!(
lost_to("read_any"),
lost_to("read_weak"),
"acknowledging a fall-through to `read_any` must not authorize one to `read_weak`"
);
}
#[test]
fn a_displaced_binding_acknowledgment_binds_to_the_replacing_checks() {
let by_id = route("/records/{id}", "GET", "gated", &["user"], &[], true);
let me = route("/records/me", "GET", "gated", &["user"], &[], true);
let binding = |path: &str, action: &str| {
format!(r#"{{"path":"{path}","method":"GET","action":"{action}","resource":"Record"}}"#)
};
let base = manifest(&by_id, "", "", &binding("/records/{id}", "read_self"));
let displaced_by = |action: &str| {
diff(
&base,
&manifest(
&format!("{by_id},{me}"),
"",
"",
&format!(
"{},{}",
binding("/records/{id}", "read_self"),
binding("/records/me", action)
),
),
)
.into_iter()
.find(|f| f.kind == "authorization_binding_displaced")
.expect("the displacement is a widening")
.canonical()
};
assert_ne!(displaced_by("read_any"), displaced_by("read_weak"));
}
#[test]
fn a_binding_removed_beside_an_explicit_head_is_not_a_widening() {
let get = route("/x", "GET", "gated", &["user"], &[], true);
let head_route = route("/x", "HEAD", "gated", &["user"], &[], true);
let binding = r#"{"path":"/x","method":"GET","action":"read","resource":"Thing"}"#;
let base = manifest(&format!("{get},{head_route}"), "", "", binding);
let head = manifest(&head_route, "", "", "");
let findings = diff(&base, &head);
assert!(
widening(&findings).is_empty(),
"the GET URL left with the route: {findings:#?}"
);
}
#[test]
fn narrowing_a_csrf_exemption_prefix_is_not_a_widening() {
let route = route("/api/{rest}", "POST", "gated", &["user"], &[], false);
let entry = r#"{"path":"/api/{rest}","method":"POST","csrf_enforced":false,"exempt":true}"#;
let base = manifest_exempt(&route, entry, &["/api"]);
let head = manifest_exempt(&route, entry, &["/api/private"]);
let findings = diff(&base, &head);
assert!(
widening(&findings).is_empty(),
"every URL still exempt was already exempt: {findings:#?}"
);
assert!(
findings.iter().any(|f| f.kind == "csrf_exemption_removed"),
"and the narrowing is still reported: {findings:#?}"
);
}
#[test]
fn broadening_a_csrf_exemption_prefix_is_a_widening() {
let route = route("/api/{rest}", "POST", "gated", &["user"], &[], false);
let entry = r#"{"path":"/api/{rest}","method":"POST","csrf_enforced":false,"exempt":true}"#;
let base = manifest_exempt(&route, entry, &["/api/private"]);
let head = manifest_exempt(&route, entry, &["/api"]);
let findings = diff(&base, &head);
let widening = widening(&findings);
assert_eq!(widening.len(), 1, "{findings:#?}");
assert_eq!(widening[0].kind, "csrf_exemption_added");
}
#[test]
fn adding_a_csrf_exemption_prefix_is_a_widening() {
let route = route("/users/{id}", "POST", "gated", &["user"], &[], false);
let entry = r#"{"path":"/users/{id}","method":"POST","csrf_enforced":true,"exempt":false}"#;
let base = manifest_exempt(&route, entry, &[]);
let head = manifest_exempt(&route, entry, &["/users/me"]);
let findings = diff(&base, &head);
let widening = widening(&findings);
assert_eq!(widening.len(), 1, "{findings:#?}");
assert_eq!(widening[0].kind, "csrf_exemption_added");
assert!(
widening[0].detail.contains("/users/me"),
"{:?}",
widening[0]
);
}
#[test]
fn removing_a_csrf_exemption_prefix_is_a_narrowing() {
let route = route("/users/{id}", "POST", "gated", &["user"], &[], false);
let entry = r#"{"path":"/users/{id}","method":"POST","csrf_enforced":true,"exempt":false}"#;
let base = manifest_exempt(&route, entry, &["/users/me"]);
let head = manifest_exempt(&route, entry, &[]);
let findings = diff(&base, &head);
assert_eq!(
kinds(&findings),
vec!["csrf_exemption_removed"],
"{findings:#?}"
);
assert!(widening(&findings).is_empty());
}
#[test]
fn a_new_get_does_not_displace_an_explicit_head() {
let explicit_head = route("/x", "HEAD", "gated", &["admin"], &[], false);
let added_get = route("/x", "GET", "gated", &["editor"], &[], false);
let base = routes_only(&explicit_head);
let head = routes_only(&format!("{explicit_head},{added_get}"));
let findings = diff(&base, &head);
assert!(
widening(&findings).is_empty(),
"the explicit HEAD still answers HEAD: {findings:#?}"
);
}
#[test]
fn a_binding_displaced_by_a_more_specific_route_is_a_widening() {
let by_id = route("/records/{id}", "GET", "gated", &["user"], &[], true);
let me = route("/records/me", "GET", "gated", &["user"], &[], true);
let binding = |path: &str, action: &str| {
format!(r#"{{"path":"{path}","method":"GET","action":"{action}","resource":"Record"}}"#)
};
let base = manifest(&by_id, "", "", &binding("/records/{id}", "read_self"));
let head = manifest(
&format!("{by_id},{me}"),
"",
"",
&format!(
"{},{}",
binding("/records/{id}", "read_self"),
binding("/records/me", "read_any")
),
);
let findings = diff(&base, &head);
let widening = widening(&findings);
assert_eq!(widening.len(), 1, "{findings:#?}");
assert_eq!(widening[0].kind, "authorization_binding_displaced");
assert!(
widening[0].detail.contains("read_self"),
"{:?}",
widening[0]
);
}
#[test]
fn a_binding_carried_onto_the_displacing_route_is_not_a_widening() {
let by_id = route("/records/{id}", "GET", "gated", &["user"], &[], true);
let me = route("/records/me", "GET", "gated", &["user"], &[], true);
let binding = |path: &str| {
format!(
r#"{{"path":"{path}","method":"GET","action":"read_self","resource":"Record"}}"#
)
};
let base = manifest(&by_id, "", "", &binding("/records/{id}"));
let head = manifest(
&format!("{by_id},{me}"),
"",
"",
&format!("{},{}", binding("/records/{id}"), binding("/records/me")),
);
assert!(
widening(&diff(&base, &head)).is_empty(),
"{:#?}",
diff(&base, &head)
);
}
#[test]
fn adding_a_more_specific_route_that_admits_more_is_a_widening() {
let by_id = route("/users/{id}", "GET", "gated", &["admin"], &[], false);
let me = route("/users/me", "GET", "gated", &["editor"], &[], false);
let base = routes_only(&by_id);
let head = routes_only(&format!("{by_id},{me}"));
let findings = diff(&base, &head);
let widening = widening(&findings);
assert_eq!(widening.len(), 1, "{findings:#?}");
assert_eq!(widening[0].kind, "route_added_shadowing");
assert_eq!(widening[0].path, "/users/me");
assert!(
widening[0].detail.contains("/users/{id}"),
"the finding must name the route whose requests it takes: {:?}",
widening[0]
);
}
#[test]
fn a_displacement_compares_against_the_route_that_was_serving_the_url() {
let prior_owner = route("/records/me/{id}", "GET", "gated", &["user"], &[], false);
let never_served = route(
"/records/{user}/private",
"GET",
"gated",
&["admin"],
&[],
false,
);
let added = route("/records/me/private", "GET", "gated", &["user"], &[], false);
let base = routes_only(&format!("{prior_owner},{never_served}"));
let head = routes_only(&format!("{prior_owner},{never_served},{added}"));
let findings = diff(&base, &head);
assert!(
widening(&findings).is_empty(),
"the route that was serving that URL has the same guard: {findings:#?}"
);
}
#[test]
fn adding_a_more_specific_route_with_the_same_guard_is_neutral() {
let by_id = route("/users/{id}", "GET", "gated", &["admin"], &[], false);
let me = route("/users/me", "GET", "gated", &["admin"], &[], false);
let base = routes_only(&by_id);
let head = routes_only(&format!("{by_id},{me}"));
let findings = diff(&base, &head);
assert_eq!(kinds(&findings), vec!["route_added_gated"], "{findings:#?}");
}
#[test]
fn adding_a_guard_over_part_of_a_public_route_is_not_a_widening() {
let by_id = route("/users/{id}", "GET", "public", &[], &[], false);
let me = route("/users/me", "GET", "gated", &["admin"], &[], false);
let base = routes_only(&by_id);
let head = routes_only(&format!("{by_id},{me}"));
assert!(
widening(&diff(&base, &head)).is_empty(),
"{:#?}",
diff(&base, &head)
);
}
#[test]
fn a_displacement_acknowledgment_does_not_survive_a_looser_route() {
let by_id = route("/users/{id}", "GET", "gated", &["admin"], &[], false);
let base = routes_only(&by_id);
let added_with = |roles: &[&str]| {
let me = route("/users/me", "GET", "gated", roles, &[], false);
diff(&base, &routes_only(&format!("{by_id},{me}")))
.into_iter()
.find(|f| f.kind == "route_added_shadowing")
.expect("the displacement is a widening")
.canonical()
};
assert_ne!(added_with(&["editor"]), added_with(&["guest"]));
}
#[test]
fn a_more_specific_survivor_gains_nothing_and_is_not_exposure() {
let survivor = route("/users/me", "GET", "public", &[], &[], false);
let removed = route("/users/{id}", "GET", "gated", &["user"], &[], false);
let base = routes_only(&format!("{survivor},{removed}"));
let head = routes_only(&survivor);
assert!(
widening(&diff(&base, &head)).is_empty(),
"{:#?}",
diff(&base, &head)
);
}
#[test]
fn a_survivor_that_wins_the_overlap_only_after_the_deletion_is_exposure() {
let survivor = route("/a/{key}/b", "GET", "public", &[], &[], false);
let removed = route("/a/x/{id}", "GET", "gated", &["user"], &[], false);
let base = routes_only(&format!("{survivor},{removed}"));
let head = routes_only(&survivor);
let findings = diff(&base, &head);
let widening = widening(&findings);
assert_eq!(widening.len(), 1, "{findings:#?}");
assert_eq!(widening[0].kind, "route_shadow_exposed");
}
#[test]
fn a_binding_whose_urls_left_with_it_is_not_a_widening() {
let me = route("/records/me", "GET", "gated", &["user"], &[], true);
let by_id = route("/records/{id}", "GET", "gated", &["user"], &[], true);
let binding = |path: &str, action: &str| {
format!(r#"{{"path":"{path}","method":"GET","action":"{action}","resource":"Record"}}"#)
};
let base = manifest(
&format!("{me},{by_id}"),
"",
"",
&format!(
"{},{}",
binding("/records/me", "read_self"),
binding("/records/{id}", "read_any")
),
);
let head = manifest(&me, "", "", &binding("/records/me", "read_self"));
assert!(
widening(&diff(&base, &head)).is_empty(),
"{:#?}",
diff(&base, &head)
);
}
#[test]
fn removing_a_route_a_gated_one_covers_is_not_a_widening() {
let by_id = route("/users/{id}", "GET", "gated", &["user"], &[], false);
let me = route("/users/me", "GET", "gated", &["user"], &[], false);
let base = routes_only(&format!("{by_id},{me}"));
let head = routes_only(&by_id);
let findings = diff(&base, &head);
assert!(widening(&findings).is_empty(), "{findings:#?}");
}
#[test]
fn removing_an_uncovered_route_stays_a_narrowing() {
let base = routes_only(&format!(
"{},{}",
route("/health", "GET", "public", &[], &[], false),
route("/admin/me", "GET", "gated", &["admin"], &[], false)
));
let head = routes_only(&route("/health", "GET", "public", &[], &[], false));
let findings = diff(&base, &head);
assert_eq!(kinds(&findings), vec!["route_removed"], "{findings:#?}");
}
#[test]
fn a_catch_all_that_swallows_a_removed_route_is_a_widening() {
let catch_all = route("/files/{*path}", "GET", "public", &[], &[], false);
let secret = route("/files/secret", "GET", "gated", &["admin"], &[], false);
let base = routes_only(&format!("{catch_all},{secret}"));
let head = routes_only(&catch_all);
let findings = diff(&base, &head);
let widening = widening(&findings);
assert_eq!(widening.len(), 1, "{findings:#?}");
assert_eq!(widening[0].kind, "route_shadow_exposed");
}
#[test]
#[allow(clippy::literal_string_with_formatting_args)]
fn a_capture_must_consume_a_character_so_a_bare_prefix_does_not_overlap() {
let ext = normalize_captures("file.{ext}");
assert!(!segments_overlap(&ext, "file."));
assert!(!segments_overlap("file.", &ext));
assert!(segments_overlap(&ext, "file.json"));
assert!(segments_overlap("file.json", &ext));
}
#[test]
#[allow(clippy::literal_string_with_formatting_args)]
fn the_capture_minimum_length_reaches_intersect_and_covers() {
let ext = normalize_captures("/file.{ext}");
assert_eq!(intersect(&ext, "/file."), None);
assert_eq!(intersect("/file.", &ext), None);
assert!(intersect(&ext, "/file.json").is_some());
assert!(!covers(&ext, "/file."));
assert!(covers(&ext, "/file.json"));
}
#[test]
fn deleting_a_guarded_bare_prefix_beside_a_public_capture_is_not_a_shadow() {
let survivor = route("/file.{ext}", "GET", "public", &[], &[], false);
let removed = route("/file.", "GET", "gated", &["user"], &[], false);
let base = routes_only(&format!("{survivor},{removed}"));
let head = routes_only(&survivor);
let findings = diff(&base, &head);
assert!(
!findings.iter().any(|f| f.kind == "route_shadow_exposed"),
"a capture cannot serve the bare prefix: {findings:#?}"
);
}
#[test]
fn a_survivor_shadows_the_methods_it_actually_answers() {
for removed_method in ["HEAD", "WS", "GET"] {
let survivor = route("/users/{id}", "GET", "public", &[], &[], false);
let guarded = route("/users/me", removed_method, "gated", &["user"], &[], false);
let base = routes_only(&format!("{survivor},{guarded}"));
let head = routes_only(&survivor);
let findings = diff(&base, &head);
assert!(
findings.iter().any(|f| f.kind == "route_shadow_exposed"),
"a GET survivor answers {removed_method} at that URL: {findings:#?}"
);
}
}
#[test]
fn a_websocket_survivor_does_not_answer_head() {
let survivor = route("/live/{id}", "WS", "public", &[], &[], false);
let guarded = route("/live/me", "HEAD", "gated", &["user"], &[], false);
let base = routes_only(&format!("{survivor},{guarded}"));
let head = routes_only(&survivor);
let findings = diff(&base, &head);
assert!(
!findings.iter().any(|f| f.kind == "route_shadow_exposed"),
"a websocket upgrade is not served for HEAD: {findings:#?}"
);
}
#[test]
fn a_shadow_acknowledgment_does_not_survive_a_looser_survivor() {
let base = routes_only(&route("/users/me", "GET", "gated", &["admin"], &[], false));
let shadowed_by = |roles: &[&str]| {
let findings = diff(
&base,
&routes_only(&route("/users/{id}", "GET", "gated", roles, &[], false)),
);
let shadow = findings
.into_iter()
.find(|f| f.kind == "route_shadow_exposed")
.expect("the fall-through is a widening");
shadow.canonical()
};
assert_ne!(
shadowed_by(&["editor"]),
shadowed_by(&["guest"]),
"acknowledging a fall-through to `editor` must not authorize one to `guest`"
);
}
#[test]
fn a_shadow_fingerprint_survives_an_unrelated_push() {
let base = routes_only(&route("/users/me", "GET", "gated", &["admin"], &[], false));
let survivor = route("/users/{id}", "GET", "gated", &["editor"], &[], false);
let elsewhere = route("/reports", "GET", "gated", &["admin"], &[], false);
let shadow = |routes: &str| {
diff(&base, &routes_only(routes))
.into_iter()
.find(|f| f.kind == "route_shadow_exposed")
.expect("the fall-through is a widening")
.canonical()
};
assert_eq!(
shadow(&survivor),
shadow(&format!("{survivor},{elsewhere}"))
);
}
#[test]
fn a_survivor_does_not_shadow_a_method_it_never_answers() {
let survivor = route("/users/{id}", "GET", "public", &[], &[], false);
let guarded = route("/users/me", "POST", "gated", &["user"], &[], false);
let base = routes_only(&format!("{survivor},{guarded}"));
let head = routes_only(&survivor);
let findings = diff(&base, &head);
assert!(
!findings.iter().any(|f| f.kind == "route_shadow_exposed"),
"{findings:#?}"
);
}
#[test]
fn a_binding_lost_to_fallthrough_is_still_a_widening() {
let routes = |extra: &str| {
let by_id = route("/records/{id}", "GET", "gated", &["user"], &[], true);
if extra.is_empty() {
by_id
} else {
format!("{by_id},{extra}")
}
};
let binding = |path: &str, action: &str| {
format!(r#"{{"path":"{path}","method":"GET","action":"{action}","resource":"Record"}}"#)
};
let base = manifest(
&routes(&route("/records/me", "GET", "gated", &["user"], &[], true)),
"",
"",
&format!(
"{},{}",
binding("/records/{id}", "read_any"),
binding("/records/me", "read_self")
),
);
let head = manifest(&routes(""), "", "", &binding("/records/{id}", "read_any"));
let findings = diff(&base, &head);
let widening = widening(&findings);
assert_eq!(widening.len(), 1, "{findings:#?}");
assert_eq!(widening[0].kind, "authorization_binding_removed");
assert!(
widening[0].detail.contains("read_self"),
"{:?}",
widening[0]
);
}
#[test]
fn a_binding_the_surviving_route_still_performs_is_not_a_widening() {
let by_id = route("/records/{id}", "GET", "gated", &["user"], &[], true);
let me = route("/records/me", "GET", "gated", &["user"], &[], true);
let binding = |path: &str| {
format!(r#"{{"path":"{path}","method":"GET","action":"read","resource":"Record"}}"#)
};
let base = manifest(
&format!("{by_id},{me}"),
"",
"",
&format!("{},{}", binding("/records/{id}"), binding("/records/me")),
);
let head = manifest(&by_id, "", "", &binding("/records/{id}"));
assert!(
widening(&diff(&base, &head)).is_empty(),
"{:#?}",
diff(&base, &head)
);
}
#[test]
fn escaped_literal_braces_are_not_captures() {
let foo = route("/{{foo}}", "GET", "public", &[], &[], false);
let bar = route("/{{bar}}", "GET", "public", &[], &[], false);
let base = routes_only(&foo);
let head = routes_only(&format!("{foo},{bar}"));
let findings = diff(&base, &head);
assert_eq!(kinds(&findings), vec!["route_added_open"], "{findings:#?}");
assert_eq!(findings[0].path, "/{{bar}}");
}
#[test]
fn renaming_a_capture_does_not_hide_a_csrf_loss() {
let base = manifest(
&route("/pay/{id}", "POST", "gated", &["user"], &[], false),
r#"{"path":"/pay/{id}","method":"POST","csrf_enforced":true,"exempt":false}"#,
"",
"",
);
let head = manifest(
&route("/pay/{payment_id}", "POST", "gated", &["user"], &[], false),
r#"{"path":"/pay/{payment_id}","method":"POST","csrf_enforced":false,"exempt":true}"#,
"",
"",
);
let findings = diff(&base, &head);
let f = only(findings.clone());
assert_eq!(f.kind, "csrf_enforcement_removed");
assert_eq!(f.severity, Severity::Widening);
assert_eq!(widening(&findings).len(), 1);
}
#[test]
fn renaming_a_capture_alone_is_not_a_csrf_change() {
let entry = |path: &str| {
format!(r#"{{"path":"{path}","method":"POST","csrf_enforced":true,"exempt":false}}"#)
};
let base = manifest(
&route("/pay/{id}", "POST", "gated", &["user"], &[], false),
&entry("/pay/{id}"),
"",
"",
);
let head = manifest(
&route("/pay/{payment_id}", "POST", "gated", &["user"], &[], false),
&entry("/pay/{payment_id}"),
"",
"",
);
assert!(diff(&base, &head).is_empty(), "{:#?}", diff(&base, &head));
}
#[test]
fn renaming_a_capture_alone_is_not_an_authorization_change() {
let binding = |path: &str| {
format!(r#"{{"path":"{path}","method":"GET","action":"read","resource":"Note"}}"#)
};
let base = manifest(
&route("/notes/{id}", "GET", "gated", &["user"], &[], true),
"",
"",
&binding("/notes/{id}"),
);
let head = manifest(
&route("/notes/{note_id}", "GET", "gated", &["user"], &[], true),
"",
"",
&binding("/notes/{note_id}"),
);
assert!(diff(&base, &head).is_empty(), "{:#?}", diff(&base, &head));
}
#[test]
fn findings_do_not_depend_on_manifest_entry_order() {
let base = routes_only(&format!(
"{},{}",
route("/a", "GET", "gated", &["admin"], &[], false),
route("/b", "GET", "gated", &["admin"], &[], false)
));
let forward = routes_only(&format!(
"{},{}",
route("/a", "GET", "public", &[], &[], false),
route("/b", "GET", "public", &[], &[], false)
));
let reversed = routes_only(&format!(
"{},{}",
route("/b", "GET", "public", &[], &[], false),
route("/a", "GET", "public", &[], &[], false)
));
assert_eq!(diff(&base, &forward), diff(&base, &reversed));
}
#[test]
fn the_collapsed_csrf_finding_still_identifies_its_routes() {
let entries = |paths: &[&str], enforced: bool| {
paths
.iter()
.map(|p| {
format!(
r#"{{"path":"{p}","method":"POST","csrf_enforced":{enforced},"exempt":false}}"#
)
})
.collect::<Vec<_>>()
.join(",")
};
let first = diff(
&manifest("", &entries(&["/r0", "/r1", "/r2"], true), "", ""),
&manifest("", &entries(&["/r0", "/r1", "/r2"], false), "", ""),
);
let second = diff(
&manifest("", &entries(&["/x0", "/x1", "/x2"], true), "", ""),
&manifest("", &entries(&["/x0", "/x1", "/x2"], false), "", ""),
);
assert_eq!(only(first.clone()).kind, "csrf_disabled");
assert_eq!(only(second.clone()).kind, "csrf_disabled");
assert_ne!(
only(first).canonical(),
only(second).canonical(),
"different routes, different acknowledgment"
);
}
#[test]
fn a_websocket_route_is_compared_like_any_other() {
let base = routes_only(&route("/live", "WS", "gated", &["member"], &[], false));
let head = routes_only(&route("/live", "WS", "public", &[], &[], false));
let f = only(diff(&base, &head));
assert_eq!(f.method, "WS");
assert_eq!(f.severity, Severity::Widening);
}
#[test]
fn a_header_present_only_in_the_head_manifest_is_reported() {
let base = manifest("", "", "", "");
let head = manifest(
"",
"",
r#"{"header":"referrer_policy","value":"no-referrer","emitted":true}"#,
"",
);
let f = only(diff(&base, &head));
assert_eq!(f.kind, "security_header_added");
assert_eq!(f.severity, Severity::Narrowing);
}
#[test]
fn a_renamed_authorize_resource_blocks_but_explains_the_pairing() {
let r = route("/posts/{id}", "PUT", "gated", &["user"], &[], true);
let binding = |resource: &str| {
format!(
r#"{{"path":"/posts/{{id}}","method":"PUT","name":"h","action":"update","resource":"{resource}","provenance":"provable"}}"#
)
};
let findings = diff(
&manifest(&r, "", "", &binding("Post")),
&manifest(&r, "", "", &binding("Article")),
);
let removed = findings
.iter()
.find(|f| f.kind == "authorization_binding_removed")
.expect("still reported");
assert_eq!(removed.severity, Severity::Widening);
assert!(
removed.detail.contains("Article"),
"the paired addition is named so a rename can be acknowledged in one step: {removed:?}"
);
}
#[test]
fn any_later_posture_change_re_asks_for_the_acknowledgment() {
let base = routes_only(&route("/admin", "GET", "gated", &["admin"], &[], false));
let first = routes_only(&route("/admin", "GET", "public", &[], &[], false));
let second = routes_only(&route("/admin", "GET", "public", &[], &[], true));
let a = only(diff(&base, &first));
let b = diff(&base, &second)
.into_iter()
.find(|f| f.kind == "classification_downgraded")
.expect("still the same widening");
assert_ne!(a.canonical(), b.canonical());
}
#[test]
fn widening_findings_are_listed_first_and_ordering_is_deterministic() {
let base = routes_only(&format!(
"{},{}",
route("/keep", "GET", "gated", &["admin"], &[], false),
route("/gone", "GET", "public", &[], &[], false)
));
let head = routes_only(&format!(
"{},{}",
route("/keep", "GET", "public", &[], &[], false),
route("/new", "GET", "public", &[], &[], false)
));
let findings = diff(&base, &head);
assert_eq!(findings[0].severity, Severity::Widening);
assert_eq!(findings.last().unwrap().severity, Severity::Narrowing);
assert_eq!(findings, diff(&base, &head));
}
#[test]
fn a_transferred_finding_binds_to_the_route_that_inherits_the_urls() {
let base = manifest(
&format!(
"{},{}",
route("/records/me", "POST", "gated", &["admin"], &[], true),
route("/records/{id}", "POST", "gated", &["admin"], &[], true),
),
"",
"",
r#"{"path":"/records/me","method":"POST","name":"h","action":"read_self","resource":"Record","provenance":"provable"}"#,
);
let replacement_requiring = |scopes: &[&str]| {
diff(
&base,
&manifest(
&route("/records/{id}", "POST", "gated", &["admin"], scopes, true),
"",
"",
r#"{"path":"/records/{id}","method":"POST","name":"h","action":"read_any","resource":"Record","provenance":"provable"}"#,
),
)
.into_iter()
.filter(|f| f.severity == Severity::Widening)
.map(|f| f.canonical())
.collect::<Vec<_>>()
};
assert_ne!(
replacement_requiring(&["mfa"]),
replacement_requiring(&[]),
"dropping the replacement's scope has to re-ask"
);
}
#[test]
fn the_collapsed_csrf_fingerprint_carries_each_routes_bindings() {
let base = manifest(
&format!(
"{},{}",
route("/a", "POST", "gated", &["admin"], &[], true),
route("/b", "POST", "gated", &["admin"], &[], true),
),
r#"{"path":"/a","method":"POST","csrf_enforced":true,"exempt":false},
{"path":"/b","method":"POST","csrf_enforced":true,"exempt":false}"#,
"",
r#"{"path":"/a","method":"POST","name":"h","action":"read","resource":"Record","provenance":"provable"}"#,
);
let checked_by = |action: &str| {
diff(
&base,
&manifest(
&format!(
"{},{}",
route("/a", "POST", "gated", &["admin"], &[], true),
route("/b", "POST", "gated", &["admin"], &[], true),
),
r#"{"path":"/a","method":"POST","csrf_enforced":false,"exempt":false},
{"path":"/b","method":"POST","csrf_enforced":false,"exempt":false}"#,
"",
&format!(
r#"{{"path":"/a","method":"POST","name":"h","action":"{action}","resource":"Record","provenance":"provable"}}"#
),
),
)
.into_iter()
.find(|f| f.kind == "csrf_disabled")
.expect("csrf off everywhere collapses")
.canonical()
};
assert_ne!(checked_by("read"), checked_by("read_any"));
}
#[test]
fn an_exemption_acknowledgment_binds_to_the_routes_it_exempts() {
let base = manifest_exempt(
&route("/api/{id}", "POST", "gated", &["admin"], &["mfa"], false),
r#"{"path":"/api/{id}","method":"POST","csrf_enforced":true,"exempt":false}"#,
&[],
);
let exempting_with = |scopes: &[&str]| {
diff(
&base,
&manifest_exempt(
&route("/api/{id}", "POST", "gated", &["admin"], scopes, false),
r#"{"path":"/api/{id}","method":"POST","csrf_enforced":true,"exempt":false}"#,
&["/api/me"],
),
)
.into_iter()
.find(|f| f.kind == "csrf_exemption_added")
.expect("a new exemption is a widening")
.canonical()
};
assert_ne!(exempting_with(&["mfa"]), exempting_with(&[]));
}
fn exempting_users_me(static_scopes: &[&str], dynamic_scopes: &[&str]) -> String {
let routes = |s: &[&str], d: &[&str]| {
format!(
"{},{}",
route("/users/me", "POST", "gated", &["admin"], s, false),
route("/users/{id}", "POST", "gated", &["admin"], d, false),
)
};
let csrf = r#"{"path":"/users/me","method":"POST","csrf_enforced":true,"exempt":false},
{"path":"/users/{id}","method":"POST","csrf_enforced":true,"exempt":false}"#;
let base = manifest_exempt(&routes(&["mfa"], &["mfa"]), csrf, &[]);
diff(
&base,
&manifest_exempt(&routes(static_scopes, dynamic_scopes), csrf, &["/users/me"]),
)
.into_iter()
.find(|f| f.kind == "csrf_exemption_added")
.expect("a new exemption is a widening")
.canonical()
}
#[test]
fn an_exemption_ignores_a_route_that_never_serves_the_exempt_url() {
assert_eq!(
exempting_users_me(&["mfa"], &["mfa"]),
exempting_users_me(&["mfa"], &[]),
);
}
#[test]
fn an_exemption_still_binds_to_the_route_that_wins_the_url() {
assert_ne!(
exempting_users_me(&["mfa"], &["mfa"]),
exempting_users_me(&[], &["mfa"]),
);
}
#[test]
fn an_exemption_binds_every_route_that_wins_part_of_the_prefix() {
let finding = |static_scopes: &[&str], dynamic_scopes: &[&str]| {
let routes = format!(
"{},{}",
route("/api/me", "POST", "gated", &["admin"], static_scopes, false),
route(
"/api/{id}",
"POST",
"gated",
&["admin"],
dynamic_scopes,
false
),
);
let csrf = r#"{"path":"/api/me","method":"POST","csrf_enforced":true,"exempt":false},
{"path":"/api/{id}","method":"POST","csrf_enforced":true,"exempt":false}"#;
diff(
&manifest_exempt(&routes, csrf, &[]),
&manifest_exempt(&routes, csrf, &["/api"]),
)
.into_iter()
.find(|f| f.kind == "csrf_exemption_added")
.expect("a new exemption is a widening")
.canonical()
};
assert_ne!(finding(&["mfa"], &["mfa"]), finding(&[], &["mfa"]));
assert_ne!(finding(&["mfa"], &["mfa"]), finding(&["mfa"], &[]));
}
fn exemption_digest(routes: &[(&str, &str, &[&str])], prefixes: &[&str]) -> String {
let entries = routes
.iter()
.map(|(p, m, scopes)| route(p, m, "gated", &["admin"], scopes, false))
.collect::<Vec<_>>()
.join(",");
let csrf = routes
.iter()
.map(|(p, m, _)| {
format!(r#"{{"path":"{p}","method":"{m}","csrf_enforced":true,"exempt":false}}"#)
})
.collect::<Vec<_>>()
.join(",");
diff(
&manifest_exempt(&entries, &csrf, &[]),
&manifest_exempt(&entries, &csrf, prefixes),
)
.into_iter()
.find(|f| f.kind == "csrf_exemption_added")
.expect("a new exemption is a widening")
.canonical()
}
#[test]
fn an_exemption_ranks_nodes_before_methods() {
let digest = |dynamic: &[&str]| {
exemption_digest(
&[
("/users/me", "GET", &["mfa"]),
("/users/{id}", "POST", dynamic),
],
&["/users/me"],
)
};
assert_eq!(digest(&["mfa"]), digest(&[]));
}
#[test]
fn a_trailing_slash_prefix_binds_the_routes_below_it() {
let digest =
|scopes: &[&str]| exemption_digest(&[("/api/{id}", "POST", scopes)], &["/api/"]);
assert_ne!(digest(&["mfa"]), digest(&[]));
}
#[test]
fn a_root_prefix_binds_every_route() {
let digest = |scopes: &[&str]| exemption_digest(&[("/api/{id}", "POST", scopes)], &["/"]);
assert_ne!(digest(&["mfa"]), digest(&[]));
}
#[test]
fn an_exemption_prefix_keeps_its_literal_braces() {
let digest = |dynamic: &[&str]| {
exemption_digest(
&[("/{{foo}}", "POST", &["mfa"]), ("/{id}", "POST", dynamic)],
&["/{{foo}}"],
)
};
assert_ne!(digest(&["mfa"]), digest(&[]));
}
#[test]
fn a_double_slash_prefix_binds_no_route() {
let digest =
|scopes: &[&str]| exemption_digest(&[("/api/{id}", "POST", scopes)], &["/api//"]);
assert_eq!(digest(&["mfa"]), digest(&[]));
}
#[test]
fn an_exemption_below_the_prefix_ignores_a_shadowed_route() {
let digest = |dynamic: &[&str]| {
exemption_digest(
&[
("/api/me/x", "POST", &["mfa"]),
("/api/{id}/x", "POST", dynamic),
],
&["/api/me"],
)
};
assert_eq!(digest(&["mfa"]), digest(&[]));
}
#[test]
fn an_exemption_with_overlapping_prefixes_still_binds() {
let digest = |scopes: &[&str]| {
exemption_digest(&[("/api/me", "POST", scopes)], &["/api", "/api/me/"])
};
assert_ne!(digest(&["mfa"]), digest(&[]));
}
#[test]
fn a_header_removal_acknowledgment_binds_to_the_headers_that_remain() {
let base = manifest(
"",
"",
r#"{"header":"strict_transport_security","value":"max-age=63072000","emitted":true}"#,
"",
);
let alongside = |remaining: &str| {
diff(&base, &manifest("", "", remaining, ""))
.into_iter()
.find(|f| f.kind == "security_header_removed")
.expect("losing HSTS is a widening")
.canonical()
};
assert_ne!(
alongside(
r#"{"header":"content_security_policy","value":"default-src 'self'","emitted":true}"#
),
alongside(""),
"dropping the compensating header has to re-ask"
);
}
#[test]
fn a_collapsed_csrf_loss_fingerprints_every_inheritor() {
let base = manifest(
&format!(
"{},{},{},{}",
route("/records/me/{id}", "POST", "gated", &["admin"], &[], false),
route(
"/records/{user}/private",
"POST",
"gated",
&["admin"],
&[],
false
),
route(
"/records/{user}/{id}",
"POST",
"gated",
&["admin"],
&[],
false
),
route("/other", "POST", "gated", &["admin"], &[], false),
),
r#"{"path":"/records/me/{id}","method":"POST","csrf_enforced":true,"exempt":false},
{"path":"/other","method":"POST","csrf_enforced":true,"exempt":false}"#,
"",
"",
);
let unselected_requiring = |scopes: &[&str]| {
let finding = diff(
&base,
&manifest(
&format!(
"{},{},{}",
route(
"/records/{user}/private",
"POST",
"gated",
&["admin"],
scopes,
false
),
route(
"/records/{user}/{id}",
"POST",
"gated",
&["admin"],
&[],
false
),
route("/other", "POST", "gated", &["admin"], &[], false),
),
r#"{"path":"/other","method":"POST","csrf_enforced":false,"exempt":false}"#,
"",
"",
),
)
.into_iter()
.find(|f| f.kind == "csrf_disabled")
.expect("csrf off everywhere collapses");
finding.canonical()
};
assert_ne!(
unselected_requiring(&["mfa"]),
unselected_requiring(&[]),
"the inheritor that was not selected still has to move the digest"
);
}
}