use fallow_types::extract::ModuleInfo;
use crate::Matcher;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProvenanceBindings {
Imports,
ImportsAndRequires,
ReleasedModule,
}
const BINDING_TRACE_ROWS: [&str; 9] = [
"command-injection",
"permissive-cors",
"electron-unsafe-webpreferences",
"insecure-temp-file",
"jwt-alg-none",
"jwt-verify-missing-algorithms",
"tls-validation-disabled",
"mysql-multiple-statements",
"world-writable-permission",
];
impl Matcher {
#[must_use]
pub fn requires_binding_trace(&self) -> bool {
BINDING_TRACE_ROWS.contains(&self.id.as_str())
|| (self.id == "weak-crypto" && self.is_literal_aware())
}
#[must_use]
pub fn provenance_satisfied(
&self,
module: &ModuleInfo,
callee_path: &str,
bindings: ProvenanceBindings,
) -> bool {
let Some(spec) = &self.import_provenance else {
return true;
};
let leading_ident = callee_path.split('.').next().unwrap_or(callee_path);
let binding_trace = self.requires_binding_trace();
let by_import = module.imports.iter().any(|import| {
import_source_matches(&import.source, spec)
&& (!binding_trace || import.local_name == leading_ident)
});
if by_import || bindings == ProvenanceBindings::Imports {
return by_import;
}
let by_require = module.require_calls.iter().any(|call| {
import_source_matches(&call.source, spec)
&& (!binding_trace
|| call.local_name.as_deref() == Some(leading_ident)
|| call
.destructured_names
.iter()
.any(|name| name == leading_ident))
});
by_require
|| (bindings == ProvenanceBindings::ReleasedModule
&& !binds_locally(module, leading_ident))
}
}
fn binds_locally(module: &ModuleInfo, ident: &str) -> bool {
module
.imports
.iter()
.any(|import| import.local_name == ident)
|| module.exports.iter().any(|export| {
export
.local_name
.as_deref()
.map_or_else(|| export.name.matches_str(ident), |local| local == ident)
})
}
#[must_use]
pub fn import_source_matches(source: &str, spec: &str) -> bool {
let source = strip_node_prefix(source);
let spec = strip_node_prefix(spec);
source == spec
|| source
.strip_prefix(spec)
.is_some_and(|rest| rest.starts_with('/'))
}
fn strip_node_prefix(value: &str) -> &str {
value.strip_prefix("node:").unwrap_or(value)
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "the tests look up catalogue rows that the catalogue must contain"
)]
mod tests {
use fallow_types::discover::FileId;
use fallow_types::extract::{
ExportInfo, ExportName, ImportInfo, ImportedName, RequireCallInfo, SinkShape, VisibilityTag,
};
use oxc_span::Span;
use super::*;
fn row(id: &str, shape: SinkShape, callee: &str) -> &'static Matcher {
crate::catalogue()
.matchers()
.iter()
.find(|matcher| {
matcher.id == id
&& matcher.sink_shape == shape
&& matcher.first_matching_pattern(callee).is_some()
})
.expect("catalogue row exists")
}
fn import(source: &str, local: &str) -> ImportInfo {
ImportInfo {
source: source.to_owned(),
imported_name: ImportedName::Named(local.to_owned()),
local_name: local.to_owned(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: Span::default(),
source_span: Span::default(),
}
}
fn require(source: &str, local: Option<&str>, destructured: &[&str]) -> RequireCallInfo {
RequireCallInfo {
source: source.to_owned(),
span: Span::default(),
source_span: Span::default(),
destructured_names: destructured.iter().map(|&name| name.to_owned()).collect(),
local_name: local.map(str::to_owned),
is_type_only: false,
}
}
fn export(name: &str) -> ExportInfo {
ExportInfo {
name: ExportName::Named(name.to_owned()),
local_name: None,
is_type_only: false,
is_side_effect_used: false,
visibility: VisibilityTag::None,
expected_unused_reason: None,
deprecated: false,
deprecated_reason: None,
span: Span::default(),
members: Vec::new(),
super_class: None,
}
}
fn module(imports: Vec<ImportInfo>, requires: Vec<RequireCallInfo>) -> ModuleInfo {
ModuleInfo {
imports,
require_calls: requires,
..ModuleInfo::empty(FileId(0))
}
}
#[test]
fn a_local_get_does_not_satisfy_the_https_row() {
let tls = row("tls-validation-disabled", SinkShape::Call, "get");
let local = module(vec![import("./get", "get")], Vec::new());
assert!(!tls.provenance_satisfied(&local, "get", ProvenanceBindings::ImportsAndRequires));
let unbound = module(Vec::new(), Vec::new());
assert!(!tls.provenance_satisfied(&unbound, "get", ProvenanceBindings::Imports));
}
#[test]
fn the_https_import_must_bind_the_leading_identifier() {
let tls = row("tls-validation-disabled", SinkShape::Call, "get");
let named = module(vec![import("node:https", "get")], Vec::new());
assert!(tls.provenance_satisfied(&named, "get", ProvenanceBindings::Imports));
let other = module(vec![import("https", "request")], Vec::new());
assert!(!tls.provenance_satisfied(&other, "get", ProvenanceBindings::Imports));
}
#[test]
fn a_row_without_binding_trace_accepts_any_import_of_the_source() {
let fs = row("path-traversal", SinkShape::MemberCall, "fs.readFile");
assert!(!fs.requires_binding_trace());
let imported = module(vec![import("fs", "files")], Vec::new());
assert!(fs.provenance_satisfied(&imported, "fs.readFile", ProvenanceBindings::Imports));
}
#[test]
fn a_require_call_counts_only_when_the_caller_admits_requires() {
let tls = row(
"tls-validation-disabled",
SinkShape::MemberCall,
"https.get",
);
let namespace = module(Vec::new(), vec![require("https", Some("https"), &[])]);
assert!(!tls.provenance_satisfied(&namespace, "https.get", ProvenanceBindings::Imports));
assert!(tls.provenance_satisfied(
&namespace,
"https.get",
ProvenanceBindings::ImportsAndRequires
));
let destructured = module(Vec::new(), vec![require("node:https", None, &["get"])]);
assert!(tls.provenance_satisfied(
&destructured,
"get",
ProvenanceBindings::ImportsAndRequires
));
}
#[test]
fn a_released_module_treats_an_unbound_identifier_as_a_hidden_require() {
let tls = row(
"tls-validation-disabled",
SinkShape::MemberCall,
"https.get",
);
let mode = ProvenanceBindings::ReleasedModule;
let unbound = module(Vec::new(), Vec::new());
assert!(tls.provenance_satisfied(&unbound, "https.get", mode));
let elsewhere = module(vec![import("./client", "https")], Vec::new());
assert!(!tls.provenance_satisfied(&elsewhere, "https.get", mode));
let get = row("tls-validation-disabled", SinkShape::Call, "get");
let exported = ModuleInfo {
exports: vec![export("get")].into(),
..module(Vec::new(), Vec::new())
};
assert!(!get.provenance_satisfied(&exported, "get", mode));
let imported = module(vec![import("node:https", "get")], Vec::new());
assert!(get.provenance_satisfied(&imported, "get", mode));
}
#[test]
fn a_row_without_provenance_is_always_satisfied() {
let row = crate::catalogue()
.matchers()
.iter()
.find(|matcher| matcher.import_provenance.is_none())
.expect("a row without provenance exists");
let empty = module(Vec::new(), Vec::new());
assert!(row.provenance_satisfied(&empty, "anything", ProvenanceBindings::Imports));
}
#[test]
fn the_source_match_admits_the_node_prefix_and_package_subpaths() {
assert!(import_source_matches("node:child_process", "child_process"));
assert!(import_source_matches("child_process", "node:child_process"));
assert!(import_source_matches("mysql2/promise", "mysql2"));
assert!(!import_source_matches("mysql2-extra", "mysql2"));
}
}