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