use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::debug;
use crate::types::{YaraRuleMetadata, YaraScanResult};
#[derive(Debug, PartialEq, Eq)]
pub enum Drift {
New,
Unchanged,
Changed,
}
pub struct BaselineStore {
path: PathBuf,
map: HashMap<String, String>,
dirty: bool,
}
impl BaselineStore {
pub fn load_default() -> Self {
let path = dirs::home_dir()
.map(|mut p| {
p.push(".ramparts");
p.push("content-baseline.json");
p
})
.unwrap_or_else(|| PathBuf::from(".ramparts/content-baseline.json"));
Self::load_from(path)
}
pub fn load_from(path: PathBuf) -> Self {
let map = std::fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_default();
Self {
path,
map,
dirty: false,
}
}
pub fn check(&mut self, namespace: &str, key: &str, fingerprint: &str) -> Drift {
let store_key = format!("{namespace}\u{1f}{key}");
match self.map.get(&store_key) {
Some(stored) if stored == fingerprint => Drift::Unchanged,
Some(_) => Drift::Changed,
None => {
self.map.insert(store_key, fingerprint.to_string());
self.dirty = true;
Drift::New
}
}
}
pub fn save(&mut self) {
if !self.dirty {
return;
}
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(serialized) = serde_json::to_string_pretty(&self.map) {
if std::fs::write(&self.path, serialized).is_ok() {
self.dirty = false;
}
}
}
}
pub fn fingerprint_fields(fields: &[(&str, &str)]) -> String {
let mut hasher = Sha256::new();
for (label, value) in fields {
hasher.update(label.as_bytes());
hasher.update(b"\x1f");
hasher.update((value.len() as u64).to_le_bytes());
hasher.update(value.as_bytes());
hasher.update(b"\x1e");
}
format!("{:x}", hasher.finalize())
}
fn drift_finding(
rule_name: &str,
target_type: &str,
target_name: &str,
description: &str,
context: String,
) -> YaraScanResult {
YaraScanResult {
target_type: target_type.to_string(),
target_name: target_name.to_string(),
rule_name: rule_name.to_string(),
rule_file: None,
matched_text: None,
context,
rule_metadata: Some(YaraRuleMetadata {
name: Some("Baseline Change".to_string()),
author: Some("Ramparts".to_string()),
date: None,
version: None,
description: Some(description.to_string()),
severity: Some("HIGH".to_string()),
category: Some("supply-chain".to_string()),
confidence: Some("MEDIUM".to_string()),
tags: vec!["baseline".to_string(), "rug-pull".to_string()],
}),
owasp_tags: crate::taxonomy::tags_for_yara_rule(rule_name),
installed_version: None,
fixed_version: None,
phase: None,
rules_executed: None,
security_issues_detected: None,
total_items_scanned: None,
total_matches: None,
status: Some("warning".to_string()),
}
}
pub fn check_tool_drift(server_key: &str, tools: &[crate::types::MCPTool]) -> Vec<YaraScanResult> {
if server_key.is_empty() {
debug!("Skipping tool-drift baseline: empty server key");
return Vec::new();
}
let mut store = BaselineStore::load_default();
let mut findings = Vec::new();
for tool in tools {
let schema = tool
.input_schema
.as_ref()
.map(std::string::ToString::to_string)
.unwrap_or_default();
let fp = fingerprint_fields(&[
("name", &tool.name),
("description", tool.description.as_deref().unwrap_or("")),
("input_schema", &schema),
]);
let key = format!("{server_key}\u{1f}{}", tool.name);
if store.check("tool", &key, &fp) == Drift::Changed {
findings.push(drift_finding(
"MCPToolChanged",
"tool",
&tool.name,
"Tool definition (description/schema) differs from the baseline recorded when this server was first scanned — post-approval swap / rug-pull pattern.",
format!("Tool '{}' on '{server_key}' changed since last baseline", tool.name),
));
}
}
store.save();
findings
}
pub fn check_skill_drift(
store: &mut BaselineStore,
path: &str,
content: &str,
) -> Option<YaraScanResult> {
let fp = fingerprint_fields(&[("content", content)]);
let drift = store.check("skill", path, &fp);
if drift == Drift::Changed {
Some(drift_finding(
"SkillContentChanged",
"prompt",
path,
"Skill file content differs from the baseline recorded when it was first scanned — a reviewed skill was modified afterwards (hot-reload abuse / malicious update pattern).",
format!("Skill file '{path}' changed since last baseline"),
))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_store() -> BaselineStore {
let dir =
std::env::temp_dir().join(format!("ramparts-baseline-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
BaselineStore::load_from(dir.join("content-baseline.json"))
}
#[test]
fn first_sight_baselines_then_detects_change() {
let mut store = temp_store();
assert_eq!(store.check("tool", "srv\u{1f}read_file", "aaa"), Drift::New);
assert_eq!(
store.check("tool", "srv\u{1f}read_file", "aaa"),
Drift::Unchanged
);
assert_eq!(
store.check("tool", "srv\u{1f}read_file", "bbb"),
Drift::Changed
);
assert_eq!(
store.check("tool", "srv\u{1f}read_file", "bbb"),
Drift::Changed
);
}
#[test]
fn store_roundtrips_through_disk() {
let mut store = temp_store();
let path = store.path.clone();
store.check("skill", "/a/SKILL.md", "abc");
store.save();
let mut reloaded = BaselineStore::load_from(path);
assert_eq!(
reloaded.check("skill", "/a/SKILL.md", "abc"),
Drift::Unchanged
);
assert_eq!(
reloaded.check("skill", "/a/SKILL.md", "xyz"),
Drift::Changed
);
}
#[test]
fn fingerprint_fields_are_length_prefixed() {
assert_ne!(
fingerprint_fields(&[("name", "ab"), ("url", "c")]),
fingerprint_fields(&[("name", "a"), ("url", "bc")])
);
}
}