DEV Community

CopperSunDev
CopperSunDev

Posted on • Originally published at coppersun.dev

Weak Random Numbers in AI-Generated Python

Python's random module is the wrong tool for generating security tokens. It works. It produces numbers. It has no bugs. The problem is that its output is predictable to an attacker with enough observed values — and AI assistants reach for it by default because it appears constantly in training data while secrets is used only in the narrow cases where the developer already knew to look for it.

Bandit B311 is the rule that catches this. BrassCoders runs Bandit as part of its scanner suite and surfaces every B311 finding in the YAML output your AI assistant reads at the start of a review session. One module swap eliminates the vulnerability.

The One-Module Problem

BrassCoders flags random module calls in security-sensitive contexts through Bandit's B311 rule, which covers random.random(), random.randint(), random.randrange(), random.choice(), random.choices(), random.sample(), random.uniform(), random.triangular(), random.randbytes(), and random.getrandbits() — every function that draws from the same Mersenne Twister state.

The Mersenne Twister is a deterministic algorithm. Python's documentation is unambiguous: the random module "should not be used for security purposes." Given 624 consecutive 32-bit outputs, an attacker can fully reconstruct the internal state and predict every subsequent value. A password-reset token generated with random.randint(100000, 999999) is predictable to any attacker who has observed enough prior outputs from that process — and web servers produce outputs constantly.

The fix is a single import change. The secrets module has been in Python's standard library since version 3.6, documented at docs.python.org/3/library/secrets.html. It draws from the operating system's CSPRNG — /dev/urandom on Unix systems — which is not recoverable from observed outputs. The call signatures for the most common operations are nearly identical to their random equivalents.

Why AI Assistants Use random

AI models reach for import random because it saturates training data — and BrassCoders exists in part because the model never distinguishes between the random.choice() in a game and the one generating a session token. Random numbers appear in tutorials, sample code, Stack Overflow answers, test harnesses, games, simulations, and data science notebooks. The model has seen random.choice() thousands of times. It has seen secrets.choice() in a narrow slice of security-focused articles.

When an AI assistant generates a password-reset flow, it patterns on what it has seen generate tokens in working code. random.randint(100000, 999999) works. The six-digit code reaches the user. The tests pass. Nothing fails at the prompt the model was completing — the failure only opens against an attacker the prompt never described.

Fu et al. (Security Weaknesses of Copilot-Generated Code in GitHub Projects, ACM TOSEM 2025) studied Copilot snippets merged into real public repositories and found CWE-330, use of insufficiently random values, the single most frequent weakness in the entire dataset. That category maps directly to the random-for-security pattern. The model is not making a mistake by its own reasoning — it is completing patterns that work for the non-security uses it has seen far more often.

What Bandit Flags

BrassCoders surfaces Bandit B311 findings at low severity, covering every function in the random module namespace. A token-generation function produces a finding shaped like this:

issues:
  - id: bandit-B311-001
    severity: low
    file_path: accounts/password_reset.py
    line_number: 14
    title: Standard Pseudo-random Generator Not Suitable for Security
    description: random.randint() uses the Mersenne Twister, a deterministic
      PRNG whose state can be recovered from sufficient observed outputs.
      Not suitable for generating security tokens, session IDs, or
      password-reset codes.
    remediation: Replace with secrets.randbelow() or secrets.token_hex().
      The secrets module (stdlib since Python 3.6) uses the OS CSPRNG.
Enter fullscreen mode Exit fullscreen mode

The severity is low because Bandit cannot determine context. A random.randint() call in a unit test fixture is not a security issue. The same call generating a six-digit password-reset token is a high-severity vulnerability — but that context lives in the surrounding code, not in the call itself.

BrassCoders reports the pattern. The AI assistant reading the findings file triages the context. That division is the point.

The AI Triage Layer's Role (Context Inference)

BrassCoders is a pattern reporter. It does not infer whether random.choice(string.ascii_letters) is generating a temporary filename or a session ID. Inferring context — demoting the finding when the variable is named cache_key versus promoting it when the function is named generate_session_token — would be BrassCoders making judgments the AI consumer is better positioned to make.

The AI assistant triaging the YAML has the full source file available. It reads the function name, the return type annotation, the call site in the authentication controller, and the test that asserts the token format. Those signals together answer the context question reliably. BrassCoders cannot see them from a single-line call match.

This is why the false positives from B311 are not a problem to eliminate. A random.choice() picking a question category for a quiz app is a false positive, and BrassCoders flags it. The AI assistant marks it not applicable in two seconds. The alternative — a rule that silences B311 when the surrounding function looks non-security-sensitive — would also silence the real password-reset token that happens to sit in a function named get_code(). That trade is not worth taking.

The Fix: import secrets

BrassCoders flags the pattern; the fix is a module swap. The secrets module exposes the operations that matter for security contexts, with signatures close enough to random that most calls change by one word:

# Before — Mersenne Twister, predictable state
import random

def generate_reset_token():
    return random.randint(100000, 999999)

def generate_session_id():
    alphabet = string.ascii_letters + string.digits
    return ''.join(random.choice(alphabet) for _ in range(32))
Enter fullscreen mode Exit fullscreen mode
# After — OS CSPRNG, not recoverable from observed outputs
import secrets

def generate_reset_token():
    return secrets.randbelow(900000) + 100000

def generate_session_id():
    alphabet = string.ascii_letters + string.digits
    return ''.join(secrets.choice(alphabet) for _ in range(32))
Enter fullscreen mode Exit fullscreen mode

For URL-safe tokens, secrets.token_urlsafe(32) produces 43 characters of URL-safe base64-encoded randomness in a single call — no manual alphabet construction, no loop. For hex tokens, secrets.token_hex(32) produces 64 hex characters. Both are documented at docs.python.org/3/library/secrets.html with explicit examples for password resets, session tokens, and temporary URLs.

The one behavioral difference worth noting: secrets.randbelow(n) is not a direct replacement for random.randint(a, b). It produces a random integer in [0, n), so the six-digit range requires secrets.randbelow(900000) + 100000. This is a two-second adjustment, and the resulting function is cryptographically sound.

pip install brasscoders
brasscoders --offline scan /path/to/your/project
Enter fullscreen mode Exit fullscreen mode

BrassCoders flags B311 on every scan. Your AI assistant triages the context. The calls that are generating tokens get fixed; the calls that are rolling dice for a game get marked not applicable. That loop takes minutes and does not require knowing ahead of time which files to look at.

Top comments (0)