src/scip.rs
//! Engine B — SCIP whole-world dep-tree **behaviour-delta** + tripwires.
//!
//! Takes in the whole world (or a product's transitive closure) and keeps delta
//! track of **every** change in the code tree — including deep transitive
//! behaviour change ("a 5th-level dependency jar that hasn't changed in years
//! suddenly changed behaviour"). Heavy by design (128GB–1TB RAM); dynamic plane
//! only — never compiled into static-holger.
//!
//! ## modgunn owns its graph input
//! modgunn does **not** depend on nornir's SCIP types (that would recreate the
//! cycle). Instead it defines its own minimal graph vocabulary — [`Symbol`],
//! [`CallEdge`], [`DepNode`], [`DepEdge`], [`VulnSink`], [`Capability`] — that a
//! producer (nornir's SCIP/knowledge extractor) populates. A [`WorldSnapshot`] is
//! an immutable bundle of those facts at one point in time.
//!
//! ## The analyses (catalog families F1–F7)
//! - [`reachability_gated_vulns`] — **A8**: a CVE only fires if its vulnerable
//! symbol is call-reachable from an entrypoint (kills version-only false
//! positives).
//! - [`blast_radius`] / [`BlastRadiusTripwire`] — **A13**: PageRank centrality over
//! the dep-graph → monitoring priority.
//! - [`capability_surface`] — **A2/A9**: the dangerous-capability set reachable
//! from entrypoints.
//! - [`diff`] folds the **delta** tripwires: [`CallGraphDeltaTripwire`] (A1),
//! [`CapabilityDriftTripwire`] (A9-drift / A10), [`SinkNewlyReachableTripwire`]
//! (A8-delta, family-1 #5), [`FingerprintDriftTripwire`] (A22) and
//! [`ObfuscationTripwire`] (A17 — entropy spike) into a [`BehaviorDelta`].
//!
//! ## Point-in-time analyses (catalog families F2–F5)
//! - [`diamond_conflicts`] — **A15**: the same package resolved at ≥2 versions in one
//! tree (a diamond / multi-version conflict).
//! - [`sink_chokepoints`] — **A14**: the single-vertex cuts on every entrypoint→sink
//! path — the chokepoints you pin/patch to sever the most reachability.
//! - [`bus_factor_findings`] — **A12**: a highly-central dep with a single maintainer
//! (a single point of failure), correlating [`blast_radius`] × maintainer count.
//! - [`typosquat_findings`] — **A8-supply**: names one edit away from a popular
//! package (typosquat) or an internal namespace resolvable publicly
//! (dependency-confusion).
//! - [`dep_fanin_outliers`] — **A16**: dep-graph fan-in metric outliers (z-score over
//! the in-degree distribution).
//! - [`release_cadence_anomalies`] — **family-2 #7**: per-package release
//! inter-arrival outliers — a long-dormant package suddenly publishing (a
//! takeover-shaped cadence anomaly), via the same z-score primitive.
//! - [`owner_cluster_findings`] — **A13-cluster (family-3 #13)**: label-propagation
//! community detection over the co-ownership graph ([`DepNode::owners`]) — deps
//! controlled by one entity are correlated, not independent, risk.
//! - [`change_velocity_anomalies`] — **family-7 #25**: per-package release-rate
//! (churn-velocity) bursts — a rolling trailing-window release count whose spike
//! is a z-score outlier vs the package's own change-rate history.
//! - [`license_transition_findings`] — **family-5 #19**: a dep that relicenses to a
//! riskier license across its version history ([`DepNode::license_history`]),
//! ranked by [`license_risk_tier`] (MIT→AGPL rug-pulls, slides to proprietary).
//! - [`embedding_novelty_findings`] — **family-4 #18**: a package version whose code
//! embedding ([`DepNode::embedding`]) sits far (cosine distance) from the ecosystem
//! norm — semantically-novel / freshly-injected code.
//! - [`taint_path_findings`] — **taint-path (source→sink)**: call-graph paths from
//! an untrusted [`Symbol::taint_source`] to a sensitive-capability sink.
//!
//! Every analysis emits [`core::Finding`](crate::core::Finding)s, folded into a
//! [`core::Verdict`](crate::core::Verdict) via [`verdict_from`], plus the
//! [`BehaviorDelta`] is the world-delta tripwire event stream.
use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
use crate::core::{ArtifactRef, Decision, Finding, Severity, Verdict};
/// A dangerous capability a symbol can directly exercise (the labelled sink set
/// the capability-surface closure A9 tracks).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Capability {
Network,
Filesystem,
Process,
Env,
DynamicCode,
Reflection,
Ffi,
Unsafe,
Clock,
Rng,
}
impl Capability {
/// A short stable label for findings.
pub fn label(&self) -> &'static str {
match self {
Capability::Network => "network",
Capability::Filesystem => "filesystem",
Capability::Process => "process",
Capability::Env => "env",
Capability::DynamicCode => "dynamic-code",
Capability::Reflection => "reflection",
Capability::Ffi => "ffi",
Capability::Unsafe => "unsafe",
Capability::Clock => "clock",
Capability::Rng => "rng",
}
}
/// Inverse of [`label`](Capability::label): parse a capability back from its
/// stable label — used to reconstruct a [`Symbol`] from the persisted
/// `scip_symbols` table when reloading a [`WorldSnapshot`] from the warehouse.
/// An unknown label yields `None` (skipped, never a wrong capability).
pub fn from_label(s: &str) -> Option<Self> {
Some(match s.trim() {
"network" => Capability::Network,
"filesystem" => Capability::Filesystem,
"process" => Capability::Process,
"env" => Capability::Env,
"dynamic-code" => Capability::DynamicCode,
"reflection" => Capability::Reflection,
"ffi" => Capability::Ffi,
"unsafe" => Capability::Unsafe,
"clock" => Capability::Clock,
"rng" => Capability::Rng,
_ => return None,
})
}
}
/// A SCIP symbol / node in the call graph. `id` is a stable moniker (cross-repo
/// symbol identity); `capabilities` are the sinks this symbol *directly* exercises.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Symbol {
pub id: String,
pub display: String,
/// Owning package identity (e.g. `cargo:serde@1.0.0`) — used to tell whether a
/// behaviour change came with a version change (A22).
pub package: String,
pub capabilities: BTreeSet<Capability>,
pub is_entrypoint: bool,
/// Whether this symbol is an **untrusted taint source** — a point where
/// untrusted input enters (a network / IPC / deserialization read). The
/// source side of the taint-path analysis ([`taint_path_findings`]). Defaults
/// to `false`; when no symbol is marked, the analysis simply doesn't fire.
pub is_taint_source: bool,
}
impl Symbol {
/// A plain (non-entrypoint, capability-free) symbol.
pub fn new(id: impl Into<String>, package: impl Into<String>) -> Self {
let id = id.into();
Self {
display: id.clone(),
id,
package: package.into(),
capabilities: BTreeSet::new(),
is_entrypoint: false,
is_taint_source: false,
}
}
pub fn entrypoint(mut self) -> Self {
self.is_entrypoint = true;
self
}
/// Mark this symbol as an untrusted taint source (the source side of
/// [`taint_path_findings`]).
pub fn taint_source(mut self) -> Self {
self.is_taint_source = true;
self
}
pub fn with_capability(mut self, c: Capability) -> Self {
self.capabilities.insert(c);
self
}
}
/// A directed call edge `from` → `to` (by [`Symbol::id`]).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CallEdge {
pub from: String,
pub to: String,
}
impl CallEdge {
pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
Self {
from: from.into(),
to: to.into(),
}
}
}
/// A resolved dependency-graph node (one package@version).
// Not `Eq`: `embedding` carries `f32` (family-4 #18), which is only `PartialEq`.
#[derive(Debug, Clone, PartialEq)]
pub struct DepNode {
pub key: String,
pub ecosystem: String,
pub name: String,
pub version: String,
/// Whether this dep carries verified provenance (drives the A13 priority bump).
pub verified: bool,
/// Number of distinct maintainers/owners of this package, if known — the
/// bus-factor input (A12). `None` when registry metadata was not captured, in
/// which case [`bus_factor_findings`] simply doesn't fire on this node.
pub maintainers: Option<u32>,
/// Publish timestamps (Unix epoch **seconds**) of **all** known releases of
/// this package — the release-cadence input (family-2 #7). Sourced from
/// registry metadata / the warehouse historized series, not from the resolved
/// closure (one snapshot pins a single version), so a producer attaches the
/// package's full publish history here. Empty when it was not captured, in
/// which case [`release_cadence_anomalies`] simply doesn't fire on this node.
pub release_epochs: Vec<i64>,
/// Distinct owner / publisher identities of this package (registry account
/// handles, org ids), if known — the owner-cluster input (family-3 #13).
/// Sourced from registry ownership metadata, not the resolved closure. Empty
/// when it was not captured, in which case [`owner_cluster_findings`] simply
/// doesn't consider this node.
pub owners: Vec<String>,
/// Per-version SPDX license history of this package in **release order**
/// (`(version, spdx)` pairs), if known — the license-transition input
/// (family-5 #19). Sourced from registry / SBOM metadata across the package's
/// versions, not the resolved closure (one snapshot pins one version), so a
/// producer attaches the full license timeline here. Empty (or a single point)
/// when it was not captured, in which case [`license_transition_findings`]
/// simply doesn't fire on this node.
pub license_history: Vec<(String, String)>,
/// The package-version **code embedding** (a dense semantic vector from the
/// nornir embedder), if computed — the embedding-novelty input (family-4 #18).
/// modgunn does not run the embedder itself (that is nornir's job); a producer
/// attaches the vector here, the same honest-optional-metadata way `owners` /
/// `release_epochs` are supplied. `None` when not computed, in which case
/// [`embedding_novelty_findings`] simply doesn't consider this node.
pub embedding: Option<Vec<f32>>,
}
impl DepNode {
pub fn new(ecosystem: &str, name: &str, version: &str) -> Self {
Self {
key: format!("{ecosystem}:{name}@{version}"),
ecosystem: ecosystem.into(),
name: name.into(),
version: version.into(),
verified: false,
maintainers: None,
release_epochs: Vec::new(),
owners: Vec::new(),
license_history: Vec::new(),
embedding: None,
}
}
/// Attach this package's distinct owner/publisher identities from registry
/// ownership metadata — the owner-cluster input (family-3 #13). Order is
/// irrelevant; [`owner_cluster_findings`] treats them as a set.
pub fn with_owners<S: Into<String>>(mut self, owners: impl IntoIterator<Item = S>) -> Self {
self.owners = owners.into_iter().map(Into::into).collect();
self
}
/// Record the maintainer/owner count from registry metadata (the A12 input).
pub fn with_maintainers(mut self, n: u32) -> Self {
self.maintainers = Some(n);
self
}
/// Attach this package's release publish timestamps (Unix epoch seconds) from
/// registry metadata — the release-cadence input (family-2 #7). Order is
/// irrelevant; [`release_cadence_anomalies`] sorts before differencing.
pub fn with_release_epochs(mut self, epochs: impl IntoIterator<Item = i64>) -> Self {
self.release_epochs = epochs.into_iter().collect();
self
}
/// Attach this package's per-version SPDX license history in **release order**
/// (`(version, spdx)` pairs) from registry / SBOM metadata — the
/// license-transition input (family-5 #19).
pub fn with_license_history<V: Into<String>, L: Into<String>>(
mut self,
history: impl IntoIterator<Item = (V, L)>,
) -> Self {
self.license_history = history
.into_iter()
.map(|(v, l)| (v.into(), l.into()))
.collect();
self
}
/// Attach this package-version's code embedding (dense semantic vector from the
/// nornir embedder) — the embedding-novelty input (family-4 #18).
pub fn with_embedding(mut self, v: impl Into<Vec<f32>>) -> Self {
self.embedding = Some(v.into());
self
}
/// Mark this dep as carrying verified provenance (the A13 priority input).
pub fn verified(mut self) -> Self {
self.verified = true;
self
}
}
/// A directed dependency edge `from` depends-on `to` (by [`DepNode::key`]).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DepEdge {
pub from: String,
pub to: String,
}
impl DepEdge {
pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
Self {
from: from.into(),
to: to.into(),
}
}
}
/// A vulnerable-function sink: [`Symbol::id`] is the function an advisory's CVE
/// lives in. Reachability to this node is what A8 gates on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VulnSink {
pub symbol: String,
pub advisory: String,
pub severity: Severity,
}
#[cfg(feature = "scan")]
impl VulnSink {
/// Build a reachability sink from an OSV **symbol-level** record — the Go vuln
/// DB's `ecosystem_specific.imports` (see [`crate::osv::parse_osv_vuln_symbols`]).
/// This is the production seed for [`WorldSnapshot::vuln_sinks`]: without it the
/// A8 gate has nothing real to gate on. The call-graph node id follows Go's
/// fully-qualified convention `<import-path>.<symbol>` (a method arrives from OSV
/// as `Type.Method`, giving `path.Type.Method`) — the SAME id a Go SCIP indexer
/// emits for [`Symbol::id`], so [`reachability_gated_vulns`] joins by string
/// equality. A package-level record (`symbol == ""`) has no single function to
/// gate on and yields `None`; the present-on-disk base join covers whole-package
/// vulnerabilities instead.
pub fn from_vuln_symbol(vs: &crate::osv::VulnSymbol) -> Option<Self> {
if vs.symbol.is_empty() || vs.package.is_empty() {
return None;
}
Some(VulnSink {
symbol: format!("{}.{}", vs.package, vs.symbol),
advisory: vs.id.clone(),
severity: vs.severity,
})
}
}
/// An immutable snapshot of the (sub-)world: the SCIP call graph + the resolved
/// dep-graph + the known vulnerable sinks, keyed by `id` so deltas are exact.
// Not `Eq`: holds `DepNode`s whose `embedding` (#18) is `f32` / only `PartialEq`.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct WorldSnapshot {
pub id: String,
pub symbols: Vec<Symbol>,
pub calls: Vec<CallEdge>,
pub dep_nodes: Vec<DepNode>,
pub dep_edges: Vec<DepEdge>,
pub vuln_sinks: Vec<VulnSink>,
}
impl WorldSnapshot {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
..Default::default()
}
}
/// Forward call-graph adjacency: caller-id → callee-ids.
fn call_adjacency(&self) -> HashMap<&str, Vec<&str>> {
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for e in &self.calls {
adj.entry(&e.from).or_default().push(&e.to);
}
adj
}
fn entrypoints(&self) -> Vec<&str> {
self.symbols
.iter()
.filter(|s| s.is_entrypoint)
.map(|s| s.id.as_str())
.collect()
}
/// The set of symbol ids reachable from `sources` over the call graph (multi-
/// source BFS).
fn reachable_from<'a>(&'a self, sources: &[&'a str]) -> HashSet<&'a str> {
let adj = self.call_adjacency();
let mut seen: HashSet<&str> = HashSet::new();
let mut q: VecDeque<&str> = VecDeque::new();
for s in sources {
if seen.insert(s) {
q.push_back(s);
}
}
while let Some(n) = q.pop_front() {
if let Some(next) = adj.get(n) {
for &m in next {
if seen.insert(m) {
q.push_back(m);
}
}
}
}
seen
}
/// The symbol ids reachable from this snapshot's entrypoints.
fn reachable_from_entrypoints(&self) -> HashSet<&str> {
let eps = self.entrypoints();
self.reachable_from(&eps)
}
/// [`reachable_from`], but treating `excluded` as removed from the graph — the
/// single-vertex-cut probe A14 ([`sink_chokepoints`]) uses to test whether a node
/// is a chokepoint (its removal disconnects the entrypoints from a sink).
fn reachable_from_excluding<'a>(
&'a self,
sources: &[&'a str],
excluded: &str,
) -> HashSet<&'a str> {
let adj = self.call_adjacency();
let mut seen: HashSet<&str> = HashSet::new();
let mut q: VecDeque<&str> = VecDeque::new();
for s in sources {
if *s != excluded && seen.insert(s) {
q.push_back(s);
}
}
while let Some(n) = q.pop_front() {
if let Some(next) = adj.get(n) {
for &m in next {
if m != excluded && seen.insert(m) {
q.push_back(m);
}
}
}
}
seen
}
fn symbol(&self, id: &str) -> Option<&Symbol> {
self.symbols.iter().find(|s| s.id == id)
}
}
/// A behaviour change detected between two [`WorldSnapshot`]s — the world-delta
/// tripwire event stream.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BehaviorDelta {
pub from: String,
pub to: String,
pub findings: Vec<Finding>,
}
impl BehaviorDelta {
/// The strongest gate decision implied by the delta's findings.
pub fn decision(&self) -> Decision {
fold_decision(&self.findings)
}
}
/// The gate [`Decision`] implied by a finding's severity (same table Engine A
/// uses; duplicated here so Engine B needs no `scan` feature).
fn decision_for(severity: Severity) -> Decision {
match severity {
Severity::Critical | Severity::High => Decision::Block,
Severity::Medium | Severity::Low => Decision::Warn,
Severity::Info => Decision::Pass,
}
}
fn fold_decision(findings: &[Finding]) -> Decision {
findings
.iter()
.map(|f| decision_for(f.severity))
.max()
.unwrap_or(Decision::Pass)
}
/// Fold a set of Engine-B findings into a [`Verdict`] for `artifact` — the same
/// verdict shape Engine A emits, so consumers handle both engines uniformly.
pub fn verdict_from(artifact: ArtifactRef, findings: Vec<Finding>, snapshot_id: &str) -> Verdict {
let decision = fold_decision(&findings);
Verdict {
artifact,
decision,
findings,
policy_version: format!("engine-b@{snapshot_id}"),
advisory_db_rev: snapshot_id.to_string(),
}
}
// ── A8 — reachability-gated vuln prioritization ──────────────────────────────
/// **A8**: only emit a finding for a [`VulnSink`] whose vulnerable symbol is
/// call-reachable from an entrypoint. This is the precision win — version-only
/// matchers fire on every present CVE; this fires only on the reachable ones.
pub fn reachability_gated_vulns(snap: &WorldSnapshot) -> Vec<Finding> {
let reachable = snap.reachable_from_entrypoints();
snap.vuln_sinks
.iter()
.filter(|s| reachable.contains(s.symbol.as_str()))
.map(|s| Finding {
severity: s.severity,
id: s.advisory.clone(),
summary: format!("reachable vulnerable symbol `{}`", s.symbol),
})
.collect()
}
/// The vuln sinks **not** reachable from entrypoints (the noise A8 suppresses) —
/// surfaced as `Info` so a consumer can still see what was present-but-unreachable.
pub fn unreachable_vulns(snap: &WorldSnapshot) -> Vec<Finding> {
let reachable = snap.reachable_from_entrypoints();
snap.vuln_sinks
.iter()
.filter(|s| !reachable.contains(s.symbol.as_str()))
.map(|s| Finding {
severity: Severity::Info,
id: s.advisory.clone(),
summary: format!("unreachable vulnerable symbol `{}` (suppressed)", s.symbol),
})
.collect()
}
// ── A2/A9 — capability surface ───────────────────────────────────────────────
/// **A2/A9**: the set of dangerous [`Capability`]s reachable from entrypoints over
/// the call graph (the labelled-sink reachability closure).
pub fn capability_surface(snap: &WorldSnapshot) -> BTreeSet<Capability> {
let reachable = snap.reachable_from_entrypoints();
let mut caps = BTreeSet::new();
for s in &snap.symbols {
if reachable.contains(s.id.as_str()) {
caps.extend(s.capabilities.iter().copied());
}
}
caps
}
// ── A13 — blast-radius centrality (PageRank over the dep-graph) ───────────────
/// **A13**: PageRank centrality over the dep-graph, returned descending by score.
/// A highly-central dep, if compromised, touches the most surface → monitoring
/// priority. `damping` 0.85 / 40 iterations is the standard configuration.
pub fn blast_radius(snap: &WorldSnapshot) -> Vec<(String, f64)> {
let nodes: Vec<&str> = snap.dep_nodes.iter().map(|n| n.key.as_str()).collect();
let n = nodes.len();
if n == 0 {
return Vec::new();
}
let idx: HashMap<&str, usize> = nodes.iter().enumerate().map(|(i, &k)| (k, i)).collect();
// out-edges per node
let mut out: Vec<Vec<usize>> = vec![Vec::new(); n];
for e in &snap.dep_edges {
if let (Some(&a), Some(&b)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
out[a].push(b);
}
}
let d = 0.85;
let base = (1.0 - d) / n as f64;
let mut rank = vec![1.0 / n as f64; n];
for _ in 0..40 {
let mut next = vec![base; n];
let mut dangling = 0.0;
for i in 0..n {
if out[i].is_empty() {
dangling += rank[i];
} else {
let share = d * rank[i] / out[i].len() as f64;
for &j in &out[i] {
next[j] += share;
}
}
}
// redistribute dangling mass uniformly
let dshare = d * dangling / n as f64;
for v in next.iter_mut() {
*v += dshare;
}
rank = next;
}
let mut scored: Vec<(String, f64)> = nodes
.iter()
.enumerate()
.map(|(i, &k)| (k.to_string(), rank[i]))
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored
}
/// **A13** as findings: flag the top-`k` most central deps; an **unverified**
/// central dep is escalated to `Medium` (the bus-factor / blast-radius priority).
pub fn blast_radius_findings(snap: &WorldSnapshot, k: usize) -> Vec<Finding> {
let verified: HashMap<&str, bool> = snap
.dep_nodes
.iter()
.map(|n| (n.key.as_str(), n.verified))
.collect();
blast_radius(snap)
.into_iter()
.take(k)
.map(|(key, score)| {
let unverified = !verified.get(key.as_str()).copied().unwrap_or(false);
Finding {
severity: if unverified {
Severity::Medium
} else {
Severity::Info
},
id: format!("MODGUNN-BLAST-RADIUS:{key}"),
summary: format!(
"blast-radius centrality {score:.4}{}",
if unverified { " (unverified dep)" } else { "" }
),
}
})
.collect()
}
// ── A15 — diamond / multi-version conflict ───────────────────────────────────
/// **A15**: the same `(ecosystem, name)` resolved at **two or more** versions in the
/// tree — a diamond dependency. Each conflict is a finding listing the versions;
/// escalated to `High` when any of the conflicting nodes is **unverified** (an
/// unverified version silently pulled into a diamond is the risky case), else `Low`.
pub fn diamond_conflicts(snap: &WorldSnapshot) -> Vec<Finding> {
// (ecosystem, name) → set of (version, verified)
let mut by_pkg: HashMap<(&str, &str), BTreeSet<String>> = HashMap::new();
let mut any_unverified: HashMap<(&str, &str), bool> = HashMap::new();
for n in &snap.dep_nodes {
let k = (n.ecosystem.as_str(), n.name.as_str());
by_pkg.entry(k).or_default().insert(n.version.clone());
let e = any_unverified.entry(k).or_insert(false);
*e = *e || !n.verified;
}
let mut out = Vec::new();
// Deterministic order (BTree over the key) so findings are stable across runs.
let mut keys: Vec<_> = by_pkg.keys().copied().collect();
keys.sort_unstable();
for k in keys {
let versions = &by_pkg[&k];
if versions.len() < 2 {
continue;
}
let unverified = any_unverified.get(&k).copied().unwrap_or(false);
out.push(Finding {
severity: if unverified {
Severity::High
} else {
Severity::Low
},
id: format!("MODGUNN-DIAMOND:{}:{}", k.0, k.1),
summary: format!(
"`{}` ({}) resolved at {} versions [{}]{}",
k.1,
k.0,
versions.len(),
versions.iter().cloned().collect::<Vec<_>>().join(", "),
if unverified {
" — an unverified version is in the diamond"
} else {
""
}
),
});
}
out
}
// ── A14 — entrypoint→sink path analysis + single-vertex chokepoints ───────────
/// **A14**: for every vuln sink **reachable** from an entrypoint, the set of
/// *chokepoint* symbols — single-vertex cuts whose removal disconnects **all**
/// entrypoints from that sink. These are the nodes to pin / patch / sever to kill
/// the most reachability. Returned as findings (`High` — a chokepoint is a
/// high-leverage control point on a live vulnerable path).
///
/// *Algorithm:* for each reachable sink, test each candidate symbol on a path to it
/// by re-running the entrypoint BFS with that node removed; if the sink is no longer
/// reachable, the node is a chokepoint. `Θ(sinks × V × (V+E))` — the heavy-tier
/// min-cut proxy (single-vertex cuts, not full max-flow).
pub fn sink_chokepoints(snap: &WorldSnapshot) -> Vec<Finding> {
let eps: Vec<&str> = snap.entrypoints();
if eps.is_empty() {
return Vec::new();
}
let reachable = snap.reachable_from(&eps);
let mut out = Vec::new();
for sink in &snap.vuln_sinks {
let target = sink.symbol.as_str();
if !reachable.contains(target) {
continue; // A8 already handles unreachable sinks
}
// Candidates: reachable symbols that are neither an entrypoint nor the sink.
let mut chokes: Vec<&str> = Vec::new();
for cand in snap.symbols.iter().map(|s| s.id.as_str()) {
if cand == target || eps.contains(&cand) || !reachable.contains(cand) {
continue;
}
if !snap.reachable_from_excluding(&eps, cand).contains(target) {
chokes.push(cand);
}
}
if !chokes.is_empty() {
chokes.sort_unstable();
out.push(Finding {
severity: Severity::High,
id: format!("MODGUNN-CHOKEPOINT:{}", sink.advisory),
summary: format!(
"vulnerable `{}` ({}) reachable only via chokepoint(s) [{}] — pin/patch to sever",
sink.symbol,
sink.advisory,
chokes.join(", ")
),
});
}
}
out
}
// ── A12 — bus-factor / single point of failure ───────────────────────────────
/// **A12**: a dep among the top-`k` most central ([`blast_radius`]) that has a
/// **single maintainer** (`maintainers <= 1`) — a highly-depended-on package one
/// account-compromise away from poisoning the tree. Fires only for nodes whose
/// maintainer count was captured; `High` (single point of failure).
pub fn bus_factor_findings(snap: &WorldSnapshot, k: usize) -> Vec<Finding> {
let maint: HashMap<&str, Option<u32>> = snap
.dep_nodes
.iter()
.map(|n| (n.key.as_str(), n.maintainers))
.collect();
blast_radius(snap)
.into_iter()
.take(k)
.filter_map(|(key, score)| {
let m = maint.get(key.as_str()).copied().flatten()?;
(m <= 1).then(|| Finding {
severity: Severity::High,
id: format!("MODGUNN-BUS-FACTOR:{key}"),
summary: format!(
"central dep (blast-radius {score:.4}) has a single maintainer — single point of failure"
),
})
})
.collect()
}
// ── A8-supply — typosquat & dependency-confusion ─────────────────────────────
/// Levenshtein edit distance between two strings (iterative, one row of state).
fn levenshtein(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, ca) in a.chars().enumerate() {
cur[0] = i + 1;
for (j, &cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
/// **A8 (supply-chain)**: name-based typosquat + dependency-confusion detection.
/// - **typosquat**: a dep name within edit distance 1 of a `popular` name it is not
/// equal to → `High` (a look-alike of a trusted package).
/// - **dependency-confusion**: a dep name under one of your `internal_namespaces`
/// (prefix match) that resolved from a **public** registry → `Critical` (an
/// internal-only name should never resolve externally).
///
/// Pure and cheap (set membership + edit distance); runs in the gate latency budget.
pub fn typosquat_findings(
snap: &WorldSnapshot,
popular: &[&str],
internal_namespaces: &[&str],
) -> Vec<Finding> {
let mut out = Vec::new();
// Deterministic: iterate dep nodes in key order.
let mut nodes: Vec<&DepNode> = snap.dep_nodes.iter().collect();
nodes.sort_by(|a, b| a.key.cmp(&b.key));
for n in nodes {
let name = n.name.as_str();
if let Some(pfx) = internal_namespaces.iter().find(|p| name.starts_with(**p)) {
out.push(Finding {
severity: Severity::Critical,
id: format!("MODGUNN-DEP-CONFUSION:{name}"),
summary: format!(
"internal-namespace package `{name}` (`{pfx}`) resolved from a public registry"
),
});
continue; // an internal name is not also a typosquat of a public one
}
if let Some(pop) = popular
.iter()
.find(|p| **p != name && levenshtein(name, p) == 1)
{
out.push(Finding {
severity: Severity::High,
id: format!("MODGUNN-TYPOSQUAT:{name}"),
summary: format!(
"dep `{name}` is one edit from popular package `{pop}` (possible typosquat)"
),
});
}
}
out
}
// ── A16 — metric-outlier detection (z-score) ─────────────────────────────────
/// Population z-score outliers: `(key, value, z)` for every sample whose absolute
/// z-score exceeds `z_threshold`. Empty / zero-variance input yields no outliers.
/// The reusable statistical core behind the A16 metric-anomaly analyses.
pub fn zscore_outliers(samples: &[(String, f64)], z_threshold: f64) -> Vec<(String, f64, f64)> {
let n = samples.len();
if n < 2 {
return Vec::new();
}
let mean = samples.iter().map(|(_, v)| *v).sum::<f64>() / n as f64;
let var = samples.iter().map(|(_, v)| (v - mean).powi(2)).sum::<f64>() / n as f64;
let sd = var.sqrt();
if sd == 0.0 {
return Vec::new();
}
samples
.iter()
.filter_map(|(k, v)| {
let z = (v - mean) / sd;
(z.abs() > z_threshold).then(|| (k.clone(), *v, z))
})
.collect()
}
/// **A16**: dep-graph **fan-in** outliers — packages depended on by an anomalously
/// large (or small) number of others, by z-score over the in-degree distribution.
/// A sudden fan-in spike is a centrality/adoption anomaly worth review; reported
/// `Medium` for a positive-z (high fan-in) outlier, else `Info`.
pub fn dep_fanin_outliers(snap: &WorldSnapshot, z_threshold: f64) -> Vec<Finding> {
let mut fanin: HashMap<&str, f64> = snap
.dep_nodes
.iter()
.map(|n| (n.key.as_str(), 0.0))
.collect();
for e in &snap.dep_edges {
if let Some(c) = fanin.get_mut(e.to.as_str()) {
*c += 1.0;
}
}
let mut samples: Vec<(String, f64)> = fanin.iter().map(|(k, v)| (k.to_string(), *v)).collect();
samples.sort_by(|a, b| a.0.cmp(&b.0)); // determinism
let mut out: Vec<Finding> = zscore_outliers(&samples, z_threshold)
.into_iter()
.map(|(key, v, z)| Finding {
severity: if z > 0.0 {
Severity::Medium
} else {
Severity::Info
},
id: format!("MODGUNN-FANIN-OUTLIER:{key}"),
summary: format!("dep fan-in {v:.0} is a z={z:.2} outlier"),
})
.collect();
out.sort_by(|a, b| a.id.cmp(&b.id));
out
}
// ── Family-2 #7 — release-cadence anomaly ────────────────────────────────────
/// **Family-2 #7 (release-cadence / timeline anomaly)**: per-package release
/// **inter-arrival** analysis. A package with a stable publish rhythm that goes
/// long-dormant and then suddenly publishes is a classic account-takeover /
/// project-revival signal — the newest release arrives after a gap that is a
/// statistical outlier against the package's own history.
///
/// *Input:* [`DepNode::release_epochs`] — the package's publish timestamps (epoch
/// seconds) from registry metadata / the warehouse series. A node with fewer than
/// **3** timestamps (< 2 inter-arrival gaps, so [`zscore_outliers`] has nothing to
/// baseline against) is skipped, as is a package with a perfectly regular cadence
/// (zero-variance gaps ⇒ no outlier).
///
/// *Algo:* sort the timestamps, difference into consecutive inter-arrival gaps,
/// then run the shared [`zscore_outliers`] primitive over the gap series. A
/// **positive** z-score gap is anomalously long (a dormancy). If that dormancy is
/// the **most recent** gap — i.e. the newest release ends a long silence — it is
/// the takeover-shaped signal and fires `High`; a positive outlier gap buried in
/// the history (an old freeze-then-resume) fires `Medium`. Packages are visited in
/// `(ecosystem, name)` key order and, within a name, the node carrying the longest
/// captured series wins, so findings are deterministic.
pub fn release_cadence_anomalies(snap: &WorldSnapshot, z_threshold: f64) -> Vec<Finding> {
// One release history per package identity (ecosystem, name): a snapshot pins a
// single version, so the timeline is a package property, not a per-node one —
// keep the longest captured series if several nodes carry one.
let mut by_pkg: HashMap<(&str, &str), &[i64]> = HashMap::new();
for n in &snap.dep_nodes {
if n.release_epochs.is_empty() {
continue;
}
let k = (n.ecosystem.as_str(), n.name.as_str());
let entry = by_pkg.entry(k).or_default();
if n.release_epochs.len() > entry.len() {
*entry = n.release_epochs.as_slice();
}
}
let mut keys: Vec<_> = by_pkg.keys().copied().collect();
keys.sort_unstable();
let mut out = Vec::new();
for k in keys {
let mut epochs = by_pkg[&k].to_vec();
epochs.sort_unstable();
if epochs.len() < 3 {
continue; // need ≥ 2 gaps to have a distribution to be an outlier of
}
// Consecutive inter-arrival gaps (days), keyed by their index so the
// z-score outlier can be located back to a position in the timeline.
let gaps: Vec<(String, f64)> = epochs
.windows(2)
.enumerate()
.map(|(i, w)| (i.to_string(), (w[1] - w[0]) as f64 / 86_400.0))
.collect();
let last_gap_idx = gaps.len() - 1;
// The single most-anomalous *long* (positive-z) gap for this package.
let mut worst: Option<(usize, f64, f64)> = None; // (gap index, days, z)
for (idx_str, days, z) in zscore_outliers(&gaps, z_threshold) {
if z <= 0.0 {
continue; // a short gap (burst) is not the dormancy signal
}
let idx: usize = idx_str.parse().unwrap_or(0);
if worst.map_or(true, |(_, _, wz)| z > wz) {
worst = Some((idx, days, z));
}
}
if let Some((idx, days, z)) = worst {
let dormant_then_publish = idx == last_gap_idx;
out.push(Finding {
severity: if dormant_then_publish { Severity::High } else { Severity::Medium },
id: format!("MODGUNN-RELEASE-CADENCE:{}:{}", k.0, k.1),
summary: format!(
"`{}` ({}) {} after a {:.0}-day dormancy (z={z:.2} vs its release history) — {}",
k.1,
k.0,
if dormant_then_publish { "published its newest release" } else { "resumed publishing" },
days,
if dormant_then_publish {
"possible account takeover / revival"
} else {
"historical freeze-then-resume"
}
),
});
}
}
out
}
// ── Family-7 #25 — change-velocity baselines ─────────────────────────────────
/// **Family-7 #25 (change-velocity baselines)**: per-package **release-rate**
/// (churn-velocity) anomaly. Sibling of, and complementary to, the release-cadence
/// analysis (family-2 #7, [`release_cadence_anomalies`]): where #7 flags a single
/// anomalous inter-arrival *gap* (a dormancy that ends in a publish), this models
/// the package's **rate of change over time** — a rolling count of releases in a
/// trailing window — and flags a **burst**: a window in which the release rate
/// spikes far above the package's own baseline velocity. A sudden flurry of
/// publishes (the "freeze-then-spike" / rapid-fire re-release shape) is the churn
/// signal of a hijacked or thrashing project.
///
/// *Input:* [`DepNode::release_epochs`] — the package's publish timestamps (epoch
/// seconds), the same historized series #7 consumes. A package with fewer than
/// **5** releases has too short a velocity series to baseline against and is
/// skipped, as is a perfectly regular (constant-velocity) cadence (zero-variance ⇒
/// no outlier). Empty series ⇒ doesn't fire.
///
/// *Algo:* sort the timestamps; for each release compute its **trailing-window
/// velocity** = the number of releases within the `window_days`-day window ending
/// at (and including) that release. Run the shared [`zscore_outliers`] primitive
/// over the velocity series; a **positive** z-score is a burst (rate spike). Report
/// the single worst (highest-z) burst per package: `High` when the burst is the
/// **most recent** release (an active spike), else `Medium` (a historical burst).
/// Deterministic: packages visited in `(ecosystem, name)` key order, longest
/// captured series wins.
pub fn change_velocity_anomalies(
snap: &WorldSnapshot,
window_days: f64,
z_threshold: f64,
) -> Vec<Finding> {
let window_secs = (window_days * 86_400.0) as i64;
// One release history per package identity (same folding as #7): a snapshot
// pins one version, so the timeline is a package property, not a per-node one.
let mut by_pkg: HashMap<(&str, &str), &[i64]> = HashMap::new();
for n in &snap.dep_nodes {
if n.release_epochs.is_empty() {
continue;
}
let k = (n.ecosystem.as_str(), n.name.as_str());
let entry = by_pkg.entry(k).or_default();
if n.release_epochs.len() > entry.len() {
*entry = n.release_epochs.as_slice();
}
}
let mut keys: Vec<_> = by_pkg.keys().copied().collect();
keys.sort_unstable();
let mut out = Vec::new();
for k in keys {
let mut epochs = by_pkg[&k].to_vec();
epochs.sort_unstable();
if epochs.len() < 5 {
continue; // too short a series to baseline a change-rate against
}
let last_idx = epochs.len() - 1;
// Trailing-window velocity per release: releases in `(t - window, t]`.
let velocity: Vec<(String, f64)> = epochs
.iter()
.enumerate()
.map(|(i, &t)| {
let lo = t - window_secs;
let count = epochs.iter().filter(|&&u| u > lo && u <= t).count();
(i.to_string(), count as f64)
})
.collect();
// The single most-anomalous *burst* (highest positive z) for this package.
let mut worst: Option<(usize, f64, f64)> = None; // (release index, velocity, z)
for (idx_str, v, z) in zscore_outliers(&velocity, z_threshold) {
if z <= 0.0 {
continue; // a slowdown (negative z) is not the burst signal
}
let idx: usize = idx_str.parse().unwrap_or(0);
if worst.map_or(true, |(_, _, wz)| z > wz) {
worst = Some((idx, v, z));
}
}
if let Some((idx, v, z)) = worst {
let active = idx == last_idx;
out.push(Finding {
severity: if active { Severity::High } else { Severity::Medium },
id: format!("MODGUNN-CHANGE-VELOCITY:{}:{}", k.0, k.1),
summary: format!(
"`{}` ({}) {} — {:.0} releases within a {:.0}-day window (z={z:.2} vs its own change-rate history){}",
k.1,
k.0,
if active { "is in an active release burst" } else { "had a historical release burst" },
v,
window_days,
if active { " → possible hijack / rapid-fire churn" } else { "" }
),
});
}
}
out
}
// ── Family-3 #13 — owner-cluster correlation (label-propagation communities) ──
/// **Family-3 #13 (owner-cluster correlation)**: community detection over the
/// **co-ownership graph** to surface clusters of dependencies controlled by the
/// same small set of owners — packages that *look* independent in the resolved
/// tree but share a maintainer/publisher, so their supply-chain risk is
/// **correlated, not independent** (one compromised account poisons the whole
/// cluster; a single entity quietly owning a swath of the tree is a
/// concentration / collusion signal).
///
/// *Input:* [`DepNode::owners`] — the distinct owner/publisher identities of a
/// package from registry ownership metadata. Nodes without captured owners are
/// ignored; a snapshot with fewer than two owned deps has no community to find and
/// the analysis stays silent (empty input ⇒ doesn't fire).
///
/// *Algo:* build the undirected **co-ownership graph** (an edge between any two
/// deps sharing ≥ 1 owner), then run **label propagation** — every node starts
/// with a unique label and repeatedly adopts the most frequent label among its
/// neighbours (ties broken by the smallest label, for determinism) until stable.
/// The resulting communities are the owner clusters. For each community of at
/// least `min_cluster_size` deps, the **dominant owner** (present in the most deps
/// of the cluster) and its coverage are reported: `High` when one owner controls
/// **every** dep in the cluster (a pure single-entity cluster), else `Medium`.
/// Deterministic: nodes are visited in key order and findings are emitted sorted
/// by each cluster's representative (smallest) dep key.
pub fn owner_cluster_findings(snap: &WorldSnapshot, min_cluster_size: usize) -> Vec<Finding> {
// Deps that carry ownership metadata, in key order (determinism).
let mut owned: Vec<&DepNode> = snap
.dep_nodes
.iter()
.filter(|n| !n.owners.is_empty())
.collect();
owned.sort_by(|a, b| a.key.cmp(&b.key));
let n = owned.len();
if n < 2 {
return Vec::new();
}
// owner → indices of deps carrying that owner.
let mut by_owner: HashMap<&str, Vec<usize>> = HashMap::new();
for (i, d) in owned.iter().enumerate() {
for o in &d.owners {
by_owner.entry(o.as_str()).or_default().push(i);
}
}
// Co-ownership adjacency: connect every pair of deps that share an owner.
let mut adj: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
for members in by_owner.values() {
for (a_pos, &a) in members.iter().enumerate() {
for &b in &members[a_pos + 1..] {
adj[a].insert(b);
adj[b].insert(a);
}
}
}
// Label propagation: adopt the most frequent neighbour label (tie → smallest).
let mut label: Vec<usize> = (0..n).collect();
for _ in 0..100 {
let mut changed = false;
for i in 0..n {
if adj[i].is_empty() {
continue;
}
let mut counts: HashMap<usize, usize> = HashMap::new();
for &j in &adj[i] {
*counts.entry(label[j]).or_default() += 1;
}
// max by count, then smallest label (b.0.cmp(a.0) makes the smaller win).
let best = *counts
.iter()
.max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))
.map(|(l, _)| l)
.unwrap();
if best != label[i] {
label[i] = best;
changed = true;
}
}
if !changed {
break;
}
}
// Group deps by final label into communities.
let mut communities: HashMap<usize, Vec<usize>> = HashMap::new();
for (i, &l) in label.iter().enumerate() {
communities.entry(l).or_default().push(i);
}
let mut clusters: Vec<Vec<usize>> = communities.into_values().collect();
for c in clusters.iter_mut() {
c.sort_unstable();
}
clusters.sort_by(|a, b| owned[a[0]].key.cmp(&owned[b[0]].key));
let mut out = Vec::new();
for c in clusters {
if c.len() < min_cluster_size {
continue;
}
// The dominant owner across the cluster (tie → lexicographically smallest).
let mut cov: HashMap<&str, usize> = HashMap::new();
for &i in &c {
for o in &owned[i].owners {
*cov.entry(o.as_str()).or_default() += 1;
}
}
let (dominant, dcov) = cov
.iter()
.max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))
.map(|(o, n)| (*o, *n))
.unwrap();
let full = dcov == c.len();
let members: Vec<&str> = c.iter().map(|&i| owned[i].name.as_str()).collect();
out.push(Finding {
severity: if full { Severity::High } else { Severity::Medium },
id: format!("MODGUNN-OWNER-CLUSTER:{}", owned[c[0]].key),
summary: format!(
"owner cluster of {} deps [{}] — owner `{dominant}` controls {dcov}/{} ({}) → correlated supply-chain risk",
c.len(),
members.join(", "),
c.len(),
if full { "the entire cluster" } else { "the plurality" }
),
});
}
out
}
// ── Family-5 #19 — license-transition tracking ───────────────────────────────
/// The supply-chain **risk tier** of an SPDX license id, ascending — higher means
/// a heavier disclosure / copyleft-infection obligation on downstream consumers:
/// **0** permissive / public-domain, **1** weak (file/library) copyleft, **2**
/// strong (whole-work) copyleft, **3** network/server-side copyleft, **4** non-open
/// / proprietary / unstated. A relicense to a *higher* tier is the
/// [`license_transition_findings`] signal. Matching is case-insensitive and
/// substring-based over the SPDX id so version/suffix variants
/// (`GPL-3.0-only`, `AGPL-3.0-or-later`, `Apache-2.0`) fold to the right tier.
pub fn license_risk_tier(spdx: &str) -> u8 {
let s = spdx.trim().to_ascii_uppercase();
if s.is_empty() || s == "NOASSERTION" || s == "UNKNOWN" || s == "NONE" {
return 4; // an unstated / unknown license is a risk of its own
}
// Network / server-side copyleft — the strongest infection surface. Checked
// first because AGPL/OSL substrings also match weaker rules.
if ["AGPL", "SSPL", "OSL", "EUPL"]
.iter()
.any(|p| s.contains(p))
{
return 3;
}
// Weak (file/library) copyleft. LGPL is here (and must precede the plain-GPL
// rule, since "LGPL" contains "GPL").
if ["LGPL", "MPL", "EPL", "CDDL", "CPL", "MS-RL"]
.iter()
.any(|p| s.contains(p))
{
return 1;
}
// Strong (whole-work) copyleft — plain GPL, once AGPL/LGPL are ruled out.
if s.contains("GPL") {
return 2;
}
// Permissive / public-domain.
if [
"MIT",
"APACHE",
"BSD",
"ISC",
"ZLIB",
"0BSD",
"UNLICENSE",
"CC0",
"WTFPL",
"BSL-1",
"BOOST",
"PSF",
"PYTHON",
"POSTGRESQL",
"NCSA",
"ARTISTIC",
]
.iter()
.any(|p| s.contains(p))
{
return 0;
}
// Everything else (commercial / proprietary / source-available EULAs like
// BUSL / Elastic / custom) is treated as non-open / encumbered.
4
}
/// **Family-5 #19 (license-transition tracking)**: flag a dependency that
/// **relicenses to a riskier license** across its version history — the classic
/// MIT→AGPL "rug-pull", or a slide to an unstated / proprietary license. This is a
/// legal-drift signal: a transitive relicense can silently impose new
/// disclosure / copyleft-contamination obligations on everything downstream of it.
///
/// *Input:* [`DepNode::license_history`] — the package's `(version, spdx)` license
/// timeline in **release order** from registry / SBOM metadata. A node with fewer
/// than two license points has no transition to detect and is skipped (empty ⇒
/// doesn't fire).
///
/// *Algo:* walk consecutive license entries; a transition to a higher
/// [`license_risk_tier`] is a riskward relicense. Report the single **largest tier
/// jump** per package (ties broken by the higher destination tier); the destination
/// tier governs severity: `High` for a move to network-copyleft or non-open /
/// unstated (tier ≥ 3), `Medium` for strong copyleft (tier 2), `Low` for weak
/// copyleft (tier 1). A relicense to an equal-or-lower tier (e.g. GPL→MIT) is never
/// flagged. Deterministic: packages visited in `(ecosystem, name)` key order; the
/// longest captured history wins per package.
///
/// *Scope:* this is the relicense-transition core; the transitive
/// copyleft-contamination-path cut (which downstream nodes a copyleft term reaches)
/// is a further extension over the dep graph.
pub fn license_transition_findings(snap: &WorldSnapshot) -> Vec<Finding> {
// One license timeline per package identity (same folding as #7/#25): a
// snapshot pins one version, so the history is a package property.
let mut by_pkg: HashMap<(&str, &str), &[(String, String)]> = HashMap::new();
for n in &snap.dep_nodes {
if n.license_history.len() < 2 {
continue;
}
let k = (n.ecosystem.as_str(), n.name.as_str());
let entry = by_pkg.entry(k).or_default();
if n.license_history.len() > entry.len() {
*entry = n.license_history.as_slice();
}
}
let mut keys: Vec<_> = by_pkg.keys().copied().collect();
keys.sort_unstable();
let mut out = Vec::new();
for k in keys {
let hist = by_pkg[&k];
// The largest riskward tier jump across consecutive versions (tie → higher
// destination tier).
let mut worst: Option<(u8, u8, &(String, String), &(String, String))> = None;
for w in hist.windows(2) {
let ft = license_risk_tier(&w[0].1);
let tt = license_risk_tier(&w[1].1);
if tt <= ft {
continue; // equal-or-safer relicense is not a risk signal
}
let jump = tt - ft;
let better = worst.map_or(true, |(wj, wt, _, _)| jump > wj || (jump == wj && tt > wt));
if better {
worst = Some((jump, tt, &w[0], &w[1]));
}
}
if let Some((_, tt, from, to)) = worst {
let severity = match tt {
t if t >= 3 => Severity::High,
2 => Severity::Medium,
_ => Severity::Low,
};
out.push(Finding {
severity,
id: format!("MODGUNN-LICENSE-TRANSITION:{}:{}", k.0, k.1),
summary: format!(
"`{}` ({}) relicensed to a riskier license: `{}` (v{}) → `{}` (v{}) — legal-drift / copyleft-contamination risk",
k.1, k.0, from.1, from.0, to.1, to.0
),
});
}
}
out
}
// ── Family-4 #18 — embedding-novelty ─────────────────────────────────────────
/// Cosine distance `1 - cos(a, b)` between two equal-length vectors — `0.0` for
/// identical direction, up to `2.0` for opposed. Returns `None` when the lengths
/// differ or either vector has zero magnitude (distance undefined).
fn cosine_distance(a: &[f32], b: &[f32]) -> Option<f64> {
if a.len() != b.len() {
return None;
}
let mut dot = 0.0f64;
let mut na = 0.0f64;
let mut nb = 0.0f64;
for (&x, &y) in a.iter().zip(b) {
dot += x as f64 * y as f64;
na += x as f64 * x as f64;
nb += y as f64 * y as f64;
}
if na == 0.0 || nb == 0.0 {
return None;
}
Some(1.0 - dot / (na.sqrt() * nb.sqrt()))
}
/// **Family-4 #18 (embedding-novelty)**: flag a package version whose code
/// embedding sits **far from the ecosystem norm** — semantically-novel code, the
/// shape of freshly-injected / unrelated payload smuggled into a package.
///
/// *Input:* [`DepNode::embedding`] — the package-version code vector from the
/// nornir embedder, attached by a producer (modgunn does not run the embedder).
/// Nodes without an embedding are ignored; an ecosystem with fewer than **3**
/// embedded nodes has no stable norm and is skipped (empty ⇒ doesn't fire).
///
/// *Algo:* per ecosystem, compute the **centroid** (component-wise mean) of its
/// embeddings, then the [`cosine_distance`] of each node to that centroid. A node
/// whose distance exceeds `distance_threshold` is a novelty outlier: `High` when it
/// is *very* far (distance > `2 × distance_threshold`), else `Medium`. Vectors
/// whose dimension disagrees with the ecosystem's first (key-sorted) embedded node
/// are skipped defensively. Deterministic: nodes visited in key order, findings
/// emitted sorted by id.
pub fn embedding_novelty_findings(snap: &WorldSnapshot, distance_threshold: f64) -> Vec<Finding> {
// Embedded nodes grouped by ecosystem, in key order (determinism).
let mut embedded: Vec<&DepNode> = snap
.dep_nodes
.iter()
.filter(|n| n.embedding.is_some())
.collect();
embedded.sort_by(|a, b| a.key.cmp(&b.key));
let mut by_eco: HashMap<&str, Vec<&DepNode>> = HashMap::new();
for n in embedded {
by_eco.entry(n.ecosystem.as_str()).or_default().push(n);
}
let mut ecos: Vec<&str> = by_eco.keys().copied().collect();
ecos.sort_unstable();
let mut out = Vec::new();
for eco in ecos {
let nodes = &by_eco[eco];
// Consistent-dimension cohort: the first (key-sorted) node fixes the dim.
let dim = nodes[0].embedding.as_ref().unwrap().len();
let cohort: Vec<&DepNode> = nodes
.iter()
.copied()
.filter(|n| n.embedding.as_ref().unwrap().len() == dim)
.collect();
if cohort.len() < 3 || dim == 0 {
continue; // too few embeddings (or empty vectors) for a stable norm
}
// Component-wise centroid.
let mut centroid = vec![0.0f64; dim];
for n in &cohort {
for (c, &x) in centroid.iter_mut().zip(n.embedding.as_ref().unwrap()) {
*c += x as f64;
}
}
for c in centroid.iter_mut() {
*c /= cohort.len() as f64;
}
let centroid_f32: Vec<f32> = centroid.iter().map(|&c| c as f32).collect();
for n in &cohort {
let Some(dist) = cosine_distance(n.embedding.as_ref().unwrap(), ¢roid_f32) else {
continue;
};
if dist > distance_threshold {
let very = dist > 2.0 * distance_threshold;
out.push(Finding {
severity: if very { Severity::High } else { Severity::Medium },
id: format!("MODGUNN-EMBEDDING-NOVELTY:{}", n.key),
summary: format!(
"`{}` ({}) embedding is cosine-distance {dist:.3} from the {eco} ecosystem norm (> {distance_threshold:.2}) — semantically-novel code",
n.name, n.ecosystem
),
});
}
}
}
out.sort_by(|a, b| a.id.cmp(&b.id));
out
}
// ── Taint-path (untrusted source → sensitive sink) ───────────────────────────
/// Relative danger rank of a taint sink capability — picks the *worst* sink a
/// symbol carries so a finding reports (and is severity-scored by) the most
/// dangerous capability reached.
fn taint_sink_rank(c: Capability) -> u8 {
match c {
Capability::DynamicCode => 5,
Capability::Process => 4,
Capability::Ffi => 3,
Capability::Filesystem => 2,
Capability::Env => 1,
_ => 0,
}
}
/// The default **sensitive-sink** capability set for taint analysis: reaching one
/// of these with untrusted (tainted) data is the dangerous event — command/process
/// spawn, dynamic code load, filesystem write, native FFI, and environment/secret
/// read. ([`Capability::Network`] is treated as the *source* side — where untrusted
/// input enters — not a sink.)
pub fn default_taint_sinks() -> BTreeSet<Capability> {
[
Capability::Process,
Capability::DynamicCode,
Capability::Filesystem,
Capability::Ffi,
Capability::Env,
]
.into_iter()
.collect()
}
/// **Taint-path analysis (source→sink)**: flag call-graph paths from an
/// **untrusted source** to a **sensitive sink** — untrusted data that can flow
/// into a dangerous operation. A source is a symbol marked
/// [`Symbol::taint_source`] (a network / IPC / deserialization read); a sink is
/// any symbol that directly exercises a capability in `sink_caps` (see
/// [`default_taint_sinks`]). A path exists when the sink symbol is call-reachable
/// from the source over the SCIP call graph.
///
/// *Input:* [`Symbol::is_taint_source`] flags (none marked ⇒ the analysis doesn't
/// fire) plus the call graph and per-symbol [`Symbol::capabilities`] already
/// carried by the snapshot.
///
/// *Algo:* per-source forward BFS ([`WorldSnapshot::reachable_from`]); every
/// reached *distinct* symbol bearing a sink capability is a source→sink taint
/// path. `Critical` when the sink is code/command execution
/// ([`Capability::DynamicCode`] / [`Capability::Process`]) — the worst case —
/// else `High`. Emitted sorted by `(source, sink)` so output is deterministic and
/// independent of input order.
pub fn taint_path_findings(snap: &WorldSnapshot, sink_caps: &BTreeSet<Capability>) -> Vec<Finding> {
let mut sources: Vec<&str> = snap
.symbols
.iter()
.filter(|s| s.is_taint_source)
.map(|s| s.id.as_str())
.collect();
if sources.is_empty() || sink_caps.is_empty() {
return Vec::new();
}
sources.sort_unstable();
let mut out = Vec::new();
for src in sources {
let reachable = snap.reachable_from(&[src]);
// (sink id, worst sink capability), deterministic order.
let mut sinks: Vec<(&str, Capability)> = Vec::new();
for s in &snap.symbols {
if s.id == src || !reachable.contains(s.id.as_str()) {
continue; // a genuine path needs a distinct, reachable sink endpoint
}
if let Some(cap) = s
.capabilities
.iter()
.filter(|c| sink_caps.contains(c))
.copied()
.max_by_key(|c| taint_sink_rank(*c))
{
sinks.push((s.id.as_str(), cap));
}
}
sinks.sort_by(|a, b| a.0.cmp(b.0));
for (sink_id, cap) in sinks {
let critical = matches!(cap, Capability::DynamicCode | Capability::Process);
out.push(Finding {
severity: if critical { Severity::Critical } else { Severity::High },
id: format!("MODGUNN-TAINT-PATH:{src}->{sink_id}"),
summary: format!(
"untrusted source `{src}` reaches sensitive `{}` sink `{sink_id}` — tainted data can flow into a {} operation",
cap.label(),
cap.label()
),
});
}
}
out
}
/// Shannon entropy (bits/symbol) of a string's bytes — the obfuscation metric A17
/// keys on (minified/encoded identifiers have near-maximal entropy). Re-exported
/// from [`crate::core`] so the A17 tripwire here and the A23 behaviour-feature
/// extractor (`scan`) share ONE definition, never a per-feature twin (L5). The
/// stable path `modgunn::scip::shannon_entropy` is preserved for existing callers.
pub use crate::core::shannon_entropy;
// ── A22 — behaviour fingerprint ──────────────────────────────────────────────
/// FNV-1a over a symbol's sorted transitive call closure — a stable in-process
/// fingerprint of its *effective behaviour*.
fn fingerprint(snap: &WorldSnapshot, root: &str) -> u64 {
let closure = snap.reachable_from(&[root]);
let mut ids: Vec<&str> = closure.into_iter().collect();
ids.sort_unstable();
let mut h: u64 = 0xcbf29ce484222325;
for id in ids {
for b in id.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h ^= 0xff; // field separator
h = h.wrapping_mul(0x100000001b3);
}
h
}
// ── Tripwires (the delta event stream) ───────────────────────────────────────
/// A tripwire: a rule/algorithm that fires on the world-delta between two
/// snapshots. Concrete tripwires implement the catalog's delta analyses.
pub trait Tripwire {
fn name(&self) -> &str;
/// Evaluate the delta `from` → `to`; return any findings raised.
fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding>;
}
/// **A1** — call-graph delta: new / removed edges between two snapshots.
#[derive(Debug, Default)]
pub struct CallGraphDeltaTripwire;
impl Tripwire for CallGraphDeltaTripwire {
fn name(&self) -> &str {
"call-graph-delta"
}
fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
let old: HashSet<&CallEdge> = from.calls.iter().collect();
let new: HashSet<&CallEdge> = to.calls.iter().collect();
let mut findings = Vec::new();
let added = new.difference(&old).count();
let removed = old.difference(&new).count();
if added > 0 || removed > 0 {
findings.push(Finding {
severity: Severity::Info,
id: "MODGUNN-CALLGRAPH-DELTA".into(),
summary: format!("{added} call edge(s) added, {removed} removed"),
});
}
findings
}
}
/// **A9-drift / A10** — capability-surface drift: a capability reachable from
/// entrypoints in `to` that was NOT reachable in `from` (the surface grew).
#[derive(Debug, Default)]
pub struct CapabilityDriftTripwire;
impl Tripwire for CapabilityDriftTripwire {
fn name(&self) -> &str {
"capability-drift"
}
fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
let before = capability_surface(from);
let after = capability_surface(to);
after
.difference(&before)
.map(|c| Finding {
severity: Severity::High,
id: format!("MODGUNN-CAPABILITY-GAINED:{}", c.label()),
summary: format!(
"capability `{}` became reachable from entrypoints",
c.label()
),
})
.collect()
}
}
/// **A8-delta / family-1 #5** — sink-newly-reachable: a vuln sink reachable in
/// `to` but not in `from`. The highest-signal delta event.
#[derive(Debug, Default)]
pub struct SinkNewlyReachableTripwire;
impl Tripwire for SinkNewlyReachableTripwire {
fn name(&self) -> &str {
"sink-newly-reachable"
}
fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
let before = from.reachable_from_entrypoints();
let before: HashSet<String> = before.into_iter().map(str::to_string).collect();
let after = to.reachable_from_entrypoints();
to.vuln_sinks
.iter()
.filter(|s| after.contains(s.symbol.as_str()) && !before.contains(&s.symbol))
.map(|s| Finding {
severity: s.severity.max(Severity::High),
id: format!("MODGUNN-SINK-NEWLY-REACHABLE:{}", s.advisory),
summary: format!(
"vulnerable symbol `{}` ({}) is newly reachable",
s.symbol, s.advisory
),
})
.collect()
}
}
/// **A22** — behaviour-fingerprint drift: a symbol whose transitive call closure
/// (its effective behaviour) changed while its package **version** and public API
/// (`display`) did NOT — the silent 5th-level-dependency signal.
#[derive(Debug, Default)]
pub struct FingerprintDriftTripwire;
impl Tripwire for FingerprintDriftTripwire {
fn name(&self) -> &str {
"fingerprint-drift"
}
fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
let mut findings = Vec::new();
for s_new in &to.symbols {
let Some(s_old) = from.symbol(&s_new.id) else {
continue;
};
// Only the *silent* case: identical package version + public API.
if s_old.package != s_new.package || s_old.display != s_new.display {
continue;
}
if fingerprint(from, &s_old.id) != fingerprint(to, &s_new.id) {
findings.push(Finding {
severity: Severity::High,
id: format!("MODGUNN-FINGERPRINT-DRIFT:{}", s_new.id),
summary: format!(
"behaviour of `{}` changed with NO version/API change ({})",
s_new.id, s_new.package
),
});
}
}
findings
}
}
/// **A17** — obfuscation / entropy spike: a symbol **newly introduced** in `to`
/// whose display name has Shannon entropy at/above `min_entropy` bits/byte — the
/// minified / base64 / packed-identifier signal of injected obfuscated code. Only
/// *new* symbols are flagged (a delta), so a stable high-entropy name doesn't
/// re-fire every diff.
#[derive(Debug)]
pub struct ObfuscationTripwire {
/// Entropy threshold in bits/byte (English identifiers sit ~3–4; packed/base64
/// blobs approach the 8-bit ceiling). Default [`Self::DEFAULT_MIN_ENTROPY`].
pub min_entropy: f64,
/// Ignore names shorter than this (short names have unstable entropy).
pub min_len: usize,
}
impl ObfuscationTripwire {
/// A conservative default: 4.0 bits/byte over names ≥ 12 chars.
pub const DEFAULT_MIN_ENTROPY: f64 = 4.0;
}
impl Default for ObfuscationTripwire {
fn default() -> Self {
Self {
min_entropy: Self::DEFAULT_MIN_ENTROPY,
min_len: 12,
}
}
}
impl Tripwire for ObfuscationTripwire {
fn name(&self) -> &str {
"obfuscation-entropy-spike"
}
fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
let old: HashSet<&str> = from.symbols.iter().map(|s| s.id.as_str()).collect();
let mut findings = Vec::new();
for s in &to.symbols {
if old.contains(s.id.as_str()) {
continue; // only newly-introduced symbols
}
if s.display.len() < self.min_len {
continue;
}
let h = shannon_entropy(&s.display);
if h >= self.min_entropy {
findings.push(Finding {
severity: Severity::Medium,
id: format!("MODGUNN-OBFUSCATION:{}", s.id),
summary: format!(
"new symbol `{}` ({}) has high-entropy name ({h:.2} bits/byte) — possible obfuscation",
s.display, s.package
),
});
}
}
findings.sort_by(|a, b| a.id.cmp(&b.id));
findings
}
}
// ── Family-5 #20 — SBOM drift (component set-diff between two snapshots) ──────
/// **Family-5 #20 (SBOM drift)**: the auditable **component set-diff** between two
/// world snapshots — *what entered and what left the dependency world, and when*.
/// This is the SBOM-level companion to the raw `warehouse::sbom_drift` helper:
/// where that returns bare `(name, version)` sets off the persisted
/// `sbom_components` table, this surfaces the drift as catalog [`Finding`]s over an
/// in-memory [`WorldSnapshot`] pair — the same shape every other Engine-B analysis
/// emits, so a consumer folds it into a [`BehaviorDelta`] / [`Verdict`] uniformly.
///
/// *Input:* [`WorldSnapshot::dep_nodes`] on both sides; a component's identity is
/// its [`DepNode::key`] (`ecosystem:name@version`), so a version bump reads as one
/// component leaving and another entering (the honest SBOM record).
///
/// *Severity:* an **entered unverified** component is `Low` (a new dependency
/// arrived carrying no provenance — the reviewable event); every other entered /
/// left component is `Info` (the neutral auditable record). Deterministic:
/// findings emitted sorted by id.
#[derive(Debug, Default)]
pub struct SbomDriftTripwire;
impl Tripwire for SbomDriftTripwire {
fn name(&self) -> &str {
"sbom-drift"
}
fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
let old: HashSet<&str> = from.dep_nodes.iter().map(|n| n.key.as_str()).collect();
let new: HashSet<&str> = to.dep_nodes.iter().map(|n| n.key.as_str()).collect();
let mut out = Vec::new();
for n in &to.dep_nodes {
if old.contains(n.key.as_str()) {
continue;
}
let unverified = !n.verified;
out.push(Finding {
severity: if unverified {
Severity::Low
} else {
Severity::Info
},
id: format!("MODGUNN-SBOM-ENTERED:{}", n.key),
summary: format!(
"component `{}` entered the world{}",
n.key,
if unverified { " (no provenance)" } else { "" }
),
});
}
for n in &from.dep_nodes {
if new.contains(n.key.as_str()) {
continue;
}
out.push(Finding {
severity: Severity::Info,
id: format!("MODGUNN-SBOM-LEFT:{}", n.key),
summary: format!("component `{}` left the world", n.key),
});
}
out.sort_by(|a, b| a.id.cmp(&b.id));
out
}
}
// ── Family-2 #6 — publisher / maintainer anomaly (ownership-transfer delta) ───
/// **Family-2 #6 (publisher/maintainer anomaly)**: a delta tripwire over the
/// **ownership** of a package between two snapshots — the account-takeover /
/// ownership-transfer signal. Where #7 ([`release_cadence_anomalies`]) watches
/// *when* a package publishes, this watches *who controls it*: a package whose
/// [`DepNode::owners`] set changes between snapshots has had an ownership event,
/// and a package whose owners are **entirely replaced** (no surviving owner) is
/// the shape of a hostile takeover — the exact pre-condition of a supply-chain
/// injection.
///
/// *Input:* [`DepNode::owners`] on the same `(ecosystem, name)` in both snapshots
/// (registry ownership metadata). A package present in only one snapshot, or with
/// no captured owners on either side, is not an *ownership-transfer* event and is
/// skipped (a newly-added dep is the SBOM-drift / policy surface, not this one).
/// [`DepNode::maintainers`] is a secondary signal: a maintainer **count collapse**
/// (many → one) on an unchanged owner set is a bus-factor-worsening co-signal.
///
/// *Severity:* `High` when the owner set is **fully replaced** (disjoint old/new —
/// a takeover), `Medium` when it changes but overlaps (a partial ownership change /
/// added or dropped co-owner), `Low` for a maintainer-count collapse with an
/// unchanged owner set. Deterministic: findings emitted sorted by package key.
#[derive(Debug, Default)]
pub struct MaintainerAnomalyTripwire;
impl Tripwire for MaintainerAnomalyTripwire {
fn name(&self) -> &str {
"maintainer-anomaly"
}
fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
// Latest owner/maintainer facts per (ecosystem, name) on each side. A
// snapshot pins one version, but ownership is a package property, so we
// fold to the package identity (last-writer in vec order is irrelevant —
// owners of the same package agree within a snapshot).
fn by_pkg(snap: &WorldSnapshot) -> HashMap<(&str, &str), (&Vec<String>, Option<u32>)> {
let mut m: HashMap<(&str, &str), (&Vec<String>, Option<u32>)> = HashMap::new();
for n in &snap.dep_nodes {
if n.owners.is_empty() {
continue;
}
m.entry((n.ecosystem.as_str(), n.name.as_str()))
.or_insert((&n.owners, n.maintainers));
}
m
}
let old = by_pkg(from);
let new = by_pkg(to);
let mut keys: Vec<_> = new
.keys()
.copied()
.filter(|k| old.contains_key(k))
.collect();
keys.sort_unstable();
let mut out = Vec::new();
for k in keys {
let (old_owners, old_maint) = old[&k];
let (new_owners, new_maint) = new[&k];
let before: BTreeSet<&str> = old_owners.iter().map(String::as_str).collect();
let after: BTreeSet<&str> = new_owners.iter().map(String::as_str).collect();
if before == after {
// No ownership change — check for a maintainer-count collapse.
if let (Some(o), Some(n)) = (old_maint, new_maint) {
if o >= 2 && n < o && n <= 1 {
out.push(Finding {
severity: Severity::Low,
id: format!("MODGUNN-MAINTAINER-COLLAPSE:{}:{}", k.0, k.1),
summary: format!(
"`{}` ({}) maintainer count collapsed {o} → {n} with no owner change — bus-factor / takeover-prep signal",
k.1, k.0
),
});
}
}
continue;
}
let disjoint = before.is_disjoint(&after);
let severity = if disjoint {
Severity::High
} else {
Severity::Medium
};
let removed: Vec<&str> = before.difference(&after).copied().collect();
let added: Vec<&str> = after.difference(&before).copied().collect();
out.push(Finding {
severity,
id: format!("MODGUNN-MAINTAINER-ANOMALY:{}:{}", k.0, k.1),
summary: format!(
"`{}` ({}) ownership {}: owners [{}] → [{}] (dropped [{}], added [{}]) — {} anomaly",
k.1,
k.0,
if disjoint { "fully replaced" } else { "changed" },
old_owners.join(", "),
new_owners.join(", "),
removed.join(", "),
added.join(", "),
if disjoint { "account-takeover-shaped" } else { "publisher/maintainer" }
),
});
}
out
}
}
// ── Family-6 #23 — honeytoken / canary ───────────────────────────────────────
/// **Family-6 #23 (honeytoken / canary)** — a planted marker whose *appearance in
/// the observed world is itself the alarm*. The operator seeds these; the analysis
/// never fires on a clean world, so any finding is high-confidence by construction.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Honeytoken {
/// An **internal package name** that must NEVER resolve to a real dependency —
/// a dependency-confusion canary. If a resolved [`DepNode`] bears this name, an
/// attacker has published a public package under a private name and the resolver
/// pulled it: the classic dependency-confusion hit. Matched case-insensitively
/// on [`DepNode::name`].
InternalName(String),
/// A **secret-shaped canary token** planted in internal code / config that must
/// never surface in the published/observed world. If the token appears in any
/// [`Symbol`] (id or display) or any [`DepNode::key`], the marker has been
/// exfiltrated or resolved externally. Matched as a substring.
CanaryToken(String),
}
impl Honeytoken {
fn value(&self) -> &str {
match self {
Honeytoken::InternalName(v) | Honeytoken::CanaryToken(v) => v,
}
}
}
/// **Family-6 #23 (honeytoken / canary)**: scan a [`WorldSnapshot`] for any planted
/// [`Honeytoken`] that has surfaced — a resolved internal name (dependency-confusion)
/// or an exfiltrated canary token. Every hit is `Critical`; an unplanted world (empty
/// `planted`) never fires. Deterministic: findings emitted sorted by id.
///
/// *Input:* the operator's planted `planted` set plus the snapshot's own
/// [`WorldSnapshot::dep_nodes`] / [`WorldSnapshot::symbols`]. No external state.
pub fn honeytoken_findings(snap: &WorldSnapshot, planted: &[Honeytoken]) -> Vec<Finding> {
let mut out = Vec::new();
for token in planted {
let needle = token.value();
if needle.is_empty() {
continue;
}
match token {
Honeytoken::InternalName(name) => {
let want = name.to_ascii_lowercase();
for n in &snap.dep_nodes {
if n.name.to_ascii_lowercase() == want {
out.push(Finding {
severity: Severity::Critical,
id: format!("MODGUNN-HONEYTOKEN-CONFUSION:{}", n.key),
summary: format!(
"internal-name honeytoken `{name}` resolved to a real dependency `{}` — dependency-confusion hit",
n.key
),
});
}
}
}
Honeytoken::CanaryToken(tok) => {
let mut hits: Vec<String> = Vec::new();
for s in &snap.symbols {
if s.id.contains(tok) || s.display.contains(tok) {
hits.push(format!("symbol:{}", s.id));
}
}
for n in &snap.dep_nodes {
if n.key.contains(tok) {
hits.push(format!("dep:{}", n.key));
}
}
for site in hits {
out.push(Finding {
severity: Severity::Critical,
id: format!("MODGUNN-HONEYTOKEN-CANARY:{tok}:{site}"),
summary: format!(
"canary token `{tok}` surfaced in the observed world at `{site}` — exfiltration / external resolution",
),
});
}
}
}
}
out.sort_by(|a, b| a.id.cmp(&b.id));
out.dedup_by(|a, b| a.id == b.id);
out
}
// ── Family-6 #21 — policy tripwires (declarative rule DSL) ────────────────────
/// **Family-6 #21 (policy tripwires)** — one declarative rule in the policy DSL. A
/// [`PolicyTripwire`] carries a set of these and fires a [`Finding`] on the
/// world-delta whenever a rule is broken. Unlike the fixed algorithmic tripwires
/// (`CallGraphDeltaTripwire` …), these are **operator-authored assertions about
/// what may change** between two snapshots — the "no new native FFI in transitive
/// deps / no dep added without provenance / capability surface may not grow without
/// review" rules the catalog calls for, expressed as data rather than code.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyRule {
/// *"The reachable capability surface may not grow without review."* Any
/// [`Capability`] reachable from entrypoints in `to` but not in `from` is a
/// violation.
NoCapabilityGrowth,
/// *"No new native code in transitive deps."* A [`NoCapabilityGrowth`] narrowed
/// to the native-execution capabilities ([`Capability::Ffi`] /
/// [`Capability::Unsafe`]) — the common "no new FFI/`unsafe`" gate.
///
/// [`NoCapabilityGrowth`]: PolicyRule::NoCapabilityGrowth
NoNewNativeCode,
/// *"No dependency added without provenance."* A [`DepNode`] present in `to` but
/// not `from` whose [`DepNode::verified`] is `false` is a violation.
NoUnverifiedDependencyAdded,
/// *"No dependency from registry X."* A [`DepNode`] added in `to` whose
/// [`DepNode::ecosystem`] equals the named registry is a violation.
ForbidEcosystem(String),
}
impl PolicyRule {
/// A short stable label for findings / logs.
pub fn label(&self) -> String {
match self {
PolicyRule::NoCapabilityGrowth => "no-capability-growth".into(),
PolicyRule::NoNewNativeCode => "no-new-native-code".into(),
PolicyRule::NoUnverifiedDependencyAdded => "no-unverified-dep-added".into(),
PolicyRule::ForbidEcosystem(e) => format!("forbid-ecosystem:{e}"),
}
}
/// Evaluate this single rule against the delta `from` → `to`.
fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
let violation = |detail: String, subject: &str| Finding {
severity: Severity::High,
id: format!("MODGUNN-POLICY:{}:{subject}", self.label()),
summary: format!("policy `{}` violated: {detail}", self.label()),
};
match self {
PolicyRule::NoCapabilityGrowth | PolicyRule::NoNewNativeCode => {
let before = capability_surface(from);
let after = capability_surface(to);
let native = matches!(self, PolicyRule::NoNewNativeCode);
let mut gained: Vec<Capability> = after.difference(&before).copied().collect();
if native {
gained.retain(|c| matches!(c, Capability::Ffi | Capability::Unsafe));
}
gained.sort_by_key(|c| c.label());
gained
.into_iter()
.map(|c| {
violation(
format!(
"capability `{}` became reachable from entrypoints",
c.label()
),
c.label(),
)
})
.collect()
}
PolicyRule::NoUnverifiedDependencyAdded | PolicyRule::ForbidEcosystem(_) => {
let old: HashSet<&str> = from.dep_nodes.iter().map(|n| n.key.as_str()).collect();
let mut added: Vec<&DepNode> = to
.dep_nodes
.iter()
.filter(|n| !old.contains(n.key.as_str()))
.collect();
added.sort_by(|a, b| a.key.cmp(&b.key));
added
.into_iter()
.filter_map(|n| match self {
PolicyRule::NoUnverifiedDependencyAdded if !n.verified => Some(violation(
format!("dependency `{}` was added without provenance", n.key),
&n.key,
)),
PolicyRule::ForbidEcosystem(e) if &n.ecosystem == e => Some(violation(
format!(
"dependency `{}` was added from forbidden registry `{e}`",
n.key
),
&n.key,
)),
_ => None,
})
.collect()
}
}
}
}
/// **Family-6 #21 (policy tripwires)**: a [`Tripwire`] that fires when any of its
/// declarative [`PolicyRule`]s is broken across the world-delta. Construct it with
/// the operator's ruleset (or [`default_policy`]) and plug it into
/// [`diff_with`] alongside the algorithmic battery.
#[derive(Debug, Clone, Default)]
pub struct PolicyTripwire {
pub rules: Vec<PolicyRule>,
}
impl PolicyTripwire {
pub fn new(rules: impl IntoIterator<Item = PolicyRule>) -> Self {
Self {
rules: rules.into_iter().collect(),
}
}
}
impl Tripwire for PolicyTripwire {
fn name(&self) -> &str {
"policy"
}
fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
let mut out = Vec::new();
for r in &self.rules {
out.extend(r.evaluate(from, to));
}
out
}
}
/// A sensible default policy: the capability surface may not grow, and no
/// dependency may be added without provenance — the two highest-value assertions
/// for a supply-chain gate.
pub fn default_policy() -> PolicyTripwire {
PolicyTripwire::new([
PolicyRule::NoCapabilityGrowth,
PolicyRule::NoUnverifiedDependencyAdded,
])
}
// ── Family-6 #22 — threshold tripwires (declarative threshold DSL) ────────────
/// **Family-6 #22 (threshold tripwires)** — one declarative *numeric* threshold in
/// the tripwire DSL. Where a [`PolicyRule`] (#21) asserts a **boolean** invariant
/// ("no new native code"), a [`ThresholdRule`] fires when a **measured quantity**
/// crosses an operator-set bound — "centrality of an unverified dep crossed T",
/// "entropy spike > σ", "capability set grew by ≥ k" — the catalog's #22.
#[derive(Debug, Clone, PartialEq)]
pub enum ThresholdRule {
/// Fire on any **unverified** dep whose blast-radius ([`blast_radius`]) PageRank
/// centrality in `to` strictly exceeds this score — a high-leverage dep the
/// world can't vouch for.
UnverifiedCentralityOver(f64),
/// Fire when the reachable [`capability_surface`] grew by **at least** this many
/// distinct capabilities across the delta.
CapabilitySurfaceGrewBy(usize),
/// Fire on any **newly-introduced** symbol whose display-name Shannon entropy
/// (over names ≥ 8 chars) strictly exceeds this bits/byte bound — a declarative
/// twin of [`ObfuscationTripwire`] with an operator-set σ.
NewSymbolEntropyOver(f64),
/// Fire on any dep whose dep-graph **fan-in** (in-degree) in `to` is at least
/// this many — a concentration threshold over the resolved tree.
FanInAtLeast(usize),
}
impl ThresholdRule {
/// A short stable label for findings / logs.
pub fn label(&self) -> String {
match self {
ThresholdRule::UnverifiedCentralityOver(t) => format!("unverified-centrality>{t}"),
ThresholdRule::CapabilitySurfaceGrewBy(k) => format!("capability-growth>={k}"),
ThresholdRule::NewSymbolEntropyOver(t) => format!("new-symbol-entropy>{t}"),
ThresholdRule::FanInAtLeast(k) => format!("fan-in>={k}"),
}
}
fn finding(&self, severity: Severity, subject: &str, detail: String) -> Finding {
Finding {
severity,
id: format!("MODGUNN-THRESHOLD:{}:{subject}", self.label()),
summary: format!("threshold `{}` crossed: {detail}", self.label()),
}
}
fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
match self {
ThresholdRule::UnverifiedCentralityOver(t) => {
let verified: HashMap<&str, bool> = to
.dep_nodes
.iter()
.map(|n| (n.key.as_str(), n.verified))
.collect();
blast_radius(to)
.into_iter()
.filter(|(k, score)| {
score > t && !verified.get(k.as_str()).copied().unwrap_or(false)
})
.map(|(k, score)| {
self.finding(
Severity::High,
&k,
format!("unverified dep `{k}` centrality {score:.4} > {t}"),
)
})
.collect()
}
ThresholdRule::CapabilitySurfaceGrewBy(k) => {
let before = capability_surface(from).len();
let after = capability_surface(to).len();
let grew = after.saturating_sub(before);
if grew >= *k && *k > 0 {
vec![self.finding(
Severity::High,
"surface",
format!("capability surface grew by {grew} (≥ {k})"),
)]
} else {
Vec::new()
}
}
ThresholdRule::NewSymbolEntropyOver(t) => {
let old: HashSet<&str> = from.symbols.iter().map(|s| s.id.as_str()).collect();
let mut new: Vec<&Symbol> = to
.symbols
.iter()
.filter(|s| !old.contains(s.id.as_str()))
.collect();
new.sort_by(|a, b| a.id.cmp(&b.id));
new.into_iter()
.filter(|s| s.display.len() >= 8)
.filter_map(|s| {
let h = shannon_entropy(&s.display);
(h > *t).then(|| {
self.finding(
Severity::Medium,
&s.id,
format!(
"new symbol `{}` entropy {h:.2} > {t} bits/byte",
s.display
),
)
})
})
.collect()
}
ThresholdRule::FanInAtLeast(k) => {
let mut indeg: HashMap<&str, usize> = HashMap::new();
for n in &to.dep_nodes {
indeg.entry(n.key.as_str()).or_insert(0);
}
for e in &to.dep_edges {
*indeg.entry(e.to.as_str()).or_insert(0) += 1;
}
let mut hits: Vec<(&str, usize)> = indeg
.into_iter()
.filter(|(_, d)| *d >= *k && *k > 0)
.collect();
hits.sort_by(|a, b| a.0.cmp(b.0));
hits.into_iter()
.map(|(key, d)| {
self.finding(
Severity::High,
key,
format!("dep `{key}` fan-in {d} (≥ {k}) — concentration point"),
)
})
.collect()
}
}
}
}
/// **Family-6 #22 (threshold tripwires)**: a [`Tripwire`] firing when any of its
/// declarative [`ThresholdRule`]s is crossed on the world-delta.
#[derive(Debug, Clone, Default)]
pub struct ThresholdTripwire {
pub rules: Vec<ThresholdRule>,
}
impl ThresholdTripwire {
pub fn new(rules: impl IntoIterator<Item = ThresholdRule>) -> Self {
Self {
rules: rules.into_iter().collect(),
}
}
}
impl Tripwire for ThresholdTripwire {
fn name(&self) -> &str {
"threshold"
}
fn evaluate(&self, from: &WorldSnapshot, to: &WorldSnapshot) -> Vec<Finding> {
let mut out = Vec::new();
for r in &self.rules {
out.extend(r.evaluate(from, to));
}
out
}
}
/// The default world-delta tripwire battery (the catalog first-slice).
pub fn default_tripwires() -> Vec<Box<dyn Tripwire>> {
vec![
Box::new(CallGraphDeltaTripwire),
Box::new(CapabilityDriftTripwire),
Box::new(SinkNewlyReachableTripwire),
Box::new(FingerprintDriftTripwire),
Box::new(ObfuscationTripwire::default()),
Box::new(MaintainerAnomalyTripwire),
Box::new(SbomDriftTripwire),
]
}
/// Diff two world snapshots into a [`BehaviorDelta`] by running the default
/// tripwire battery and folding their findings.
pub fn diff(from: &WorldSnapshot, to: &WorldSnapshot) -> BehaviorDelta {
diff_with(from, to, &default_tripwires())
}
/// Diff with an explicit tripwire set (for the heavy world-delta site to plug in
/// extra analyses).
pub fn diff_with(
from: &WorldSnapshot,
to: &WorldSnapshot,
tripwires: &[Box<dyn Tripwire>],
) -> BehaviorDelta {
let mut findings = Vec::new();
for tw in tripwires {
findings.extend(tw.evaluate(from, to));
}
let delta = BehaviorDelta {
from: from.id.clone(),
to: to.id.clone(),
findings,
};
// Introspection marker — Engine B reports each world-delta diff to the test
// matrix. A clean delta (no tripwire finding) is the healthy verdict.
#[cfg(feature = "testmatrix")]
crate::functional_status(
"modgunn/engine-b-scip",
"behavior_diff",
delta.findings.is_empty(),
&format!(
"{} → {}: {} tripwire finding(s)",
delta.from,
delta.to,
delta.findings.len()
),
);
delta
}
#[cfg(test)]
mod tests {
use super::*;
/// The OSV-symbol → reachability-sink bridge: a Go `(path, symbol)` becomes the
/// fully-qualified call-graph id `path.symbol` the A8 gate joins on; a method
/// keeps its `Type.Method`; a whole-package record (`symbol == ""`) has no single
/// function to gate on → `None`. RED if the id convention drifts from what a Go
/// SCIP indexer emits for `Symbol::id`.
#[cfg(feature = "scan")]
#[test]
fn vuln_sink_from_go_symbol_uses_fully_qualified_id() {
use crate::osv::VulnSymbol;
let f = VulnSymbol {
id: "GO-2024-0001".into(),
ecosystem: "go".into(),
package: "github.com/foo/bar".into(),
symbol: "Parse".into(),
severity: Severity::High,
};
let sink = VulnSink::from_vuln_symbol(&f).expect("named symbol yields a sink");
assert_eq!(
sink.symbol, "github.com/foo/bar.Parse",
"path.symbol call-graph id"
);
assert_eq!(sink.advisory, "GO-2024-0001");
let m = VulnSymbol {
symbol: "Decoder.Decode".into(),
..f.clone()
};
assert_eq!(
VulnSink::from_vuln_symbol(&m).unwrap().symbol,
"github.com/foo/bar.Decoder.Decode"
);
let whole = VulnSymbol {
symbol: String::new(),
..f
};
assert!(
VulnSink::from_vuln_symbol(&whole).is_none(),
"whole-package record is not a single sink"
);
}
/// A snapshot: entrypoint `main` → `mid` → `net_sink` (which does Network),
/// plus an unreachable `dead` symbol that holds a CVE.
fn base() -> WorldSnapshot {
let mut s = WorldSnapshot::new("snap-1");
s.symbols = vec![
Symbol::new("main", "app@1.0.0").entrypoint(),
Symbol::new("mid", "midlib@1.0.0"),
Symbol::new("net_sink", "netlib@1.0.0").with_capability(Capability::Network),
Symbol::new("dead", "deadlib@1.0.0"),
];
s.calls = vec![
CallEdge::new("main", "mid"),
CallEdge::new("mid", "net_sink"),
];
s.vuln_sinks = vec![VulnSink {
symbol: "dead".into(),
advisory: "CVE-DEAD".into(),
severity: Severity::High,
}];
s
}
#[test]
fn a8_reachable_vuln_fires_unreachable_suppressed() {
let mut s = base();
// Add a reachable vuln on `net_sink`.
s.vuln_sinks.push(VulnSink {
symbol: "net_sink".into(),
advisory: "CVE-LIVE".into(),
severity: Severity::Critical,
});
let fired = reachability_gated_vulns(&s);
assert_eq!(fired.len(), 1, "only the reachable vuln fires");
assert_eq!(fired[0].id, "CVE-LIVE");
// the dead one is suppressed but visible as Info
let suppressed = unreachable_vulns(&s);
assert_eq!(suppressed.len(), 1);
assert_eq!(suppressed[0].id, "CVE-DEAD");
assert_eq!(suppressed[0].severity, Severity::Info);
}
#[test]
fn a9_capability_surface_is_reachable_only() {
let s = base();
let caps = capability_surface(&s);
assert!(caps.contains(&Capability::Network));
assert_eq!(caps.len(), 1);
}
#[test]
fn a13_blast_radius_ranks_the_hub_top() {
let mut s = WorldSnapshot::new("dep");
// a -> hub, b -> hub, c -> hub : hub is the most depended-on.
for (e, n, v) in [
("app", "a", "1"),
("app", "b", "1"),
("app", "c", "1"),
("dep", "hub", "1"),
] {
s.dep_nodes.push(DepNode::new(e, n, v));
}
let k = |n: &str| format!("{}:{}@1", if n == "hub" { "dep" } else { "app" }, n);
s.dep_edges = vec![
DepEdge::new(k("a"), k("hub")),
DepEdge::new(k("b"), k("hub")),
DepEdge::new(k("c"), k("hub")),
];
let ranked = blast_radius(&s);
assert_eq!(ranked[0].0, k("hub"), "the hub has the highest centrality");
}
#[test]
fn capability_drift_tripwire_fires_when_surface_grows() {
let from = base();
let mut to = base();
to.id = "snap-2".into();
// `mid` now also reaches a filesystem sink.
to.symbols
.push(Symbol::new("fs_sink", "fslib@1.0.0").with_capability(Capability::Filesystem));
to.calls.push(CallEdge::new("mid", "fs_sink"));
let tw = CapabilityDriftTripwire;
let f = tw.evaluate(&from, &to);
assert_eq!(f.len(), 1);
assert!(f[0].id.contains("filesystem"));
assert_eq!(f[0].severity, Severity::High);
}
#[test]
fn sink_newly_reachable_is_highest_signal() {
let from = base();
let mut to = base();
to.id = "snap-2".into();
// `dead` becomes reachable: main -> dead.
to.calls.push(CallEdge::new("main", "dead"));
let tw = SinkNewlyReachableTripwire;
let f = tw.evaluate(&from, &to);
assert_eq!(f.len(), 1);
assert!(f[0].id.contains("CVE-DEAD"));
assert_eq!(f[0].severity, Severity::High);
}
#[test]
fn fingerprint_drift_fires_only_on_silent_change() {
let from = base();
let mut to = base();
to.id = "snap-2".into();
// `mid`'s behaviour changes (now also calls net via a new edge) but its
// package version + display are unchanged -> the silent signal.
to.calls.push(CallEdge::new("mid", "dead"));
let tw = FingerprintDriftTripwire;
let f = tw.evaluate(&from, &to);
assert!(f.iter().any(|x| x.id.contains("FINGERPRINT-DRIFT:mid")));
// If the version DID change, it is NOT a silent drift -> no finding.
let mut to2 = to.clone();
for s in to2.symbols.iter_mut() {
if s.id == "mid" {
s.package = "midlib@2.0.0".into();
}
}
let f2 = tw.evaluate(&from, &to2);
assert!(
!f2.iter().any(|x| x.id.contains("mid")),
"version change is not silent drift"
);
}
#[test]
fn diff_folds_all_tripwires_into_a_behavior_delta() {
let from = base();
let mut to = base();
to.id = "snap-2".into();
to.calls.push(CallEdge::new("main", "dead")); // sink-newly-reachable
let delta = diff(&from, &to);
assert_eq!(delta.from, "snap-1");
assert_eq!(delta.to, "snap-2");
assert!(delta
.findings
.iter()
.any(|f| f.id.contains("SINK-NEWLY-REACHABLE")));
assert!(delta
.findings
.iter()
.any(|f| f.id == "MODGUNN-CALLGRAPH-DELTA"));
assert_eq!(
delta.decision(),
Decision::Block,
"a newly-reachable High sink blocks"
);
}
#[test]
fn verdict_from_folds_findings_like_engine_a() {
let s = base();
let mut s = s;
s.vuln_sinks.push(VulnSink {
symbol: "net_sink".into(),
advisory: "CVE-LIVE".into(),
severity: Severity::Critical,
});
let findings = reachability_gated_vulns(&s);
let aref = ArtifactRef {
ecosystem: "world".into(),
name: "closure".into(),
version: "snap-1".into(),
blob_sha256: "snap-1".into(),
};
let v = verdict_from(aref, findings, &s.id);
assert_eq!(v.decision, Decision::Block);
assert_eq!(v.advisory_db_rev, "snap-1");
}
#[test]
fn a15_diamond_flags_multi_version_and_escalates_unverified() {
let mut s = WorldSnapshot::new("d");
// `serde` at two versions — one unverified → High. `once` single version → none.
s.dep_nodes = vec![
DepNode::new("cargo", "serde", "1.0.0").verified(),
DepNode::new("cargo", "serde", "1.1.0"), // unverified
DepNode::new("cargo", "once", "1.0.0").verified(),
];
let f = diamond_conflicts(&s);
assert_eq!(f.len(), 1, "only serde is a diamond");
assert!(f[0].id.contains("serde"));
assert_eq!(
f[0].severity,
Severity::High,
"unverified version in the diamond escalates"
);
// All-verified diamond stays Low.
s.dep_nodes[1] = DepNode::new("cargo", "serde", "1.1.0").verified();
assert_eq!(diamond_conflicts(&s)[0].severity, Severity::Low);
}
#[test]
fn a14_chokepoint_is_the_only_bridge_to_a_sink() {
// main -> gate -> vuln ; gate is the single-vertex cut to `vuln`.
let mut s = WorldSnapshot::new("c");
s.symbols = vec![
Symbol::new("main", "app@1").entrypoint(),
Symbol::new("gate", "mid@1"),
Symbol::new("vuln", "bad@1"),
];
s.calls = vec![CallEdge::new("main", "gate"), CallEdge::new("gate", "vuln")];
s.vuln_sinks = vec![VulnSink {
symbol: "vuln".into(),
advisory: "CVE-X".into(),
severity: Severity::High,
}];
let f = sink_chokepoints(&s);
assert_eq!(f.len(), 1);
assert!(f[0].summary.contains("gate"), "gate is the chokepoint");
assert!(
!f[0].summary.contains("main"),
"an entrypoint is never a chokepoint"
);
// Add a second independent path main -> vuln : now there is NO single-vertex cut.
s.calls.push(CallEdge::new("main", "vuln"));
assert!(
sink_chokepoints(&s).is_empty(),
"two disjoint paths ⇒ no single chokepoint"
);
}
#[test]
fn a12_bus_factor_flags_central_single_maintainer() {
let mut s = WorldSnapshot::new("b");
for (e, n, v) in [("app", "a", "1"), ("app", "b", "1"), ("dep", "hub", "1")] {
let node = DepNode::new(e, n, v);
s.dep_nodes.push(if n == "hub" {
node.with_maintainers(1)
} else {
node.with_maintainers(9)
});
}
let k = |n: &str| format!("{}:{}@1", if n == "hub" { "dep" } else { "app" }, n);
s.dep_edges = vec![
DepEdge::new(k("a"), k("hub")),
DepEdge::new(k("b"), k("hub")),
];
let f = bus_factor_findings(&s, 3);
assert_eq!(f.len(), 1, "only the central single-maintainer hub fires");
assert!(f[0].id.contains("hub"));
assert_eq!(f[0].severity, Severity::High);
}
#[test]
fn a8_typosquat_and_dependency_confusion() {
let mut s = WorldSnapshot::new("t");
s.dep_nodes = vec![
DepNode::new("npm", "reqiest", "1.0.0"), // one edit from "request" → typosquat
DepNode::new("npm", "request", "1.0.0"), // exact popular → clean
DepNode::new("npm", "@acme/secrets", "1.0.0"), // internal namespace → dep-confusion
];
let f = typosquat_findings(&s, &["request", "lodash"], &["@acme/"]);
assert!(f
.iter()
.any(|x| x.id == "MODGUNN-TYPOSQUAT:reqiest" && x.severity == Severity::High));
assert!(f
.iter()
.any(|x| x.id == "MODGUNN-DEP-CONFUSION:@acme/secrets"
&& x.severity == Severity::Critical));
assert!(
!f.iter().any(|x| x.id.contains(":request")),
"the exact popular name is not flagged"
);
}
#[test]
fn levenshtein_basics() {
assert_eq!(levenshtein("request", "reqiest"), 1);
assert_eq!(levenshtein("abc", "abc"), 0);
assert_eq!(levenshtein("", "abc"), 3);
assert_eq!(levenshtein("kitten", "sitting"), 3);
}
#[test]
fn a16_fanin_outlier_and_zscore() {
// hub has fan-in 3, everything else 0 → hub is a positive-z outlier.
let mut s = WorldSnapshot::new("f");
for (e, n) in [("app", "a"), ("app", "b"), ("app", "c"), ("dep", "hub")] {
s.dep_nodes.push(DepNode::new(e, n, "1"));
}
let key = |n: &str| format!("{}:{}@1", if n == "hub" { "dep" } else { "app" }, n);
s.dep_edges = vec![
DepEdge::new(key("a"), key("hub")),
DepEdge::new(key("b"), key("hub")),
DepEdge::new(key("c"), key("hub")),
];
let f = dep_fanin_outliers(&s, 1.0);
assert!(f
.iter()
.any(|x| x.id.contains("hub") && x.severity == Severity::Medium));
// zero-variance input → no outliers.
assert!(zscore_outliers(&[("x".into(), 5.0), ("y".into(), 5.0)], 0.5).is_empty());
}
#[test]
fn release_cadence_flags_dormant_then_publish() {
const DAY: i64 = 86_400;
let mut s = WorldSnapshot::new("cadence");
// `steady`: a stable ~weekly rhythm for 10 releases, then a ~3-year silence
// broken by a sudden newest publish — the takeover-shaped signal.
let mut steady = vec![0i64];
for _ in 0..9 {
steady.push(steady.last().unwrap() + 7 * DAY);
}
steady.push(steady.last().unwrap() + 1100 * DAY); // long dormancy → newest release
// `regular`: a metronome cadence — no gap is an outlier, must NOT fire.
let regular: Vec<i64> = (0..10).map(|i| i * 7 * DAY).collect();
s.dep_nodes = vec![
DepNode::new("cargo", "steady", "2.0.0").with_release_epochs(steady.clone()),
DepNode::new("cargo", "regular", "2.0.0").with_release_epochs(regular),
// too few timestamps to baseline against → skipped, never fires.
DepNode::new("cargo", "young", "0.1.0").with_release_epochs([0, 7 * DAY]),
];
let f = release_cadence_anomalies(&s, 2.5);
assert_eq!(f.len(), 1, "only the dormant-then-publish package fires");
assert_eq!(f[0].id, "MODGUNN-RELEASE-CADENCE:cargo:steady");
assert_eq!(
f[0].severity,
Severity::High,
"newest-release dormancy is the takeover signal"
);
assert!(f[0].summary.contains("newest release"));
// Order-independence: shuffling the timestamps yields the same verdict.
let mut shuffled = steady.clone();
shuffled.reverse();
s.dep_nodes[0] = DepNode::new("cargo", "steady", "2.0.0").with_release_epochs(shuffled);
assert_eq!(
release_cadence_anomalies(&s, 2.5)[0].id,
"MODGUNN-RELEASE-CADENCE:cargo:steady"
);
// An empty timeline is silent (input simply not captured).
s.dep_nodes = vec![DepNode::new("cargo", "steady", "2.0.0")];
assert!(release_cadence_anomalies(&s, 2.5).is_empty());
}
#[test]
fn change_velocity_flags_release_burst_only() {
const DAY: i64 = 86_400;
let mut s = WorldSnapshot::new("velocity");
// `burst`: a slow monthly cadence (velocity ~1 / 30-day window) for 10
// releases, then a sudden flurry of 5 releases on consecutive days — the
// rapid-fire churn signal, and it is the *newest* activity → High.
let mut burst: Vec<i64> = (0..10).map(|i| i * 30 * DAY).collect();
let base = 500 * DAY;
for i in 0..5 {
burst.push(base + i * DAY);
}
// `steady`: a metronome monthly cadence — constant velocity, no burst → silent.
let steady: Vec<i64> = (0..15).map(|i| i * 30 * DAY).collect();
s.dep_nodes = vec![
DepNode::new("cargo", "burst", "3.0.0").with_release_epochs(burst.clone()),
DepNode::new("cargo", "steady", "3.0.0").with_release_epochs(steady),
// Too few releases to baseline a velocity against → skipped, never fires.
DepNode::new("cargo", "young", "0.2.0").with_release_epochs([0, DAY, 2 * DAY]),
// A lone late publish after a long freeze is a #7 dormancy, NOT a
// velocity burst (its trailing-window count is 1) → must stay silent here.
DepNode::new("cargo", "revived", "9.0.0").with_release_epochs([
0,
7 * DAY,
14 * DAY,
21 * DAY,
28 * DAY,
1100 * DAY,
]),
];
let f = change_velocity_anomalies(&s, 30.0, 2.0);
assert_eq!(f.len(), 1, "only the release-burst package fires");
assert_eq!(f[0].id, "MODGUNN-CHANGE-VELOCITY:cargo:burst");
assert_eq!(
f[0].severity,
Severity::High,
"the burst is the newest activity"
);
assert!(f[0].summary.contains("active release burst"));
// Order-independence: shuffling the timestamps yields the same verdict.
let mut shuffled = burst.clone();
shuffled.reverse();
s.dep_nodes[0] = DepNode::new("cargo", "burst", "3.0.0").with_release_epochs(shuffled);
assert_eq!(
change_velocity_anomalies(&s, 30.0, 2.0)[0].id,
"MODGUNN-CHANGE-VELOCITY:cargo:burst"
);
// An empty timeline is silent (input simply not captured).
s.dep_nodes = vec![DepNode::new("cargo", "burst", "3.0.0")];
assert!(change_velocity_anomalies(&s, 30.0, 2.0).is_empty());
}
#[test]
fn a17_obfuscation_fires_on_new_high_entropy_symbol() {
let mut from = WorldSnapshot::new("o1");
from.symbols = vec![Symbol::new("readConfigFile", "app@1")];
let mut to = WorldSnapshot::new("o2");
to.symbols = vec![
Symbol::new("readConfigFile", "app@1"), // pre-existing, ignored
{
let mut s = Symbol::new("x1Yb3Kf9Qm2Wp7Zr4Ns8Lc6Vd0", "app@1"); // packed/random, high entropy
s.display = "x1Yb3Kf9Qm2Wp7Zr4Ns8Lc6Vd0".into();
s
},
];
let tw = ObfuscationTripwire::default();
let f = tw.evaluate(&from, &to);
assert_eq!(f.len(), 1, "only the new high-entropy symbol fires");
assert!(f[0].id.contains("x1Yb3Kf9"));
// a plain-English new name of the same length does NOT fire.
let mut to2 = WorldSnapshot::new("o3");
to2.symbols = vec![
Symbol::new("readConfigFile", "app@1"),
Symbol::new("initializeAllServicesAndConnections", "app@1"),
];
assert!(
tw.evaluate(&from, &to2).is_empty(),
"low-entropy identifier is clean"
);
}
#[test]
fn shannon_entropy_ranks_random_above_repetitive() {
assert!(shannon_entropy("aaaaaaaa") < 0.01);
assert!(shannon_entropy("x1Yb3Kf9Qm2Wp7Zr4Ns8Lc6Vd0") > 4.0);
assert!(shannon_entropy("initializeAllServicesAndConnections") < 4.0);
assert_eq!(shannon_entropy(""), 0.0);
}
#[test]
fn owner_cluster_flags_single_entity_cluster_only() {
let mut s = WorldSnapshot::new("owners");
s.dep_nodes = vec![
// A single entity `eve` quietly owns three "independent" packages →
// one co-ownership community, full coverage → High.
DepNode::new("npm", "p1", "1").with_owners(["eve"]),
DepNode::new("npm", "p2", "1").with_owners(["eve"]),
DepNode::new("npm", "p3", "1").with_owners(["eve"]),
// Two genuinely independent, unrelated owners → singleton communities,
// must stay silent.
DepNode::new("npm", "q1", "1").with_owners(["alice"]),
DepNode::new("npm", "q2", "1").with_owners(["bob"]),
// No ownership metadata captured → ignored entirely.
DepNode::new("npm", "r1", "1"),
];
let f = owner_cluster_findings(&s, 2);
assert_eq!(f.len(), 1, "only the eve-controlled cluster fires");
assert_eq!(
f[0].severity,
Severity::High,
"one owner controls the whole cluster"
);
assert!(f[0].summary.contains("eve"));
assert!(f[0].summary.contains("controls 3/3"));
// Order-independence: shuffling the dep-node order yields the same verdict.
s.dep_nodes.reverse();
let f2 = owner_cluster_findings(&s, 2);
assert_eq!(f2, f, "verdict is independent of dep-node order");
// A cluster where no single owner covers every dep → Medium (a shared
// owner links the deps transitively, but coverage is only a plurality).
let mut m = WorldSnapshot::new("owners-partial");
m.dep_nodes = vec![
DepNode::new("npm", "a", "1").with_owners(["eve", "x"]),
DepNode::new("npm", "b", "1").with_owners(["eve", "y"]),
DepNode::new("npm", "c", "1").with_owners(["y", "z"]),
];
let fm = owner_cluster_findings(&m, 2);
assert_eq!(fm.len(), 1, "a,b,c form one community via eve/y overlap");
assert_eq!(
fm[0].severity,
Severity::Medium,
"no owner covers the whole cluster"
);
// min_cluster_size gate: raising the floor above the cluster size silences it.
assert!(owner_cluster_findings(&s, 4).is_empty());
}
#[test]
fn license_risk_tier_ranks_copyleft_above_permissive() {
assert_eq!(license_risk_tier("MIT"), 0);
assert_eq!(license_risk_tier("Apache-2.0"), 0);
assert_eq!(license_risk_tier("BSD-3-Clause"), 0);
assert_eq!(license_risk_tier("LGPL-3.0-only"), 1); // weak, not strong
assert_eq!(license_risk_tier("MPL-2.0"), 1);
assert_eq!(license_risk_tier("GPL-3.0-or-later"), 2);
assert_eq!(license_risk_tier("AGPL-3.0-only"), 3); // AGPL is not plain GPL
assert_eq!(license_risk_tier("SSPL-1.0"), 3);
assert_eq!(license_risk_tier("BUSL-1.1"), 4); // source-available, non-open
assert_eq!(license_risk_tier(""), 4);
assert_eq!(license_risk_tier("NOASSERTION"), 4);
}
#[test]
fn license_transition_flags_riskward_relicense_only() {
let mut s = WorldSnapshot::new("license");
s.dep_nodes = vec![
// Rug-pull: MIT → Apache (no jump) → AGPL (permissive→network-copyleft).
// The largest jump (Apache→AGPL) is reported → High.
DepNode::new("cargo", "rugpull", "3.0.0").with_license_history([
("1.0.0", "MIT"),
("2.0.0", "Apache-2.0"),
("3.0.0", "AGPL-3.0-only"),
]),
// MIT → GPL: permissive → strong copyleft → Medium.
DepNode::new("npm", "copyleft", "2.0.0")
.with_license_history([("1.0.0", "MIT"), ("2.0.0", "GPL-3.0-or-later")]),
// GPL → MIT relaxation (riskier → safer) → must NOT fire.
DepNode::new("pip", "relaxed", "2.0.0")
.with_license_history([("1.0.0", "GPL-3.0"), ("2.0.0", "MIT")]),
// Stable permissive across versions → silent.
DepNode::new("cargo", "stable", "2.0.0")
.with_license_history([("1.0.0", "MIT"), ("2.0.0", "MIT")]),
// No captured license history → ignored entirely.
DepNode::new("cargo", "nolic", "1.0.0"),
];
let f = license_transition_findings(&s);
assert_eq!(f.len(), 2, "only the two riskward relicenses fire");
let rug = f
.iter()
.find(|x| x.id == "MODGUNN-LICENSE-TRANSITION:cargo:rugpull")
.unwrap();
assert_eq!(
rug.severity,
Severity::High,
"→AGPL network-copyleft is High"
);
assert!(
rug.summary.contains("Apache-2.0") && rug.summary.contains("AGPL-3.0-only"),
"reports the largest jump (Apache→AGPL), not the no-op MIT→Apache step"
);
let cl = f
.iter()
.find(|x| x.id == "MODGUNN-LICENSE-TRANSITION:npm:copyleft")
.unwrap();
assert_eq!(
cl.severity,
Severity::Medium,
"→GPL strong-copyleft is Medium"
);
assert!(
!f.iter().any(|x| x.id.contains("relaxed")),
"a relax to a safer license never fires"
);
assert!(
!f.iter().any(|x| x.id.contains("stable")),
"a stable license never fires"
);
// Order-independence over the dep-node set.
s.dep_nodes.reverse();
assert_eq!(
license_transition_findings(&s),
f,
"verdict is independent of dep-node order"
);
// Empty / single-point history ⇒ silent.
let mut e = WorldSnapshot::new("e");
e.dep_nodes = vec![
DepNode::new("cargo", "x", "1"),
DepNode::new("cargo", "y", "1").with_license_history([("1.0.0", "MIT")]),
];
assert!(license_transition_findings(&e).is_empty());
}
#[test]
fn embedding_novelty_flags_far_from_ecosystem_norm_only() {
let mut s = WorldSnapshot::new("embed");
s.dep_nodes = vec![
// cargo cohort: three near-identical vectors clustered around [1,0,0]…
DepNode::new("cargo", "n1", "1").with_embedding(vec![1.0f32, 0.0, 0.0]),
DepNode::new("cargo", "n2", "1").with_embedding(vec![0.9f32, 0.1, 0.0]),
DepNode::new("cargo", "n3", "1").with_embedding(vec![1.0f32, 0.05, 0.0]),
// …and one orthogonal, semantically-novel outlier → fires.
DepNode::new("cargo", "novel", "1").with_embedding(vec![0.0f32, 1.0, 0.0]),
// pip cohort: three tightly-clustered vectors, no outlier → silent.
DepNode::new("pip", "p1", "1").with_embedding(vec![0.0f32, 0.0, 1.0]),
DepNode::new("pip", "p2", "1").with_embedding(vec![0.02f32, 0.0, 1.0]),
DepNode::new("pip", "p3", "1").with_embedding(vec![0.0f32, 0.01, 1.0]),
// npm: only two embedded nodes → no stable norm, skipped entirely.
DepNode::new("npm", "q1", "1").with_embedding(vec![1.0f32, 0.0]),
DepNode::new("npm", "q2", "1").with_embedding(vec![0.0f32, 1.0]),
// No embedding captured → ignored.
DepNode::new("cargo", "noembed", "1"),
];
let f = embedding_novelty_findings(&s, 0.4);
assert_eq!(f.len(), 1, "only the orthogonal cargo outlier fires");
assert_eq!(f[0].id, "MODGUNN-EMBEDDING-NOVELTY:cargo:novel@1");
assert_eq!(f[0].severity, Severity::Medium, "far but not > 2×threshold");
assert!(f[0].summary.contains("semantically-novel"));
// Order-independence over the dep-node set.
s.dep_nodes.reverse();
assert_eq!(
embedding_novelty_findings(&s, 0.4),
f,
"verdict is independent of node order"
);
// No embeddings captured ⇒ silent (honest empty input).
let mut e = WorldSnapshot::new("e");
e.dep_nodes = vec![
DepNode::new("cargo", "x", "1"),
DepNode::new("cargo", "y", "1"),
];
assert!(embedding_novelty_findings(&e, 0.4).is_empty());
}
#[test]
fn cosine_distance_basics() {
assert_eq!(cosine_distance(&[1.0, 0.0], &[1.0, 0.0]), Some(0.0));
assert_eq!(cosine_distance(&[1.0, 0.0], &[0.0, 1.0]), Some(1.0)); // orthogonal
assert_eq!(cosine_distance(&[1.0], &[1.0, 0.0]), None); // dim mismatch
assert_eq!(cosine_distance(&[0.0, 0.0], &[1.0, 0.0]), None); // zero magnitude
}
#[test]
fn taint_path_flags_reachable_source_to_sink_only() {
let mut s = WorldSnapshot::new("taint");
s.symbols = vec![
Symbol::new("net_in", "net@1")
.taint_source()
.with_capability(Capability::Network),
Symbol::new("parse", "app@1"),
Symbol::new("exec", "os@1").with_capability(Capability::Process),
// A sensitive sink NOT reachable from the source → must stay silent.
Symbol::new("orphan_write", "fs@1").with_capability(Capability::Filesystem),
];
s.calls = vec![
CallEdge::new("net_in", "parse"),
CallEdge::new("parse", "exec"),
];
let f = taint_path_findings(&s, &default_taint_sinks());
assert_eq!(f.len(), 1, "only the reachable source→sink path fires");
assert_eq!(f[0].id, "MODGUNN-TAINT-PATH:net_in->exec");
assert_eq!(
f[0].severity,
Severity::Critical,
"process-exec sink is critical"
);
// No taint source marked ⇒ the analysis doesn't fire (honest empty input).
let mut clean = s.clone();
for sym in clean.symbols.iter_mut() {
sym.is_taint_source = false;
}
assert!(taint_path_findings(&clean, &default_taint_sinks()).is_empty());
// Order-independence: reversing symbols + edges gives an identical verdict.
let mut shuffled = s.clone();
shuffled.symbols.reverse();
shuffled.calls.reverse();
assert_eq!(taint_path_findings(&shuffled, &default_taint_sinks()), f);
}
#[test]
fn maintainer_anomaly_flags_ownership_transfer() {
let mk = |owners: &[&str], maint: u32| {
DepNode::new("npm", "left-pad", "1")
.with_owners(owners.to_vec())
.with_maintainers(maint)
};
let mut from = WorldSnapshot::new("m1");
from.dep_nodes = vec![mk(&["alice", "bob"], 2)];
// Full takeover: owners entirely replaced → High.
let mut to = WorldSnapshot::new("m2");
to.dep_nodes = vec![mk(&["mallory"], 1)];
let f = MaintainerAnomalyTripwire.evaluate(&from, &to);
assert_eq!(f.len(), 1);
assert_eq!(f[0].id, "MODGUNN-MAINTAINER-ANOMALY:npm:left-pad");
assert_eq!(
f[0].severity,
Severity::High,
"disjoint owner set is a takeover"
);
assert!(f[0].summary.contains("fully replaced"));
// Partial change (a co-owner added) → Medium.
let mut to2 = WorldSnapshot::new("m3");
to2.dep_nodes = vec![mk(&["alice", "bob", "carol"], 3)];
let f2 = MaintainerAnomalyTripwire.evaluate(&from, &to2);
assert_eq!(f2.len(), 1);
assert_eq!(f2[0].severity, Severity::Medium);
// Identical ownership ⇒ no anomaly.
assert!(MaintainerAnomalyTripwire.evaluate(&from, &from).is_empty());
// Maintainer-count collapse with unchanged owners → Low co-signal.
let mut to3 = WorldSnapshot::new("m4");
to3.dep_nodes = vec![mk(&["alice", "bob"], 1)];
let f3 = MaintainerAnomalyTripwire.evaluate(&from, &to3);
assert_eq!(f3.len(), 1);
assert_eq!(f3[0].severity, Severity::Low);
assert!(f3[0].id.contains("MAINTAINER-COLLAPSE"));
// A package present in only one snapshot is not an ownership *transfer*.
let mut only_new = WorldSnapshot::new("m5");
only_new.dep_nodes = vec![
mk(&["dave"], 1),
DepNode::new("npm", "fresh", "1").with_owners(vec!["eve"]),
];
let f4 = MaintainerAnomalyTripwire.evaluate(&from, &only_new);
assert!(f4.iter().all(|x| !x.summary.contains("fresh")));
}
#[test]
fn sbom_drift_reports_entered_and_left_components() {
let mut from = WorldSnapshot::new("s1");
from.dep_nodes = vec![
DepNode::new("cargo", "keep", "1").verified(),
DepNode::new("cargo", "gone", "1"),
];
let mut to = WorldSnapshot::new("s2");
to.dep_nodes = vec![
DepNode::new("cargo", "keep", "1").verified(),
DepNode::new("cargo", "newdep", "2"), // entered, unverified → Low
];
let f = SbomDriftTripwire.evaluate(&from, &to);
assert_eq!(f.len(), 2, "one entered + one left");
let entered = f.iter().find(|x| x.id.contains("SBOM-ENTERED")).unwrap();
assert_eq!(entered.id, "MODGUNN-SBOM-ENTERED:cargo:newdep@2");
assert_eq!(
entered.severity,
Severity::Low,
"unverified entrant is reviewable"
);
let left = f.iter().find(|x| x.id.contains("SBOM-LEFT")).unwrap();
assert_eq!(left.id, "MODGUNN-SBOM-LEFT:cargo:gone@1");
assert_eq!(left.severity, Severity::Info);
// Identical snapshots ⇒ no drift.
assert!(SbomDriftTripwire.evaluate(&from, &from).is_empty());
}
#[test]
fn policy_tripwire_fires_on_declarative_rule_breaks() {
// from: main -> mid (no dangerous cap reachable), one verified dep.
let mut from = WorldSnapshot::new("p1");
from.symbols = vec![
Symbol::new("main", "app@1").entrypoint(),
Symbol::new("mid", "lib@1"),
];
from.calls = vec![CallEdge::new("main", "mid")];
from.dep_nodes = vec![DepNode::new("cargo", "lib", "1").verified()];
// to: mid now reaches an FFI sink AND an unverified npm dep was added.
let mut to = from.clone();
to.id = "p2".into();
to.symbols
.push(Symbol::new("ffi", "native@1").with_capability(Capability::Ffi));
to.calls.push(CallEdge::new("mid", "ffi"));
to.dep_nodes.push(DepNode::new("npm", "added", "2")); // unverified
let tw = PolicyTripwire::new([
PolicyRule::NoNewNativeCode,
PolicyRule::NoUnverifiedDependencyAdded,
PolicyRule::ForbidEcosystem("npm".into()),
]);
let f = tw.evaluate(&from, &to);
// native-code growth (ffi) + unverified-dep-added + forbidden-ecosystem(npm)
assert_eq!(f.len(), 3, "all three rules fire");
assert!(f.iter().all(|x| x.severity == Severity::High));
assert!(f.iter().any(|x| x.id.contains("no-new-native-code")));
assert!(f.iter().any(|x| x.id.contains("no-unverified-dep-added")));
assert!(f.iter().any(|x| x.id.contains("forbid-ecosystem:npm")));
// No delta ⇒ no violations (a clean gate).
assert!(tw.evaluate(&from, &from).is_empty());
// NoNewNativeCode ignores a merely-network capability gain (not native).
let mut net = from.clone();
net.symbols
.push(Symbol::new("n", "net@1").with_capability(Capability::Network));
net.calls.push(CallEdge::new("mid", "n"));
let only_native = PolicyTripwire::new([PolicyRule::NoNewNativeCode]);
assert!(
only_native.evaluate(&from, &net).is_empty(),
"network is not native code"
);
// …but the broad NoCapabilityGrowth does catch it.
let broad = PolicyTripwire::new([PolicyRule::NoCapabilityGrowth]);
assert_eq!(broad.evaluate(&from, &net).len(), 1);
}
#[test]
fn threshold_tripwire_crosses_numeric_bounds() {
// A dep graph where `hub` is a high-fan-in, unverified central node.
let mut to = WorldSnapshot::new("t2");
let k = |n: &str| format!("cargo:{n}@1");
for n in ["a", "b", "c", "hub"] {
let node = DepNode::new("cargo", n, "1");
to.dep_nodes
.push(if n == "hub" { node } else { node.verified() });
}
to.dep_edges = vec![
DepEdge::new(k("a"), k("hub")),
DepEdge::new(k("b"), k("hub")),
DepEdge::new(k("c"), k("hub")),
];
let from = WorldSnapshot::new("t1");
// Fan-in ≥ 3 → only `hub` qualifies.
let fanin = ThresholdTripwire::new([ThresholdRule::FanInAtLeast(3)]);
let f = fanin.evaluate(&from, &to);
assert_eq!(f.len(), 1);
assert!(f[0].id.contains("cargo:hub@1"));
// Unverified centrality: `hub` is both central and unverified → fires;
// the verified leaves never do.
let cen = ThresholdTripwire::new([ThresholdRule::UnverifiedCentralityOver(0.10)]);
let cf = cen.evaluate(&from, &to);
assert!(cf.iter().all(|x| x.id.contains("cargo:hub@1")));
assert!(
!cf.is_empty(),
"the unverified hub crosses the centrality bound"
);
// Capability-surface growth ≥ 1.
let mut base = WorldSnapshot::new("c1");
base.symbols = vec![Symbol::new("m", "app@1").entrypoint()];
let mut grown = base.clone();
grown.id = "c2".into();
grown
.symbols
.push(Symbol::new("net", "n@1").with_capability(Capability::Network));
grown.calls.push(CallEdge::new("m", "net"));
let capg = ThresholdTripwire::new([ThresholdRule::CapabilitySurfaceGrewBy(1)]);
assert_eq!(capg.evaluate(&base, &grown).len(), 1);
assert!(capg.evaluate(&base, &base).is_empty(), "no growth ⇒ silent");
// New-symbol entropy over σ: a packed/base64-looking new name.
let mut e1 = WorldSnapshot::new("e1");
e1.symbols = vec![Symbol::new("plain", "app@1")];
let mut e2 = e1.clone();
e2.id = "e2".into();
e2.symbols.push(Symbol {
display: "aGVsbG8gd29ybGQ7ZXhlYw".into(),
..Symbol::new("packed", "x@1")
});
let ent = ThresholdTripwire::new([ThresholdRule::NewSymbolEntropyOver(3.5)]);
let ef = ent.evaluate(&e1, &e2);
assert_eq!(ef.len(), 1);
assert!(ef[0].id.contains("packed"));
assert_eq!(ef[0].severity, Severity::Medium);
}
#[test]
fn honeytoken_fires_only_when_a_planted_marker_surfaces() {
let mut s = WorldSnapshot::new("h1");
s.dep_nodes = vec![
DepNode::new("npm", "acme-internal-utils", "9.9.9"), // internal name resolved!
DepNode::new("npm", "left-pad", "1"),
];
s.symbols = vec![
Symbol::new("net_read", "app@1"),
Symbol {
display: "leak CANARY-7f3a-DEADBEEF here".into(),
..Symbol::new("logfn", "app@1")
},
];
let planted = vec![
Honeytoken::InternalName("acme-internal-utils".into()),
Honeytoken::CanaryToken("CANARY-7f3a-DEADBEEF".into()),
];
let f = honeytoken_findings(&s, &planted);
assert_eq!(
f.len(),
2,
"both the confusion hit and the canary leak fire"
);
assert!(f.iter().all(|x| x.severity == Severity::Critical));
assert!(f.iter().any(|x| x.id.contains("HONEYTOKEN-CONFUSION")));
assert!(f.iter().any(|x| x.id.contains("HONEYTOKEN-CANARY")));
// Case-insensitive internal-name match.
let ci = honeytoken_findings(
&s,
&[Honeytoken::InternalName("ACME-Internal-Utils".into())],
);
assert_eq!(ci.len(), 1);
// A clean world (no planted markers surface) never fires.
let mut clean = WorldSnapshot::new("h2");
clean.dep_nodes = vec![DepNode::new("npm", "left-pad", "1")];
clean.symbols = vec![Symbol::new("main", "app@1")];
assert!(honeytoken_findings(&clean, &planted).is_empty());
// Empty planted set ⇒ silent (honest empty input).
assert!(honeytoken_findings(&s, &[]).is_empty());
}
}