1use fallow_types::extract::ModuleInfo;
10
11use crate::Matcher;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ProvenanceBindings {
16 Imports,
18 ImportsAndRequires,
23 ReleasedModule,
32}
33
34const BINDING_TRACE_ROWS: [&str; 9] = [
38 "command-injection",
39 "permissive-cors",
40 "electron-unsafe-webpreferences",
41 "insecure-temp-file",
42 "jwt-alg-none",
43 "jwt-verify-missing-algorithms",
44 "tls-validation-disabled",
45 "mysql-multiple-statements",
46 "world-writable-permission",
47];
48
49impl Matcher {
50 #[must_use]
53 pub fn requires_binding_trace(&self) -> bool {
54 BINDING_TRACE_ROWS.contains(&self.id.as_str())
55 || (self.id == "weak-crypto" && self.is_literal_aware())
56 }
57
58 #[must_use]
67 pub fn provenance_satisfied(
68 &self,
69 module: &ModuleInfo,
70 callee_path: &str,
71 bindings: ProvenanceBindings,
72 ) -> bool {
73 let Some(spec) = &self.import_provenance else {
74 return true;
75 };
76 let leading_ident = callee_path.split('.').next().unwrap_or(callee_path);
77 let binding_trace = self.requires_binding_trace();
78 let by_import = module.imports.iter().any(|import| {
79 import_source_matches(&import.source, spec)
80 && (!binding_trace || import.local_name == leading_ident)
81 });
82 if by_import || bindings == ProvenanceBindings::Imports {
83 return by_import;
84 }
85 let by_require = module.require_calls.iter().any(|call| {
86 import_source_matches(&call.source, spec)
87 && (!binding_trace
88 || call.local_name.as_deref() == Some(leading_ident)
89 || call
90 .destructured_names
91 .iter()
92 .any(|name| name == leading_ident))
93 });
94 by_require
95 || (bindings == ProvenanceBindings::ReleasedModule
96 && !binds_locally(module, leading_ident))
97 }
98}
99
100fn binds_locally(module: &ModuleInfo, ident: &str) -> bool {
102 module
103 .imports
104 .iter()
105 .any(|import| import.local_name == ident)
106 || module.exports.iter().any(|export| {
107 export
108 .local_name
109 .as_deref()
110 .map_or_else(|| export.name.matches_str(ident), |local| local == ident)
111 })
112}
113
114#[must_use]
120pub fn import_source_matches(source: &str, spec: &str) -> bool {
121 let source = strip_node_prefix(source);
122 let spec = strip_node_prefix(spec);
123 source == spec
124 || source
125 .strip_prefix(spec)
126 .is_some_and(|rest| rest.starts_with('/'))
127}
128
129fn strip_node_prefix(value: &str) -> &str {
130 value.strip_prefix("node:").unwrap_or(value)
131}
132
133#[cfg(test)]
134#[allow(
135 clippy::expect_used,
136 reason = "the tests look up catalogue rows that the catalogue must contain"
137)]
138mod tests {
139 use fallow_types::discover::FileId;
140 use fallow_types::extract::{
141 ExportInfo, ExportName, ImportInfo, ImportedName, RequireCallInfo, SinkShape, VisibilityTag,
142 };
143 use oxc_span::Span;
144
145 use super::*;
146
147 fn row(id: &str, shape: SinkShape, callee: &str) -> &'static Matcher {
148 crate::catalogue()
149 .matchers()
150 .iter()
151 .find(|matcher| {
152 matcher.id == id
153 && matcher.sink_shape == shape
154 && matcher.first_matching_pattern(callee).is_some()
155 })
156 .expect("catalogue row exists")
157 }
158
159 fn import(source: &str, local: &str) -> ImportInfo {
160 ImportInfo {
161 source: source.to_owned(),
162 imported_name: ImportedName::Named(local.to_owned()),
163 local_name: local.to_owned(),
164 is_type_only: false,
165 is_type_only_star: false,
166 from_style: false,
167 span: Span::default(),
168 source_span: Span::default(),
169 }
170 }
171
172 fn require(source: &str, local: Option<&str>, destructured: &[&str]) -> RequireCallInfo {
173 RequireCallInfo {
174 source: source.to_owned(),
175 span: Span::default(),
176 source_span: Span::default(),
177 destructured_names: destructured.iter().map(|&name| name.to_owned()).collect(),
178 local_name: local.map(str::to_owned),
179 is_type_only: false,
180 }
181 }
182
183 fn export(name: &str) -> ExportInfo {
184 ExportInfo {
185 name: ExportName::Named(name.to_owned()),
186 local_name: None,
187 is_type_only: false,
188 is_side_effect_used: false,
189 visibility: VisibilityTag::None,
190 expected_unused_reason: None,
191 deprecated: false,
192 deprecated_reason: None,
193 span: Span::default(),
194 members: Vec::new(),
195 super_class: None,
196 }
197 }
198
199 fn module(imports: Vec<ImportInfo>, requires: Vec<RequireCallInfo>) -> ModuleInfo {
200 ModuleInfo {
201 imports,
202 require_calls: requires,
203 ..ModuleInfo::empty(FileId(0))
204 }
205 }
206
207 #[test]
208 fn a_local_get_does_not_satisfy_the_https_row() {
209 let tls = row("tls-validation-disabled", SinkShape::Call, "get");
210 let local = module(vec![import("./get", "get")], Vec::new());
211 assert!(!tls.provenance_satisfied(&local, "get", ProvenanceBindings::ImportsAndRequires));
212 let unbound = module(Vec::new(), Vec::new());
213 assert!(!tls.provenance_satisfied(&unbound, "get", ProvenanceBindings::Imports));
214 }
215
216 #[test]
217 fn the_https_import_must_bind_the_leading_identifier() {
218 let tls = row("tls-validation-disabled", SinkShape::Call, "get");
219 let named = module(vec![import("node:https", "get")], Vec::new());
220 assert!(tls.provenance_satisfied(&named, "get", ProvenanceBindings::Imports));
221 let other = module(vec![import("https", "request")], Vec::new());
222 assert!(!tls.provenance_satisfied(&other, "get", ProvenanceBindings::Imports));
223 }
224
225 #[test]
226 fn a_row_without_binding_trace_accepts_any_import_of_the_source() {
227 let fs = row("path-traversal", SinkShape::MemberCall, "fs.readFile");
228 assert!(!fs.requires_binding_trace());
229 let imported = module(vec![import("fs", "files")], Vec::new());
230 assert!(fs.provenance_satisfied(&imported, "fs.readFile", ProvenanceBindings::Imports));
231 }
232
233 #[test]
234 fn a_require_call_counts_only_when_the_caller_admits_requires() {
235 let tls = row(
236 "tls-validation-disabled",
237 SinkShape::MemberCall,
238 "https.get",
239 );
240 let namespace = module(Vec::new(), vec![require("https", Some("https"), &[])]);
241 assert!(!tls.provenance_satisfied(&namespace, "https.get", ProvenanceBindings::Imports));
242 assert!(tls.provenance_satisfied(
243 &namespace,
244 "https.get",
245 ProvenanceBindings::ImportsAndRequires
246 ));
247 let destructured = module(Vec::new(), vec![require("node:https", None, &["get"])]);
248 assert!(tls.provenance_satisfied(
249 &destructured,
250 "get",
251 ProvenanceBindings::ImportsAndRequires
252 ));
253 }
254
255 #[test]
256 fn a_released_module_treats_an_unbound_identifier_as_a_hidden_require() {
257 let tls = row(
258 "tls-validation-disabled",
259 SinkShape::MemberCall,
260 "https.get",
261 );
262 let mode = ProvenanceBindings::ReleasedModule;
263 let unbound = module(Vec::new(), Vec::new());
264 assert!(tls.provenance_satisfied(&unbound, "https.get", mode));
265 let elsewhere = module(vec![import("./client", "https")], Vec::new());
266 assert!(!tls.provenance_satisfied(&elsewhere, "https.get", mode));
267 let get = row("tls-validation-disabled", SinkShape::Call, "get");
268 let exported = ModuleInfo {
269 exports: vec![export("get")].into(),
270 ..module(Vec::new(), Vec::new())
271 };
272 assert!(!get.provenance_satisfied(&exported, "get", mode));
273 let imported = module(vec![import("node:https", "get")], Vec::new());
274 assert!(get.provenance_satisfied(&imported, "get", mode));
275 }
276
277 #[test]
278 fn a_row_without_provenance_is_always_satisfied() {
279 let row = crate::catalogue()
280 .matchers()
281 .iter()
282 .find(|matcher| matcher.import_provenance.is_none())
283 .expect("a row without provenance exists");
284 let empty = module(Vec::new(), Vec::new());
285 assert!(row.provenance_satisfied(&empty, "anything", ProvenanceBindings::Imports));
286 }
287
288 #[test]
289 fn the_source_match_admits_the_node_prefix_and_package_subpaths() {
290 assert!(import_source_matches("node:child_process", "child_process"));
291 assert!(import_source_matches("child_process", "node:child_process"));
292 assert!(import_source_matches("mysql2/promise", "mysql2"));
293 assert!(!import_source_matches("mysql2-extra", "mysql2"));
294 }
295}