1use rustc_hash::FxHashMap;
2use rustc_hash::FxHashSet;
3
4use fallow_config::{
5 EffectKind, ResolvedBoundaryConfig, ResolvedConfig, RulePackDef, RulePackRule,
6 RulePackRuleKind, Severity,
7};
8use fallow_types::extract::ModuleInfo;
9use fallow_types::results::{PolicyRuleKind, PolicyViolation, PolicyViolationSeverity};
10
11use crate::discover::FileId;
12use crate::graph::ModuleGraph;
13use crate::suppress::SuppressionContext;
14
15use super::boundary_calls::canonical_callee_path;
16use super::security::{CalleePattern, catalogue_matchers};
17use super::{LineOffsetsMap, byte_offset_to_line_col};
18
19struct CompiledRule<'a> {
21 pack: &'a str,
22 rule: &'a RulePackRule,
23 callee_patterns: Vec<CalleePattern>,
25 effects: FxHashSet<EffectKind>,
26 zones: FxHashSet<String>,
27 files: Vec<globset::GlobMatcher>,
28 exclude: Vec<globset::GlobMatcher>,
29}
30
31impl CompiledRule<'_> {
32 fn applies_to(&self, relative: &str, zone: Option<&str>) -> bool {
34 compiled_scope_applies(&self.files, &self.exclude, &self.zones, relative, zone)
35 }
36
37 fn effective_severity(&self, master: Severity) -> Severity {
39 self.rule.severity.unwrap_or(master)
40 }
41
42 fn matches_callee(&self, module: &ModuleInfo, callee_path: &str) -> bool {
46 if self
47 .callee_patterns
48 .iter()
49 .any(|pattern| pattern.matches(callee_path))
50 {
51 return true;
52 }
53 canonical_callee_path(module, callee_path).is_some_and(|canonical| {
54 self.callee_patterns
55 .iter()
56 .any(|pattern| pattern.matches(&canonical))
57 })
58 }
59
60 fn matches_effect(
61 &self,
62 module: &ModuleInfo,
63 callee_path: &str,
64 declared_deps: &FxHashSet<String>,
65 ) -> Option<EffectKind> {
66 effect_for_callee(module, callee_path, declared_deps)
67 .filter(|effect| self.effects.contains(effect))
68 }
69}
70
71#[must_use]
73pub fn rules_applying_to_path<'a>(
74 rule_packs: &'a [RulePackDef],
75 boundaries: &ResolvedBoundaryConfig,
76 rel_path: &str,
77) -> Vec<(&'a str, &'a RulePackRule)> {
78 let zone = boundaries.classify_zone(rel_path);
79 rule_packs
80 .iter()
81 .flat_map(|pack| {
82 pack.rules
83 .iter()
84 .filter(move |rule| raw_rule_scope_applies(rule, boundaries, rel_path, zone))
85 .map(|rule| (pack.name.as_str(), rule))
86 })
87 .collect()
88}
89
90fn raw_rule_scope_applies(
91 rule: &RulePackRule,
92 boundaries: &ResolvedBoundaryConfig,
93 relative: &str,
94 zone: Option<&str>,
95) -> bool {
96 let files = compile_scope_globs(&rule.files);
97 let exclude = compile_scope_globs(&rule.exclude);
98 let zones = rule.zones.iter().cloned().collect();
99 let zone = zone.or_else(|| boundaries.classify_zone(relative));
100 compiled_scope_applies(&files, &exclude, &zones, relative, zone)
101}
102
103fn compile_scope_globs(patterns: &[String]) -> Vec<globset::GlobMatcher> {
104 patterns
107 .iter()
108 .filter_map(|pattern| globset::Glob::new(pattern).ok())
109 .map(|glob| glob.compile_matcher())
110 .collect()
111}
112
113fn compiled_scope_applies(
114 files: &[globset::GlobMatcher],
115 exclude: &[globset::GlobMatcher],
116 zones: &FxHashSet<String>,
117 relative: &str,
118 zone: Option<&str>,
119) -> bool {
120 (files.is_empty() || files.iter().any(|m| m.is_match(relative)))
121 && !exclude.iter().any(|m| m.is_match(relative))
122 && (zones.is_empty() || zone.is_some_and(|zone| zones.contains(zone)))
123}
124
125pub fn find_policy_violations(
152 graph: &ModuleGraph,
153 modules: &[ModuleInfo],
154 config: &ResolvedConfig,
155 declared_deps: &FxHashSet<String>,
156 suppressions: &SuppressionContext<'_>,
157 line_offsets_by_file: &LineOffsetsMap<'_>,
158) -> Vec<PolicyViolation> {
159 if config.rule_packs.is_empty() {
160 return Vec::new();
161 }
162
163 let rules = compile_rules(config);
164 if rules.is_empty() {
165 return Vec::new();
166 }
167
168 let modules_by_id: FxHashMap<FileId, &ModuleInfo> =
169 modules.iter().map(|m| (m.file_id, m)).collect();
170
171 let mut scoped_file_counts: Vec<usize> = vec![0; rules.len()];
175 let mut zones_by_file: FxHashMap<FileId, Option<&str>> = FxHashMap::default();
176
177 let mut violations = Vec::new();
178 for node in &graph.modules {
179 let zone = *zones_by_file.entry(node.file_id).or_insert_with(|| {
180 node.path.strip_prefix(&config.root).ok().and_then(|path| {
181 let relative = path.to_string_lossy().replace('\\', "/");
182 config.boundaries.classify_zone(&relative)
183 })
184 });
185 collect_node_policy_violations(&mut PolicyNodeInput {
186 node,
187 config,
188 rules: &rules,
189 zone,
190 modules_by_id: &modules_by_id,
191 declared_deps,
192 suppressions,
193 line_offsets_by_file,
194 scoped_file_counts: &mut scoped_file_counts,
195 violations: &mut violations,
196 });
197 }
198
199 for (index, rule) in rules.iter().enumerate() {
200 if !rule.rule.files.is_empty() && scoped_file_counts[index] == 0 {
201 tracing::warn!(
202 "rule pack '{}': rule '{}' has `files` globs that matched no analyzed file; the \
203 rule currently enforces nothing",
204 rule.pack,
205 rule.rule.id
206 );
207 }
208 }
209
210 violations
211}
212
213struct PolicyNodeInput<'a> {
216 node: &'a crate::graph::ModuleNode,
217 config: &'a ResolvedConfig,
218 rules: &'a [CompiledRule<'a>],
219 zone: Option<&'a str>,
220 modules_by_id: &'a FxHashMap<FileId, &'a ModuleInfo>,
221 declared_deps: &'a FxHashSet<String>,
222 suppressions: &'a SuppressionContext<'a>,
223 line_offsets_by_file: &'a LineOffsetsMap<'a>,
224 scoped_file_counts: &'a mut [usize],
225 violations: &'a mut Vec<PolicyViolation>,
226}
227
228fn collect_node_policy_violations(input: &mut PolicyNodeInput<'_>) {
232 let node = input.node;
233 let Some(scope) = scoped_policy_rules(
234 node,
235 input.config,
236 input.rules,
237 input.zone,
238 input.scoped_file_counts,
239 ) else {
240 return;
241 };
242
243 let Some(module) = input.modules_by_id.get(&node.file_id) else {
244 return;
245 };
246
247 collect_banned_imports(&mut PolicyCollectionInput {
248 in_scope: &scope.in_scope,
249 module,
250 node,
251 master: scope.master,
252 declared_deps: input.declared_deps,
253 suppressions: input.suppressions,
254 line_offsets_by_file: input.line_offsets_by_file,
255 violations: input.violations,
256 });
257 collect_banned_exports(&mut PolicyCollectionInput {
258 in_scope: &scope.in_scope,
259 module,
260 node,
261 master: scope.master,
262 declared_deps: input.declared_deps,
263 suppressions: input.suppressions,
264 line_offsets_by_file: input.line_offsets_by_file,
265 violations: input.violations,
266 });
267 collect_banned_effects(&mut PolicyCollectionInput {
268 in_scope: &scope.in_scope,
269 module,
270 node,
271 master: scope.master,
272 declared_deps: input.declared_deps,
273 suppressions: input.suppressions,
274 line_offsets_by_file: input.line_offsets_by_file,
275 violations: input.violations,
276 });
277 collect_banned_calls(&mut PolicyCollectionInput {
278 in_scope: &scope.in_scope,
279 module,
280 node,
281 master: scope.master,
282 declared_deps: input.declared_deps,
283 suppressions: input.suppressions,
284 line_offsets_by_file: input.line_offsets_by_file,
285 violations: input.violations,
286 });
287}
288
289struct ScopedPolicyRules<'a> {
290 master: Severity,
291 in_scope: Vec<(usize, &'a CompiledRule<'a>)>,
292}
293
294fn scoped_policy_rules<'a>(
295 node: &crate::graph::ModuleNode,
296 config: &ResolvedConfig,
297 rules: &'a [CompiledRule<'a>],
298 zone: Option<&str>,
299 scoped_file_counts: &mut [usize],
300) -> Option<ScopedPolicyRules<'a>> {
301 if !node.is_reachable() && !node.is_entry_point() {
302 return None;
303 }
304 let Ok(relative) = node.path.strip_prefix(&config.root) else {
305 return None;
306 };
307 let relative = relative.to_string_lossy().replace('\\', "/");
308
309 let master = config.resolve_rules_for_path(&node.path).policy_violation;
310 if master == Severity::Off {
311 return None;
312 }
313
314 let in_scope: Vec<(usize, &CompiledRule<'_>)> = rules
315 .iter()
316 .enumerate()
317 .filter(|(_, rule)| rule.applies_to(&relative, zone))
318 .collect();
319 if in_scope.is_empty() {
320 return None;
321 }
322 for (index, _) in &in_scope {
323 scoped_file_counts[*index] += 1;
324 }
325
326 Some(ScopedPolicyRules { master, in_scope })
327}
328
329fn compile_rules(config: &ResolvedConfig) -> Vec<CompiledRule<'_>> {
332 let mut rules = Vec::new();
333 for pack in &config.rule_packs {
334 for rule in &pack.rules {
335 if rule.severity == Some(Severity::Off) {
336 continue;
337 }
338 let callee_patterns = rule
341 .callees
342 .iter()
343 .filter_map(|raw| CalleePattern::parse(raw))
344 .collect();
345 let effects = rule.effects.iter().copied().collect();
346 let zones = rule.zones.iter().cloned().collect();
347 rules.push(CompiledRule {
348 pack: pack.name.as_str(),
349 rule,
350 callee_patterns,
351 effects,
352 zones,
353 files: compile_scope_globs(&rule.files),
354 exclude: compile_scope_globs(&rule.exclude),
355 });
356 }
357 }
358 rules
359}
360
361struct PolicyCollectionInput<'a> {
364 in_scope: &'a [(usize, &'a CompiledRule<'a>)],
365 module: &'a ModuleInfo,
366 node: &'a crate::graph::ModuleNode,
367 master: Severity,
368 declared_deps: &'a FxHashSet<String>,
369 suppressions: &'a SuppressionContext<'a>,
370 line_offsets_by_file: &'a LineOffsetsMap<'a>,
371 violations: &'a mut Vec<PolicyViolation>,
372}
373
374fn collect_banned_imports(input: &mut PolicyCollectionInput<'_>) {
375 let ctx = BannedImportCtx {
376 node: input.node,
377 suppressions: input.suppressions,
378 line_offsets_by_file: input.line_offsets_by_file,
379 };
380 for (_, rule) in input.in_scope {
381 if rule.rule.kind != RulePackRuleKind::BannedImport {
382 continue;
383 }
384 let Some(severity) = wire_severity(rule.effective_severity(input.master)) else {
385 continue;
386 };
387 let sites = input
388 .module
389 .imports
390 .iter()
391 .map(|import| {
392 (
393 import.source.as_str(),
394 import.is_type_only,
395 import.span.start,
396 )
397 })
398 .chain(input.module.re_exports.iter().map(|re_export| {
399 (
400 re_export.source.as_str(),
401 re_export.is_type_only,
402 re_export.span.start,
403 )
404 }));
405 for (source, is_type_only, span_start) in sites {
406 push_banned_import_if_matched(
407 &ctx,
408 rule,
409 severity,
410 &BannedImportSite {
411 source,
412 is_type_only,
413 span_start,
414 },
415 input.violations,
416 );
417 }
418 }
419}
420
421struct BannedImportSite<'a> {
423 source: &'a str,
424 is_type_only: bool,
425 span_start: u32,
426}
427
428struct BannedImportCtx<'a> {
430 node: &'a crate::graph::ModuleNode,
431 suppressions: &'a SuppressionContext<'a>,
432 line_offsets_by_file: &'a LineOffsetsMap<'a>,
433}
434
435fn push_banned_import_if_matched(
438 ctx: &BannedImportCtx<'_>,
439 rule: &CompiledRule<'_>,
440 severity: PolicyViolationSeverity,
441 site: &BannedImportSite<'_>,
442 violations: &mut Vec<PolicyViolation>,
443) {
444 if rule.rule.ignore_type_only && site.is_type_only {
445 return;
446 }
447 if !rule
448 .rule
449 .specifiers
450 .iter()
451 .any(|specifier| specifier_matches(site.source, specifier))
452 {
453 return;
454 }
455 let (line, col) =
456 byte_offset_to_line_col(ctx.line_offsets_by_file, ctx.node.file_id, site.span_start);
457 if ctx
458 .suppressions
459 .is_policy_suppressed(ctx.node.file_id, line, rule.pack, &rule.rule.id)
460 {
461 return;
462 }
463 violations.push(PolicyViolation {
464 path: ctx.node.path.clone(),
465 line,
466 col,
467 pack: rule.pack.to_owned(),
468 rule_id: rule.rule.id.clone(),
469 kind: PolicyRuleKind::BannedImport,
470 matched: site.source.to_owned(),
471 severity,
472 message: rule.rule.message.clone(),
473 });
474}
475
476fn collect_banned_exports(input: &mut PolicyCollectionInput<'_>) {
479 for (_, rule) in input.in_scope {
480 if rule.rule.kind != RulePackRuleKind::BannedExport {
481 continue;
482 }
483 let Some(severity) = wire_severity(rule.effective_severity(input.master)) else {
484 continue;
485 };
486 for export in &input.module.exports {
487 if rule.rule.ignore_type_only && export.is_type_only {
488 continue;
489 }
490 if !rule
491 .rule
492 .exports
493 .iter()
494 .any(|pattern| export_pattern_matches(&export.name, pattern))
495 {
496 continue;
497 }
498 let (line, col) = byte_offset_to_line_col(
499 input.line_offsets_by_file,
500 input.node.file_id,
501 export.span.start,
502 );
503 if input.suppressions.is_policy_suppressed(
504 input.node.file_id,
505 line,
506 rule.pack,
507 &rule.rule.id,
508 ) {
509 continue;
510 }
511 input.violations.push(PolicyViolation {
512 path: input.node.path.clone(),
513 line,
514 col,
515 pack: rule.pack.to_owned(),
516 rule_id: rule.rule.id.clone(),
517 kind: PolicyRuleKind::BannedExport,
518 matched: export.name.to_string(),
519 severity,
520 message: rule.rule.message.clone(),
521 });
522 }
523 }
524}
525
526fn collect_banned_effects(input: &mut PolicyCollectionInput<'_>) {
527 for callee_use in &input.module.callee_uses {
528 let matched = input.in_scope.iter().find_map(|(_, rule)| {
529 if rule.rule.kind != RulePackRuleKind::BannedEffect {
530 return None;
531 }
532 let severity = wire_severity(rule.effective_severity(input.master))?;
533 rule.matches_effect(input.module, &callee_use.callee_path, input.declared_deps)
534 .map(|effect| (rule, severity, effect))
535 });
536 let Some((rule, severity, effect)) = matched else {
537 continue;
538 };
539 let (line, col) = byte_offset_to_line_col(
540 input.line_offsets_by_file,
541 input.node.file_id,
542 callee_use.span_start,
543 );
544 if input.suppressions.is_policy_suppressed(
545 input.node.file_id,
546 line,
547 rule.pack,
548 &rule.rule.id,
549 ) {
550 continue;
551 }
552 input.violations.push(PolicyViolation {
553 path: input.node.path.clone(),
554 line,
555 col,
556 pack: rule.pack.to_owned(),
557 rule_id: rule.rule.id.clone(),
558 kind: PolicyRuleKind::BannedEffect,
559 matched: format!("{}: {}", effect.as_str(), callee_use.callee_path),
560 severity,
561 message: rule.rule.message.clone(),
562 });
563 }
564}
565
566fn collect_banned_calls(input: &mut PolicyCollectionInput<'_>) {
570 for callee_use in &input.module.callee_uses {
571 let matched = input.in_scope.iter().find_map(|(_, rule)| {
572 if rule.rule.kind != RulePackRuleKind::BannedCall {
573 return None;
574 }
575 let severity = wire_severity(rule.effective_severity(input.master))?;
576 rule.matches_callee(input.module, &callee_use.callee_path)
577 .then_some((rule, severity))
578 });
579 let Some((rule, severity)) = matched else {
580 continue;
581 };
582 let (line, col) = byte_offset_to_line_col(
583 input.line_offsets_by_file,
584 input.node.file_id,
585 callee_use.span_start,
586 );
587 if input.suppressions.is_policy_suppressed(
588 input.node.file_id,
589 line,
590 rule.pack,
591 &rule.rule.id,
592 ) {
593 continue;
594 }
595 input.violations.push(PolicyViolation {
596 path: input.node.path.clone(),
597 line,
598 col,
599 pack: rule.pack.to_owned(),
600 rule_id: rule.rule.id.clone(),
601 kind: PolicyRuleKind::BannedCall,
602 matched: callee_use.callee_path.clone(),
603 severity,
604 message: rule.rule.message.clone(),
605 });
606 }
607}
608
609fn effect_for_callee(
610 module: &ModuleInfo,
611 callee_path: &str,
612 declared_deps: &FxHashSet<String>,
613) -> Option<EffectKind> {
614 let written = catalogue_matchers()
615 .iter()
616 .find(|matcher| matcher_matches_callee(matcher, module, callee_path, declared_deps))
617 .map(|matcher| matcher.effect);
618 if written.is_some() {
619 return written;
620 }
621 let canonical = canonical_callee_path(module, callee_path)?;
622 catalogue_matchers()
623 .iter()
624 .find(|matcher| matcher_matches_callee(matcher, module, &canonical, declared_deps))
625 .map(|matcher| matcher.effect)
626}
627
628fn matcher_matches_callee(
629 matcher: &super::security::Matcher,
630 module: &ModuleInfo,
631 callee_path: &str,
632 declared_deps: &FxHashSet<String>,
633) -> bool {
634 matcher.enabler_satisfied(declared_deps)
635 && provenance_satisfied(matcher, module, callee_path)
636 && matcher.first_matching_pattern(callee_path).is_some()
637}
638
639fn provenance_satisfied(
640 matcher: &super::security::Matcher,
641 module: &ModuleInfo,
642 callee_path: &str,
643) -> bool {
644 let Some(spec) = &matcher.import_provenance else {
645 return true;
646 };
647 let leading_ident = callee_path.split('.').next().unwrap_or(callee_path);
648 module.imports.iter().any(|imp| {
649 import_source_matches(&imp.source, spec)
650 && (!requires_binding_trace(matcher) || imp.local_name == leading_ident)
651 }) || module.require_calls.iter().any(|call| {
652 import_source_matches(&call.source, spec)
653 && (!requires_binding_trace(matcher)
654 || call.local_name.as_deref() == Some(leading_ident)
655 || call
656 .destructured_names
657 .iter()
658 .any(|name| name == leading_ident))
659 })
660}
661
662fn requires_binding_trace(matcher: &super::security::Matcher) -> bool {
663 matches!(
664 matcher.id.as_str(),
665 "command-injection"
666 | "permissive-cors"
667 | "electron-unsafe-webpreferences"
668 | "insecure-temp-file"
669 | "jwt-alg-none"
670 | "jwt-verify-missing-algorithms"
671 | "tls-validation-disabled"
672 | "mysql-multiple-statements"
673 | "world-writable-permission"
674 ) || (matcher.id == "weak-crypto" && matcher.is_literal_aware())
675}
676
677fn import_source_matches(source: &str, spec: &str) -> bool {
678 fn strip_node_prefix(value: &str) -> &str {
679 value.strip_prefix("node:").unwrap_or(value)
680 }
681
682 let source = strip_node_prefix(source);
683 let spec = strip_node_prefix(spec);
684 source == spec
685 || source
686 .strip_prefix(spec)
687 .is_some_and(|rest| rest.starts_with('/'))
688}
689
690fn specifier_matches(raw: &str, pattern: &str) -> bool {
694 if let Some(prefix) = pattern.strip_suffix("/*") {
695 return raw
696 .strip_prefix(prefix)
697 .is_some_and(|rest| rest.starts_with('/'));
698 }
699 raw == pattern
700 || raw
701 .strip_prefix(pattern)
702 .is_some_and(|rest| rest.starts_with('/'))
703}
704
705fn export_pattern_matches(name: &fallow_types::extract::ExportName, pattern: &str) -> bool {
706 pattern.strip_suffix('*').map_or_else(
707 || name.matches_str(pattern),
708 |prefix| name.to_string().starts_with(prefix),
709 )
710}
711
712const fn wire_severity(severity: Severity) -> Option<PolicyViolationSeverity> {
715 match severity {
716 Severity::Error => Some(PolicyViolationSeverity::Error),
717 Severity::Warn => Some(PolicyViolationSeverity::Warn),
718 Severity::Off => None,
719 }
720}
721
722#[cfg(test)]
723mod tests;