package astquery import "github.com/zzet/gortex/internal/parser" // Bandit-parity Python SAST ruleset. Patterns target the tree-sitter // Python grammar; capture names follow the convention // // @match — the row's anchor (the call / statement to highlight) // @fn — the called function (for `eval(...)`-shaped patterns) // @mod — the module identifier in dotted-attr calls // @attr — the attribute identifier in dotted-attr calls // @kw — keyword argument name // @val — keyword / positional argument value // @name — assignment LHS name / imported module name // // Rules are grouped by vulnerability family so adding a rule to a // family (new pickle helper, another deserialiser) is a one-line // change. func init() { registerPythonInjection() registerPythonDeserialisation() registerPythonSubprocess() registerPythonCryptoWeakHash() registerPythonCryptoWeakCipher() registerPythonCryptoModeECB() registerPythonNetwork() registerPythonSSLTLS() registerPythonXML() registerPythonDjango() registerPythonFlask() registerPythonJinja() registerPythonSQLi() registerPythonHardcoded() registerPythonRandom() registerPythonFilesystem() registerPythonArchive() registerPythonImports() registerPythonLogging() registerPythonRequests() registerPythonParamiko() registerPythonExceptionHandling() } // --------------------------------------------------------------------------- // 1. Injection — eval / exec / compile (CWE-95) // --------------------------------------------------------------------------- func registerPythonInjection() { mustRegisterSAST( sastRule{ Name: "py-eval-use", Description: "Python `eval()` invocation — executes an arbitrary expression. Any caller-controlled input becomes code execution. Use `ast.literal_eval` for safe parsing.", Severity: "error", CWE: "CWE-95", OWASP: "A03:2021-Injection", Tags: []string{"injection", "code-injection"}, References: []string{"bandit:B307"}, Pat: map[string]string{ "python": `((call function: (identifier) @fn) @match (#eq? @fn "eval"))`, }, }, sastRule{ Name: "py-exec-use", Description: "Python `exec()` invocation — runs an arbitrary statement string. Caller-controlled input is full RCE.", Severity: "error", CWE: "CWE-95", OWASP: "A03:2021-Injection", Tags: []string{"injection", "code-injection"}, References: []string{"bandit:B102"}, Pat: map[string]string{ "python": `((call function: (identifier) @fn) @match (#eq? @fn "exec"))`, }, }, sastRule{ Name: "py-compile-use", Description: "Python `compile()` invocation — produces a code object from a string. Useful for ahead-of-time, but a frequent precursor to `eval` / `exec` of user input.", Severity: "warning", CWE: "CWE-95", Tags: []string{"injection", "code-injection"}, Pat: map[string]string{ "python": `((call function: (identifier) @fn) @match (#eq? @fn "compile"))`, }, }, sastRule{ Name: "py-input-use-py2", Description: "Python 2 `input()` evaluates its argument as a Python expression (equivalent to `eval(raw_input())`). On Python 3 this is harmless, but a codebase that targets both runtimes leaks RCE in the older path.", Severity: "warning", CWE: "CWE-20", Tags: []string{"injection", "python2"}, References: []string{"bandit:B322"}, Pat: map[string]string{ "python": `((call function: (identifier) @fn (#eq? @fn "input")) @match)`, }, }, ) } // --------------------------------------------------------------------------- // 2. Deserialisation — pickle, marshal, yaml.load, shelve (CWE-502) // --------------------------------------------------------------------------- func registerPythonDeserialisation() { mustRegisterSAST( sastRule{ Name: "py-pickle-load", Description: "`pickle.load` / `pickle.loads` / `cPickle.load` / `cPickle.loads` deserialise arbitrary Python objects — anything attacker-controlled is RCE. Use `json` or a typed schema (msgpack, protobuf).", Severity: "error", CWE: "CWE-502", OWASP: "A08:2021-Software and Data Integrity Failures", Tags: []string{"deserialization", "rce"}, References: []string{"bandit:B301", "bandit:B403"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr) @target) @match (#match? @mod "^(pickle|cPickle|_pickle|dill)$") (#match? @attr "^(load|loads|Unpickler)$"))`, }, }, sastRule{ Name: "py-marshal-load", Description: "`marshal.load` / `marshal.loads` — Python's internal serialisation; arbitrary attacker bytes execute on load. Reserve for pyc files the runtime itself emits.", Severity: "error", CWE: "CWE-502", Tags: []string{"deserialization", "rce"}, References: []string{"bandit:B302"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "marshal") (#match? @attr "^(load|loads)$"))`, }, }, sastRule{ Name: "py-yaml-load-unsafe", Description: "`yaml.load` without an explicit `Loader=yaml.SafeLoader` parses arbitrary YAML tags — `!!python/object/apply` is RCE. Use `yaml.safe_load` or pass `Loader=yaml.SafeLoader`.", Severity: "error", CWE: "CWE-502", OWASP: "A08:2021-Software and Data Integrity Failures", Tags: []string{"deserialization", "yaml"}, References: []string{"bandit:B506"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "yaml") (#eq? @attr "load"))`, }, }, sastRule{ Name: "py-shelve-open", Description: "`shelve.open(...)` deserialises pickle under the hood; opening an attacker-controlled file means RCE.", Severity: "warning", CWE: "CWE-502", Tags: []string{"deserialization"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "shelve") (#eq? @attr "open"))`, }, }, ) } // --------------------------------------------------------------------------- // 3. Subprocess / shell (CWE-78) // --------------------------------------------------------------------------- func registerPythonSubprocess() { mustRegisterSAST( sastRule{ Name: "py-subprocess-shell-true", Description: "`subprocess.Popen|call|run|check_output|check_call(..., shell=True)` — runs the command line through `/bin/sh -c`. Any caller-controlled argument is command injection. Pass an argv list instead.", Severity: "error", CWE: "CWE-78", OWASP: "A03:2021-Injection", Tags: []string{"injection", "command-injection", "subprocess"}, References: []string{"bandit:B602", "bandit:B604"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr) arguments: (argument_list (keyword_argument name: (identifier) @kw value: (true)))) @match (#eq? @mod "subprocess") (#match? @attr "^(Popen|call|run|check_output|check_call)$") (#eq? @kw "shell"))`, }, }, sastRule{ Name: "py-os-system", Description: "`os.system(...)` runs the command via `/bin/sh -c`. Any non-literal argument is command injection — use `subprocess.run([..], shell=False)`.", Severity: "error", CWE: "CWE-78", OWASP: "A03:2021-Injection", Tags: []string{"injection", "command-injection"}, References: []string{"bandit:B605"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "os") (#eq? @attr "system"))`, }, }, sastRule{ Name: "py-os-popen", Description: "`os.popen(...)` is a thin shim over `subprocess.Popen(..., shell=True)` and inherits the same injection surface. Deprecated since 2.6.", Severity: "error", CWE: "CWE-78", Tags: []string{"injection", "command-injection", "deprecated"}, References: []string{"bandit:B605"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "os") (#match? @attr "^(popen|popen2|popen3|popen4)$"))`, }, }, sastRule{ Name: "py-os-spawn", Description: "`os.spawnl` / `os.spawnv` / `os.spawnle` / `os.spawnve` / `os.exec*` family — superseded by `subprocess` for a reason; the shell variants (`spawnlpe`, etc.) inherit shell quoting issues.", Severity: "warning", CWE: "CWE-78", Tags: []string{"command-injection", "deprecated"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "os") (#match? @attr "^(spawnl|spawnle|spawnlp|spawnlpe|spawnv|spawnve|spawnvp|spawnvpe|execl|execle|execlp|execlpe|execv|execve|execvp|execvpe)$"))`, }, }, sastRule{ Name: "py-commands-getoutput", Description: "`commands.getoutput` / `commands.getstatusoutput` — Python 2 stdlib, shells out via `/bin/sh -c`. Removed in Python 3; flag any lingering imports.", Severity: "warning", CWE: "CWE-78", Tags: []string{"command-injection", "deprecated"}, References: []string{"bandit:B404"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "commands") (#match? @attr "^(getoutput|getstatusoutput)$"))`, }, }, sastRule{ Name: "py-popen2", Description: "`popen2.popen2` / `popen2.popen3` / `popen2.Popen3` / `popen2.Popen4` — Python 2 stdlib, deprecated. All variants shell-quote arguments themselves and are routinely misused.", Severity: "warning", CWE: "CWE-78", Tags: []string{"command-injection", "deprecated"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "popen2"))`, }, }, sastRule{ Name: "py-shell-true-import", Description: "`from os import system` or `from subprocess import Popen` — the rename hides the dangerous call from downstream `os.system` greps. Audit every use site.", Severity: "info", CWE: "CWE-78", Tags: []string{"command-injection", "import-alias"}, Pat: map[string]string{ "python": `((import_from_statement module_name: (dotted_name (identifier) @mod)) @match (#match? @mod "^(os|subprocess|commands|popen2)$"))`, }, }, ) } // --------------------------------------------------------------------------- // 4. Crypto — weak hash (CWE-327) // --------------------------------------------------------------------------- func registerPythonCryptoWeakHash() { mustRegisterSAST( sastRule{ Name: "py-hashlib-weak-direct", Description: "`hashlib.md5(...)` / `hashlib.sha1(...)` — cryptographically broken for any security-sensitive purpose. Use `hashlib.sha256` (or BLAKE2 / SHA-3) for fingerprinting and `argon2` / `bcrypt` / `scrypt` / `pbkdf2_hmac` for password hashing.", Severity: "error", CWE: "CWE-327", OWASP: "A02:2021-Cryptographic Failures", Tags: []string{"crypto", "weak-hash"}, References: []string{"bandit:B303", "bandit:B324"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "hashlib") (#match? @attr "^(md5|sha1|md4)$"))`, }, }, sastRule{ Name: "py-hashlib-new-weak", Description: "`hashlib.new(\"md5\"|\"sha1\"|\"md4\"|\"ripemd160\")` — same weak-hash story as direct ctor, hidden behind the `new()` factory.", Severity: "error", CWE: "CWE-327", OWASP: "A02:2021-Cryptographic Failures", Tags: []string{"crypto", "weak-hash"}, References: []string{"bandit:B324"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr) arguments: (argument_list (string) @algo)) @match (#eq? @mod "hashlib") (#eq? @attr "new") (#match? @algo "[\"']?(md5|sha1|md4|ripemd160)[\"']?"))`, }, }, sastRule{ Name: "py-pycryptodome-weak-hash", Description: "`from Crypto.Hash import MD5|SHA1|MD4|RIPEMD` (or pycryptodome's `Cryptodome.Hash` mirror) — pycryptodome wrappers for cryptographically-broken hashes.", Severity: "error", CWE: "CWE-327", Tags: []string{"crypto", "weak-hash"}, Pat: map[string]string{ "python": `((import_from_statement module_name: (dotted_name) @mod name: (dotted_name (identifier) @name)) @match (#match? @mod "^(Crypto|Cryptodome)\\.Hash$") (#match? @name "^(MD5|MD4|SHA1|RIPEMD)$"))`, }, }, ) } // --------------------------------------------------------------------------- // 5. Crypto — weak cipher (CWE-327) // --------------------------------------------------------------------------- func registerPythonCryptoWeakCipher() { mustRegisterSAST( sastRule{ Name: "py-weak-cipher-pycrypto", Description: "`from Crypto.Cipher import DES|ARC2|ARC4|Blowfish` (or pycryptodome's `Cryptodome.Cipher` mirror) — DES / RC2 / RC4 / 64-bit Blowfish are all broken. Use AES-GCM or ChaCha20-Poly1305.", Severity: "error", CWE: "CWE-327", OWASP: "A02:2021-Cryptographic Failures", Tags: []string{"crypto", "weak-cipher"}, References: []string{"bandit:B304"}, Pat: map[string]string{ "python": `((import_from_statement module_name: (dotted_name) @mod name: (dotted_name (identifier) @name)) @match (#match? @mod "^(Crypto|Cryptodome)\\.Cipher$") (#match? @name "^(DES|DES3|ARC2|ARC4|XOR|Blowfish)$"))`, }, }, sastRule{ Name: "py-cryptography-weak-cipher", Description: "`cryptography.hazmat.primitives.ciphers.algorithms.TripleDES|Blowfish|ARC4|IDEA|CAST5|SEED` — legacy ciphers retained for interop only; new code should use AES-GCM or ChaCha20-Poly1305.", Severity: "warning", CWE: "CWE-327", Tags: []string{"crypto", "weak-cipher"}, Pat: map[string]string{ "python": `((call function: (attribute attribute: (identifier) @algo)) @match (#match? @algo "^(TripleDES|Blowfish|ARC4|IDEA|CAST5|SEED)$"))`, }, }, ) } // --------------------------------------------------------------------------- // 6. Crypto — ECB mode (CWE-327) // --------------------------------------------------------------------------- func registerPythonCryptoModeECB() { mustRegisterSAST( sastRule{ Name: "py-aes-mode-ecb", Description: "AES in ECB mode (`AES.MODE_ECB` / `modes.ECB()`) — does not hide patterns in the plaintext; identical blocks produce identical ciphertext. Use AES-GCM or AES-CBC with a random IV + MAC.", Severity: "error", CWE: "CWE-327", OWASP: "A02:2021-Cryptographic Failures", Tags: []string{"crypto", "ecb"}, References: []string{"bandit:B305"}, Pat: map[string]string{ "python": `((attribute object: (identifier) @mod attribute: (identifier) @attr) @match (#match? @attr "^(MODE_ECB|ECB)$"))`, }, }, ) } // --------------------------------------------------------------------------- // 7. Network — cleartext protocols (CWE-319) // --------------------------------------------------------------------------- func registerPythonNetwork() { mustRegisterSAST( sastRule{ Name: "py-telnetlib-import", Description: "`import telnetlib` — Telnet transmits credentials in cleartext. Use SSH (paramiko / asyncssh) or HTTPS. Removed from stdlib in Python 3.13.", Severity: "error", CWE: "CWE-319", OWASP: "A02:2021-Cryptographic Failures", Tags: []string{"cleartext", "deprecated"}, References: []string{"bandit:B401"}, Pat: map[string]string{ "python": `((import_statement name: (dotted_name (identifier) @name)) @match (#eq? @name "telnetlib"))`, }, }, sastRule{ Name: "py-ftplib-import", Description: "`import ftplib` — FTP transmits credentials in cleartext. Use SFTP (paramiko) or FTPS (`ftplib.FTP_TLS`).", Severity: "warning", CWE: "CWE-319", Tags: []string{"cleartext"}, References: []string{"bandit:B402"}, Pat: map[string]string{ "python": `((import_statement name: (dotted_name (identifier) @name)) @match (#eq? @name "ftplib"))`, }, }, sastRule{ Name: "py-nntplib-import", Description: "`import nntplib` — NNTP transmits credentials in cleartext; deprecated in Python 3.11, removed in 3.13.", Severity: "warning", CWE: "CWE-319", Tags: []string{"cleartext", "deprecated"}, Pat: map[string]string{ "python": `((import_statement name: (dotted_name (identifier) @name)) @match (#eq? @name "nntplib"))`, }, }, sastRule{ Name: "py-imaplib-no-starttls", Description: "`imaplib.IMAP4(...)` (not `IMAP4_SSL`) connects without TLS. Use `IMAP4_SSL` or call `IMAP4.starttls()` immediately after connect.", Severity: "warning", CWE: "CWE-319", Tags: []string{"cleartext"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "imaplib") (#eq? @attr "IMAP4"))`, }, }, sastRule{ Name: "py-poplib-no-tls", Description: "`poplib.POP3(...)` (not `POP3_SSL`) connects without TLS. POP3 transmits credentials in cleartext otherwise.", Severity: "warning", CWE: "CWE-319", Tags: []string{"cleartext"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "poplib") (#eq? @attr "POP3"))`, }, }, sastRule{ Name: "py-smtplib-no-starttls", Description: "`smtplib.SMTP(...)` without a subsequent `starttls()` call leaks credentials in cleartext. Use `SMTP_SSL` or call `starttls()` immediately.", Severity: "info", CWE: "CWE-319", Tags: []string{"cleartext"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "smtplib") (#eq? @attr "SMTP"))`, }, }, sastRule{ Name: "py-socket-bind-all-interfaces", Description: "Socket bound to `0.0.0.0` / empty string — exposes the service on every interface, often unintentional in development paths shipped to prod. Bind to `127.0.0.1` unless the service is genuinely public.", Severity: "warning", CWE: "CWE-605", Tags: []string{"bind", "all-interfaces"}, References: []string{"bandit:B104"}, Pat: map[string]string{ "python": `((call function: (attribute attribute: (identifier) @attr) arguments: (argument_list (tuple (string) @host))) @match (#eq? @attr "bind") (#match? @host "^[\"']?(0\\.0\\.0\\.0|::|\\s*)[\"']?$"))`, }, }, ) } // --------------------------------------------------------------------------- // 8. SSL / TLS (CWE-295, CWE-326) // --------------------------------------------------------------------------- func registerPythonSSLTLS() { mustRegisterSAST( sastRule{ Name: "py-ssl-unverified-context", Description: "`ssl._create_unverified_context()` — bypasses certificate validation. Lets any MITM impersonate the server.", Severity: "error", CWE: "CWE-295", OWASP: "A02:2021-Cryptographic Failures", Tags: []string{"tls", "no-verify"}, References: []string{"bandit:B501"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "ssl") (#eq? @attr "_create_unverified_context"))`, }, }, sastRule{ Name: "py-ssl-no-verify", Description: "`ssl.CERT_NONE` selected as `verify_mode` — disables certificate validation. Any MITM with a self-signed cert is accepted.", Severity: "error", CWE: "CWE-295", Tags: []string{"tls", "no-verify"}, References: []string{"bandit:B504"}, Pat: map[string]string{ "python": `((attribute object: (identifier) @mod attribute: (identifier) @attr) @match (#eq? @mod "ssl") (#eq? @attr "CERT_NONE"))`, }, }, sastRule{ Name: "py-ssl-old-protocol", Description: "`ssl.PROTOCOL_SSLv2|SSLv3|TLSv1|TLSv1_1` — broken or deprecated TLS versions. Use `PROTOCOL_TLS_CLIENT` / `PROTOCOL_TLS_SERVER` which negotiate the highest mutually-supported version.", Severity: "error", CWE: "CWE-326", OWASP: "A02:2021-Cryptographic Failures", Tags: []string{"tls", "weak-protocol"}, References: []string{"bandit:B502", "bandit:B503"}, Pat: map[string]string{ "python": `((attribute object: (identifier) @mod attribute: (identifier) @attr) @match (#eq? @mod "ssl") (#match? @attr "^(PROTOCOL_SSLv2|PROTOCOL_SSLv3|PROTOCOL_TLSv1|PROTOCOL_TLSv1_1)$"))`, }, }, sastRule{ Name: "py-paramiko-autoadd-policy", Description: "`paramiko.AutoAddPolicy()` — accepts any host key automatically, defeating SSH host-key verification. Use `paramiko.RejectPolicy()` with a known_hosts file.", Severity: "error", CWE: "CWE-295", Tags: []string{"ssh", "host-key"}, References: []string{"bandit:B507"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "paramiko") (#match? @attr "^(AutoAddPolicy|MissingHostKeyPolicy|WarningPolicy)$"))`, }, }, ) } // --------------------------------------------------------------------------- // 9. XML — XXE / billion-laughs / external-entity expansion (CWE-611) // --------------------------------------------------------------------------- func registerPythonXML() { mustRegisterSAST( sastRule{ Name: "py-xml-etree-no-defusedxml", Description: "`from xml.etree import …` / `import xml.etree.…` — stdlib XML parser is vulnerable to XXE + billion-laughs. Use `defusedxml.ElementTree` instead. Rule fires on the import; review the call site.", Severity: "error", CWE: "CWE-611", OWASP: "A05:2021-Security Misconfiguration", Tags: []string{"xxe", "xml"}, References: []string{"bandit:B313", "bandit:B314"}, Pat: map[string]string{ "python": `((import_from_statement module_name: (dotted_name) @mod) @match (#match? @mod "^xml\\.etree(\\.|$)")) ((import_statement name: (dotted_name) @mod) @match (#match? @mod "^xml\\.etree(\\.|$)"))`, }, }, sastRule{ Name: "py-xml-sax-no-defusedxml", Description: "`xml.sax.*` import — XXE-vulnerable stdlib parser. Use `defusedxml.sax`.", Severity: "error", CWE: "CWE-611", Tags: []string{"xxe", "xml"}, References: []string{"bandit:B317"}, Pat: map[string]string{ "python": `((import_from_statement module_name: (dotted_name) @mod) @match (#match? @mod "^xml\\.sax(\\.|$)")) ((import_statement name: (dotted_name) @mod) @match (#match? @mod "^xml\\.sax(\\.|$)"))`, }, }, sastRule{ Name: "py-xml-minidom-no-defusedxml", Description: "`xml.dom.minidom` import — XXE-vulnerable stdlib parser. Use `defusedxml.minidom`.", Severity: "error", CWE: "CWE-611", Tags: []string{"xxe", "xml"}, References: []string{"bandit:B318"}, Pat: map[string]string{ "python": `((import_from_statement module_name: (dotted_name) @mod name: (dotted_name (identifier) @name)) @match (#eq? @mod "xml.dom") (#eq? @name "minidom")) ((import_from_statement module_name: (dotted_name) @mod) @match (#eq? @mod "xml.dom.minidom")) ((import_statement name: (dotted_name) @mod) @match (#eq? @mod "xml.dom.minidom"))`, }, }, sastRule{ Name: "py-xml-pulldom-no-defusedxml", Description: "`xml.dom.pulldom` import — XXE-vulnerable stdlib parser. Use `defusedxml.pulldom`.", Severity: "error", CWE: "CWE-611", Tags: []string{"xxe", "xml"}, Pat: map[string]string{ "python": `((import_from_statement module_name: (dotted_name) @mod name: (dotted_name (identifier) @name)) @match (#eq? @mod "xml.dom") (#eq? @name "pulldom")) ((import_from_statement module_name: (dotted_name) @mod) @match (#eq? @mod "xml.dom.pulldom")) ((import_statement name: (dotted_name) @mod) @match (#eq? @mod "xml.dom.pulldom"))`, }, }, sastRule{ Name: "py-lxml-no-resolve-entities", Description: "`lxml` import — XXE-vulnerable unless every parser is built with `resolve_entities=False`. Use `defusedxml.lxml`, or audit every call site manually.", Severity: "warning", CWE: "CWE-611", Tags: []string{"xxe", "xml", "lxml"}, Pat: map[string]string{ "python": `((import_from_statement module_name: (dotted_name) @mod) @match (#match? @mod "^lxml(\\.|$)")) ((import_statement name: (dotted_name) @mod) @match (#match? @mod "^lxml(\\.|$)"))`, }, }, sastRule{ Name: "py-xmlrpc-import", Description: "`import xmlrpc.client` / `import xmlrpc.server` — XML-RPC unmarshals method arguments via the standard XML parser; same XXE risk as raw `xml.etree`. Use `defusedxml.xmlrpc.monkey_patch()` or migrate to JSON-RPC.", Severity: "warning", CWE: "CWE-611", Tags: []string{"xxe", "xml-rpc"}, References: []string{"bandit:B411"}, Pat: map[string]string{ "python": `((import_statement name: (dotted_name) @name) @match (#match? @name "^xmlrpc(\\.client|\\.server)?$"))`, }, }, ) } // --------------------------------------------------------------------------- // 10. Django (CWE-79 / CWE-89 / CWE-489 / CWE-352) // --------------------------------------------------------------------------- func registerPythonDjango() { mustRegisterSAST( sastRule{ Name: "py-django-mark-safe", Description: "`django.utils.safestring.mark_safe(...)` — flags arbitrary text as safe-from-escaping. Calling with user input is XSS.", Severity: "error", CWE: "CWE-79", OWASP: "A03:2021-Injection", Tags: []string{"xss", "django"}, References: []string{"bandit:B703"}, Pat: map[string]string{ "python": `((call function: [(identifier) @fn (attribute attribute: (identifier) @fn)]) @match (#eq? @fn "mark_safe"))`, }, }, sastRule{ Name: "py-django-extra-method", Description: "Django ORM `.extra(...)` clause — bypasses ORM escaping and lets raw SQL fragments through.", Severity: "error", CWE: "CWE-89", OWASP: "A03:2021-Injection", Tags: []string{"sqli", "django"}, References: []string{"bandit:B610"}, Pat: map[string]string{ "python": `((call function: (attribute attribute: (identifier) @fn)) @match (#eq? @fn "extra"))`, }, }, sastRule{ Name: "py-django-raw-query", Description: "Django ORM `.raw(...)` clause — executes raw SQL on the manager; trivially exploitable with user input.", Severity: "error", CWE: "CWE-89", OWASP: "A03:2021-Injection", Tags: []string{"sqli", "django"}, References: []string{"bandit:B611"}, Pat: map[string]string{ "python": `((call function: (attribute attribute: (identifier) @fn)) @match (#eq? @fn "raw"))`, }, }, sastRule{ Name: "py-django-csrf-exempt", Description: "`@csrf_exempt` decorator disables CSRF protection on a view. Use `@csrf_protect` or design the view to be safe-by-default.", Severity: "warning", CWE: "CWE-352", OWASP: "A01:2021-Broken Access Control", Tags: []string{"csrf", "django"}, Pat: map[string]string{ "python": `((decorator [(identifier) @fn (attribute attribute: (identifier) @fn) (call function: [(identifier) @fn (attribute attribute: (identifier) @fn)])]) @match (#eq? @fn "csrf_exempt"))`, }, }, sastRule{ Name: "py-django-debug-true", Description: "`DEBUG = True` at module scope in settings — leaks stack traces, environment, and SECRET_KEY in error pages.", Severity: "warning", CWE: "CWE-489", OWASP: "A05:2021-Security Misconfiguration", Tags: []string{"django", "debug"}, Pat: map[string]string{ "python": `((assignment left: (identifier) @name right: (true)) @match (#eq? @name "DEBUG"))`, }, }, sastRule{ Name: "py-django-allowed-hosts-wildcard", Description: "`ALLOWED_HOSTS = ['*']` — disables Django's Host header validation. Allows host header attacks (cache poisoning, password-reset poisoning).", Severity: "warning", CWE: "CWE-942", Tags: []string{"django", "host-header"}, Pat: map[string]string{ "python": `((assignment left: (identifier) @name right: (list (string) @host)) @match (#eq? @name "ALLOWED_HOSTS") (#match? @host "[\"']?\\*[\"']?"))`, }, }, ) } // --------------------------------------------------------------------------- // 11. Flask (CWE-489 / CWE-79) // --------------------------------------------------------------------------- func registerPythonFlask() { mustRegisterSAST( sastRule{ Name: "py-flask-debug-true", Description: "`app.run(debug=True)` — exposes the Werkzeug debugger on the network. Anyone who hits the dev server can run arbitrary Python via the debug console.", Severity: "error", CWE: "CWE-489", OWASP: "A05:2021-Security Misconfiguration", Tags: []string{"flask", "debug"}, References: []string{"bandit:B201"}, Pat: map[string]string{ "python": `((call function: (attribute attribute: (identifier) @fn) arguments: (argument_list (keyword_argument name: (identifier) @kw value: (true)))) @match (#eq? @fn "run") (#eq? @kw "debug"))`, }, }, sastRule{ Name: "py-flask-render-template-string", Description: "`flask.render_template_string(...)` — Jinja2 templating from a runtime string; if the string contains user input, this is SSTI / RCE.", Severity: "warning", CWE: "CWE-94", OWASP: "A03:2021-Injection", Tags: []string{"flask", "ssti"}, Pat: map[string]string{ "python": `((call function: [(identifier) @fn (attribute attribute: (identifier) @fn)]) @match (#eq? @fn "render_template_string"))`, }, }, sastRule{ Name: "py-flask-redirect-user-input", Description: "`flask.redirect(...)` to a value that flows from `request.*` — open redirect. Validate against an allow-list of paths.", Severity: "info", CWE: "CWE-601", Tags: []string{"flask", "open-redirect"}, Pat: map[string]string{ "python": `((call function: [(identifier) @fn (attribute attribute: (identifier) @fn)] arguments: (argument_list (attribute object: (identifier) @src))) @match (#eq? @fn "redirect") (#eq? @src "request"))`, }, }, sastRule{ Name: "py-flask-send-file-user-input", Description: "`flask.send_file(path)` where `path` flows from `request.*` — path traversal: an attacker reads any file the process can open. Resolve and validate against a base directory.", Severity: "error", CWE: "CWE-22", OWASP: "A01:2021-Broken Access Control", Tags: []string{"flask", "path-traversal"}, Pat: map[string]string{ "python": `((call function: [(identifier) @fn (attribute attribute: (identifier) @fn)] arguments: (argument_list (attribute object: (identifier) @src))) @match (#eq? @fn "send_file") (#eq? @src "request"))`, }, }, ) } // --------------------------------------------------------------------------- // 12. Jinja2 (CWE-79) // --------------------------------------------------------------------------- func registerPythonJinja() { mustRegisterSAST( sastRule{ Name: "py-jinja2-autoescape-false", Description: "`jinja2.Environment(autoescape=False)` — explicitly opts out of HTML escaping. Any user-controlled variable rendered into a template becomes XSS.", Severity: "error", CWE: "CWE-79", OWASP: "A03:2021-Injection", Tags: []string{"xss", "jinja2"}, References: []string{"bandit:B701"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr) arguments: (argument_list (keyword_argument name: (identifier) @kw value: (false)))) @match (#eq? @mod "jinja2") (#eq? @attr "Environment") (#eq? @kw "autoescape"))`, }, }, sastRule{ Name: "py-mako-template-no-default-filters", Description: "`mako.template.Template(...)` without `default_filters=['h']` doesn't HTML-escape by default — XSS when used to render web responses.", Severity: "info", CWE: "CWE-79", Tags: []string{"xss", "mako"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "mako") (#eq? @attr "Template"))`, }, }, ) } // --------------------------------------------------------------------------- // 13. SQL injection — formatted query strings (CWE-89) // --------------------------------------------------------------------------- func registerPythonSQLi() { mustRegisterSAST( sastRule{ Name: "py-sqli-execute-format", Description: "`cursor.execute(\"...{}...\".format(x))` — Python str.format() builds the query before the driver sees it. The driver's placeholder mechanism (`%s` + `params` tuple) is bypassed entirely.", Severity: "error", CWE: "CWE-89", OWASP: "A03:2021-Injection", Tags: []string{"sqli", "format"}, Pat: map[string]string{ "python": `((call function: (attribute attribute: (identifier) @fn) arguments: (argument_list (call function: (attribute attribute: (identifier) @sfn)))) @match (#match? @fn "^(execute|executemany|raw|fetch|fetchall|fetchone)$") (#eq? @sfn "format"))`, }, }, sastRule{ Name: "py-sqli-execute-percent", Description: "`cursor.execute(\"... %s ...\" % values)` — Python % formatting builds the query string before parameterisation. Same SQL-injection risk as `.format()`.", Severity: "error", CWE: "CWE-89", OWASP: "A03:2021-Injection", Tags: []string{"sqli", "percent-format"}, Pat: map[string]string{ "python": `((call function: (attribute attribute: (identifier) @fn) arguments: (argument_list (binary_operator operator: "%"))) @match (#match? @fn "^(execute|executemany|raw|fetch|fetchall|fetchone)$"))`, }, }, sastRule{ Name: "py-sqli-execute-fstring", Description: "`cursor.execute(f\"... {val} ...\")` — f-string interpolation runs before the driver; user-controlled `val` is SQL-injection.", Severity: "error", CWE: "CWE-89", OWASP: "A03:2021-Injection", Tags: []string{"sqli", "fstring"}, Pat: map[string]string{ "python": `((call function: (attribute attribute: (identifier) @fn) arguments: (argument_list (string (interpolation)))) @match (#match? @fn "^(execute|executemany|raw|fetch|fetchall|fetchone)$"))`, }, }, sastRule{ Name: "py-sqlalchemy-text-with-fstring", Description: "`sqlalchemy.text(f\"...\")` — building the SQL text via f-string interpolation rather than `:bindparam` placeholders is SQL-injection.", Severity: "error", CWE: "CWE-89", OWASP: "A03:2021-Injection", Tags: []string{"sqli", "sqlalchemy"}, Pat: map[string]string{ "python": `((call function: [(identifier) @fn (attribute attribute: (identifier) @fn)] arguments: (argument_list (string (interpolation)))) @match (#eq? @fn "text"))`, }, }, ) } // --------------------------------------------------------------------------- // 14. Hardcoded credentials (CWE-798) // --------------------------------------------------------------------------- func registerPythonHardcoded() { mustRegisterSAST( sastRule{ Name: "py-hardcoded-credential-keyword-arg", Description: "Function call passes `password=\"…\"` / `secret=\"…\"` / `api_key=\"…\"` etc. with a non-trivial string literal — embeds the credential in source and version control. Move to env / secrets manager.", Severity: "error", CWE: "CWE-798", OWASP: "A07:2021-Identification and Authentication Failures", Tags: []string{"secrets", "credentials"}, References: []string{"bandit:B106"}, Pat: map[string]string{ "python": `((call arguments: (argument_list (keyword_argument name: (identifier) @name value: (string) @val))) @match (#match? @name "(?i)^(password|passwd|pass|secret|api_?key|token|aws_?secret(_?key)?|access_?key|private_?key)$"))`, }, PostFilter: secretLiteralLooksReal, }, sastRule{ Name: "py-hardcoded-credential-default-arg", Description: "Function default parameter named like a credential (`password=\"...\"`, `api_key=\"...\"`) with a non-trivial string literal — exposed in `help()` output and source control.", Severity: "error", CWE: "CWE-798", Tags: []string{"secrets", "credentials"}, References: []string{"bandit:B107"}, Pat: map[string]string{ "python": `((default_parameter name: (identifier) @name value: (string) @val) @match (#match? @name "(?i)^(password|passwd|pass|secret|api_?key|token|aws_?secret(_?key)?|access_?key|private_?key)$"))`, }, PostFilter: secretLiteralLooksReal, }, sastRule{ Name: "py-django-secret-key-hardcoded", Description: "`SECRET_KEY = \"...\"` literal in Django settings — must be loaded from env / KMS, not committed.", Severity: "error", CWE: "CWE-798", Tags: []string{"secrets", "django"}, Pat: map[string]string{ "python": `((assignment left: (identifier) @name right: (string) @val) @match (#eq? @name "SECRET_KEY"))`, }, PostFilter: secretLiteralLooksReal, }, ) } // secretLiteralLooksReal drops obvious placeholder strings so the // credential detectors don't spam every example / test fixture. // Shared by py-hardcoded-credential-* and py-django-secret-key-*. func secretLiteralLooksReal(qr parser.QueryResult, _ []byte) bool { v, ok := qr.Captures["val"] if !ok { return false } text := trimQuotes(v.Text) if len(text) < 8 { return false } low := lower(text) for _, marker := range []string{"todo", "fixme", "changeme", "placeholder", "example", "your-", "xxx", "***", "...", "<", "test", "demo", "dummy", "sample"} { if containsStr(low, marker) { return false } } return true } // --------------------------------------------------------------------------- // 15. Insecure random (CWE-330) // --------------------------------------------------------------------------- func registerPythonRandom() { mustRegisterSAST( sastRule{ Name: "py-random-for-secret", Description: "`random.random` / `random.randint` / `random.choice` / `random.choices` etc. — `random` is a Mersenne Twister, not cryptographically secure. Use `secrets` module for any token / id / key.", Severity: "warning", CWE: "CWE-330", OWASP: "A02:2021-Cryptographic Failures", Tags: []string{"crypto", "insecure-random"}, References: []string{"bandit:B311"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "random") (#match? @attr "^(random|randint|randrange|choice|choices|sample|uniform|getrandbits|shuffle)$"))`, }, }, sastRule{ Name: "py-uuid1-predictable", Description: "`uuid.uuid1()` embeds the host MAC address and a timestamp — predictable across hosts. Use `uuid.uuid4()` for unguessable identifiers.", Severity: "info", CWE: "CWE-330", Tags: []string{"random", "uuid"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "uuid") (#eq? @attr "uuid1"))`, }, }, ) } // --------------------------------------------------------------------------- // 16. Filesystem — tempfile.mktemp / hardcoded tmp paths / chmod (CWE-377, CWE-732) // --------------------------------------------------------------------------- func registerPythonFilesystem() { mustRegisterSAST( sastRule{ Name: "py-tempfile-mktemp", Description: "`tempfile.mktemp()` — race-prone: the returned name can be created by another process between the call and the open. Use `tempfile.mkstemp()` or `tempfile.NamedTemporaryFile()`.", Severity: "error", CWE: "CWE-377", Tags: []string{"tempfile", "race"}, References: []string{"bandit:B306"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "tempfile") (#eq? @attr "mktemp"))`, }, }, sastRule{ Name: "py-hardcoded-tmp-path", Description: "Hardcoded `/tmp/...` / `/var/tmp/...` path passed to `open(...)` / `os.path.join` etc. — predictable temp paths invite symlink attacks. Use `tempfile.mkstemp()`.", Severity: "warning", CWE: "CWE-377", Tags: []string{"tempfile", "race"}, References: []string{"bandit:B108"}, Pat: map[string]string{ "python": `((string) @match (#match? @match "^[\"'](/tmp|/var/tmp|/dev/shm)/[^\"']+[\"']$"))`, }, }, sastRule{ Name: "py-chmod-world-writable", Description: "`os.chmod(..., 0o666)` / `0o777` / `stat.S_IWOTH` — world-writable file. Almost always a privilege-management bug.", Severity: "warning", CWE: "CWE-732", Tags: []string{"permissions"}, References: []string{"bandit:B103"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr) arguments: (argument_list (_) (integer) @mode)) @match (#eq? @mod "os") (#eq? @attr "chmod") (#match? @mode "^(0o?7[0-7]7|0o?6[0-7]6)$"))`, }, }, ) } // --------------------------------------------------------------------------- // 17. Archive extraction — zipslip / tar traversal (CWE-22) // --------------------------------------------------------------------------- func registerPythonArchive() { mustRegisterSAST( sastRule{ Name: "py-tarfile-extractall", Description: "`tarfile.extractall(...)` without `filter=` (3.12+) or per-member path validation — \"tar slip\" / path-traversal: a malicious archive writes outside the destination.", Severity: "warning", CWE: "CWE-22", Tags: []string{"path-traversal", "tar-slip"}, References: []string{"bandit:B202"}, Pat: map[string]string{ "python": `((call function: (attribute attribute: (identifier) @fn)) @match (#match? @fn "^(extractall|extract)$"))`, }, PostFilter: func(qr parser.QueryResult, _ []byte) bool { m, ok := qr.Captures["match"] if !ok { return true } // 3.12+'s `filter=` kwarg (filter="data" / "tar" // / a custom `TarInfo → TarInfo` callback) makes // the call safe. If the caller passed it, drop // the match. return !containsStr(m.Text, "filter=") }, }, sastRule{ Name: "py-shutil-unpack-archive-user-input", Description: "`shutil.unpack_archive(...)` — no per-member validation; identical zipslip / tarslip exposure as raw `tarfile.extractall`.", Severity: "info", CWE: "CWE-22", Tags: []string{"path-traversal"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#eq? @mod "shutil") (#eq? @attr "unpack_archive"))`, }, }, ) } // --------------------------------------------------------------------------- // 18. Insecure imports of removed / weak modules // --------------------------------------------------------------------------- func registerPythonImports() { mustRegisterSAST( sastRule{ Name: "py-import-pickle", Description: "`import pickle` / `import cPickle` / `import dill` — flag every site so an audit can ensure no `load()` is called on untrusted bytes.", Severity: "info", CWE: "CWE-502", Tags: []string{"deserialization", "audit-hook"}, References: []string{"bandit:B403"}, Pat: map[string]string{ "python": `((import_statement name: (dotted_name (identifier) @name)) @match (#match? @name "^(pickle|cPickle|dill|_pickle)$"))`, }, }, sastRule{ Name: "py-import-subprocess", Description: "`import subprocess` — flag for audit. Most uses are fine, but cohort-wide most command-injection vulns trace back to this module.", Severity: "info", CWE: "CWE-78", Tags: []string{"audit-hook"}, References: []string{"bandit:B404"}, Pat: map[string]string{ "python": `((import_statement name: (dotted_name (identifier) @name)) @match (#eq? @name "subprocess"))`, }, }, ) } // --------------------------------------------------------------------------- // 19. Logging (CWE-117, CWE-94) // --------------------------------------------------------------------------- func registerPythonLogging() { mustRegisterSAST( sastRule{ Name: "py-logging-config-listen", Description: "`logging.config.listen()` — accepts a config update on a TCP socket; any caller can re-program the logger to call arbitrary handlers. RCE-class.", Severity: "error", CWE: "CWE-94", Tags: []string{"logging", "remote-config"}, Pat: map[string]string{ "python": `((call function: (attribute object: (attribute object: (identifier) @mod attribute: (identifier) @sub) attribute: (identifier) @fn)) @match (#eq? @mod "logging") (#eq? @sub "config") (#eq? @fn "listen"))`, }, }, ) } // --------------------------------------------------------------------------- // 20. requests / urllib (CWE-295, CWE-918, CWE-22) // --------------------------------------------------------------------------- func registerPythonRequests() { mustRegisterSAST( sastRule{ Name: "py-requests-verify-false", Description: "`requests.get|post|put|delete|...(verify=False)` — disables TLS certificate verification. Any MITM with a self-signed cert intercepts the request.", Severity: "error", CWE: "CWE-295", OWASP: "A02:2021-Cryptographic Failures", Tags: []string{"tls", "no-verify"}, References: []string{"bandit:B501"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr) arguments: (argument_list (keyword_argument name: (identifier) @kw value: (false)))) @match (#match? @mod "^(requests|httpx)$") (#match? @attr "^(get|post|put|delete|patch|head|options|request|Session)$") (#eq? @kw "verify"))`, }, }, sastRule{ Name: "py-urlopen-file-scheme", Description: "`urllib.urlopen(...)` / `urllib.request.urlopen(...)` / `urllib2.urlopen(...)` accepts any URL — `file://` reads local files, `http://attacker/` is SSRF. Validate the scheme allow-list.", Severity: "warning", CWE: "CWE-918", OWASP: "A10:2021-Server-Side Request Forgery (SSRF)", Tags: []string{"ssrf"}, References: []string{"bandit:B310"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr)) @match (#match? @mod "^(urllib|urllib2|urllib3|request)$") (#match? @attr "^(urlopen|urlretrieve)$")) ((call function: (attribute object: (attribute attribute: (identifier) @mid) attribute: (identifier) @attr)) @match (#eq? @mid "request") (#match? @attr "^(urlopen|urlretrieve)$"))`, }, }, sastRule{ Name: "py-requests-host-validation", Description: "`requests.get(, ...)` — flag every request whose URL flows from a non-literal expression. The agent should verify SSRF is impossible at every site (allow-list of hosts, fixed-URL pattern).", Severity: "info", CWE: "CWE-918", Tags: []string{"ssrf", "audit-hook"}, Pat: map[string]string{ "python": `((call function: (attribute object: (identifier) @mod attribute: (identifier) @attr) arguments: (argument_list . (call) @url)) @match (#match? @mod "^(requests|httpx)$") (#match? @attr "^(get|post|put|delete|patch|head|options|request)$"))`, }, }, ) } // --------------------------------------------------------------------------- // 21. Paramiko / SSH // --------------------------------------------------------------------------- func registerPythonParamiko() { mustRegisterSAST( sastRule{ Name: "py-paramiko-exec-command", Description: "`paramiko.SSHClient.exec_command(cmd)` — the remote shell parses `cmd`. Any caller-controlled portion of `cmd` is remote command injection.", Severity: "warning", CWE: "CWE-78", Tags: []string{"command-injection", "ssh"}, Pat: map[string]string{ "python": `((call function: (attribute attribute: (identifier) @fn)) @match (#eq? @fn "exec_command"))`, }, }, ) } // --------------------------------------------------------------------------- // 22. Exception handling — try/except: pass, try/except: continue (CWE-703) // --------------------------------------------------------------------------- func registerPythonExceptionHandling() { mustRegisterSAST( sastRule{ Name: "py-try-except-continue", Description: "`except: continue` — silently swallows every exception inside a loop. Production debugging gets nothing; consider logging or narrowing the except.", Severity: "info", CWE: "CWE-703", Tags: []string{"exception", "swallow"}, References: []string{"bandit:B112"}, Pat: map[string]string{ "python": `((except_clause (block (continue_statement) @body)) @match)`, }, }, ) } // --------------------------------------------------------------------------- // helpers used by PostFilters above — small lower-case wrappers around // strings.ToLower / strings.Contains so the rule files don't repeat // the import. // --------------------------------------------------------------------------- func trimQuotes(s string) string { if len(s) >= 2 { first, last := s[0], s[len(s)-1] if (first == '"' || first == '\'' || first == '`') && first == last { return s[1 : len(s)-1] } } return s } func lower(s string) string { out := make([]byte, len(s)) for i := 0; i < len(s); i++ { c := s[i] if c >= 'A' && c <= 'Z' { c += 'a' - 'A' } out[i] = c } return string(out) } func containsStr(haystack, needle string) bool { if len(needle) == 0 { return true } if len(needle) > len(haystack) { return false } last := len(haystack) - len(needle) for i := 0; i <= last; i++ { if haystack[i:i+len(needle)] == needle { return true } } return false }