1use camino::Utf8Path;
13use ruff_python_ast::token::TokenKind;
14use ruff_python_ast::visitor::{walk_expr, walk_stmt, Visitor};
15use ruff_python_ast::{
16 Expr, ExprContext, Parameters, Stmt, StmtClassDef, StmtFunctionDef, StmtImport, StmtImportFrom,
17};
18use ruff_python_parser::parse_module;
19use ruff_source_file::LineIndex;
20use ruff_text_size::{Ranged, TextRange, TextSize};
21use std::collections::{HashMap, HashSet};
22
23#[derive(Debug, thiserror::Error)]
24pub enum ParseError {
25 #[error("failed to initialize the Python grammar")]
26 Grammar,
27 #[error("parser produced no tree for {0}")]
28 NoTree(String),
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum DefKind {
34 Function,
35 Class,
36 Variable,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Definition {
43 pub name: String,
44 pub kind: DefKind,
45 pub line: u32,
46 pub end_line: u32,
47 pub private_by_convention: bool,
49 pub decorators: Vec<String>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Import {
57 pub module: String,
60 pub relative_dots: u8,
62 pub names: Vec<String>,
64 pub bindings: Vec<String>,
67 pub is_star: bool,
69 pub type_checking_only: bool,
72 pub line: u32,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct FunctionComplexity {
79 pub name: String,
80 pub line: u32,
81 pub end_line: u32,
83 pub cyclomatic: u32,
85 pub cognitive: u32,
87 pub params_total: u32,
89 pub params_annotated: u32,
91 pub return_annotated: bool,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct SecurityHit {
99 pub rule: &'static str,
101 pub line: u32,
102 pub detail: String,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct CallSite {
108 pub callee: String,
109 pub line: u32,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ScopeFinding {
115 pub name: String,
116 pub line: u32,
117 pub is_param: bool,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct ClassInfo {
126 pub name: String,
127 pub line: u32,
128 pub end_line: u32,
129 pub is_private: bool,
131 pub decorators: Vec<String>,
133 pub bases: Vec<String>,
135 pub is_enum: bool,
137 pub methods: Vec<(String, Vec<String>)>,
139 pub members: Vec<ClassMember>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct ClassMember {
147 pub name: String,
148 pub line: u32,
149 pub end_line: u32,
150 pub is_method: bool,
152 pub is_private: bool,
153 pub decorators: Vec<String>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct UnreachableCode {
162 pub line: u32,
163 pub after: &'static str,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct TypeLeak {
171 pub function: String,
173 pub type_name: String,
175 pub line: u32,
176 pub is_return: bool,
178}
179
180#[derive(Debug, Clone)]
182pub struct ParsedModule {
183 pub path: camino::Utf8PathBuf,
184 pub definitions: Vec<Definition>,
185 pub imports: Vec<Import>,
186 pub nested_imports: Vec<Import>,
190 pub calls: Vec<CallSite>,
191 pub functions: Vec<FunctionComplexity>,
192 pub security_hits: Vec<SecurityHit>,
193 pub dunder_all: Option<Vec<String>>,
194 pub used_names: Vec<String>,
195 pub local_uses: Vec<String>,
196 pub attr_accessed: Vec<String>,
201 pub module_used: Vec<String>,
207 pub ignores: Vec<(u32, String)>,
208 pub scope_findings: Vec<ScopeFinding>,
209 pub classes: Vec<ClassInfo>,
210 pub unreachable: Vec<UnreachableCode>,
212 pub type_leaks: Vec<TypeLeak>,
214 pub name_counts: HashMap<String, u32>,
215 pub has_dynamic_sink: bool,
216 pub halstead_volume: f64,
217 had_errors: bool,
218}
219
220impl ParsedModule {
221 pub fn had_errors(&self) -> bool {
223 self.had_errors
224 }
225}
226
227#[derive(Default)]
230pub struct PyParser;
231
232impl PyParser {
233 pub fn new() -> Result<Self, ParseError> {
234 Ok(Self)
235 }
236
237 pub fn parse(&mut self, path: &Utf8Path, source: &str) -> Result<ParsedModule, ParseError> {
239 let li = LineIndex::from_source_text(source);
240 let mut m = ParsedModule {
241 path: path.to_owned(),
242 definitions: Vec::new(),
243 imports: Vec::new(),
244 nested_imports: Vec::new(),
245 calls: Vec::new(),
246 functions: Vec::new(),
247 security_hits: Vec::new(),
248 dunder_all: None,
249 used_names: Vec::new(),
250 local_uses: Vec::new(),
251 attr_accessed: Vec::new(),
252 module_used: Vec::new(),
253 ignores: Vec::new(),
254 scope_findings: Vec::new(),
255 classes: Vec::new(),
256 unreachable: Vec::new(),
257 type_leaks: Vec::new(),
258 name_counts: HashMap::new(),
259 has_dynamic_sink: false,
260 halstead_volume: 0.0,
261 had_errors: false,
262 };
263
264 let parsed = match parse_module(source) {
265 Ok(p) => p,
266 Err(_) => {
267 m.had_errors = true;
269 return Ok(m);
270 }
271 };
272 m.had_errors = !parsed.errors().is_empty();
273 let module = parsed.syntax();
274
275 let mut name_tokens: Vec<(TextSize, &str)> = Vec::new();
279 let mut h_total_ops = 0u64;
280 let mut h_total_oprs = 0u64;
281 let mut h_ops: HashSet<TokenKind> = HashSet::new();
282 let mut h_oprs: HashSet<&str> = HashSet::new();
283 for tok in parsed.tokens() {
284 let kind = tok.kind();
285 let text = &source[tok.range()];
286 if kind == TokenKind::Name {
287 *m.name_counts.entry(text.to_string()).or_insert(0) += 1;
288 m.used_names.push(text.to_string());
289 name_tokens.push((tok.range().start(), text));
290 }
291 if kind == TokenKind::Comment {
292 if let Some(rules) = parse_ignore_comment(text) {
293 let line = line1(&li, tok.range().start());
294 for r in rules {
295 m.ignores.push((line, r));
296 }
297 }
298 }
299 if is_operand(kind) {
301 h_total_oprs += 1;
302 h_oprs.insert(text);
303 } else if !kind.is_trivia()
304 && !matches!(
305 kind,
306 TokenKind::Newline
307 | TokenKind::Indent
308 | TokenKind::Dedent
309 | TokenKind::EndOfFile
310 )
311 {
312 h_total_ops += 1;
313 h_ops.insert(kind);
314 }
315 }
316 m.used_names.sort();
317 m.used_names.dedup();
318 let vocab = (h_ops.len() + h_oprs.len()) as f64;
319 let length = (h_total_ops + h_total_oprs) as f64;
320 m.halstead_volume = if vocab <= 1.0 {
321 0.0
322 } else {
323 length * vocab.log2()
324 };
325
326 scan_top_level(&module.body, &li, false, &mut m);
328
329 let mut nested = NestedImportVisitor {
332 li: &li,
333 depth: 0,
334 out: Vec::new(),
335 };
336 for stmt in &module.body {
337 nested.visit_stmt(stmt);
338 }
339 m.nested_imports = nested.out;
340
341 let mut main = MainVisitor { li: &li, m: &mut m };
343 for stmt in &module.body {
344 main.visit_stmt(stmt);
345 }
346
347 let mut lu = LocalUseVisitor {
350 uses: Vec::new(),
351 attrs: Vec::new(),
352 };
353 for stmt in &module.body {
354 lu.visit_stmt(stmt);
355 }
356 lu.uses.sort();
357 lu.uses.dedup();
358 m.local_uses = lu.uses;
359 lu.attrs.sort();
360 lu.attrs.dedup();
361 m.attr_accessed = lu.attrs;
362
363 let mut res = Resolver {
366 scopes: Vec::new(),
367 used: HashSet::new(),
368 };
369 for stmt in &module.body {
370 res.visit_stmt(stmt);
371 }
372 let mut mu: Vec<String> = res.used.into_iter().collect();
373 mu.sort();
374 m.module_used = mu;
375
376 let mut defs = DefVisitor {
378 funcs: Vec::new(),
379 classes: Vec::new(),
380 };
381 for stmt in &module.body {
382 defs.visit_stmt(stmt);
383 }
384 for f in &defs.funcs {
385 m.functions.push(function_complexity(f, &li));
386 analyze_scope(f, &name_tokens, &mut m.scope_findings, &li);
387 }
388 m.functions.sort_by_key(|f| f.line);
389 m.scope_findings.sort_by_key(|s| s.line);
390 for c in &defs.classes {
391 m.classes.push(class_info(c, &li));
392 }
393 m.classes.sort_by_key(|c| c.line);
394
395 let mut ur = UnreachableVisitor {
398 li: &li,
399 out: Vec::new(),
400 };
401 ur.scan(&module.body);
402 for stmt in &module.body {
403 ur.visit_stmt(stmt);
404 }
405 ur.out.sort_by_key(|u| u.line);
406 ur.out.dedup();
407 m.unreachable = ur.out;
408
409 scan_type_leaks(&module.body, &li, &mut m.type_leaks);
411 m.type_leaks
412 .sort_by(|a, b| a.line.cmp(&b.line).then(a.type_name.cmp(&b.type_name)));
413 m.type_leaks.dedup();
414
415 security_imports(&mut m);
417 m.security_hits
418 .sort_by(|a, b| a.line.cmp(&b.line).then(a.rule.cmp(b.rule)));
419 m.security_hits
420 .dedup_by(|a, b| a.rule == b.rule && a.line == b.line);
421
422 Ok(m)
423 }
424}
425
426const DYNAMIC_SINKS: &[&str] = &["getattr", "setattr", "eval", "exec", "__import__"];
431
432fn line1(li: &LineIndex, off: TextSize) -> u32 {
434 li.line_index(off).get() as u32
435}
436
437fn end_line1(li: &LineIndex, range: TextRange) -> u32 {
439 let end = range.end();
440 if end > range.start() {
441 line1(li, end.checked_sub(TextSize::from(1)).unwrap_or(end))
442 } else {
443 line1(li, end)
444 }
445}
446
447fn is_operand(kind: TokenKind) -> bool {
449 matches!(
450 kind,
451 TokenKind::Name
452 | TokenKind::Int
453 | TokenKind::Float
454 | TokenKind::Complex
455 | TokenKind::String
456 | TokenKind::FStringStart
457 | TokenKind::FStringMiddle
458 | TokenKind::FStringEnd
459 | TokenKind::True
460 | TokenKind::False
461 | TokenKind::None
462 )
463}
464
465fn expr_path(e: &Expr) -> Option<String> {
467 match e {
468 Expr::Name(n) => Some(n.id.as_str().to_string()),
469 Expr::Attribute(a) => Some(format!("{}.{}", expr_path(&a.value)?, a.attr.as_str())),
470 _ => None,
471 }
472}
473
474fn decorator_path(e: &Expr) -> Option<String> {
476 match e {
477 Expr::Call(c) => expr_path(&c.func),
478 other => expr_path(other),
479 }
480}
481
482fn is_private(name: &str) -> bool {
483 name.starts_with('_')
484}
485
486fn scan_top_level(stmts: &[Stmt], li: &LineIndex, type_checking: bool, m: &mut ParsedModule) {
491 for stmt in stmts {
492 match stmt {
493 Stmt::FunctionDef(f) => m.definitions.push(Definition {
494 private_by_convention: is_private(f.name.as_str()),
495 name: f.name.to_string(),
496 kind: DefKind::Function,
497 line: line1(li, f.range().start()),
498 end_line: end_line1(li, f.range()),
499 decorators: f
500 .decorator_list
501 .iter()
502 .filter_map(|d| decorator_path(&d.expression))
503 .collect(),
504 }),
505 Stmt::ClassDef(c) => m.definitions.push(Definition {
506 private_by_convention: is_private(c.name.as_str()),
507 name: c.name.to_string(),
508 kind: DefKind::Class,
509 line: line1(li, c.range().start()),
510 end_line: end_line1(li, c.range()),
511 decorators: c
512 .decorator_list
513 .iter()
514 .filter_map(|d| decorator_path(&d.expression))
515 .collect(),
516 }),
517 Stmt::Import(i) => parse_import(i, li, &mut m.imports),
518 Stmt::ImportFrom(i) => {
519 let mut imp = parse_import_from(i, li);
520 imp.type_checking_only = type_checking;
521 m.imports.push(imp);
522 }
523 Stmt::Assign(a) => {
524 if let [Expr::Name(target)] = a.targets.as_slice() {
525 let name = target.id.as_str();
526 if name == "__all__" {
527 if let Some(items) = string_list(&a.value) {
528 m.dunder_all = Some(items);
529 }
530 } else {
531 m.definitions.push(Definition {
532 private_by_convention: is_private(name),
533 name: name.to_string(),
534 kind: DefKind::Variable,
535 line: line1(li, a.range().start()),
536 end_line: end_line1(li, a.range()),
537 decorators: Vec::new(),
538 });
539 }
540 }
541 }
542 Stmt::AnnAssign(a) => {
543 if let Expr::Name(target) = &*a.target {
544 let name = target.id.as_str();
545 if name == "__all__" {
546 if let Some(v) = &a.value {
547 if let Some(items) = string_list(v) {
548 m.dunder_all = Some(items);
549 }
550 }
551 } else {
552 m.definitions.push(Definition {
553 private_by_convention: is_private(name),
554 name: name.to_string(),
555 kind: DefKind::Variable,
556 line: line1(li, a.range().start()),
557 end_line: end_line1(li, a.range()),
558 decorators: Vec::new(),
559 });
560 }
561 }
562 }
563 Stmt::If(i) => {
565 let tc = type_checking || is_type_checking_guard(&i.test);
566 let before = m.imports.len();
567 scan_top_level(&i.body, li, tc, m);
568 for clause in &i.elif_else_clauses {
569 scan_top_level(&clause.body, li, tc, m);
570 }
571 if tc {
572 for imp in m.imports[before..].iter_mut() {
573 imp.type_checking_only = true;
574 }
575 }
576 }
577 Stmt::Try(t) => {
578 scan_top_level(&t.body, li, type_checking, m);
579 for h in &t.handlers {
580 let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
581 scan_top_level(&eh.body, li, type_checking, m);
582 }
583 scan_top_level(&t.orelse, li, type_checking, m);
584 scan_top_level(&t.finalbody, li, type_checking, m);
585 }
586 _ => {}
587 }
588 }
589}
590
591struct NestedImportVisitor<'a> {
594 li: &'a LineIndex,
595 depth: u32,
596 out: Vec<Import>,
597}
598
599impl<'a> Visitor<'a> for NestedImportVisitor<'a> {
600 fn visit_stmt(&mut self, stmt: &'a Stmt) {
601 match stmt {
602 Stmt::FunctionDef(_) | Stmt::ClassDef(_) => {
603 self.depth += 1;
604 walk_stmt(self, stmt);
605 self.depth -= 1;
606 }
607 Stmt::Import(i) if self.depth > 0 => {
608 parse_import(i, self.li, &mut self.out);
609 walk_stmt(self, stmt);
610 }
611 Stmt::ImportFrom(i) if self.depth > 0 => {
612 self.out.push(parse_import_from(i, self.li));
613 walk_stmt(self, stmt);
614 }
615 _ => walk_stmt(self, stmt),
616 }
617 }
618}
619
620fn is_type_checking_guard(test: &Expr) -> bool {
622 if let Expr::BooleanLiteral(b) = test {
623 return !b.value; }
625 expr_path(test)
626 .map(|p| p.contains("TYPE_CHECKING"))
627 .unwrap_or(false)
628}
629
630fn parse_import(i: &StmtImport, li: &LineIndex, out: &mut Vec<Import>) {
631 let line = line1(li, i.range().start());
632 for alias in &i.names {
633 let module = alias.name.as_str().to_string();
634 let binding = match &alias.asname {
635 Some(a) => a.as_str().to_string(),
636 None => module.split('.').next().unwrap_or(&module).to_string(),
637 };
638 if !module.is_empty() {
639 out.push(Import {
640 module,
641 relative_dots: 0,
642 names: vec![],
643 bindings: if binding.is_empty() {
644 vec![]
645 } else {
646 vec![binding]
647 },
648 is_star: false,
649 type_checking_only: false,
650 line,
651 });
652 }
653 }
654}
655
656fn parse_import_from(i: &StmtImportFrom, li: &LineIndex) -> Import {
657 let line = line1(li, i.range().start());
658 let module = i.module.as_ref().map(|m| m.to_string()).unwrap_or_default();
659 let mut names = Vec::new();
660 let mut bindings = Vec::new();
661 let mut is_star = false;
662 for alias in &i.names {
663 let name = alias.name.as_str();
664 if name == "*" {
665 is_star = true;
666 continue;
667 }
668 names.push(name.to_string());
669 bindings.push(match &alias.asname {
670 Some(a) => a.as_str().to_string(),
671 None => name.to_string(),
672 });
673 }
674 Import {
675 module,
676 relative_dots: i.level.min(u8::MAX as u32) as u8,
677 names,
678 bindings,
679 is_star,
680 type_checking_only: false,
681 line,
682 }
683}
684
685fn string_list(e: &Expr) -> Option<Vec<String>> {
687 let elts = match e {
688 Expr::List(l) => &l.elts,
689 Expr::Tuple(t) => &t.elts,
690 _ => return None,
691 };
692 Some(
693 elts.iter()
694 .filter_map(|el| match el {
695 Expr::StringLiteral(s) => Some(s.value.to_str().to_string()),
696 _ => None,
697 })
698 .collect(),
699 )
700}
701
702fn function_complexity(f: &StmtFunctionDef, li: &LineIndex) -> FunctionComplexity {
707 let (params_total, params_annotated) = count_params(&f.parameters);
708 let mut cv = CycloVisitor { count: 0 };
709 for s in &f.body {
710 cv.visit_stmt(s);
711 }
712 FunctionComplexity {
713 name: f.name.to_string(),
714 line: line1(li, f.range().start()),
715 end_line: end_line1(li, f.range()),
716 cyclomatic: 1 + cv.count,
717 cognitive: cog_stmts(&f.body, 0),
718 params_total,
719 params_annotated,
720 return_annotated: f.returns.is_some(),
721 }
722}
723
724fn count_params(params: &Parameters) -> (u32, u32) {
725 let positional: Vec<_> = params
726 .posonlyargs
727 .iter()
728 .chain(params.args.iter())
729 .collect();
730 let mut total = 0u32;
731 let mut annotated = 0u32;
732 for (idx, p) in positional.iter().enumerate() {
733 let name = p.parameter.name.as_str();
734 if idx == 0 && (name == "self" || name == "cls") {
735 continue;
736 }
737 total += 1;
738 if p.parameter.annotation.is_some() {
739 annotated += 1;
740 }
741 }
742 for p in ¶ms.kwonlyargs {
743 total += 1;
744 if p.parameter.annotation.is_some() {
745 annotated += 1;
746 }
747 }
748 (total, annotated.min(total))
749}
750
751struct CycloVisitor {
753 count: u32,
754}
755impl<'a> Visitor<'a> for CycloVisitor {
756 fn visit_stmt(&mut self, stmt: &'a Stmt) {
757 match stmt {
758 Stmt::FunctionDef(_) | Stmt::ClassDef(_) => return, Stmt::If(i) => {
760 self.count += 1 + i
761 .elif_else_clauses
762 .iter()
763 .filter(|c| c.test.is_some())
764 .count() as u32;
765 }
766 Stmt::For(_) | Stmt::While(_) => self.count += 1,
767 Stmt::Try(t) => self.count += t.handlers.len() as u32,
768 Stmt::Assert(_) => self.count += 1,
769 Stmt::Match(mt) => self.count += mt.cases.len() as u32,
770 _ => {}
771 }
772 walk_stmt(self, stmt);
773 }
774 fn visit_expr(&mut self, expr: &'a Expr) {
775 match expr {
776 Expr::BoolOp(b) => self.count += (b.values.len() as u32).saturating_sub(1),
777 Expr::If(_) => self.count += 1, Expr::ListComp(c) => self.count += comp_points(&c.generators),
779 Expr::SetComp(c) => self.count += comp_points(&c.generators),
780 Expr::DictComp(c) => self.count += comp_points(&c.generators),
781 Expr::Generator(c) => self.count += comp_points(&c.generators),
782 _ => {}
783 }
784 walk_expr(self, expr);
785 }
786}
787
788fn comp_points(gens: &[ruff_python_ast::Comprehension]) -> u32 {
789 gens.iter().map(|g| 1 + g.ifs.len() as u32).sum()
790}
791
792fn cog_stmts(stmts: &[Stmt], nesting: u32) -> u32 {
794 stmts.iter().map(|s| cog_stmt(s, nesting)).sum()
795}
796
797fn cog_stmt(s: &Stmt, nesting: u32) -> u32 {
798 match s {
799 Stmt::FunctionDef(_) | Stmt::ClassDef(_) => 0,
800 Stmt::If(i) => {
801 let mut c = 1 + nesting + cog_cond(&i.test);
802 c += cog_stmts(&i.body, nesting + 1);
803 for clause in &i.elif_else_clauses {
804 c += 1; if let Some(t) = &clause.test {
806 c += cog_cond(t);
807 }
808 c += cog_stmts(&clause.body, nesting + 1);
809 }
810 c
811 }
812 Stmt::For(f) => {
813 1 + nesting + cog_stmts(&f.body, nesting + 1) + cog_stmts(&f.orelse, nesting + 1)
814 }
815 Stmt::While(w) => {
816 1 + nesting
817 + cog_cond(&w.test)
818 + cog_stmts(&w.body, nesting + 1)
819 + cog_stmts(&w.orelse, nesting + 1)
820 }
821 Stmt::With(w) => cog_stmts(&w.body, nesting),
822 Stmt::Try(t) => {
823 let mut c = cog_stmts(&t.body, nesting);
824 for h in &t.handlers {
825 let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
826 c += 1 + nesting + cog_stmts(&eh.body, nesting + 1);
827 }
828 c += cog_stmts(&t.orelse, nesting) + cog_stmts(&t.finalbody, nesting);
829 c
830 }
831 Stmt::Match(mt) => {
832 let mut c = 0;
833 for case in &mt.cases {
834 c += 1 + nesting + cog_stmts(&case.body, nesting + 1);
835 }
836 c
837 }
838 Stmt::Expr(e) => cog_cond(&e.value),
839 Stmt::Return(r) => r.value.as_ref().map(|v| cog_cond(v)).unwrap_or(0),
840 Stmt::Assign(a) => cog_cond(&a.value),
841 Stmt::AugAssign(a) => cog_cond(&a.value),
842 Stmt::AnnAssign(a) => a.value.as_ref().map(|v| cog_cond(v)).unwrap_or(0),
843 _ => 0,
844 }
845}
846
847fn cog_cond(e: &Expr) -> u32 {
849 let mut v = CondVisitor { count: 0 };
850 v.visit_expr(e);
851 v.count
852}
853struct CondVisitor {
854 count: u32,
855}
856impl<'a> Visitor<'a> for CondVisitor {
857 fn visit_expr(&mut self, expr: &'a Expr) {
858 match expr {
859 Expr::BoolOp(b) => self.count += (b.values.len() as u32).saturating_sub(1),
860 Expr::If(_) => self.count += 1,
861 _ => {}
862 }
863 walk_expr(self, expr);
864 }
865}
866
867const SCOPE_DYNAMIC: &[&str] = &["locals", "vars", "globals", "eval", "exec"];
872
873fn analyze_scope(
874 f: &StmtFunctionDef,
875 name_tokens: &[(TextSize, &str)],
876 out: &mut Vec<ScopeFinding>,
877 li: &LineIndex,
878) {
879 let range = f.range();
881 let mut freq: HashMap<&str, u32> = HashMap::new();
882 for (off, text) in name_tokens {
883 if *off >= range.start() && *off < range.end() {
884 *freq.entry(*text).or_insert(0) += 1;
885 }
886 }
887 if SCOPE_DYNAMIC.iter().any(|d| freq.contains_key(*d)) {
888 return;
889 }
890
891 let mut gv = GlobalVisitor {
893 names: HashSet::new(),
894 };
895 for s in &f.body {
896 gv.visit_stmt(s);
897 }
898 let declared_global = gv.names;
899
900 let decorated = !f.decorator_list.is_empty();
901 let fname = f.name.as_str();
902 let is_dunder = fname.starts_with("__") && fname.ends_with("__");
903 let stub = is_stub_body(&f.body);
904
905 if !decorated && !is_dunder && !stub {
906 let positional: Vec<_> = f
907 .parameters
908 .posonlyargs
909 .iter()
910 .chain(f.parameters.args.iter())
911 .collect();
912 for (idx, p) in positional.iter().enumerate() {
913 let name = p.parameter.name.as_str();
914 if idx == 0 && (name == "self" || name == "cls") {
915 continue;
916 }
917 if name.starts_with('_') || declared_global.contains(name) {
918 continue;
919 }
920 if freq.get(name).copied().unwrap_or(0) == 1 {
921 out.push(ScopeFinding {
922 line: line1(li, p.parameter.range().start()),
923 name: name.to_string(),
924 is_param: true,
925 });
926 }
927 }
928 for p in &f.parameters.kwonlyargs {
929 let name = p.parameter.name.as_str();
930 if name.starts_with('_') || declared_global.contains(name) {
931 continue;
932 }
933 if freq.get(name).copied().unwrap_or(0) == 1 {
934 out.push(ScopeFinding {
935 line: line1(li, p.parameter.range().start()),
936 name: name.to_string(),
937 is_param: true,
938 });
939 }
940 }
941 }
942
943 for stmt in &f.body {
945 if let Stmt::Assign(a) = stmt {
946 if let [Expr::Name(target)] = a.targets.as_slice() {
947 let name = target.id.as_str();
948 if name == "_" || declared_global.contains(name) {
949 continue;
950 }
951 if freq.get(name).copied().unwrap_or(0) == 1 {
952 out.push(ScopeFinding {
953 line: line1(li, a.range().start()),
954 name: name.to_string(),
955 is_param: false,
956 });
957 }
958 }
959 }
960 }
961}
962
963struct GlobalVisitor {
964 names: HashSet<String>,
965}
966impl<'a> Visitor<'a> for GlobalVisitor {
967 fn visit_stmt(&mut self, stmt: &'a Stmt) {
968 match stmt {
969 Stmt::Global(g) => {
970 for n in &g.names {
971 self.names.insert(n.as_str().to_string());
972 }
973 }
974 Stmt::Nonlocal(g) => {
975 for n in &g.names {
976 self.names.insert(n.as_str().to_string());
977 }
978 }
979 _ => {}
980 }
981 walk_stmt(self, stmt);
982 }
983}
984
985fn is_stub_body(body: &[Stmt]) -> bool {
987 body.iter().all(|s| match s {
988 Stmt::Pass(_) => true,
989 Stmt::Raise(_) => true,
990 Stmt::Expr(e) => matches!(&*e.value, Expr::StringLiteral(_) | Expr::EllipsisLiteral(_)),
991 _ => false,
992 })
993}
994
995fn class_info(c: &StmtClassDef, li: &LineIndex) -> ClassInfo {
1000 let mut methods = Vec::new();
1001 let mut members: Vec<ClassMember> = Vec::new();
1002 for stmt in &c.body {
1003 match stmt {
1004 Stmt::FunctionDef(f) => {
1005 methods.push((f.name.to_string(), self_attrs(f)));
1006 members.push(ClassMember {
1007 name: f.name.to_string(),
1008 line: line1(li, f.range().start()),
1009 end_line: end_line1(li, f.range()),
1010 is_method: true,
1011 is_private: is_private(f.name.as_str()),
1012 decorators: f
1013 .decorator_list
1014 .iter()
1015 .filter_map(|d| decorator_path(&d.expression))
1016 .collect(),
1017 });
1018 }
1019 Stmt::Assign(a) => {
1020 if let [Expr::Name(t)] = a.targets.as_slice() {
1021 members.push(class_attr_member(t.id.as_str(), a.range(), li));
1022 }
1023 }
1024 Stmt::AnnAssign(a) => {
1025 if let Expr::Name(t) = &*a.target {
1026 members.push(class_attr_member(t.id.as_str(), a.range(), li));
1027 }
1028 }
1029 _ => {}
1030 }
1031 }
1032 let bases: Vec<String> = c
1033 .arguments
1034 .as_ref()
1035 .map(|args| args.args.iter().filter_map(expr_path).collect())
1036 .unwrap_or_default();
1037 let is_enum = bases.iter().any(|b| {
1038 let last = b.rsplit('.').next().unwrap_or(b);
1039 matches!(
1040 last,
1041 "Enum" | "IntEnum" | "StrEnum" | "Flag" | "IntFlag" | "ReprEnum" | "EnumMeta"
1042 )
1043 });
1044 ClassInfo {
1045 name: c.name.to_string(),
1046 line: line1(li, c.range().start()),
1047 end_line: end_line1(li, c.range()),
1048 is_private: is_private(c.name.as_str()),
1049 decorators: c
1050 .decorator_list
1051 .iter()
1052 .filter_map(|d| decorator_path(&d.expression))
1053 .collect(),
1054 bases,
1055 is_enum,
1056 methods,
1057 members,
1058 }
1059}
1060
1061fn class_attr_member(name: &str, range: TextRange, li: &LineIndex) -> ClassMember {
1062 ClassMember {
1063 name: name.to_string(),
1064 line: line1(li, range.start()),
1065 end_line: end_line1(li, range),
1066 is_method: false,
1067 is_private: is_private(name),
1068 decorators: Vec::new(),
1069 }
1070}
1071
1072struct UnreachableVisitor<'li> {
1077 li: &'li LineIndex,
1078 out: Vec<UnreachableCode>,
1079}
1080impl<'li> UnreachableVisitor<'li> {
1081 fn scan(&mut self, body: &[Stmt]) {
1083 for (i, stmt) in body.iter().enumerate() {
1084 if let Some(term) = terminator_kind(stmt) {
1085 if let Some(next) = body.get(i + 1) {
1086 self.out.push(UnreachableCode {
1088 line: line1(self.li, next.range().start()),
1089 after: term,
1090 });
1091 }
1092 break; }
1094 }
1095 }
1096}
1097impl<'a, 'li> Visitor<'a> for UnreachableVisitor<'li> {
1098 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1099 match stmt {
1101 Stmt::FunctionDef(f) => self.scan(&f.body),
1102 Stmt::ClassDef(c) => self.scan(&c.body),
1103 Stmt::If(i) => {
1104 self.scan(&i.body);
1105 for c in &i.elif_else_clauses {
1106 self.scan(&c.body);
1107 }
1108 }
1109 Stmt::For(f) => {
1110 self.scan(&f.body);
1111 self.scan(&f.orelse);
1112 }
1113 Stmt::While(w) => {
1114 self.scan(&w.body);
1115 self.scan(&w.orelse);
1116 }
1117 Stmt::With(w) => self.scan(&w.body),
1118 Stmt::Try(t) => {
1119 self.scan(&t.body);
1120 for h in &t.handlers {
1121 let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
1122 self.scan(&eh.body);
1123 }
1124 self.scan(&t.orelse);
1125 self.scan(&t.finalbody);
1126 }
1127 Stmt::Match(mt) => {
1128 for case in &mt.cases {
1129 self.scan(&case.body);
1130 }
1131 }
1132 _ => {}
1133 }
1134 walk_stmt(self, stmt);
1135 }
1136}
1137
1138fn terminator_kind(stmt: &Stmt) -> Option<&'static str> {
1140 match stmt {
1141 Stmt::Return(_) => Some("return"),
1142 Stmt::Raise(_) => Some("raise"),
1143 Stmt::Break(_) => Some("break"),
1144 Stmt::Continue(_) => Some("continue"),
1145 Stmt::Expr(e) if is_noreturn_call(&e.value) => Some("exit call"),
1146 _ => None,
1147 }
1148}
1149
1150fn is_noreturn_call(e: &Expr) -> bool {
1152 if let Expr::Call(c) = e {
1153 if let Some(p) = expr_path(&c.func) {
1154 return matches!(p.as_str(), "sys.exit" | "os._exit" | "exit" | "quit");
1157 }
1158 }
1159 false
1160}
1161
1162fn is_private_type(name: &str) -> bool {
1169 name.starts_with('_') && !(name.starts_with("__") && name.ends_with("__"))
1170}
1171
1172fn scan_type_leaks(body: &[Stmt], li: &LineIndex, out: &mut Vec<TypeLeak>) {
1173 let mut typevars: HashSet<String> = HashSet::new();
1176 collect_typevars(body, &mut typevars);
1177 for stmt in body {
1178 match stmt {
1179 Stmt::FunctionDef(f) if !is_private(f.name.as_str()) => {
1180 collect_fn_leaks(None, f, li, &typevars, out);
1181 }
1182 Stmt::ClassDef(c) if !is_private(c.name.as_str()) => {
1183 for s in &c.body {
1184 if let Stmt::FunctionDef(f) = s {
1185 if !is_private(f.name.as_str()) {
1186 collect_fn_leaks(Some(c.name.as_str()), f, li, &typevars, out);
1187 }
1188 }
1189 }
1190 }
1191 _ => {}
1192 }
1193 }
1194}
1195
1196fn collect_typevars(body: &[Stmt], out: &mut HashSet<String>) {
1198 for stmt in body {
1199 if let Stmt::Assign(a) = stmt {
1200 if let (Some(Expr::Name(t)), Expr::Call(c)) = (a.targets.first(), &*a.value) {
1201 if let Some(p) = expr_path(&c.func) {
1202 let last = p.rsplit('.').next().unwrap_or(&p);
1203 if matches!(last, "TypeVar" | "ParamSpec" | "TypeVarTuple") {
1204 out.insert(t.id.as_str().to_string());
1205 }
1206 }
1207 }
1208 }
1209 }
1210}
1211
1212fn collect_fn_leaks(
1213 class: Option<&str>,
1214 f: &StmtFunctionDef,
1215 li: &LineIndex,
1216 typevars: &HashSet<String>,
1217 out: &mut Vec<TypeLeak>,
1218) {
1219 let qualified = match class {
1220 Some(c) => format!("{c}.{}", f.name),
1221 None => f.name.to_string(),
1222 };
1223 let push_leaks = |ann: &Expr, line: u32, is_return: bool, out: &mut Vec<TypeLeak>| {
1224 let mut idents = Vec::new();
1225 annotation_idents(ann, &mut idents);
1226 for id in idents {
1227 if is_private_type(&id) && !typevars.contains(&id) {
1228 out.push(TypeLeak {
1229 function: qualified.clone(),
1230 type_name: id,
1231 line,
1232 is_return,
1233 });
1234 }
1235 }
1236 };
1237 for p in f
1238 .parameters
1239 .posonlyargs
1240 .iter()
1241 .chain(f.parameters.args.iter())
1242 .chain(f.parameters.kwonlyargs.iter())
1243 {
1244 if let Some(ann) = &p.parameter.annotation {
1245 push_leaks(ann, line1(li, p.parameter.range().start()), false, out);
1246 }
1247 }
1248 if let Some(r) = &f.returns {
1249 push_leaks(r, line1(li, f.range().start()), true, out);
1250 }
1251}
1252
1253fn annotation_idents(e: &Expr, out: &mut Vec<String>) {
1257 match e {
1258 Expr::Name(n) => out.push(n.id.as_str().to_string()),
1259 Expr::Attribute(a) => {
1260 annotation_idents(&a.value, out);
1261 out.push(a.attr.as_str().to_string());
1262 }
1263 Expr::Subscript(s) => {
1264 annotation_idents(&s.value, out);
1265 annotation_idents(&s.slice, out);
1266 }
1267 Expr::Tuple(t) => t.elts.iter().for_each(|el| annotation_idents(el, out)),
1268 Expr::List(l) => l.elts.iter().for_each(|el| annotation_idents(el, out)),
1269 Expr::BinOp(b) => {
1270 annotation_idents(&b.left, out);
1271 annotation_idents(&b.right, out);
1272 }
1273 Expr::StringLiteral(s) => {
1274 for tok in identifier_tokens(s.value.to_str()) {
1275 out.push(tok);
1276 }
1277 }
1278 _ => {}
1279 }
1280}
1281
1282fn self_attrs(f: &StmtFunctionDef) -> Vec<String> {
1283 let mut v = SelfAttrVisitor {
1284 attrs: std::collections::BTreeSet::new(),
1285 };
1286 for s in &f.body {
1287 v.visit_stmt(s);
1288 }
1289 v.attrs.into_iter().collect()
1290}
1291
1292struct SelfAttrVisitor {
1293 attrs: std::collections::BTreeSet<String>,
1294}
1295impl<'a> Visitor<'a> for SelfAttrVisitor {
1296 fn visit_expr(&mut self, expr: &'a Expr) {
1297 if let Expr::Attribute(a) = expr {
1298 if let Expr::Name(obj) = &*a.value {
1299 if obj.id.as_str() == "self" || obj.id.as_str() == "cls" {
1300 self.attrs.insert(a.attr.as_str().to_string());
1301 }
1302 }
1303 }
1304 walk_expr(self, expr);
1305 }
1306}
1307
1308struct DefVisitor<'a> {
1313 funcs: Vec<&'a StmtFunctionDef>,
1314 classes: Vec<&'a StmtClassDef>,
1315}
1316impl<'a> Visitor<'a> for DefVisitor<'a> {
1317 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1318 match stmt {
1319 Stmt::FunctionDef(f) => self.funcs.push(f),
1320 Stmt::ClassDef(c) => self.classes.push(c),
1321 _ => {}
1322 }
1323 walk_stmt(self, stmt);
1324 }
1325}
1326
1327struct LocalUseVisitor {
1332 uses: Vec<String>,
1333 attrs: Vec<String>,
1335}
1336impl<'a> Visitor<'a> for LocalUseVisitor {
1337 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1338 if matches!(stmt, Stmt::Import(_) | Stmt::ImportFrom(_)) {
1340 return;
1341 }
1342 if let Stmt::AnnAssign(a) = stmt {
1344 collect_annotation_strings(&a.annotation, &mut self.uses);
1345 }
1346 if let Stmt::FunctionDef(f) = stmt {
1347 if let Some(r) = &f.returns {
1348 collect_annotation_strings(r, &mut self.uses);
1349 }
1350 for p in f
1351 .parameters
1352 .posonlyargs
1353 .iter()
1354 .chain(f.parameters.args.iter())
1355 .chain(f.parameters.kwonlyargs.iter())
1356 {
1357 if let Some(ann) = &p.parameter.annotation {
1358 collect_annotation_strings(ann, &mut self.uses);
1359 }
1360 }
1361 }
1362 walk_stmt(self, stmt);
1363 }
1364 fn visit_expr(&mut self, expr: &'a Expr) {
1365 match expr {
1366 Expr::Name(n) => self.uses.push(n.id.as_str().to_string()),
1367 Expr::Attribute(a) => {
1368 self.uses.push(a.attr.as_str().to_string());
1369 self.attrs.push(a.attr.as_str().to_string());
1370 }
1371 _ => {}
1372 }
1373 walk_expr(self, expr);
1374 }
1375}
1376
1377fn collect_annotation_strings(e: &Expr, out: &mut Vec<String>) {
1380 match e {
1381 Expr::StringLiteral(s) => {
1382 for tok in identifier_tokens(s.value.to_str()) {
1383 out.push(tok);
1384 }
1385 }
1386 Expr::Subscript(s) => {
1387 collect_annotation_strings(&s.value, out);
1388 collect_annotation_strings(&s.slice, out);
1389 }
1390 Expr::Tuple(t) => {
1391 for el in &t.elts {
1392 collect_annotation_strings(el, out);
1393 }
1394 }
1395 Expr::List(l) => {
1396 for el in &l.elts {
1397 collect_annotation_strings(el, out);
1398 }
1399 }
1400 Expr::BinOp(b) => {
1401 collect_annotation_strings(&b.left, out);
1402 collect_annotation_strings(&b.right, out);
1403 }
1404 _ => {}
1405 }
1406}
1407
1408fn identifier_tokens(s: &str) -> Vec<String> {
1409 let mut out = Vec::new();
1410 let mut cur = String::new();
1411 let flush = |cur: &mut String, out: &mut Vec<String>| {
1412 if !cur.is_empty() && !cur.chars().next().unwrap().is_ascii_digit() {
1413 out.push(std::mem::take(cur));
1414 } else {
1415 cur.clear();
1416 }
1417 };
1418 for ch in s.chars() {
1419 if ch.is_ascii_alphanumeric() || ch == '_' {
1420 cur.push(ch);
1421 } else {
1422 flush(&mut cur, &mut out);
1423 }
1424 }
1425 flush(&mut cur, &mut out);
1426 out
1427}
1428
1429struct FnScope {
1442 locals: HashSet<String>,
1443 globals: HashSet<String>,
1444}
1445
1446struct Resolver {
1447 scopes: Vec<FnScope>,
1448 used: HashSet<String>,
1449}
1450
1451impl Resolver {
1452 fn resolve_load(&mut self, name: &str) {
1453 for s in self.scopes.iter().rev() {
1454 if s.globals.contains(name) {
1455 self.used.insert(name.to_string()); return;
1457 }
1458 if s.locals.contains(name) {
1459 return; }
1461 }
1462 self.used.insert(name.to_string());
1464 }
1465
1466 fn enter_function(&mut self, f: &StmtFunctionDef) {
1467 let mut bv = BindingVisitor {
1468 locals: HashSet::new(),
1469 globals: HashSet::new(),
1470 };
1471 for p in param_names(&f.parameters) {
1472 bv.locals.insert(p);
1473 }
1474 for stmt in &f.body {
1475 bv.visit_stmt(stmt);
1476 }
1477 for g in &bv.globals {
1479 bv.locals.remove(g);
1480 }
1481 self.scopes.push(FnScope {
1482 locals: bv.locals,
1483 globals: bv.globals,
1484 });
1485 }
1486}
1487
1488impl<'a> Visitor<'a> for Resolver {
1489 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1490 match stmt {
1491 Stmt::FunctionDef(f) => {
1492 for d in &f.decorator_list {
1495 self.visit_expr(&d.expression);
1496 }
1497 self.enter_function(f);
1498 for stmt in &f.body {
1499 self.visit_stmt(stmt);
1500 }
1501 self.scopes.pop();
1502 }
1503 Stmt::ClassDef(c) => {
1504 for d in &c.decorator_list {
1505 self.visit_expr(&d.expression);
1506 }
1507 if let Some(args) = &c.arguments {
1508 for a in args.args.iter() {
1509 self.visit_expr(a);
1510 }
1511 for kw in args.keywords.iter() {
1512 self.visit_expr(&kw.value);
1513 }
1514 }
1515 for stmt in &c.body {
1517 self.visit_stmt(stmt);
1518 }
1519 }
1520 _ => walk_stmt(self, stmt),
1521 }
1522 }
1523
1524 fn visit_expr(&mut self, expr: &'a Expr) {
1525 match expr {
1526 Expr::Name(n) => {
1527 if matches!(n.ctx, ExprContext::Load) {
1528 self.resolve_load(n.id.as_str());
1529 }
1530 }
1531 Expr::Lambda(l) => {
1532 let mut locals = HashSet::new();
1533 if let Some(params) = &l.parameters {
1534 for p in param_names(params) {
1535 locals.insert(p);
1536 }
1537 }
1538 self.scopes.push(FnScope {
1539 locals,
1540 globals: HashSet::new(),
1541 });
1542 self.visit_expr(&l.body);
1543 self.scopes.pop();
1544 }
1545 _ => walk_expr(self, expr),
1546 }
1547 }
1548}
1549
1550struct BindingVisitor {
1554 locals: HashSet<String>,
1555 globals: HashSet<String>,
1556}
1557impl<'a> Visitor<'a> for BindingVisitor {
1558 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1559 match stmt {
1560 Stmt::FunctionDef(f) => {
1561 self.locals.insert(f.name.to_string());
1562 }
1563 Stmt::ClassDef(c) => {
1564 self.locals.insert(c.name.to_string());
1565 }
1566 Stmt::Global(g) => {
1567 for n in &g.names {
1568 self.globals.insert(n.to_string());
1569 }
1570 }
1571 Stmt::Nonlocal(g) => {
1572 for n in &g.names {
1573 self.locals.insert(n.to_string());
1575 }
1576 }
1577 _ => walk_stmt(self, stmt),
1578 }
1579 }
1580 fn visit_expr(&mut self, expr: &'a Expr) {
1581 match expr {
1582 Expr::Name(n) if matches!(n.ctx, ExprContext::Store) => {
1583 self.locals.insert(n.id.as_str().to_string());
1584 }
1585 Expr::Lambda(_) => {}
1587 _ => walk_expr(self, expr),
1588 }
1589 }
1590}
1591
1592fn param_names(params: &Parameters) -> Vec<String> {
1593 let mut out = Vec::new();
1594 for p in params
1595 .posonlyargs
1596 .iter()
1597 .chain(params.args.iter())
1598 .chain(params.kwonlyargs.iter())
1599 {
1600 out.push(p.parameter.name.as_str().to_string());
1601 }
1602 if let Some(v) = ¶ms.vararg {
1603 out.push(v.name.as_str().to_string());
1604 }
1605 if let Some(k) = ¶ms.kwarg {
1606 out.push(k.name.as_str().to_string());
1607 }
1608 out
1609}
1610
1611struct MainVisitor<'a, 'm> {
1616 li: &'a LineIndex,
1617 m: &'m mut ParsedModule,
1618}
1619impl<'a, 'm> Visitor<'a> for MainVisitor<'a, 'm> {
1620 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1621 match stmt {
1622 Stmt::Assign(a) => {
1623 if let [Expr::Name(t)] = a.targets.as_slice() {
1624 security_secret(t.id.as_str(), &a.value, a.range(), self.li, self.m);
1625 }
1626 }
1627 Stmt::AnnAssign(a) => {
1628 if let (Expr::Name(t), Some(v)) = (&*a.target, &a.value) {
1629 security_secret(t.id.as_str(), v, a.range(), self.li, self.m);
1630 }
1631 }
1632 Stmt::Try(t) => {
1633 for h in &t.handlers {
1636 let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
1637 let broad = match &eh.type_ {
1638 None => true,
1639 Some(ty) => expr_path(ty)
1640 .map(|p| {
1641 matches!(
1642 p.rsplit('.').next().unwrap_or(&p),
1643 "Exception" | "BaseException"
1644 )
1645 })
1646 .unwrap_or(false),
1647 };
1648 if broad && eh.body.iter().all(|s| matches!(s, Stmt::Pass(_))) {
1649 self.m.security_hits.push(SecurityHit {
1650 rule: "try-except-pass",
1651 line: line1(self.li, eh.range().start()),
1652 detail:
1653 "broad `except: pass` silently swallows errors; log or handle them"
1654 .into(),
1655 });
1656 }
1657 }
1658 }
1659 _ => {}
1660 }
1661 walk_stmt(self, stmt);
1662 }
1663 fn visit_expr(&mut self, expr: &'a Expr) {
1664 if let Expr::Call(c) = expr {
1665 let callee = expr_path(&c.func).unwrap_or_default();
1666 if !callee.is_empty() {
1667 if DYNAMIC_SINKS.contains(&callee.as_str()) || callee.starts_with("importlib") {
1668 self.m.has_dynamic_sink = true;
1669 }
1670 self.m.calls.push(CallSite {
1671 callee: callee.clone(),
1672 line: line1(self.li, c.func.range().start()),
1673 });
1674 }
1675 security_call(c, &callee, line1(self.li, c.range().start()), self.m);
1676 }
1677 walk_expr(self, expr);
1678 }
1679}
1680
1681const SECRET_NAMES: &[&str] = &[
1682 "password",
1683 "passwd",
1684 "secret",
1685 "token",
1686 "api_key",
1687 "apikey",
1688 "access_key",
1689 "secret_key",
1690 "private_key",
1691 "auth_token",
1692];
1693
1694fn security_secret(
1695 name: &str,
1696 value: &Expr,
1697 range: TextRange,
1698 li: &LineIndex,
1699 m: &mut ParsedModule,
1700) {
1701 let lname = name.to_ascii_lowercase();
1702 if !SECRET_NAMES.iter().any(|s| lname.contains(s)) {
1703 return;
1704 }
1705 if let Expr::StringLiteral(s) = value {
1706 let val = s.value.to_str();
1707 if val.len() >= 4 && !val.contains("${") && !val.eq_ignore_ascii_case("changeme") {
1708 m.security_hits.push(SecurityHit {
1709 rule: "hardcoded-secret",
1710 line: line1(li, range.start()),
1711 detail: format!("`{name}` assigned a hardcoded string literal"),
1712 });
1713 }
1714 }
1715}
1716
1717const WEAK_CIPHERS: &[&str] = &[
1718 "DES",
1719 "DES3",
1720 "TripleDES",
1721 "ARC2",
1722 "RC2",
1723 "ARC4",
1724 "RC4",
1725 "Blowfish",
1726 "IDEA",
1727 "CAST",
1728 "XOR",
1729];
1730
1731fn kwarg_bool(c: &ruff_python_ast::ExprCall, name: &str, want: bool) -> bool {
1732 c.arguments
1733 .find_keyword(name)
1734 .map(|kw| matches!(&kw.value, Expr::BooleanLiteral(b) if b.value == want))
1735 .unwrap_or(false)
1736}
1737
1738fn has_kwarg(c: &ruff_python_ast::ExprCall, name: &str) -> bool {
1739 c.arguments.find_keyword(name).is_some()
1740}
1741
1742fn first_positional_is_string(c: &ruff_python_ast::ExprCall) -> bool {
1743 matches!(c.arguments.args.first(), Some(Expr::StringLiteral(_)))
1744}
1745
1746fn is_dynamic_string(arg: &Expr) -> bool {
1747 match arg {
1748 Expr::FString(_) => true,
1749 Expr::BinOp(_) => true,
1750 Expr::Call(c) => expr_path(&c.func)
1751 .map(|p| p.ends_with(".format"))
1752 .unwrap_or(false),
1753 _ => false,
1754 }
1755}
1756
1757fn args_reference_ecb(c: &ruff_python_ast::ExprCall) -> bool {
1759 let refs = |e: &Expr| {
1760 expr_path(e)
1761 .map(|p| p.contains("MODE_ECB"))
1762 .unwrap_or(false)
1763 };
1764 c.arguments.args.iter().any(refs) || c.arguments.keywords.iter().any(|k| refs(&k.value))
1765}
1766
1767fn security_call(c: &ruff_python_ast::ExprCall, f: &str, line: u32, m: &mut ParsedModule) {
1768 let last = f.rsplit('.').next().unwrap_or(f);
1769 let mut hit = |rule: &'static str, detail: String| {
1770 m.security_hits.push(SecurityHit { rule, line, detail });
1771 };
1772
1773 if matches!(
1777 f,
1778 "eval" | "exec" | "compile" | "builtins.eval" | "builtins.exec" | "builtins.compile"
1779 ) && !first_positional_is_string(c)
1780 {
1781 hit(
1782 "dangerous-eval",
1783 format!("`{f}` on a non-literal expression executes dynamic code"),
1784 );
1785 }
1786 if f == "yaml.load" && !has_kwarg(c, "Loader") {
1787 hit(
1788 "unsafe-yaml-load",
1789 "yaml.load without an explicit Loader= is unsafe; use yaml.safe_load".into(),
1790 );
1791 }
1792 if matches!(
1793 f,
1794 "pickle.load"
1795 | "pickle.loads"
1796 | "cPickle.load"
1797 | "cPickle.loads"
1798 | "marshal.load"
1799 | "marshal.loads"
1800 | "dill.load"
1801 | "dill.loads"
1802 | "shelve.open"
1803 | "jsonpickle.decode"
1804 ) {
1805 hit(
1806 "unsafe-deserialization",
1807 format!("`{f}` can execute arbitrary code on untrusted input"),
1808 );
1809 }
1810 if matches!(
1811 last,
1812 "call" | "run" | "Popen" | "check_output" | "check_call"
1813 ) && kwarg_bool(c, "shell", true)
1814 {
1815 hit(
1816 "subprocess-shell-true",
1817 "subprocess call with shell=True risks shell injection".into(),
1818 );
1819 }
1820 if matches!(f, "os.system" | "os.popen" | "os.popen2" | "os.popen3") {
1821 hit(
1822 "subprocess-shell-true",
1823 format!("`{f}` runs a command through the shell; prefer subprocess with an argv list"),
1824 );
1825 }
1826 if kwarg_bool(c, "verify", false) {
1827 hit(
1828 "tls-verify-disabled",
1829 "TLS certificate verification disabled (verify=False)".into(),
1830 );
1831 }
1832 if f == "ssl._create_unverified_context" {
1833 hit(
1834 "tls-verify-disabled",
1835 "ssl._create_unverified_context disables certificate validation".into(),
1836 );
1837 }
1838 if matches!(f, "hashlib.md5" | "hashlib.sha1" | "md5.new") {
1839 hit(
1840 "weak-hash",
1841 format!("`{f}` is a weak hash; use sha256+ (or pass usedforsecurity=False)"),
1842 );
1843 }
1844 if WEAK_CIPHERS.contains(&last) {
1845 hit(
1846 "weak-cipher",
1847 format!("`{f}` is a broken/weak cipher; use AES-GCM or ChaCha20-Poly1305"),
1848 );
1849 }
1850 if args_reference_ecb(c) {
1851 hit(
1852 "weak-cipher",
1853 "ECB mode leaks plaintext structure; use an authenticated mode (GCM)".into(),
1854 );
1855 }
1856 if matches!(
1857 f,
1858 "random.random"
1859 | "random.randint"
1860 | "random.randrange"
1861 | "random.choice"
1862 | "random.getrandbits"
1863 ) {
1864 hit(
1865 "insecure-random",
1866 format!("`{f}` is not cryptographically secure; use the `secrets` module for tokens"),
1867 );
1868 }
1869 if matches!(
1870 last,
1871 "execute" | "executemany" | "executescript" | "raw" | "extra"
1872 ) {
1873 if let Some(arg) = c.arguments.args.first() {
1874 if is_dynamic_string(arg) {
1875 hit(
1876 "sql-injection",
1877 format!(
1878 "`{last}(...)` builds SQL from a dynamic string; use parameterized queries"
1879 ),
1880 );
1881 }
1882 }
1883 }
1884 if matches!(
1885 f,
1886 "requests.get"
1887 | "requests.post"
1888 | "requests.put"
1889 | "requests.delete"
1890 | "requests.patch"
1891 | "requests.head"
1892 | "requests.request"
1893 ) && !has_kwarg(c, "timeout")
1894 {
1895 hit(
1896 "request-without-timeout",
1897 format!("`{f}` without a timeout= can block indefinitely"),
1898 );
1899 }
1900 if last == "run" && kwarg_bool(c, "debug", true) {
1903 hit(
1904 "flask-debug-true",
1905 "running a web app with debug=True exposes the interactive debugger".into(),
1906 );
1907 }
1908 if last == "Environment" && kwarg_bool(c, "autoescape", false) {
1911 hit(
1912 "jinja2-autoescape-false",
1913 "Jinja2 Environment with autoescape=False risks XSS; enable autoescaping".into(),
1914 );
1915 }
1916}
1917
1918fn security_imports(m: &mut ParsedModule) {
1919 let mut hits: Vec<SecurityHit> = Vec::new();
1920 for imp in &m.imports {
1921 let from_crypto = imp.module.contains("Crypto") || imp.module.contains("cryptography");
1922 if !from_crypto {
1923 continue;
1924 }
1925 for name in &imp.names {
1926 if WEAK_CIPHERS.contains(&name.as_str()) {
1927 hits.push(SecurityHit {
1928 rule: "weak-cipher",
1929 line: imp.line,
1930 detail: format!(
1931 "`{name}` (imported from `{}`) is a broken/weak cipher; use AES-GCM or ChaCha20-Poly1305",
1932 imp.module
1933 ),
1934 });
1935 }
1936 }
1937 if imp.names.is_empty() {
1938 if let Some(seg) = imp.module.rsplit('.').next() {
1939 if WEAK_CIPHERS.contains(&seg) {
1940 hits.push(SecurityHit {
1941 rule: "weak-cipher",
1942 line: imp.line,
1943 detail: format!(
1944 "`{}` is a broken/weak cipher; use AES-GCM or ChaCha20-Poly1305",
1945 imp.module
1946 ),
1947 });
1948 }
1949 }
1950 }
1951 }
1952 m.security_hits.extend(hits);
1953}
1954
1955fn parse_ignore_comment(text: &str) -> Option<Vec<String>> {
1957 let t = text.trim_start_matches('#').trim();
1958 let rest = t.strip_prefix("mollify:")?.trim();
1959 let rest = rest.strip_prefix("ignore")?.trim();
1960 if let Some(inner) = rest.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
1961 let rules: Vec<String> = inner
1962 .split(',')
1963 .map(|s| s.trim().to_string())
1964 .filter(|s| !s.is_empty())
1965 .collect();
1966 if rules.is_empty() {
1967 Some(vec!["*".into()])
1968 } else {
1969 Some(rules)
1970 }
1971 } else if rest.is_empty() {
1972 Some(vec!["*".into()])
1973 } else {
1974 None
1975 }
1976}
1977
1978#[cfg(test)]
1979mod tests {
1980 use super::*;
1981
1982 fn parse(src: &str) -> ParsedModule {
1983 let mut p = PyParser::new().unwrap();
1984 p.parse(Utf8Path::new("m.py"), src).unwrap()
1985 }
1986
1987 #[test]
1988 fn extracts_functions_and_classes() {
1989 let m = parse("def foo():\n pass\n\nclass Bar:\n pass\n");
1990 let names: Vec<_> = m.definitions.iter().map(|d| d.name.as_str()).collect();
1991 assert!(names.contains(&"foo"));
1992 assert!(names.contains(&"Bar"));
1993 }
1994
1995 #[test]
1996 fn private_convention_detected() {
1997 let m = parse("def _helper():\n pass\n");
1998 assert!(m.definitions[0].private_by_convention);
1999 }
2000
2001 #[test]
2002 fn detects_expanded_security_rules() {
2003 let m = parse(
2004 "app.run(debug=True)\nenv = Environment(autoescape=False)\ntry:\n risky()\nexcept Exception:\n pass\n",
2005 );
2006 let rules: Vec<_> = m.security_hits.iter().map(|h| h.rule).collect();
2007 assert!(rules.contains(&"flask-debug-true"), "got {rules:?}");
2008 assert!(rules.contains(&"jinja2-autoescape-false"), "got {rules:?}");
2009 assert!(rules.contains(&"try-except-pass"), "got {rules:?}");
2010 let narrow = parse("try:\n x()\nexcept ValueError:\n pass\n");
2012 assert!(!narrow
2013 .security_hits
2014 .iter()
2015 .any(|h| h.rule == "try-except-pass"));
2016 }
2017
2018 #[test]
2019 fn extracts_imports() {
2020 let m = parse("import os\nfrom a.b import c, d\nfrom . import e\nfrom x import *\n");
2021 assert!(m.imports.iter().any(|i| i.module == "os"));
2022 let frm = m.imports.iter().find(|i| i.module == "a.b").unwrap();
2023 assert_eq!(frm.names, vec!["c", "d"]);
2024 assert!(m.imports.iter().any(|i| i.relative_dots == 1));
2025 assert!(m.imports.iter().any(|i| i.is_star));
2026 }
2027
2028 #[test]
2029 fn extracts_dunder_all() {
2030 let m = parse("__all__ = ['foo', 'bar']\n");
2031 assert_eq!(m.dunder_all, Some(vec!["foo".into(), "bar".into()]));
2032 }
2033
2034 #[test]
2035 fn detects_security_candidates() {
2036 let m = parse("import subprocess\npassword = \"hunter2xyz\"\nsubprocess.run(cmd, shell=True)\neval(user_input)\n");
2037 let rules: Vec<_> = m.security_hits.iter().map(|h| h.rule).collect();
2038 assert!(rules.contains(&"hardcoded-secret"), "got {rules:?}");
2039 assert!(rules.contains(&"subprocess-shell-true"), "got {rules:?}");
2040 assert!(rules.contains(&"dangerous-eval"), "got {rules:?}");
2041 let ok = parse("eval(\"1+1\")\n");
2042 assert!(!ok.security_hits.iter().any(|h| h.rule == "dangerous-eval"));
2043 }
2044
2045 #[test]
2046 fn dangerous_eval_only_matches_builtins_not_methods() {
2047 for src in [
2050 "session.exec(select(Item))\n",
2051 "conn.exec(query)\n",
2052 "obj.eval(expr)\n",
2053 "db.compile(stmt)\n",
2054 ] {
2055 let m = parse(src);
2056 assert!(
2057 !m.security_hits.iter().any(|h| h.rule == "dangerous-eval"),
2058 "method call wrongly flagged: {src}"
2059 );
2060 }
2061 for src in [
2063 "exec(code)\n",
2064 "eval(user_input)\n",
2065 "compile(src, '<s>', 'exec')\n",
2066 ] {
2067 let m = parse(src);
2068 assert!(
2069 m.security_hits.iter().any(|h| h.rule == "dangerous-eval"),
2070 "builtin not flagged: {src}"
2071 );
2072 }
2073 }
2074
2075 #[test]
2076 fn detects_weak_cipher_imports() {
2077 let m = parse(
2078 "from Crypto.Cipher import DES as pycrypto_des\n\
2079 from Cryptodome.Cipher import ARC4 as ax\n\
2080 cipher = pycrypto_des.new(key, pycrypto_des.MODE_CTR)\n\
2081 c2 = ax.new(key)\n",
2082 );
2083 let cipher_hits: Vec<_> = m
2084 .security_hits
2085 .iter()
2086 .filter(|h| h.rule == "weak-cipher")
2087 .collect();
2088 assert_eq!(
2089 cipher_hits.len(),
2090 2,
2091 "expected DES + ARC4 imports flagged, got {:?}",
2092 m.security_hits
2093 );
2094 let lines: Vec<u32> = cipher_hits.iter().map(|h| h.line).collect();
2095 assert!(lines.contains(&1) && lines.contains(&2), "lines {lines:?}");
2096 }
2097
2098 #[test]
2099 fn detects_weak_cipher_direct_constructor_and_ecb() {
2100 let m = parse(
2101 "from cryptography.hazmat.primitives.ciphers import algorithms, modes, Cipher\n\
2102 c = Cipher(algorithms.ARC4(key), mode=None)\n",
2103 );
2104 assert!(
2105 m.security_hits.iter().any(|h| h.rule == "weak-cipher"),
2106 "expected ARC4 constructor flagged, got {:?}",
2107 m.security_hits
2108 );
2109 let ecb = parse("from Crypto.Cipher import AES\nc = AES.new(key, AES.MODE_ECB)\n");
2110 assert!(
2111 ecb.security_hits.iter().any(|h| h.rule == "weak-cipher"),
2112 "expected ECB mode flagged, got {:?}",
2113 ecb.security_hits
2114 );
2115 }
2116
2117 #[test]
2118 fn strong_cipher_and_modes_not_flagged() {
2119 let m = parse(
2120 "from cryptography.hazmat.primitives.ciphers import algorithms, modes, Cipher\n\
2121 c = Cipher(algorithms.AES(key), modes.GCM(iv))\n",
2122 );
2123 assert!(
2124 !m.security_hits.iter().any(|h| h.rule == "weak-cipher"),
2125 "AES-GCM should not be flagged, got {:?}",
2126 m.security_hits
2127 );
2128 let unrelated = parse("from myapp.utils import DES\nDES.do_thing()\n");
2129 assert!(
2130 !unrelated
2131 .security_hits
2132 .iter()
2133 .any(|h| h.rule == "weak-cipher"),
2134 "non-crypto `DES` import should not be flagged, got {:?}",
2135 unrelated.security_hits
2136 );
2137 }
2138
2139 #[test]
2140 fn counts_type_annotations() {
2141 let m = parse("def f(a: int, b) -> int:\n return a\n\nclass C:\n def m(self, x: int):\n return x\n");
2142 let f = m.functions.iter().find(|f| f.name == "f").unwrap();
2143 assert_eq!(f.params_total, 2);
2144 assert_eq!(f.params_annotated, 1);
2145 assert!(f.return_annotated);
2146 let mm = m.functions.iter().find(|f| f.name == "m").unwrap();
2147 assert_eq!(mm.params_total, 1, "self should be excluded");
2148 assert_eq!(mm.params_annotated, 1);
2149 assert!(!mm.return_annotated);
2150 }
2151
2152 #[test]
2153 fn computes_complexity() {
2154 let m = parse("def f(x):\n if x:\n for i in range(x):\n if i and x:\n return i\n return 0\n");
2155 let f = m.functions.iter().find(|f| f.name == "f").unwrap();
2156 assert!(f.cyclomatic >= 4, "cyclo {:?}", f.cyclomatic);
2157 assert!(f.cognitive >= 3, "cog {:?}", f.cognitive);
2158 }
2159
2160 #[test]
2161 fn captures_decorators() {
2162 let m = parse("import app\n@app.route('/x')\ndef view():\n return 1\n");
2163 let d = m.definitions.iter().find(|d| d.name == "view").unwrap();
2164 assert!(
2165 d.decorators.iter().any(|x| x == "app.route"),
2166 "got {:?}",
2167 d.decorators
2168 );
2169 }
2170
2171 #[test]
2172 fn detects_dynamic_sink() {
2173 let m = parse("x = getattr(obj, 'attr')\n");
2174 assert!(m.has_dynamic_sink);
2175 let m2 = parse("y = 1 + 2\n");
2176 assert!(!m2.has_dynamic_sink);
2177 }
2178
2179 #[test]
2180 fn conditional_import_seen() {
2181 let m = parse("try:\n import fast\nexcept ImportError:\n import slow as fast\n");
2182 assert!(m.imports.iter().any(|i| i.module == "fast"));
2183 }
2184
2185 #[test]
2186 fn scope_resolution_excludes_shadows_and_attributes() {
2187 let m = parse(
2192 "def helper():\n pass\n\ndef f():\n helper = 1\n return helper\n\nobj.helper()\n",
2193 );
2194 assert!(
2195 !m.module_used.iter().any(|s| s == "helper"),
2196 "module_used should exclude shadowed/attribute `helper`: {:?}",
2197 m.module_used
2198 );
2199 let m2 = parse("def g():\n pass\n\ng()\n");
2201 assert!(
2202 m2.module_used.iter().any(|s| s == "g"),
2203 "{:?}",
2204 m2.module_used
2205 );
2206 let m3 =
2209 parse("counter = 0\n\ndef bump():\n global counter\n counter = counter + 1\n");
2210 assert!(
2211 m3.module_used.iter().any(|s| s == "counter"),
2212 "{:?}",
2213 m3.module_used
2214 );
2215 let m4 = parse("counter = 0\n\ndef bump():\n counter = counter + 1\n");
2217 assert!(
2218 !m4.module_used.iter().any(|s| s == "counter"),
2219 "{:?}",
2220 m4.module_used
2221 );
2222 }
2223}