Skip to main content

jsdet_cli/
sinks.rs

1/// Security sink knowledge base loaded from TOML.
2///
3/// Sinks describe security-sensitive APIs, permissions, CSP weaknesses,
4/// and runtime observations that should be reported as findings.
5use std::sync::OnceLock;
6
7use anyhow::Result;
8use serde::Deserialize;
9
10use crate::sarif::Level;
11
12/// One security sink definition.
13#[derive(Debug, Clone, Deserialize)]
14pub struct SinkDefinition {
15    pub name: String,
16    #[serde(rename = "category")]
17    pub _category: String,
18    pub severity: String,
19    pub description: String,
20    pub permission: Option<String>,
21    pub api: Option<String>,
22    #[serde(default)]
23    pub api_secondary: Option<String>,
24    pub csp_check: Option<String>,
25    pub observation: Option<String>,
26    pub cwe: Option<String>,
27    #[serde(default)]
28    pub no_finding: bool,
29}
30
31/// Top-level TOML structure.
32#[derive(Debug, Clone, Deserialize)]
33struct SinkFile {
34    sink: Vec<SinkDefinition>,
35}
36
37/// Registry of all loaded sinks.
38#[derive(Debug, Clone)]
39pub struct SinkRegistry {
40    sinks: Vec<SinkDefinition>,
41}
42
43static GLOBAL_REGISTRY: OnceLock<SinkRegistry> = OnceLock::new();
44
45impl SinkRegistry {
46    /// Return the global lazily-initialized registry.
47    pub fn global() -> &'static SinkRegistry {
48        GLOBAL_REGISTRY.get_or_init(|| {
49            Self::load().unwrap_or_else(|e| {
50                eprintln!("Failed to load sink registry: {e}. Using embedded defaults.");
51                Self::from_toml(include_str!("../sinks.toml"))
52                    .unwrap_or_else(|e| panic!("Embedded sinks.toml is invalid: {e}"))
53            })
54        })
55    }
56
57    /// Load the registry from `JSDET_SINKS_PATH` or fall back to the embedded TOML.
58    pub fn load() -> Result<Self> {
59        if let Ok(path) = std::env::var("JSDET_SINKS_PATH") {
60            let contents = std::fs::read_to_string(&path)
61                .map_err(|e| anyhow::anyhow!("Failed to read {path}: {e}"))?;
62            Self::from_toml(&contents)
63        } else {
64            Self::from_toml(include_str!("../sinks.toml"))
65        }
66    }
67
68    /// Parse a sink registry from a TOML string.
69    pub fn from_toml(toml_str: &str) -> Result<Self> {
70        let file: SinkFile = toml::from_str(toml_str)
71            .map_err(|e| anyhow::anyhow!("Failed to parse sinks TOML: {e}"))?;
72        Ok(Self { sinks: file.sink })
73    }
74
75    /// Sinks that match against manifest permissions.
76    pub fn permission_sinks(&self) -> Vec<&SinkDefinition> {
77        self.sinks
78            .iter()
79            .filter(|s| s.permission.is_some())
80            .collect()
81    }
82
83    /// Sinks that match against API call observations.
84    pub fn api_sinks(&self) -> Vec<&SinkDefinition> {
85        self.sinks.iter().filter(|s| s.api.is_some()).collect()
86    }
87
88    /// Sinks that match against CSP checks.
89    pub fn csp_sinks(&self) -> Vec<&SinkDefinition> {
90        self.sinks
91            .iter()
92            .filter(|s| s.csp_check.is_some())
93            .collect()
94    }
95
96    /// Sinks that match against non-API runtime observations.
97    pub fn observation_sinks(&self) -> Vec<&SinkDefinition> {
98        self.sinks
99            .iter()
100            .filter(|s| s.observation.is_some())
101            .collect()
102    }
103
104    /// Convert a severity string to a SARIF level.
105    pub fn severity_to_level(&self, severity: &str) -> Level {
106        match severity {
107            "Error" => Level::Error,
108            "Warning" => Level::Warning,
109            "Note" => Level::Note,
110            _ => Level::Warning,
111        }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    const TEST_TOML: &str = r#"
120[[sink]]
121name = "test-perm"
122category = "test"
123severity = "Error"
124description = "Test permission"
125permission = "debugger"
126cwe = "CWE-250"
127
128[[sink]]
129name = "test-api"
130category = "test"
131severity = "Warning"
132description = "Test API"
133api = "executeScript"
134api_secondary = "addListener"
135
136[[sink]]
137name = "test-csp"
138category = "test"
139severity = "Note"
140description = "Test CSP"
141csp_check = "allows_eval"
142
143[[sink]]
144name = "test-obs"
145category = "test"
146severity = "Warning"
147description = "Test observation"
148observation = "DynamicCodeExec"
149no_finding = true
150"#;
151
152    #[test]
153    fn from_toml_parses_sinks() {
154        let reg = SinkRegistry::from_toml(TEST_TOML).unwrap();
155        assert_eq!(reg.sinks.len(), 4);
156    }
157
158    #[test]
159    fn permission_sinks_filters_correctly() {
160        let reg = SinkRegistry::from_toml(TEST_TOML).unwrap();
161        let perms = reg.permission_sinks();
162        assert_eq!(perms.len(), 1);
163        assert_eq!(perms[0].name, "test-perm");
164        assert_eq!(perms[0].permission.as_deref(), Some("debugger"));
165    }
166
167    #[test]
168    fn api_sinks_filters_correctly() {
169        let reg = SinkRegistry::from_toml(TEST_TOML).unwrap();
170        let apis = reg.api_sinks();
171        assert_eq!(apis.len(), 1);
172        assert_eq!(apis[0].name, "test-api");
173        assert_eq!(apis[0].api.as_deref(), Some("executeScript"));
174        assert_eq!(apis[0].api_secondary.as_deref(), Some("addListener"));
175    }
176
177    #[test]
178    fn csp_sinks_filters_correctly() {
179        let reg = SinkRegistry::from_toml(TEST_TOML).unwrap();
180        let csps = reg.csp_sinks();
181        assert_eq!(csps.len(), 1);
182        assert_eq!(csps[0].name, "test-csp");
183        assert_eq!(csps[0].csp_check.as_deref(), Some("allows_eval"));
184    }
185
186    #[test]
187    fn observation_sinks_filters_correctly() {
188        let reg = SinkRegistry::from_toml(TEST_TOML).unwrap();
189        let obs = reg.observation_sinks();
190        assert_eq!(obs.len(), 1);
191        assert_eq!(obs[0].name, "test-obs");
192        assert_eq!(obs[0].observation.as_deref(), Some("DynamicCodeExec"));
193        assert!(obs[0].no_finding);
194    }
195
196    #[test]
197    fn severity_to_level_mapping() {
198        let reg = SinkRegistry::from_toml(TEST_TOML).unwrap();
199        assert!(matches!(reg.severity_to_level("Error"), Level::Error));
200        assert!(matches!(reg.severity_to_level("Warning"), Level::Warning));
201        assert!(matches!(reg.severity_to_level("Note"), Level::Note));
202        assert!(matches!(reg.severity_to_level("Unknown"), Level::Warning));
203    }
204
205    #[test]
206    fn embedded_toml_loads() {
207        let reg = SinkRegistry::from_toml(include_str!("../sinks.toml")).unwrap();
208        assert!(!reg.sinks.is_empty());
209        // Spot-check a few expected sinks.
210        assert!(
211            reg.permission_sinks()
212                .iter()
213                .any(|s| s.permission == Some("debugger".into()))
214        );
215        assert!(
216            reg.api_sinks()
217                .iter()
218                .any(|s| s.api == Some("executeScript".into()))
219        );
220        assert!(
221            reg.csp_sinks()
222                .iter()
223                .any(|s| s.csp_check == Some("allows_eval".into()))
224        );
225        assert!(
226            reg.observation_sinks()
227                .iter()
228                .any(|s| s.observation == Some("DynamicCodeExec".into()))
229        );
230    }
231}