a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
1217 lines
53 KiB
Go
1217 lines
53 KiB
Go
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(<dynamic url>, ...)` — 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
|
|
}
|