use rustc_hash::FxHashMap;
use rustc_hash::FxHashSet;
use fallow_config::{
EffectKind, ResolvedBoundaryConfig, ResolvedConfig, RulePackDef, RulePackRule,
RulePackRuleKind, Severity,
};
use fallow_types::extract::ModuleInfo;
use fallow_types::results::{PolicyRuleKind, PolicyViolation, PolicyViolationSeverity};
use crate::discover::FileId;
use crate::graph::ModuleGraph;
use crate::suppress::SuppressionContext;
use super::boundary_calls::canonical_callee_path;
use super::security::{CalleePattern, catalogue_matchers};
use super::{LineOffsetsMap, byte_offset_to_line_col};
struct CompiledRule<'a> {
pack: &'a str,
rule: &'a RulePackRule,
callee_patterns: Vec<CalleePattern>,
effects: FxHashSet<EffectKind>,
zones: FxHashSet<String>,
files: Vec<globset::GlobMatcher>,
exclude: Vec<globset::GlobMatcher>,
}
impl CompiledRule<'_> {
fn applies_to(&self, relative: &str, zone: Option<&str>) -> bool {
compiled_scope_applies(&self.files, &self.exclude, &self.zones, relative, zone)
}
fn effective_severity(&self, master: Severity) -> Severity {
self.rule.severity.unwrap_or(master)
}
fn matches_callee(&self, module: &ModuleInfo, callee_path: &str) -> bool {
if self
.callee_patterns
.iter()
.any(|pattern| pattern.matches(callee_path))
{
return true;
}
canonical_callee_path(module, callee_path).is_some_and(|canonical| {
self.callee_patterns
.iter()
.any(|pattern| pattern.matches(&canonical))
})
}
fn matches_effect(
&self,
module: &ModuleInfo,
callee_path: &str,
declared_deps: &FxHashSet<String>,
) -> Option<EffectKind> {
effect_for_callee(module, callee_path, declared_deps)
.filter(|effect| self.effects.contains(effect))
}
}
#[must_use]
pub fn rules_applying_to_path<'a>(
rule_packs: &'a [RulePackDef],
boundaries: &ResolvedBoundaryConfig,
rel_path: &str,
) -> Vec<(&'a str, &'a RulePackRule)> {
let zone = boundaries.classify_zone(rel_path);
rule_packs
.iter()
.flat_map(|pack| {
pack.rules
.iter()
.filter(move |rule| raw_rule_scope_applies(rule, boundaries, rel_path, zone))
.map(|rule| (pack.name.as_str(), rule))
})
.collect()
}
fn raw_rule_scope_applies(
rule: &RulePackRule,
boundaries: &ResolvedBoundaryConfig,
relative: &str,
zone: Option<&str>,
) -> bool {
let files = compile_scope_globs(&rule.files);
let exclude = compile_scope_globs(&rule.exclude);
let zones = rule.zones.iter().cloned().collect();
let zone = zone.or_else(|| boundaries.classify_zone(relative));
compiled_scope_applies(&files, &exclude, &zones, relative, zone)
}
fn compile_scope_globs(patterns: &[String]) -> Vec<globset::GlobMatcher> {
patterns
.iter()
.filter_map(|pattern| globset::Glob::new(pattern).ok())
.map(|glob| glob.compile_matcher())
.collect()
}
fn compiled_scope_applies(
files: &[globset::GlobMatcher],
exclude: &[globset::GlobMatcher],
zones: &FxHashSet<String>,
relative: &str,
zone: Option<&str>,
) -> bool {
(files.is_empty() || files.iter().any(|m| m.is_match(relative)))
&& !exclude.iter().any(|m| m.is_match(relative))
&& (zones.is_empty() || zone.is_some_and(|zone| zones.contains(zone)))
}
pub fn find_policy_violations(
graph: &ModuleGraph,
modules: &[ModuleInfo],
config: &ResolvedConfig,
declared_deps: &FxHashSet<String>,
suppressions: &SuppressionContext<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<PolicyViolation> {
if config.rule_packs.is_empty() {
return Vec::new();
}
let rules = compile_rules(config);
if rules.is_empty() {
return Vec::new();
}
let modules_by_id: FxHashMap<FileId, &ModuleInfo> =
modules.iter().map(|m| (m.file_id, m)).collect();
let mut scoped_file_counts: Vec<usize> = vec![0; rules.len()];
let mut zones_by_file: FxHashMap<FileId, Option<&str>> = FxHashMap::default();
let mut violations = Vec::new();
for node in &graph.modules {
let zone = *zones_by_file.entry(node.file_id).or_insert_with(|| {
node.path.strip_prefix(&config.root).ok().and_then(|path| {
let relative = path.to_string_lossy().replace('\\', "/");
config.boundaries.classify_zone(&relative)
})
});
collect_node_policy_violations(&mut PolicyNodeInput {
node,
config,
rules: &rules,
zone,
modules_by_id: &modules_by_id,
declared_deps,
suppressions,
line_offsets_by_file,
scoped_file_counts: &mut scoped_file_counts,
violations: &mut violations,
});
}
for (index, rule) in rules.iter().enumerate() {
if !rule.rule.files.is_empty() && scoped_file_counts[index] == 0 {
tracing::warn!(
"rule pack '{}': rule '{}' has `files` globs that matched no analyzed file; the \
rule currently enforces nothing",
rule.pack,
rule.rule.id
);
}
}
violations
}
struct PolicyNodeInput<'a> {
node: &'a crate::graph::ModuleNode,
config: &'a ResolvedConfig,
rules: &'a [CompiledRule<'a>],
zone: Option<&'a str>,
modules_by_id: &'a FxHashMap<FileId, &'a ModuleInfo>,
declared_deps: &'a FxHashSet<String>,
suppressions: &'a SuppressionContext<'a>,
line_offsets_by_file: &'a LineOffsetsMap<'a>,
scoped_file_counts: &'a mut [usize],
violations: &'a mut Vec<PolicyViolation>,
}
fn collect_node_policy_violations(input: &mut PolicyNodeInput<'_>) {
let node = input.node;
let Some(scope) = scoped_policy_rules(
node,
input.config,
input.rules,
input.zone,
input.scoped_file_counts,
) else {
return;
};
let Some(module) = input.modules_by_id.get(&node.file_id) else {
return;
};
collect_banned_imports(&mut PolicyCollectionInput {
in_scope: &scope.in_scope,
module,
node,
master: scope.master,
declared_deps: input.declared_deps,
suppressions: input.suppressions,
line_offsets_by_file: input.line_offsets_by_file,
violations: input.violations,
});
collect_banned_exports(&mut PolicyCollectionInput {
in_scope: &scope.in_scope,
module,
node,
master: scope.master,
declared_deps: input.declared_deps,
suppressions: input.suppressions,
line_offsets_by_file: input.line_offsets_by_file,
violations: input.violations,
});
collect_banned_effects(&mut PolicyCollectionInput {
in_scope: &scope.in_scope,
module,
node,
master: scope.master,
declared_deps: input.declared_deps,
suppressions: input.suppressions,
line_offsets_by_file: input.line_offsets_by_file,
violations: input.violations,
});
collect_banned_calls(&mut PolicyCollectionInput {
in_scope: &scope.in_scope,
module,
node,
master: scope.master,
declared_deps: input.declared_deps,
suppressions: input.suppressions,
line_offsets_by_file: input.line_offsets_by_file,
violations: input.violations,
});
}
struct ScopedPolicyRules<'a> {
master: Severity,
in_scope: Vec<(usize, &'a CompiledRule<'a>)>,
}
fn scoped_policy_rules<'a>(
node: &crate::graph::ModuleNode,
config: &ResolvedConfig,
rules: &'a [CompiledRule<'a>],
zone: Option<&str>,
scoped_file_counts: &mut [usize],
) -> Option<ScopedPolicyRules<'a>> {
if !node.is_reachable() && !node.is_entry_point() {
return None;
}
let Ok(relative) = node.path.strip_prefix(&config.root) else {
return None;
};
let relative = relative.to_string_lossy().replace('\\', "/");
let master = config.resolve_rules_for_path(&node.path).policy_violation;
if master == Severity::Off {
return None;
}
let in_scope: Vec<(usize, &CompiledRule<'_>)> = rules
.iter()
.enumerate()
.filter(|(_, rule)| rule.applies_to(&relative, zone))
.collect();
if in_scope.is_empty() {
return None;
}
for (index, _) in &in_scope {
scoped_file_counts[*index] += 1;
}
Some(ScopedPolicyRules { master, in_scope })
}
fn compile_rules(config: &ResolvedConfig) -> Vec<CompiledRule<'_>> {
let mut rules = Vec::new();
for pack in &config.rule_packs {
for rule in &pack.rules {
if rule.severity == Some(Severity::Off) {
continue;
}
let callee_patterns = rule
.callees
.iter()
.filter_map(|raw| CalleePattern::parse(raw))
.collect();
let effects = rule.effects.iter().copied().collect();
let zones = rule.zones.iter().cloned().collect();
rules.push(CompiledRule {
pack: pack.name.as_str(),
rule,
callee_patterns,
effects,
zones,
files: compile_scope_globs(&rule.files),
exclude: compile_scope_globs(&rule.exclude),
});
}
}
rules
}
struct PolicyCollectionInput<'a> {
in_scope: &'a [(usize, &'a CompiledRule<'a>)],
module: &'a ModuleInfo,
node: &'a crate::graph::ModuleNode,
master: Severity,
declared_deps: &'a FxHashSet<String>,
suppressions: &'a SuppressionContext<'a>,
line_offsets_by_file: &'a LineOffsetsMap<'a>,
violations: &'a mut Vec<PolicyViolation>,
}
fn collect_banned_imports(input: &mut PolicyCollectionInput<'_>) {
let ctx = BannedImportCtx {
node: input.node,
suppressions: input.suppressions,
line_offsets_by_file: input.line_offsets_by_file,
};
for (_, rule) in input.in_scope {
if rule.rule.kind != RulePackRuleKind::BannedImport {
continue;
}
let Some(severity) = wire_severity(rule.effective_severity(input.master)) else {
continue;
};
let sites = input
.module
.imports
.iter()
.map(|import| {
(
import.source.as_str(),
import.is_type_only,
import.span.start,
)
})
.chain(input.module.re_exports.iter().map(|re_export| {
(
re_export.source.as_str(),
re_export.is_type_only,
re_export.span.start,
)
}));
for (source, is_type_only, span_start) in sites {
push_banned_import_if_matched(
&ctx,
rule,
severity,
&BannedImportSite {
source,
is_type_only,
span_start,
},
input.violations,
);
}
}
}
struct BannedImportSite<'a> {
source: &'a str,
is_type_only: bool,
span_start: u32,
}
struct BannedImportCtx<'a> {
node: &'a crate::graph::ModuleNode,
suppressions: &'a SuppressionContext<'a>,
line_offsets_by_file: &'a LineOffsetsMap<'a>,
}
fn push_banned_import_if_matched(
ctx: &BannedImportCtx<'_>,
rule: &CompiledRule<'_>,
severity: PolicyViolationSeverity,
site: &BannedImportSite<'_>,
violations: &mut Vec<PolicyViolation>,
) {
if rule.rule.ignore_type_only && site.is_type_only {
return;
}
if !rule
.rule
.specifiers
.iter()
.any(|specifier| specifier_matches(site.source, specifier))
{
return;
}
let (line, col) =
byte_offset_to_line_col(ctx.line_offsets_by_file, ctx.node.file_id, site.span_start);
if ctx
.suppressions
.is_policy_suppressed(ctx.node.file_id, line, rule.pack, &rule.rule.id)
{
return;
}
violations.push(PolicyViolation {
path: ctx.node.path.clone(),
line,
col,
pack: rule.pack.to_owned(),
rule_id: rule.rule.id.clone(),
kind: PolicyRuleKind::BannedImport,
matched: site.source.to_owned(),
severity,
message: rule.rule.message.clone(),
});
}
fn collect_banned_exports(input: &mut PolicyCollectionInput<'_>) {
for (_, rule) in input.in_scope {
if rule.rule.kind != RulePackRuleKind::BannedExport {
continue;
}
let Some(severity) = wire_severity(rule.effective_severity(input.master)) else {
continue;
};
for export in &input.module.exports {
if rule.rule.ignore_type_only && export.is_type_only {
continue;
}
if !rule
.rule
.exports
.iter()
.any(|pattern| export_pattern_matches(&export.name, pattern))
{
continue;
}
let (line, col) = byte_offset_to_line_col(
input.line_offsets_by_file,
input.node.file_id,
export.span.start,
);
if input.suppressions.is_policy_suppressed(
input.node.file_id,
line,
rule.pack,
&rule.rule.id,
) {
continue;
}
input.violations.push(PolicyViolation {
path: input.node.path.clone(),
line,
col,
pack: rule.pack.to_owned(),
rule_id: rule.rule.id.clone(),
kind: PolicyRuleKind::BannedExport,
matched: export.name.to_string(),
severity,
message: rule.rule.message.clone(),
});
}
}
}
fn collect_banned_effects(input: &mut PolicyCollectionInput<'_>) {
for callee_use in &input.module.callee_uses {
let matched = input.in_scope.iter().find_map(|(_, rule)| {
if rule.rule.kind != RulePackRuleKind::BannedEffect {
return None;
}
let severity = wire_severity(rule.effective_severity(input.master))?;
rule.matches_effect(input.module, &callee_use.callee_path, input.declared_deps)
.map(|effect| (rule, severity, effect))
});
let Some((rule, severity, effect)) = matched else {
continue;
};
let (line, col) = byte_offset_to_line_col(
input.line_offsets_by_file,
input.node.file_id,
callee_use.span_start,
);
if input.suppressions.is_policy_suppressed(
input.node.file_id,
line,
rule.pack,
&rule.rule.id,
) {
continue;
}
input.violations.push(PolicyViolation {
path: input.node.path.clone(),
line,
col,
pack: rule.pack.to_owned(),
rule_id: rule.rule.id.clone(),
kind: PolicyRuleKind::BannedEffect,
matched: format!("{}: {}", effect.as_str(), callee_use.callee_path),
severity,
message: rule.rule.message.clone(),
});
}
}
fn collect_banned_calls(input: &mut PolicyCollectionInput<'_>) {
for callee_use in &input.module.callee_uses {
let matched = input.in_scope.iter().find_map(|(_, rule)| {
if rule.rule.kind != RulePackRuleKind::BannedCall {
return None;
}
let severity = wire_severity(rule.effective_severity(input.master))?;
rule.matches_callee(input.module, &callee_use.callee_path)
.then_some((rule, severity))
});
let Some((rule, severity)) = matched else {
continue;
};
let (line, col) = byte_offset_to_line_col(
input.line_offsets_by_file,
input.node.file_id,
callee_use.span_start,
);
if input.suppressions.is_policy_suppressed(
input.node.file_id,
line,
rule.pack,
&rule.rule.id,
) {
continue;
}
input.violations.push(PolicyViolation {
path: input.node.path.clone(),
line,
col,
pack: rule.pack.to_owned(),
rule_id: rule.rule.id.clone(),
kind: PolicyRuleKind::BannedCall,
matched: callee_use.callee_path.clone(),
severity,
message: rule.rule.message.clone(),
});
}
}
fn effect_for_callee(
module: &ModuleInfo,
callee_path: &str,
declared_deps: &FxHashSet<String>,
) -> Option<EffectKind> {
let written = catalogue_matchers()
.iter()
.find(|matcher| matcher_matches_callee(matcher, module, callee_path, declared_deps))
.map(|matcher| matcher.effect);
if written.is_some() {
return written;
}
let canonical = canonical_callee_path(module, callee_path)?;
catalogue_matchers()
.iter()
.find(|matcher| matcher_matches_callee(matcher, module, &canonical, declared_deps))
.map(|matcher| matcher.effect)
}
fn matcher_matches_callee(
matcher: &super::security::Matcher,
module: &ModuleInfo,
callee_path: &str,
declared_deps: &FxHashSet<String>,
) -> bool {
matcher.enabler_satisfied(declared_deps)
&& provenance_satisfied(matcher, module, callee_path)
&& matcher.first_matching_pattern(callee_path).is_some()
}
fn provenance_satisfied(
matcher: &super::security::Matcher,
module: &ModuleInfo,
callee_path: &str,
) -> bool {
let Some(spec) = &matcher.import_provenance else {
return true;
};
let leading_ident = callee_path.split('.').next().unwrap_or(callee_path);
module.imports.iter().any(|imp| {
import_source_matches(&imp.source, spec)
&& (!requires_binding_trace(matcher) || imp.local_name == leading_ident)
}) || module.require_calls.iter().any(|call| {
import_source_matches(&call.source, spec)
&& (!requires_binding_trace(matcher)
|| call.local_name.as_deref() == Some(leading_ident)
|| call
.destructured_names
.iter()
.any(|name| name == leading_ident))
})
}
fn requires_binding_trace(matcher: &super::security::Matcher) -> bool {
matches!(
matcher.id.as_str(),
"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"
) || (matcher.id == "weak-crypto" && matcher.is_literal_aware())
}
fn import_source_matches(source: &str, spec: &str) -> bool {
fn strip_node_prefix(value: &str) -> &str {
value.strip_prefix("node:").unwrap_or(value)
}
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 specifier_matches(raw: &str, pattern: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix("/*") {
return raw
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('/'));
}
raw == pattern
|| raw
.strip_prefix(pattern)
.is_some_and(|rest| rest.starts_with('/'))
}
fn export_pattern_matches(name: &fallow_types::extract::ExportName, pattern: &str) -> bool {
pattern.strip_suffix('*').map_or_else(
|| name.matches_str(pattern),
|prefix| name.to_string().starts_with(prefix),
)
}
const fn wire_severity(severity: Severity) -> Option<PolicyViolationSeverity> {
match severity {
Severity::Error => Some(PolicyViolationSeverity::Error),
Severity::Warn => Some(PolicyViolationSeverity::Warn),
Severity::Off => None,
}
}
#[cfg(test)]
mod tests;