package astquery import ( "context" "testing" "github.com/stretchr/testify/require" ) // TestPythonRulesCompile drives each registered Python SAST rule // against a stub source so we catch tree-sitter pattern compile // errors at unit-test time rather than waiting for an end-to-end // `analyze sast` call. func TestPythonRulesCompile(t *testing.T) { const stub = "x = 1\n" for _, info := range DescribeDetectors() { if info.Category != CategorySAST { continue } hasPython := false for _, l := range info.Languages { if l == "python" { hasPython = true break } } if !hasPython { continue } t.Run(info.Name, func(t *testing.T) { res, err := RunOnSource(context.Background(), Options{Detector: info.Name}, "sample.py", "python", []byte(stub)) require.NoError(t, err, "rule %s pattern failed to compile", info.Name) _ = res }) } } // Per-rule firing tests. Table-driven: each entry pairs the rule // name with (a) source that MUST trigger the rule and (b) source // that MUST NOT trigger the rule. func TestPythonRulesFire(t *testing.T) { cases := []struct { name string bad string good string }{ // --- Injection {"py-eval-use", `eval("1+1")`, `import ast; ast.literal_eval("1+1")`}, {"py-exec-use", `exec("print(1)")`, `print(1)`}, {"py-compile-use", `code = compile("print(1)", "", "exec")`, `code = 'print(1)'`}, // --- Deserialisation {"py-pickle-load", `import pickle data = pickle.loads(blob)`, `import json data = json.loads(blob)`}, {"py-marshal-load", `import marshal data = marshal.loads(blob)`, `import json data = json.loads(blob)`}, {"py-yaml-load-unsafe", `import yaml cfg = yaml.load(text)`, `import yaml cfg = yaml.safe_load(text)`}, {"py-shelve-open", `import shelve db = shelve.open("path")`, `import json db = json.load(open("path"))`}, // --- Subprocess {"py-subprocess-shell-true", `import subprocess subprocess.run(cmd, shell=True)`, `import subprocess subprocess.run(["ls", "-la"], shell=False)`}, {"py-os-system", `import os os.system("ls " + d)`, `import subprocess subprocess.run(["ls", d])`}, {"py-os-popen", `import os os.popen("ls").read()`, `import subprocess subprocess.check_output(["ls"])`}, {"py-os-spawn", `import os os.spawnl(os.P_WAIT, "/bin/ls", "ls")`, `import subprocess subprocess.run(["ls"])`}, // --- Crypto {"py-hashlib-weak-direct", `import hashlib h = hashlib.md5(b"x")`, `import hashlib h = hashlib.sha256(b"x")`}, {"py-hashlib-new-weak", `import hashlib h = hashlib.new("md5")`, `import hashlib h = hashlib.new("sha256")`}, {"py-pycryptodome-weak-hash", `from Crypto.Hash import MD5`, `from Crypto.Hash import SHA256`}, {"py-weak-cipher-pycrypto", `from Crypto.Cipher import DES`, `from Crypto.Cipher import AES`}, {"py-aes-mode-ecb", `from Crypto.Cipher import AES c = AES.new(key, AES.MODE_ECB)`, `from Crypto.Cipher import AES c = AES.new(key, AES.MODE_GCM, nonce)`}, // --- Network {"py-telnetlib-import", `import telnetlib`, `import paramiko`}, {"py-ftplib-import", `import ftplib`, `from ftplib import FTP_TLS`}, {"py-imaplib-no-starttls", `import imaplib m = imaplib.IMAP4(host)`, `import imaplib m = imaplib.IMAP4_SSL(host)`}, {"py-poplib-no-tls", `import poplib p = poplib.POP3(host)`, `import poplib p = poplib.POP3_SSL(host)`}, {"py-smtplib-no-starttls", `import smtplib s = smtplib.SMTP(host)`, `import smtplib s = smtplib.SMTP_SSL(host)`}, // --- SSL/TLS {"py-ssl-unverified-context", `import ssl ctx = ssl._create_unverified_context()`, `import ssl ctx = ssl.create_default_context()`}, {"py-ssl-no-verify", `import ssl ctx.verify_mode = ssl.CERT_NONE`, `import ssl ctx.verify_mode = ssl.CERT_REQUIRED`}, {"py-ssl-old-protocol", `import ssl ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1)`, `import ssl ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)`}, {"py-paramiko-autoadd-policy", `import paramiko c = paramiko.SSHClient() c.set_missing_host_key_policy(paramiko.AutoAddPolicy())`, `import paramiko c = paramiko.SSHClient() c.set_missing_host_key_policy(paramiko.RejectPolicy())`}, // --- XML {"py-xml-etree-no-defusedxml", `from xml.etree import ElementTree ElementTree.parse(path)`, `import defusedxml.ElementTree as ET ET.parse(path)`}, {"py-xml-minidom-no-defusedxml", `from xml.dom import minidom minidom.parseString(s)`, `from defusedxml import minidom minidom.parseString(s)`}, // --- Django / Flask / Jinja {"py-django-mark-safe", `from django.utils.safestring import mark_safe html = mark_safe(user_input)`, `from django.utils.html import escape html = escape(user_input)`}, {"py-django-debug-true", `DEBUG = True`, `DEBUG = False`}, {"py-django-allowed-hosts-wildcard", `ALLOWED_HOSTS = ['*']`, `ALLOWED_HOSTS = ['example.com']`}, {"py-flask-debug-true", `app.run(debug=True)`, `app.run(debug=False)`}, {"py-jinja2-autoescape-false", `import jinja2 env = jinja2.Environment(autoescape=False)`, `import jinja2 env = jinja2.Environment(autoescape=True)`}, // --- SQLi {"py-sqli-execute-format", `cursor.execute("SELECT * FROM t WHERE x = {}".format(v))`, `cursor.execute("SELECT * FROM t WHERE x = %s", (v,))`}, {"py-sqli-execute-percent", `cursor.execute("SELECT * FROM t WHERE x = %s" % v)`, `cursor.execute("SELECT * FROM t WHERE x = %s", (v,))`}, {"py-sqli-execute-fstring", `cursor.execute(f"SELECT * FROM t WHERE x = {v}")`, `cursor.execute("SELECT * FROM t WHERE x = %s", (v,))`}, // --- Hardcoded credentials {"py-hardcoded-credential-keyword-arg", `connect(host="db", password="actualSecret123")`, `connect(host="db", password=os.environ["DBPASS"])`}, {"py-hardcoded-credential-default-arg", `def login(user, password="actualSecret123"): ...`, `def login(user, password=None): ...`}, {"py-django-secret-key-hardcoded", `SECRET_KEY = "actualSecret123Xyz"`, `SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]`}, // --- Random {"py-random-for-secret", `import random token = random.randint(0, 1<<63)`, `import secrets token = secrets.randbits(64)`}, {"py-uuid1-predictable", `import uuid sid = uuid.uuid1()`, `import uuid sid = uuid.uuid4()`}, // --- Filesystem {"py-tempfile-mktemp", `import tempfile name = tempfile.mktemp()`, `import tempfile fd, name = tempfile.mkstemp()`}, {"py-hardcoded-tmp-path", `open("/tmp/foo")`, `import tempfile tempfile.mkstemp()`}, // --- Archives {"py-tarfile-extractall", `import tarfile t = tarfile.open(p) t.extractall(dest)`, `import tarfile t = tarfile.open(p) t.extractall(dest, filter="data")`}, {"py-shutil-unpack-archive-user-input", `import shutil shutil.unpack_archive(p, d)`, `# nothing`}, // --- Imports {"py-import-pickle", `import pickle`, `import json`}, {"py-import-subprocess", `import subprocess`, `import json`}, // --- Logging {"py-logging-config-listen", `import logging.config t = logging.config.listen()`, `import logging.config logging.config.dictConfig(cfg)`}, // --- requests {"py-requests-verify-false", `import requests r = requests.get(url, verify=False)`, `import requests r = requests.get(url, verify=True)`}, {"py-urlopen-file-scheme", `from urllib import request request.urlopen(url)`, `# nothing`}, // --- Paramiko {"py-paramiko-exec-command", `ssh = paramiko.SSHClient() ssh.connect(host) stdin, out, err = ssh.exec_command(cmd)`, `# nothing`}, // --- Exception handling {"py-try-except-continue", `for x in xs: try: do(x) except Exception: continue`, `for x in xs: try: do(x) except Exception: log.error("oops")`}, } for _, c := range cases { c := c t.Run(c.name, func(t *testing.T) { bad := runDetector(t, c.name, "python", "case.py", c.bad) require.GreaterOrEqual(t, bad.Total, 1, "rule %q should fire on bad fixture; got 0 matches", c.name) good := runDetector(t, c.name, "python", "case.py", c.good) require.Equal(t, 0, good.Total, "rule %q should be silent on good fixture; got %d matches", c.name, good.Total) }) } }