Skip to main content

fallow_security/
provenance.rs

1//! Import provenance of catalogue rows.
2//!
3//! A row with an `import_provenance` matches a callee only when the module
4//! imports that source. The security detectors, the policy effect lookup and
5//! the mutation plan all use the one predicate in this module, so a callee
6//! name such as `get` never counts as `https.get` in a module that does not
7//! import `node:https`.
8
9use fallow_types::extract::ModuleInfo;
10
11use crate::Matcher;
12
13/// The module bindings that can prove an import provenance.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ProvenanceBindings {
16    /// ES `import` declarations only.
17    Imports,
18    /// ES `import` declarations and CommonJS `require()` calls.
19    ///
20    /// `ModuleInfo::release_resolution_payload` drains `require_calls`, so a
21    /// caller that reads a released module sees ES imports only.
22    ImportsAndRequires,
23    /// [`Self::ImportsAndRequires`], and also a leading identifier that no ES
24    /// import and no export of the module binds.
25    ///
26    /// A released module keeps no `require()` facts, so such an identifier
27    /// can be a CommonJS binding of the source. A caller that must not miss
28    /// a sink, such as the side-effect prune of the mutation plan, uses this
29    /// mode. An identifier that an ES import of another source or a local
30    /// export binds does not satisfy the row.
31    ReleasedModule,
32}
33
34/// Rows that must bind the provenance import to the leading identifier of the
35/// callee path. A module that only imports the source is not sufficient for
36/// these rows (the `child_process.fork()` precedent).
37const 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    /// Whether the row requires the provenance import's local binding to be
51    /// the leading identifier of the callee path.
52    #[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    /// Whether the module satisfies the row's import provenance for the
59    /// callee path.
60    ///
61    /// A row without provenance is always satisfied. Otherwise the module
62    /// must import a source that matches the spec (see
63    /// [`import_source_matches`]). For a binding-trace row
64    /// ([`Self::requires_binding_trace`]), the import must also bind the
65    /// leading identifier of `callee_path`.
66    #[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
100/// Whether an ES import or an export of the module binds the identifier.
101fn 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/// Compare an import source with a provenance spec.
115///
116/// The `node:` prefix is optional on both sides (`node:child_process` matches
117/// `child_process`), and a package subpath matches its package
118/// (`mysql2/promise` matches `mysql2`).
119#[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}