use crate::graph::{AuthorityGraph, EdgeId, NodeId, TrustZone};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PropagationPath {
pub source: NodeId,
pub sink: NodeId,
pub edges: Vec<EdgeId>,
pub crossed_boundary: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub boundary_crossing: Option<(TrustZone, TrustZone)>,
}
pub const DEFAULT_MAX_HOPS: usize = 4;
pub const DENSE_GRAPH_NODE_THRESHOLD: usize = 50_000;
pub const DENSE_GRAPH_EDGE_RATIO: usize = 5;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DenseGraphError {
pub nodes: usize,
pub edges: usize,
}
impl std::fmt::Display for DenseGraphError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"graph too dense to scan safely (V={}, E={}); use --force-scan-dense to override",
self.nodes, self.edges
)
}
}
impl std::error::Error for DenseGraphError {}
pub fn is_dense_graph(graph: &AuthorityGraph) -> bool {
let v = graph.nodes.len();
let e = graph.edges.len();
v > DENSE_GRAPH_NODE_THRESHOLD && e > v.saturating_mul(DENSE_GRAPH_EDGE_RATIO)
}
pub fn propagation_analysis(graph: &AuthorityGraph, max_hops: usize) -> Vec<PropagationPath> {
propagation_analysis_inner(graph, max_hops)
}
pub fn propagation_analysis_checked(
graph: &AuthorityGraph,
max_hops: usize,
force_dense: bool,
) -> Result<Vec<PropagationPath>, DenseGraphError> {
if !force_dense && is_dense_graph(graph) {
return Err(DenseGraphError {
nodes: graph.nodes.len(),
edges: graph.edges.len(),
});
}
Ok(propagation_analysis_inner(graph, max_hops))
}
fn propagation_analysis_inner(graph: &AuthorityGraph, max_hops: usize) -> Vec<PropagationPath> {
let n = graph.nodes.len();
let mut results = Vec::new();
if n == 0 {
return results;
}
let mut adj_out: Vec<Vec<(EdgeId, NodeId)>> = vec![Vec::new(); n];
let mut accessors_for: Vec<Vec<NodeId>> = vec![Vec::new(); n];
for edge in &graph.edges {
if edge.from < n && edge.to < n {
adj_out[edge.from].push((edge.id, edge.to));
if edge.kind == crate::graph::EdgeKind::HasAccessTo {
accessors_for[edge.to].push(edge.from);
}
}
}
let mut visited: Vec<bool> = vec![false; n];
let mut queue: VecDeque<(NodeId, Vec<EdgeId>, usize)> = VecDeque::new();
for source_node in graph.authority_sources() {
let accessor_steps = &accessors_for[source_node.id];
if accessor_steps.is_empty() {
continue;
}
for v in visited.iter_mut() {
*v = false;
}
queue.clear();
for &start_step in accessor_steps {
if !visited[start_step] {
visited[start_step] = true;
for &(edge_id, to) in &adj_out[start_step] {
queue.push_back((to, vec![edge_id], 1));
}
}
}
let source_zone = source_node.trust_zone;
while let Some((current_id, path, depth)) = queue.pop_front() {
if visited[current_id] {
continue;
}
if depth > max_hops {
continue;
}
visited[current_id] = true;
let current_node = match graph.node(current_id) {
Some(n) => n,
None => continue,
};
let current_zone = current_node.trust_zone;
if current_zone.is_lower_than(&source_zone) {
results.push(PropagationPath {
source: source_node.id,
sink: current_id,
edges: path.clone(),
crossed_boundary: true,
boundary_crossing: Some((source_zone, current_zone)),
});
}
if depth >= max_hops {
continue;
}
for &(edge_id, to) in &adj_out[current_id] {
if !visited[to] {
let mut new_path = path.clone();
new_path.push(edge_id);
queue.push_back((to, new_path, depth + 1));
}
}
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::*;
fn make_source(file: &str) -> PipelineSource {
PipelineSource {
file: file.into(),
repo: None,
git_ref: None,
commit_sha: None,
}
}
#[test]
fn detects_secret_propagation_across_trust_boundary() {
let mut g = AuthorityGraph::new(make_source("test.yml"));
let secret = g.add_node(NodeKind::Secret, "AWS_KEY", TrustZone::FirstParty);
let build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
let artifact = g.add_node(NodeKind::Artifact, "dist.tar.gz", TrustZone::FirstParty);
let deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::ThirdParty);
g.add_edge(build, secret, EdgeKind::HasAccessTo);
g.add_edge(build, artifact, EdgeKind::Produces);
g.add_edge(artifact, deploy, EdgeKind::Consumes);
let paths = propagation_analysis(&g, DEFAULT_MAX_HOPS);
assert!(!paths.is_empty(), "should detect propagation");
assert!(paths
.iter()
.any(|p| p.source == secret && p.crossed_boundary));
}
#[test]
fn no_finding_when_same_trust_zone() {
let mut g = AuthorityGraph::new(make_source("test.yml"));
let secret = g.add_node(NodeKind::Secret, "TOKEN", TrustZone::FirstParty);
let step_a = g.add_node(NodeKind::Step, "lint", TrustZone::FirstParty);
let step_b = g.add_node(NodeKind::Step, "test", TrustZone::FirstParty);
let artifact = g.add_node(NodeKind::Artifact, "output", TrustZone::FirstParty);
g.add_edge(step_a, secret, EdgeKind::HasAccessTo);
g.add_edge(step_a, artifact, EdgeKind::Produces);
g.add_edge(artifact, step_b, EdgeKind::Consumes);
let paths = propagation_analysis(&g, DEFAULT_MAX_HOPS);
let boundary_crossings: Vec<_> = paths.iter().filter(|p| p.crossed_boundary).collect();
assert!(
boundary_crossings.is_empty(),
"no boundary crossing expected"
);
}
#[test]
fn respects_max_hops() {
let mut g = AuthorityGraph::new(make_source("test.yml"));
let secret = g.add_node(NodeKind::Secret, "KEY", TrustZone::FirstParty);
let s1 = g.add_node(NodeKind::Step, "s1", TrustZone::FirstParty);
let a1 = g.add_node(NodeKind::Artifact, "a1", TrustZone::FirstParty);
let s2 = g.add_node(NodeKind::Step, "s2", TrustZone::FirstParty);
let a2 = g.add_node(NodeKind::Artifact, "a2", TrustZone::FirstParty);
let s3 = g.add_node(NodeKind::Step, "s3", TrustZone::Untrusted);
g.add_edge(s1, secret, EdgeKind::HasAccessTo);
g.add_edge(s1, a1, EdgeKind::Produces);
g.add_edge(a1, s2, EdgeKind::Consumes);
g.add_edge(s2, a2, EdgeKind::Produces);
g.add_edge(a2, s3, EdgeKind::Consumes);
let paths_short = propagation_analysis(&g, 2);
let boundary_short: Vec<_> = paths_short.iter().filter(|p| p.crossed_boundary).collect();
assert!(
boundary_short.is_empty(),
"should not reach untrusted at depth 2"
);
let paths_long = propagation_analysis(&g, 5);
let boundary_long: Vec<_> = paths_long.iter().filter(|p| p.crossed_boundary).collect();
assert!(
!boundary_long.is_empty(),
"should reach untrusted at depth 5"
);
}
#[test]
fn identity_is_authority_source() {
let mut g = AuthorityGraph::new(make_source("test.yml"));
let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
let step = g.add_node(NodeKind::Step, "publish", TrustZone::FirstParty);
let action = g.add_node(
NodeKind::Image,
"third-party/deploy@main",
TrustZone::Untrusted,
);
g.add_edge(step, identity, EdgeKind::HasAccessTo);
g.add_edge(step, action, EdgeKind::UsesImage);
let paths = propagation_analysis(&g, DEFAULT_MAX_HOPS);
assert!(paths
.iter()
.any(|p| p.source == identity && p.crossed_boundary));
}
#[test]
fn dense_graph_check_rejects_oversized_input() {
let mut g = AuthorityGraph::new(make_source("dense.yml"));
let n = DENSE_GRAPH_NODE_THRESHOLD + 10;
let e_per_node = DENSE_GRAPH_EDGE_RATIO + 1;
for i in 0..n {
g.add_node(NodeKind::Step, format!("s{i}"), TrustZone::FirstParty);
}
for i in 0..n {
for k in 0..e_per_node {
let to = (i + k + 1) % n;
g.add_edge(i, to, EdgeKind::DelegatesTo);
}
}
assert!(is_dense_graph(&g), "fixture should trip the density gate");
let err = propagation_analysis_checked(&g, DEFAULT_MAX_HOPS, false).unwrap_err();
assert_eq!(err.nodes, n);
assert!(err.to_string().contains("--force-scan-dense"));
assert!(err.to_string().contains("graph too dense"));
}
#[test]
fn force_scan_dense_overrides_the_gate() {
let mut g = AuthorityGraph::new(make_source("dense.yml"));
let n = DENSE_GRAPH_NODE_THRESHOLD + 5;
let e_per_node = DENSE_GRAPH_EDGE_RATIO + 1;
for i in 0..n {
g.add_node(NodeKind::Step, format!("s{i}"), TrustZone::FirstParty);
}
for i in 0..n {
for k in 0..e_per_node {
let to = (i + k + 1) % n;
g.add_edge(i, to, EdgeKind::DelegatesTo);
}
}
assert!(is_dense_graph(&g));
let result = propagation_analysis_checked(&g, DEFAULT_MAX_HOPS, true);
assert!(
result.is_ok(),
"force_dense=true must override the density gate"
);
assert!(result.unwrap().is_empty());
}
#[test]
fn small_graph_below_threshold_passes_check() {
let mut g = AuthorityGraph::new(make_source("small.yml"));
let secret = g.add_node(NodeKind::Secret, "K", TrustZone::FirstParty);
let step = g.add_node(NodeKind::Step, "s", TrustZone::Untrusted);
g.add_edge(step, secret, EdgeKind::HasAccessTo);
assert!(!is_dense_graph(&g));
let result = propagation_analysis_checked(&g, DEFAULT_MAX_HOPS, false);
assert!(result.is_ok());
}
}