1use std::sync::OnceLock;
6
7use anyhow::Result;
8use serde::Deserialize;
9
10use crate::sarif::Level;
11
12#[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#[derive(Debug, Clone, Deserialize)]
33struct SinkFile {
34 sink: Vec<SinkDefinition>,
35}
36
37#[derive(Debug, Clone)]
39pub struct SinkRegistry {
40 sinks: Vec<SinkDefinition>,
41}
42
43static GLOBAL_REGISTRY: OnceLock<SinkRegistry> = OnceLock::new();
44
45impl SinkRegistry {
46 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 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 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 pub fn permission_sinks(&self) -> Vec<&SinkDefinition> {
77 self.sinks
78 .iter()
79 .filter(|s| s.permission.is_some())
80 .collect()
81 }
82
83 pub fn api_sinks(&self) -> Vec<&SinkDefinition> {
85 self.sinks.iter().filter(|s| s.api.is_some()).collect()
86 }
87
88 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 pub fn observation_sinks(&self) -> Vec<&SinkDefinition> {
98 self.sinks
99 .iter()
100 .filter(|s| s.observation.is_some())
101 .collect()
102 }
103
104 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 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}