use super::summarization::{sink_summary, source_summary};
use super::utils::{
all_external_sinks_first_party_or_trusted, artifact_kind_for_node, artifact_paths,
build_sibling_clusters, node_has_sink, node_has_source,
};
use super::{ArtifactTaintRule, ArtifactTaintRuleGroup, TaintSinkKind, TaintSourceKind};
use crate::artifact_graph::ArtifactGraph;
use crate::findings::{EvidenceKind, Finding, MatchTarget, RecommendedAction, SignalClass};
use std::collections::BTreeSet;
const TRUSTED_HOST_DOWNGRADE_RULE_IDS: &[&str] = &[
"ARTIFACT_TAINT_SECRET_TO_EXTERNAL_NETWORK",
"ARTIFACT_TAINT_IDENTITY_TO_EXTERNAL_NETWORK",
];
fn rule_opts_into_trusted_host_downgrade(rule: &ArtifactTaintRule) -> bool {
TRUSTED_HOST_DOWNGRADE_RULE_IDS.contains(&rule.id.as_str())
}
fn build_taint_finding(
rule: &ArtifactTaintRule,
node_path: &str,
src: &str,
snk: &str,
kind: crate::findings::ArtifactKind,
apply_downgrade: bool,
confidence_multiplier: f32,
) -> Finding {
let mut action = rule.action;
let mut signal_class_override: Option<SignalClass> = None;
let mut reason = rule.reason.clone();
let mut sink_note = String::new();
if apply_downgrade && rule_opts_into_trusted_host_downgrade(rule) {
action = match action {
RecommendedAction::Block => RecommendedAction::RequireApproval,
other => other,
};
signal_class_override = Some(SignalClass::ReviewSignal);
reason.push_str(
" (downgraded: every external sink is a trusted-API host or first-party to a credential the artifact reads)",
);
sink_note = " sinks_trusted=true".to_string();
}
let mut builder = Finding::builder(rule.id.clone(), rule.category)
.severity(rule.severity)
.confidence(rule.confidence * confidence_multiplier)
.action(action)
.evidence_kind(EvidenceKind::Behavior)
.artifact(kind, Some(node_path.to_string()))
.matched_on(MatchTarget::ReferencedFile {
path: node_path.to_string(),
})
.match_value(format!(
"family={} source={} sink={}{}",
rule.family, src, snk, sink_note,
))
.reason(reason);
if let Some(sc) = signal_class_override {
builder = builder.signal_class(sc);
}
builder.build()
}
pub(super) fn derive_per_node_taint_findings(
graph: &ArtifactGraph,
groups: &[ArtifactTaintRuleGroup],
suppress_downgrade: bool,
) -> Vec<Finding> {
let mut findings = Vec::new();
for node_path in &artifact_paths(graph) {
for group in groups {
if !node_has_source(graph, node_path, group.source)
|| !node_has_sink(graph, node_path, group.sink)
{
continue;
}
let src = source_summary(graph, node_path, group.source);
let snk = sink_summary(graph, node_path, group.sink);
let kind = artifact_kind_for_node(graph, node_path);
let apply_downgrade = !suppress_downgrade
&& matches!(group.sink, TaintSinkKind::ExternalNetwork)
&& matches!(
group.source,
TaintSourceKind::SecretAccess | TaintSourceKind::IdentityAccess,
)
&& all_external_sinks_first_party_or_trusted(graph, node_path);
for rule in &group.rules {
findings.push(build_taint_finding(
rule,
node_path,
&src,
&snk,
kind,
apply_downgrade,
1.0,
));
}
}
}
findings
}
pub(super) fn derive_cross_node_taint_findings(
graph: &ArtifactGraph,
groups: &[ArtifactTaintRuleGroup],
suppress_downgrade: bool,
) -> Vec<Finding> {
const MAX_CROSS_NODE_FINDINGS_PER_CLUSTER: usize = 50;
const MAX_CROSS_NODE_FINDINGS_TOTAL: usize = 100;
let sibling_clusters = build_sibling_clusters(graph);
debug_assert!(
groups.len() <= MAX_CROSS_NODE_FINDINGS_PER_CLUSTER,
"Number of taint rule groups ({}) exceeds per-cluster budget ({}); each group will be capped to 1 finding",
groups.len(),
MAX_CROSS_NODE_FINDINGS_PER_CLUSTER
);
let per_group_budget = if groups.is_empty() {
0
} else {
(MAX_CROSS_NODE_FINDINGS_PER_CLUSTER / groups.len()).max(1)
};
let mut findings = Vec::new();
for cluster in &sibling_clusters {
if cluster.len() < 2 {
continue;
}
if findings.len() >= MAX_CROSS_NODE_FINDINGS_TOTAL {
break;
}
for group in groups {
let source_nodes: Vec<&String> = cluster
.iter()
.filter(|path| node_has_source(graph, path, group.source))
.collect();
let sink_nodes: Vec<&String> = cluster
.iter()
.filter(|path| node_has_sink(graph, path, group.sink))
.collect();
let mut group_finding_count = 0_usize;
'group: for source_node in &source_nodes {
for sink_node in &sink_nodes {
if source_node == sink_node {
continue; }
let src = source_summary(graph, source_node, group.source);
let snk = sink_summary(graph, sink_node, group.sink);
let kind = artifact_kind_for_node(graph, source_node);
let apply_downgrade = !suppress_downgrade
&& matches!(group.sink, TaintSinkKind::ExternalNetwork)
&& matches!(
group.source,
TaintSourceKind::SecretAccess | TaintSourceKind::IdentityAccess,
)
&& all_external_sinks_first_party_or_trusted(graph, sink_node);
for rule in &group.rules {
if findings.len() >= MAX_CROSS_NODE_FINDINGS_TOTAL {
break 'group;
}
if group_finding_count >= per_group_budget {
break 'group;
}
findings.push(build_taint_finding(
rule,
source_node,
&src,
&snk,
kind,
apply_downgrade,
0.9,
));
group_finding_count += 1;
}
}
}
}
}
findings
}
const _: () = {
let _ = std::mem::size_of::<BTreeSet<String>>();
};