Weak Random Numbers in AI-Generated Python

Python's random module is predictable to attackers. BrassCoders flags it via Bandit B311 — the fix is one import swap to secrets.

Copper Sun Brass Team · · 6 min read
securityai-code-reviewbenchmarks

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.

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))
# 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))

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

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.

Frequently Asked Questions

Why is Python's random module insecure for passwords and tokens?

Python's random module uses the Mersenne Twister algorithm, a deterministic pseudo-random number generator documented at docs.python.org. Given enough observed outputs, an attacker can recover the internal state and predict every subsequent value — including your next password-reset token. Python's own documentation explicitly warns that random 'should not be used for security purposes.'

What does BrassCoders flag with Bandit B311?

BrassCoders runs Bandit's B311 rule, which fires on any call to random.random(), random.randint(), random.randrange(), random.choice(), random.choices(), random.sample(), random.uniform(), random.triangular(), random.randbytes(), and random.getrandbits(). The rule fires on the structural pattern — Bandit does not attempt to determine whether the call is in a security-sensitive context. That context judgment belongs to the AI assistant reading the findings file.

Is random.random() always a security bug?

No. random.choice() picking a random element for a game, random.randint() seeding a simulation, random.sample() for statistical sampling — none of these are security bugs. Bandit B311 fires on the structural pattern regardless of context. BrassCoders reports the finding; the AI assistant triaging the YAML confirms whether the specific call is in a security-sensitive path. The false positive is not a failure — it is the correct division of labor.

What is the secrets module and how does it differ from random?

Python's secrets module, in the standard library since Python 3.6, uses a cryptographically secure pseudo-random number generator (CSPRNG) — on most platforms, /dev/urandom or the OS equivalent. It offers secrets.token_hex(), secrets.token_urlsafe(), secrets.token_bytes(), secrets.choice(), and secrets.randbelow(). The call signatures for secrets.choice() and secrets.token_bytes() are nearly identical to their random counterparts, so the fix is a module swap with minimal code changes.

Does this pattern appear more often in AI-generated code specifically?

The evidence points that way. Fu et al. (ACM TOSEM 2025) studied GitHub Copilot snippets merged into real public repositories and found CWE-330, use of insufficiently random values, the single most frequent weakness in the dataset. The explanation fits: AI models reach for the familiar API. Python's random module is everywhere in training data — for games, simulations, testing. The model produces it fluently and without distinguishing whether the surrounding code is generating a password-reset token or rolling dice.