Repository navigation
fix(python): find blocked imports by parsing, not by line prefix #2831
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -346,66 +346,53 @@ impl Default for Python { | |
| } | ||
| } | ||
|
|
||
| // Important security decision: find imports by parsing, not by scanning lines. | ||
| // A line-prefix scan missed `pass; import re`, `if x: import re` and any other | ||
| // statement that puts the import after a prefix. Monty 0.0.19 has no import | ||
| // hook, so bashkit parses with the same parser Monty runs first | ||
| // (`ruff_python_parser::parse_module`) and walks every statement body. | ||
| // Monty has no `__import__` or `importlib`, so only `import` / `from` statements | ||
| // can load a module. Code that fails to parse is let through: Monty parses it | ||
| // the same way, reports the SyntaxError and runs nothing. | ||
| fn is_disabled_import(code: &str) -> Option<&'static str> { | ||
| let is_disabled_module = |module: &str| { | ||
| DISABLED_STDLIB_MODULES.iter().any(|disabled| { | ||
| module == *disabled | ||
| || (module.starts_with(disabled) | ||
| && module | ||
| .as_bytes() | ||
| .get(disabled.len()) | ||
| .is_some_and(|next| *next == b'.')) | ||
| use ruff_python_ast::statement_visitor::{StatementVisitor, walk_stmt}; | ||
| use ruff_python_ast::{Stmt, StmtImport, StmtImportFrom}; | ||
|
|
||
| fn disabled(module: &str) -> Option<&'static str> { | ||
| DISABLED_STDLIB_MODULES.iter().copied().find(|disabled| { | ||
| module | ||
| .strip_prefix(disabled) | ||
| .is_some_and(|rest| rest.is_empty() || rest.starts_with('.')) | ||
| }) | ||
| }; | ||
| } | ||
|
|
||
| for raw_line in code.lines() { | ||
| let line = raw_line.split('#').next().unwrap_or("").trim(); | ||
| if line.is_empty() { | ||
| continue; | ||
| } | ||
| struct ImportFinder(Option<&'static str>); | ||
|
|
||
| if let Some(imports) = line.strip_prefix("import ") { | ||
| for import in imports.split(',') { | ||
| let module = import | ||
| .split_whitespace() | ||
| .next() | ||
| .unwrap_or("") | ||
| .trim() | ||
| .trim_matches(|c: char| c == '\'' || c == '"') | ||
| .trim_end_matches(|c: char| { | ||
| !(c.is_ascii_alphanumeric() || c == '_' || c == '.') | ||
| }) | ||
| .to_string(); | ||
| if is_disabled_module(&module) { | ||
| return Some("re"); | ||
| } | ||
| impl<'a> StatementVisitor<'a> for ImportFinder { | ||
| fn visit_stmt(&mut self, stmt: &'a Stmt) { | ||
| if self.0.is_some() { | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| if let Some(from_stmt) = line.strip_prefix("from ") { | ||
| let module = from_stmt | ||
| .split_whitespace() | ||
| .next() | ||
| .unwrap_or("") | ||
| .trim() | ||
| .trim_matches(|c: char| c == '\'' || c == '"') | ||
| .trim_end_matches(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.')) | ||
| .to_string(); | ||
| if is_disabled_module(&module) { | ||
| return Some("re"); | ||
| match stmt { | ||
| Stmt::Import(StmtImport { names, .. }) => { | ||
| self.0 = names.iter().find_map(|alias| disabled(alias.name.as_str())); | ||
| } | ||
| Stmt::ImportFrom(StmtImportFrom { module, names, .. }) => { | ||
| self.0 = match module { | ||
| Some(module) => disabled(module.as_str()), | ||
| // `from . import re` | ||
| None => names.iter().find_map(|alias| disabled(alias.name.as_str())), | ||
| }; | ||
| } | ||
| _ => walk_stmt(self, stmt), | ||
| } | ||
| } | ||
|
|
||
| if line.contains("__import__('re'") || line.contains("__import__(\"re\"") { | ||
| return Some("re"); | ||
| } | ||
| if line.contains("importlib.import_module('re'") | ||
| || line.contains("importlib.import_module(\"re\"") | ||
| { | ||
| return Some("re"); | ||
| } | ||
| } | ||
| None | ||
|
|
||
| let parsed = ruff_python_parser::parse_module(code).ok()?; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Untrusted input is fully AST-parsed before any budget is charged: the caller runs this gate and only afterwards calls |
||
| let mut finder = ImportFinder(None); | ||
| finder.visit_body(&parsed.syntax().body); | ||
| finder.0 | ||
| } | ||
|
|
||
| #[async_trait] | ||
|
|
@@ -1913,10 +1900,61 @@ mod tests { | |
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_re_module_dynamic_import_is_blocked() { | ||
| let r = run(&["-c", "m = __import__('re')"], None).await; | ||
| async fn test_re_module_dynamic_import_is_unavailable() { | ||
| // Monty has no `__import__` or `importlib`, so import statements are | ||
| // the only way to load a module. Pin that: if Monty adds either, the | ||
| // AST gate in `is_disabled_import` must learn about it. | ||
| let r = run(&["-c", "m = __import__('re')\nprint(m)"], None).await; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] The |
||
| assert_eq!(r.exit_code, 1, "{}", r.stdout); | ||
| assert!(r.stdout.is_empty(), "{}", r.stdout); | ||
| let r = run(&["-c", "import importlib"], None).await; | ||
| assert_eq!(r.exit_code, 1); | ||
| assert!(r.stderr.contains("importing module 're' is disabled")); | ||
| assert!( | ||
| r.stderr.contains("No module named 'importlib'"), | ||
| "{}", | ||
| r.stderr | ||
| ); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_re_module_hidden_imports_are_blocked() { | ||
| // Regression: the gate used to match only lines starting with | ||
| // `import`/`from`, so any statement prefix bypassed it. | ||
| for code in [ | ||
| "pass; import re", | ||
| "x = 1; from re import split", | ||
| "if True: import re", | ||
| "if True:\n import re", | ||
| "def f():\n import re\n return re\nf()", | ||
| "try:\n import re\nexcept Exception:\n pass", | ||
| "for _ in [1]: import json, re", | ||
| "with open('/tmp/x', 'w') as f: import re.sub", | ||
| "import json as j, re as r", | ||
| "class C:\n import re", | ||
| ] { | ||
| let r = run(&["-c", code], None).await; | ||
| assert_eq!(r.exit_code, 1, "{code}: {}", r.stderr); | ||
| assert!( | ||
| r.stderr.contains("importing module 're' is disabled"), | ||
| "{code}: {}", | ||
| r.stderr | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_re_named_text_is_not_an_import() { | ||
| // Strings, comments and other modules that merely mention `re` run. | ||
| let r = run( | ||
| &[ | ||
| "-c", | ||
| "import json\nre = 'import re'\n# import re\nprint(re, json.dumps('re'))", | ||
| ], | ||
| None, | ||
| ) | ||
| .await; | ||
| assert_eq!(r.exit_code, 0, "{}", r.stderr); | ||
| assert_eq!(r.stdout, "import re \"re\"\n"); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Nothing enforces the "keep in step with Monty's pin" invariant:
.github/dependabot.ymlignoresmonty/monty-types(held at 0.0.19) but not these two crates, so a Dependabot bump ofruff_python_parser/astpast Monty's bundled 0.0.3 silently skews the gate's grammar from the executor's. Skew in the wrong direction (gate rejects what Monty accepts) turns into pass-through (parse_module(...).ok()?returnsNone) and reopens the hole. Please add the same ignore entries forruff_python_parser/ruff_python_ast.