openqodex 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +35 -0
- package/README.md +160 -0
- package/demo/baseline/.github/workflows/ci.yml +18 -0
- package/demo/baseline/Dockerfile +12 -0
- package/demo/baseline/app/__init__.py +0 -0
- package/demo/baseline/app/server.py +37 -0
- package/demo/baseline/package-lock.json +21 -0
- package/demo/baseline/package.json +9 -0
- package/demo/baseline/requirements.txt +1 -0
- package/demo/baseline/scripts/deploy.sh +13 -0
- package/demo/expected.json +126 -0
- package/demo/planted/.github/workflows/ci.yml +20 -0
- package/demo/planted/Dockerfile +11 -0
- package/demo/planted/app/config.py +3 -0
- package/demo/planted/app/search.py +17 -0
- package/demo/planted/app/server.py +40 -0
- package/demo/planted/package-lock.json +21 -0
- package/demo/planted/package.json +9 -0
- package/demo/planted/scripts/deploy.sh +13 -0
- package/dist/bin.js +42546 -0
- package/docs/agents.md +121 -0
- package/docs/cli.md +161 -0
- package/docs/config.md +165 -0
- package/docs/custom-scanners.md +164 -0
- package/docs/faq.md +54 -0
- package/docs/github-action.md +73 -0
- package/docs/index.md +29 -0
- package/docs/llms.txt +13 -0
- package/docs/quickstart.md +82 -0
- package/docs/scanners.md +148 -0
- package/docs/security.md +74 -0
- package/docs/telemetry.md +9 -0
- package/lenses/a11y-icon-only-button-no-aria-label.md +51 -0
- package/lenses/array-iteration-missing-key-prop.md +31 -0
- package/lenses/async-await-in-loop-n-plus-one.md +42 -0
- package/lenses/async-click-double-fire-race.md +59 -0
- package/lenses/async-floating-promise.md +40 -0
- package/lenses/async-promise-all-swallows-errors.md +42 -0
- package/lenses/async-unhandled-rejection-in-handler.md +42 -0
- package/lenses/auth-missing-on-state-change-route.md +60 -0
- package/lenses/auth-role-from-user-input.md +49 -0
- package/lenses/auth-timing-attack-password-compare.md +59 -0
- package/lenses/cookie-missing-secure-httponly.md +46 -0
- package/lenses/cors-wildcard-with-credentials.md +47 -0
- package/lenses/crypto-jwt-verify-without-algo-allowlist.md +41 -0
- package/lenses/crypto-math-random-for-tokens.md +53 -0
- package/lenses/crypto-md5-sha1-for-secrets.md +60 -0
- package/lenses/env-vars-read-at-module-top.md +42 -0
- package/lenses/eval-on-user-input.md +51 -0
- package/lenses/fetch-without-timeout.md +41 -0
- package/lenses/id-enumeration-sequential.md +49 -0
- package/lenses/interactive-state-decoupled-from-output.md +67 -0
- package/lenses/json-parse-no-try-catch.md +41 -0
- package/lenses/missing-rate-limit-on-auth.md +49 -0
- package/lenses/oauth-scope-wider-than-use.md +79 -0
- package/lenses/object-spread-clobber.md +44 -0
- package/lenses/open-redirect-from-untrusted-host.md +55 -0
- package/lenses/orm-drizzle-on-conflict-clobber.md +47 -0
- package/lenses/path-traversal-in-fs-access.md +59 -0
- package/lenses/pii-in-url-or-log.md +48 -0
- package/lenses/race-check-then-act.md +49 -0
- package/lenses/react-dangerously-set-inner-html.md +32 -0
- package/lenses/react-fetch-in-effect-without-abort.md +31 -0
- package/lenses/react-stale-closure-in-callback.md +28 -0
- package/lenses/react-state-set-in-render.md +33 -0
- package/lenses/react-use-effect-missing-cleanup.md +29 -0
- package/lenses/react-use-effect-missing-deps.md +30 -0
- package/lenses/regexp-from-user-input.md +43 -0
- package/lenses/return-shape-contract-break.md +71 -0
- package/lenses/secrets-logged-in-error-path.md +60 -0
- package/lenses/sql-migration-references-later-object.md +47 -0
- package/lenses/sql-string-concatenation.md +63 -0
- package/lenses/ssrf-server-side-fetch.md +69 -0
- package/lenses/supabase-comment-on-function-unqualified.md +37 -0
- package/lenses/supabase-function-default-public-execute.md +50 -0
- package/lenses/supabase-security-definer-no-search-path.md +34 -0
- package/lenses/supabase-single-500-on-no-match.md +65 -0
- package/lenses/upsert-state-column.md +57 -0
- package/lenses/url-not-encoded-for-user-id.md +78 -0
- package/lenses/use-state-default-not-functional.md +30 -0
- package/package.json +57 -0
- package/skills/openqodex/SKILL.md +141 -0
- package/templates/README.md +67 -0
- package/templates/claude-code/settings-hook.json +16 -0
- package/templates/cline/openqodex.md +9 -0
- package/templates/codex/AGENTS-section.md +8 -0
- package/templates/codex/hooks.json +16 -0
- package/templates/cursor/openqodex.mdc +15 -0
- package/toolchain.json +345 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: react-stale-closure-in-callback
|
|
3
|
+
description: Event handler / timer closure captures a stale state value
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.tsx"
|
|
7
|
+
- "*.jsx"
|
|
8
|
+
- "**/*.tsx"
|
|
9
|
+
- "**/*.jsx"
|
|
10
|
+
hunk_regex: "\\b(setTimeout|setInterval|addEventListener|subscribe|on[A-Z]\\w+)\\b"
|
|
11
|
+
confidence_floor: 0.75
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
A callback registered inside a render (event listener, `setTimeout`,
|
|
15
|
+
`setInterval`, subscription, or memoized handler) reads `useState`
|
|
16
|
+
values from the enclosing scope. Once registered, the closure keeps
|
|
17
|
+
the **initial** state forever; later re-renders don't refresh it.
|
|
18
|
+
The user sees "I clicked the new value but got the old one."
|
|
19
|
+
|
|
20
|
+
Flag when the callback's body reads a useState variable AND the
|
|
21
|
+
callback is registered outside an effect with that variable in its
|
|
22
|
+
dependency list.
|
|
23
|
+
|
|
24
|
+
Suppress when:
|
|
25
|
+
- the value is accessed via a `useRef.current` indirection
|
|
26
|
+
- the callback is re-registered each render via a `useEffect` with the
|
|
27
|
+
stateful deps listed (so the closure refreshes)
|
|
28
|
+
- the value is provably immutable (initialized once, never reset)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: react-state-set-in-render
|
|
3
|
+
description: useState setter called during render body, infinite loop
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.tsx"
|
|
7
|
+
- "*.jsx"
|
|
8
|
+
- "**/*.tsx"
|
|
9
|
+
- "**/*.jsx"
|
|
10
|
+
hunk_regex: "\\bset[A-Z]\\w*\\s*\\("
|
|
11
|
+
confidence_floor: 0.75
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
A `useState` setter called from the top-level render body (not inside
|
|
15
|
+
an event handler, effect, or callback) triggers a re-render, which
|
|
16
|
+
calls the component again, which calls the setter again: an infinite
|
|
17
|
+
re-render. React 18+ throws "Too many re-renders" but only at runtime,
|
|
18
|
+
and only when the path is actually hit; conditional set-in-render
|
|
19
|
+
escapes static analysis.
|
|
20
|
+
|
|
21
|
+
Flag a `setX(...)` call placed:
|
|
22
|
+
- at the top level of a functional component body
|
|
23
|
+
- inside an `if` / ternary / short-circuit that has a chance of
|
|
24
|
+
evaluating to true on first render
|
|
25
|
+
|
|
26
|
+
Suppress when the call is inside:
|
|
27
|
+
- a `useEffect` / `useLayoutEffect` body
|
|
28
|
+
- a handler returned from JSX (`onClick={() => setX(...)}`)
|
|
29
|
+
- a `useState` initial-value function (`useState(() => ...)`)
|
|
30
|
+
- the conditional `if (cond) { setX(prev => ...) }` "derive state from
|
|
31
|
+
props" pattern, where `cond` compares the prop to existing state
|
|
32
|
+
and is provably guarded (this is the React-docs-blessed escape
|
|
33
|
+
hatch; raise only if the guard looks broken)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: react-use-effect-missing-cleanup
|
|
3
|
+
description: useEffect subscribes / starts a timer / opens a socket without returning a cleanup
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.tsx"
|
|
7
|
+
- "*.jsx"
|
|
8
|
+
- "**/*.tsx"
|
|
9
|
+
- "**/*.jsx"
|
|
10
|
+
hunk_regex: "useEffect\\s*\\([\\s\\S]{0,400}?(addEventListener|setInterval|setTimeout|subscribe|new\\s+WebSocket|new\\s+EventSource|observe\\()"
|
|
11
|
+
confidence_floor: 0.7
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
A `useEffect` registers a subscription, timer, observer, or event
|
|
15
|
+
listener but never returns a cleanup function. When the component
|
|
16
|
+
unmounts (or the effect re-runs because a dep changed) the
|
|
17
|
+
side-effect keeps running against a stale component: a memory leak in
|
|
18
|
+
the cheap case, double-fire in state-mutating handlers in the bad
|
|
19
|
+
case, "can't update unmounted component" warnings either way.
|
|
20
|
+
|
|
21
|
+
Flag when the effect body contains `addEventListener`, `setInterval`,
|
|
22
|
+
`setTimeout`, a `.subscribe(`, `new WebSocket`, `new EventSource`, or
|
|
23
|
+
a `MutationObserver` / `IntersectionObserver` / `ResizeObserver`
|
|
24
|
+
without a matching `return () => { ... }` that removes / clears /
|
|
25
|
+
unsubscribes / disconnects it.
|
|
26
|
+
|
|
27
|
+
Suppress when the side-effect is intentionally one-shot and the
|
|
28
|
+
target lives at most as long as the component (e.g. a `setTimeout`
|
|
29
|
+
whose handler navigates away unconditionally).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: react-use-effect-missing-deps
|
|
3
|
+
description: useEffect / useMemo / useCallback with an incomplete dependency array
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.tsx"
|
|
7
|
+
- "*.jsx"
|
|
8
|
+
- "**/*.tsx"
|
|
9
|
+
- "**/*.jsx"
|
|
10
|
+
hunk_regex: "\\b(useEffect|useMemo|useCallback|useLayoutEffect)\\s*\\("
|
|
11
|
+
confidence_floor: 0.75
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
A `useEffect` / `useMemo` / `useCallback` hook reads a value from
|
|
15
|
+
component scope (state, props, derived variable, function) but omits
|
|
16
|
+
it from the dependency array. On re-render the closure captures the
|
|
17
|
+
stale value and the effect either runs against outdated data, never
|
|
18
|
+
re-runs when it should, or memoizes incorrectly.
|
|
19
|
+
|
|
20
|
+
Flag when the hook's body references an identifier that is **not** in
|
|
21
|
+
its second-argument array and is **not** stable across renders
|
|
22
|
+
(`useRef.current`, module-level constant, dispatch from `useReducer`,
|
|
23
|
+
setter returned by `useState`).
|
|
24
|
+
|
|
25
|
+
Suppress when:
|
|
26
|
+
- the deps array is omitted entirely (then it runs every render, a
|
|
27
|
+
different bug, but separate finding)
|
|
28
|
+
- the referenced identifier is a setter / ref / constant
|
|
29
|
+
- a lint comment explicitly disables `react-hooks/exhaustive-deps`
|
|
30
|
+
with a justification on the line above
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: regexp-from-user-input
|
|
3
|
+
description: new RegExp(...) built from user input, ReDoS / injection
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.ts"
|
|
7
|
+
- "*.tsx"
|
|
8
|
+
- "*.js"
|
|
9
|
+
- "*.jsx"
|
|
10
|
+
- "*.mjs"
|
|
11
|
+
- "*.cjs"
|
|
12
|
+
- "**/*.ts"
|
|
13
|
+
- "**/*.tsx"
|
|
14
|
+
- "**/*.js"
|
|
15
|
+
- "**/*.jsx"
|
|
16
|
+
- "**/*.mjs"
|
|
17
|
+
- "**/*.cjs"
|
|
18
|
+
hunk_regex: "new\\s+RegExp\\s*\\("
|
|
19
|
+
security: true
|
|
20
|
+
confidence_floor: 0.7
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
`new RegExp(input)` is called with a value derived from request
|
|
24
|
+
input. Two problems:
|
|
25
|
+
1. **ReDoS**: a crafted input like `(a+)+$` chained with a long
|
|
26
|
+
string of `a`'s freezes the V8 regex engine, hanging the request
|
|
27
|
+
thread (a single attacker can take down the API with one call).
|
|
28
|
+
2. **Regex injection**: the input redefines the search semantics:
|
|
29
|
+
`.*` instead of the literal dot the dev expected.
|
|
30
|
+
|
|
31
|
+
Flag when the first argument to `new RegExp(...)` is a value
|
|
32
|
+
sourced from `req.body|req.query|req.params|form input|env`.
|
|
33
|
+
|
|
34
|
+
Suppress when:
|
|
35
|
+
- the input has been run through `escapeRegExp` (treats it as a
|
|
36
|
+
literal pattern)
|
|
37
|
+
- the regex compile is bounded by a length check (`if (input.length
|
|
38
|
+
> 64) return 400`) and a complexity-aware runtime (`re2` library,
|
|
39
|
+
which is linear-time)
|
|
40
|
+
- the input is matched against an allow-list before compile
|
|
41
|
+
- the regex is for a non-blocking single-shot search against a
|
|
42
|
+
bounded short string (still recommend escapeRegExp, but the DoS
|
|
43
|
+
window is closed)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: return-shape-contract-break
|
|
3
|
+
description: A function/endpoint that returns a structured object drops or renames a key/column its callers read
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.sql"
|
|
7
|
+
- "**/*.sql"
|
|
8
|
+
- "*.ts"
|
|
9
|
+
- "*.tsx"
|
|
10
|
+
- "*.js"
|
|
11
|
+
- "*.jsx"
|
|
12
|
+
- "**/*.ts"
|
|
13
|
+
- "**/*.tsx"
|
|
14
|
+
- "**/*.js"
|
|
15
|
+
- "**/*.jsx"
|
|
16
|
+
hunk_regex: "jsonb_build_object|json_build_object|jsonb_agg|json_agg|RETURNS\\s+(jsonb|json|TABLE|record|setof)|return\\s+\\{|res\\.json\\(|c\\.json\\(|\\bNextResponse\\.json\\("
|
|
17
|
+
confidence_floor: 0.7
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
A function, RPC, or endpoint that returns a structured payload (a
|
|
21
|
+
`jsonb_build_object` / `json_agg` in SQL, a returned object literal,
|
|
22
|
+
a `res.json(...)` / `c.json(...)` / `NextResponse.json(...)` body, a
|
|
23
|
+
`RETURNS TABLE`/composite shape) has the SET OF KEYS it returns
|
|
24
|
+
CHANGED by this diff: a key or column present on the `-` side is gone
|
|
25
|
+
or renamed on the `+` side. Removing or renaming a field from a
|
|
26
|
+
payload something already consumes is a silent breaking change: every
|
|
27
|
+
reader that referenced the old key now gets `undefined` / `NULL`, and
|
|
28
|
+
nothing in the producing file errors, so the diff reads clean in
|
|
29
|
+
isolation.
|
|
30
|
+
|
|
31
|
+
This is the highest-recall way to miss a real bug on an otherwise
|
|
32
|
+
tidy refactor. The evidence is the SHAPE of the returned document
|
|
33
|
+
across the diff, not the logic inside it: compare the keys emitted on
|
|
34
|
+
the `-` side against the `+` side, key by key.
|
|
35
|
+
|
|
36
|
+
Flag when this diff:
|
|
37
|
+
- removes a key from a `jsonb_build_object` / object literal /
|
|
38
|
+
response body that the previous version emitted, OR
|
|
39
|
+
- renames such a key (old name vanishes, new name appears), OR
|
|
40
|
+
- drops a column from a `RETURNS TABLE` / view / `SELECT *`-feeding
|
|
41
|
+
shape,
|
|
42
|
+
|
|
43
|
+
AND you cannot see, within the diff, that every consumer of the old
|
|
44
|
+
key was also removed. Trace the consumer when you can (search for the
|
|
45
|
+
key name, the RPC/function name, or the endpoint path); a removed key
|
|
46
|
+
that is still read downstream is a `major` breaking change. When the
|
|
47
|
+
consumer is out of the diff and you cannot confirm it was updated,
|
|
48
|
+
still raise it; default to "this breaks a contract" rather than
|
|
49
|
+
assuming the caller was migrated.
|
|
50
|
+
|
|
51
|
+
A strong corroborating signal: the diff also DELETES a comment or
|
|
52
|
+
doc that asserted the contract (e.g. a comment promising keys are
|
|
53
|
+
"additive" / "kept for back-compat" / "stable"). When a PR removes
|
|
54
|
+
language that promised stability and the code then breaks it, that is
|
|
55
|
+
a high-confidence flag; read the removed comments, not just the
|
|
56
|
+
removed code.
|
|
57
|
+
|
|
58
|
+
Suppress when:
|
|
59
|
+
- the diff also removes (or the change description states it
|
|
60
|
+
removes) every consumer of the dropped key; a coordinated removal is not a
|
|
61
|
+
break,
|
|
62
|
+
- the key is renamed AND the diff updates the readers in the same
|
|
63
|
+
change,
|
|
64
|
+
- the payload is brand-new in this change (no prior `-` side), so there
|
|
65
|
+
is no existing contract to break,
|
|
66
|
+
- the field is internal/never-serialized (e.g. a temp variable in a
|
|
67
|
+
CTE that was never part of the returned object).
|
|
68
|
+
|
|
69
|
+
Severity: `major` when a removed/renamed key is consumed by code
|
|
70
|
+
outside the diff (real break); `minor` when the consumer is unclear
|
|
71
|
+
but the field plausibly mattered.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: secrets-logged-in-error-path
|
|
3
|
+
description: Error / catch path logs an object that includes secrets, tokens, or request headers
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.ts"
|
|
7
|
+
- "*.tsx"
|
|
8
|
+
- "*.js"
|
|
9
|
+
- "*.jsx"
|
|
10
|
+
- "*.mjs"
|
|
11
|
+
- "*.cjs"
|
|
12
|
+
- "*.py"
|
|
13
|
+
- "*.go"
|
|
14
|
+
- "*.rb"
|
|
15
|
+
- "*.java"
|
|
16
|
+
- "*.kt"
|
|
17
|
+
- "**/*.ts"
|
|
18
|
+
- "**/*.tsx"
|
|
19
|
+
- "**/*.js"
|
|
20
|
+
- "**/*.jsx"
|
|
21
|
+
- "**/*.mjs"
|
|
22
|
+
- "**/*.cjs"
|
|
23
|
+
- "**/*.py"
|
|
24
|
+
- "**/*.go"
|
|
25
|
+
- "**/*.rb"
|
|
26
|
+
- "**/*.java"
|
|
27
|
+
- "**/*.kt"
|
|
28
|
+
hunk_regex: "catch\\s*\\(|console\\.(log|error|warn)|logger\\.(error|warn|info)|log\\.(error|warn|info)|printStackTrace\\s*\\("
|
|
29
|
+
security: true
|
|
30
|
+
confidence_floor: 0.7
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
An error / catch handler logs an object that includes credentials,
|
|
34
|
+
authorization headers, API keys, request bodies (which may include
|
|
35
|
+
passwords), or full HTTP request/response payloads. These show up
|
|
36
|
+
in log aggregators (Datadog, Sentry, CloudWatch) where engineers,
|
|
37
|
+
support, or third-party integrations can read them: a compliance
|
|
38
|
+
issue at minimum, a credential-leak vector at worst.
|
|
39
|
+
|
|
40
|
+
Flag when a log call inside a catch / error path passes:
|
|
41
|
+
- a full `req` / `request` / `ctx.request` object
|
|
42
|
+
- a full `error` whose properties include request headers /
|
|
43
|
+
response bodies (look for axios / fetch error shapes: `err.config.headers`,
|
|
44
|
+
`err.response.config.headers`)
|
|
45
|
+
- a literal `password` / `token` / `secret` / `api_key` / `apiKey`
|
|
46
|
+
variable
|
|
47
|
+
- environment-derived secrets
|
|
48
|
+
|
|
49
|
+
Suppress when:
|
|
50
|
+
- the log payload is the explicit error message string only
|
|
51
|
+
- the object is passed through a redaction layer (`pino` with
|
|
52
|
+
`redact`, `winston` with format filter, custom `scrubSecrets`
|
|
53
|
+
helper), visible on the same call or in the logger setup
|
|
54
|
+
- the only fields included are explicitly safe ones (status code,
|
|
55
|
+
method, path, user id)
|
|
56
|
+
|
|
57
|
+
In Java and Kotlin the idiom is `e.printStackTrace()` or
|
|
58
|
+
`logger.error(message, e)` where the exception, or an object logged
|
|
59
|
+
beside it, carries the request headers, an `Authorization` value or a
|
|
60
|
+
provider token.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sql-migration-references-later-object
|
|
3
|
+
description: A migration references a column/table/function created by a later-timestamped migration (fails on fresh apply)
|
|
4
|
+
triggers:
|
|
5
|
+
# Both forms: "**/" compiles to ".*/", which requires a slash before
|
|
6
|
+
# "migrations", so it covers nested dirs (supabase/migrations/...,
|
|
7
|
+
# db/migrations/...) but NOT a root-level migrations/ dir. The bare
|
|
8
|
+
# "migrations/*.sql" pattern catches that case, same dual-pattern
|
|
9
|
+
# convention the *.sql / **/*.sql lenses use.
|
|
10
|
+
files:
|
|
11
|
+
- "migrations/*.sql"
|
|
12
|
+
- "**/migrations/*.sql"
|
|
13
|
+
hunk_regex: '@include|\breferences\b|\balter\s+table\b|\badd\s+column\b|\bcreate\s+(or\s+replace\s+)?(view|function|trigger|policy)\b|\bjoin\b'
|
|
14
|
+
confidence_floor: 0.75
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
Migrations apply in timestamp (filename) order. Code that runs fine
|
|
18
|
+
against an already-migrated database can still break a fresh apply
|
|
19
|
+
(`supabase db reset`, a clean prod deploy, CI) if a migration references
|
|
20
|
+
a schema object (a column, table, type, function, policy) that is only
|
|
21
|
+
created by a migration with a LATER timestamp. The bug is invisible
|
|
22
|
+
locally because the object already exists; it only surfaces on a
|
|
23
|
+
from-scratch run.
|
|
24
|
+
|
|
25
|
+
This is especially easy to introduce with `@include`-style migrations: a
|
|
26
|
+
migration that `@include`s a function file inherits every object that
|
|
27
|
+
function selects/joins. If that function references a column added in a
|
|
28
|
+
later migration, this migration fails first.
|
|
29
|
+
|
|
30
|
+
Flag when a migration in this diff (or a function/file it `@include`s)
|
|
31
|
+
references an object whose creating/altering statement lives in a
|
|
32
|
+
migration timestamped AFTER this one. To check: identify the referenced
|
|
33
|
+
columns/tables/functions, then search the migrations directory for where
|
|
34
|
+
each is created (`ADD COLUMN`, `CREATE TABLE/FUNCTION/TYPE`) and compare
|
|
35
|
+
filename timestamps. Use your tools; this needs reading files outside
|
|
36
|
+
the diff.
|
|
37
|
+
|
|
38
|
+
Suppress when:
|
|
39
|
+
- every referenced object is created in an earlier or same-timestamp
|
|
40
|
+
migration,
|
|
41
|
+
- the object is a Postgres built-in, an extension object, or created
|
|
42
|
+
outside the migrations dir (e.g. a baseline/squash schema that always
|
|
43
|
+
applies first),
|
|
44
|
+
- the reference is inside a string/comment, not executed SQL.
|
|
45
|
+
|
|
46
|
+
Severity: `major`: a deterministic fresh-apply / CI failure, not a
|
|
47
|
+
runtime edge case.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sql-string-concatenation
|
|
3
|
+
description: SQL built via string concatenation / template-string interpolation of unsanitized input
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.ts"
|
|
7
|
+
- "*.tsx"
|
|
8
|
+
- "*.js"
|
|
9
|
+
- "*.jsx"
|
|
10
|
+
- "*.mjs"
|
|
11
|
+
- "*.cjs"
|
|
12
|
+
- "*.py"
|
|
13
|
+
- "*.go"
|
|
14
|
+
- "*.rb"
|
|
15
|
+
- "*.java"
|
|
16
|
+
- "*.kt"
|
|
17
|
+
- "*.cs"
|
|
18
|
+
- "*.php"
|
|
19
|
+
- "**/*.ts"
|
|
20
|
+
- "**/*.tsx"
|
|
21
|
+
- "**/*.js"
|
|
22
|
+
- "**/*.jsx"
|
|
23
|
+
- "**/*.mjs"
|
|
24
|
+
- "**/*.cjs"
|
|
25
|
+
- "**/*.py"
|
|
26
|
+
- "**/*.go"
|
|
27
|
+
- "**/*.rb"
|
|
28
|
+
- "**/*.java"
|
|
29
|
+
- "**/*.kt"
|
|
30
|
+
- "**/*.cs"
|
|
31
|
+
- "**/*.php"
|
|
32
|
+
hunk_regex: "\\b(SELECT|INSERT|UPDATE|DELETE|WHERE|VALUES)\\b|createStatement\\s*\\(|executeQuery\\s*\\(|create(Native)?Query\\s*\\("
|
|
33
|
+
security: true
|
|
34
|
+
confidence_floor: 0.75
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
A raw SQL string is built by concatenating / interpolating a
|
|
38
|
+
variable into the query. If the variable's value traces back to a
|
|
39
|
+
user input, request parameter, environment variable, or any
|
|
40
|
+
external source, this is SQL injection. The diff often hides this
|
|
41
|
+
behind innocuous helpers (`buildWhere(...)`, `paramFilter(...)`).
|
|
42
|
+
|
|
43
|
+
Flag when:
|
|
44
|
+
- `SELECT|INSERT|UPDATE|DELETE|WHERE` literal appears in a string
|
|
45
|
+
built with `+ var`, `${var}`, `format(...)`, `.format(...)`,
|
|
46
|
+
`f"..."`, or `sprintf` against a variable whose provenance is not
|
|
47
|
+
a hardcoded constant
|
|
48
|
+
- the variable comes from `req.body|req.query|req.params|args|
|
|
49
|
+
argv|env|input`
|
|
50
|
+
|
|
51
|
+
Suppress when:
|
|
52
|
+
- the query uses parameter placeholders (`$1`, `?`, `:name`) with
|
|
53
|
+
bound values
|
|
54
|
+
- the ORM call is the parameterized path (Drizzle's `eq`/`and`,
|
|
55
|
+
Kysely's `.where(col, '=', val)`, Prisma's `where: {col: val}`)
|
|
56
|
+
- the interpolated value is an IDENTIFIER (table/column) and the
|
|
57
|
+
identifier is sourced from an allow-list / enum check earlier in
|
|
58
|
+
the function (still raise if you can't see the check)
|
|
59
|
+
|
|
60
|
+
In Java and Kotlin the idiom is `createStatement()` plus
|
|
61
|
+
`executeQuery("... " + value)`, or a JPA `createQuery` /
|
|
62
|
+
`createNativeQuery` string glued together, where `prepareStatement`
|
|
63
|
+
with bind parameters is the fix.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ssrf-server-side-fetch
|
|
3
|
+
description: Server-side HTTP request whose host or URL comes from user input
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.ts"
|
|
7
|
+
- "*.tsx"
|
|
8
|
+
- "*.js"
|
|
9
|
+
- "*.mjs"
|
|
10
|
+
- "*.py"
|
|
11
|
+
- "*.rb"
|
|
12
|
+
- "*.go"
|
|
13
|
+
- "*.java"
|
|
14
|
+
- "*.kt"
|
|
15
|
+
- "**/*.ts"
|
|
16
|
+
- "**/*.tsx"
|
|
17
|
+
- "**/*.js"
|
|
18
|
+
- "**/*.mjs"
|
|
19
|
+
- "**/*.py"
|
|
20
|
+
- "**/*.rb"
|
|
21
|
+
- "**/*.go"
|
|
22
|
+
- "**/*.java"
|
|
23
|
+
- "**/*.kt"
|
|
24
|
+
hunk_regex: "fetch\\(|axios\\.|http\\.get|requests\\.(get|post)|urllib|HttpClient|RestTemplate|WebClient|Net::HTTP|http\\.NewRequest"
|
|
25
|
+
security: true
|
|
26
|
+
confidence_floor: 0.75
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
The server makes an HTTP request, and the host or the whole URL comes
|
|
30
|
+
from a request field, a header, or a value a user stored earlier
|
|
31
|
+
(a webhook target, an avatar URL, an "import from URL" box, a
|
|
32
|
+
provider's base URL held in a settings row). The attacker then chooses
|
|
33
|
+
where your server connects. Inside a cloud network that reaches the
|
|
34
|
+
instance metadata endpoint (`169.254.169.254`), internal admin services
|
|
35
|
+
on private ranges, and anything listening on localhost, none of which
|
|
36
|
+
is reachable from the internet, which is exactly why they are
|
|
37
|
+
unauthenticated.
|
|
38
|
+
|
|
39
|
+
What to check before flagging:
|
|
40
|
+
|
|
41
|
+
- Where the host comes from. Trace the value back: a request body or
|
|
42
|
+
query field, a header, or a stored row a user controls all count. A
|
|
43
|
+
constant base URL with only a path segment from input does not.
|
|
44
|
+
- Whether the host is allow-listed against an explicit set, and whether
|
|
45
|
+
the check runs on the URL that is finally fetched rather than on a
|
|
46
|
+
copy parsed earlier.
|
|
47
|
+
- Whether the scheme is pinned to http or https, so `file://`,
|
|
48
|
+
`gopher://` and friends are refused.
|
|
49
|
+
- Whether private, loopback and link-local ranges are refused after DNS
|
|
50
|
+
resolution, not just by a string check on the hostname.
|
|
51
|
+
- Whether redirects are followed. An allow-listed host that answers 302
|
|
52
|
+
to `http://169.254.169.254/` defeats a host check done once up front.
|
|
53
|
+
|
|
54
|
+
What to cite: the line making the request, quoted whole with file and
|
|
55
|
+
line, and, as supporting quotes, the line the untrusted host arrives on
|
|
56
|
+
and any validation you did find, so the finding shows what guard is
|
|
57
|
+
missing rather than asserting there is none.
|
|
58
|
+
|
|
59
|
+
Suppress when:
|
|
60
|
+
|
|
61
|
+
- the base URL is a constant or comes from configuration and only a path
|
|
62
|
+
or query value comes from input
|
|
63
|
+
- the value is checked against an allow-list of hosts, or resolved and
|
|
64
|
+
checked against blocked ranges, before the request
|
|
65
|
+
- the request goes through a proxy or fetch helper in the repository
|
|
66
|
+
that does those checks (find it and read it before deciding)
|
|
67
|
+
|
|
68
|
+
A request with a trusted host and no timeout is a different problem:
|
|
69
|
+
that is the `fetch-without-timeout` lens, not this one.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: supabase-comment-on-function-unqualified
|
|
3
|
+
description: COMMENT ON FUNCTION uses an unqualified name while the function is created schema-qualified
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.sql"
|
|
7
|
+
- "**/*.sql"
|
|
8
|
+
hunk_regex: 'COMMENT\s+ON\s+FUNCTION'
|
|
9
|
+
confidence_floor: 0.75
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
`COMMENT ON FUNCTION <name>(<args>)` resolves `<name>` against the
|
|
13
|
+
applying session's `search_path` at migration time. When the function
|
|
14
|
+
is created schema-qualified (`CREATE ... FUNCTION public.foo(...)`) but
|
|
15
|
+
the comment names it unqualified (`COMMENT ON FUNCTION foo(...)`), the
|
|
16
|
+
statement fails with `function ... does not exist` on any session whose
|
|
17
|
+
`search_path` doesn't include that schema, and it's inconsistent with
|
|
18
|
+
the qualified `CREATE`. A function-body `SET search_path = public,
|
|
19
|
+
pg_temp` does NOT help: that governs the function's own runtime, not the
|
|
20
|
+
session running the `COMMENT`.
|
|
21
|
+
|
|
22
|
+
Flag when, in this diff, a `COMMENT ON FUNCTION` names a function
|
|
23
|
+
unqualified while the corresponding `CREATE [OR REPLACE] FUNCTION` (in
|
|
24
|
+
the same file or the migration it includes) is schema-qualified, OR when
|
|
25
|
+
it diverges from the repo's prevailing convention (search sibling
|
|
26
|
+
`COMMENT ON FUNCTION` statements; most will use `public.`).
|
|
27
|
+
|
|
28
|
+
Suppress when:
|
|
29
|
+
- the comment is already schema-qualified to match the CREATE,
|
|
30
|
+
- the function is genuinely created unqualified and the repo convention
|
|
31
|
+
is unqualified comments throughout,
|
|
32
|
+
- the migration explicitly `SET search_path` for the statement scope.
|
|
33
|
+
|
|
34
|
+
Severity: `minor` (apply-time failure is gated on the runner's
|
|
35
|
+
search_path, which in Supabase's standard runner is usually `public`, so
|
|
36
|
+
it often won't fire in practice, but it's a real consistency/robustness
|
|
37
|
+
defect).
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: supabase-function-default-public-execute
|
|
3
|
+
description: A new/replaced Postgres function exposed via PostgREST is left with the default PUBLIC EXECUTE grant
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.sql"
|
|
7
|
+
- "**/*.sql"
|
|
8
|
+
hunk_regex: 'CREATE\s+(OR\s+REPLACE\s+)?FUNCTION'
|
|
9
|
+
confidence_floor: 0.7
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
Postgres grants `EXECUTE` to `PUBLIC` by default on every new function.
|
|
13
|
+
In Supabase, PostgREST exposes any function in an exposed schema
|
|
14
|
+
(usually `public`) as an RPC endpoint (`POST /rest/v1/rpc/<fn>`)
|
|
15
|
+
callable by the `anon` and `authenticated` roles. So a `CREATE [OR
|
|
16
|
+
REPLACE] FUNCTION public.<fn>` that returns privileged data or performs
|
|
17
|
+
a privileged action, with no accompanying grant management, is callable
|
|
18
|
+
by anyone holding an anon/JWT key, even if the only intended caller is
|
|
19
|
+
an admin-gated Edge Function using `service_role`.
|
|
20
|
+
|
|
21
|
+
Flag when this diff adds or replaces a function in an exposed schema AND
|
|
22
|
+
does NOT also `REVOKE EXECUTE ... FROM PUBLIC` (and grant EXECUTE only
|
|
23
|
+
to the intended role, e.g. `service_role` / `authenticated`). The risk
|
|
24
|
+
is highest when:
|
|
25
|
+
- the name or comment signals admin/internal scope (`admin_*`,
|
|
26
|
+
`internal_*`, "admin-only", "backend"), or
|
|
27
|
+
- the body reads instance-wide / cross-tenant tables (users, teams,
|
|
28
|
+
billing, activity, metrics) rather than the caller's own rows.
|
|
29
|
+
|
|
30
|
+
`SECURITY INVOKER` (the default) reduces but does not remove the risk:
|
|
31
|
+
RLS on the base tables only helps if RLS is actually enabled on every
|
|
32
|
+
table the function touches, and the RPC endpoint is still reachable.
|
|
33
|
+
Treat a missing `REVOKE PUBLIC` on a privileged function as a finding;
|
|
34
|
+
recommend `REVOKE EXECUTE ON FUNCTION public.<fn>(...) FROM PUBLIC;`
|
|
35
|
+
plus an explicit `GRANT` to the intended role.
|
|
36
|
+
|
|
37
|
+
Suppress when:
|
|
38
|
+
- the diff (or the same migration) already revokes PUBLIC and grants the
|
|
39
|
+
intended role,
|
|
40
|
+
- the function returns only public, non-sensitive data and is meant to
|
|
41
|
+
be world-callable,
|
|
42
|
+
- the repo has an established global migration / convention that revokes
|
|
43
|
+
PUBLIC EXECUTE on all functions (verify it exists before staying
|
|
44
|
+
silent),
|
|
45
|
+
- the change is purely cosmetic (renamed param, comment) on a function
|
|
46
|
+
whose grants were already managed.
|
|
47
|
+
|
|
48
|
+
Severity: `major` when an admin/privileged function is left
|
|
49
|
+
PUBLIC-callable; `minor` for defense-in-depth hardening on a
|
|
50
|
+
non-sensitive one.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: supabase-security-definer-no-search-path
|
|
3
|
+
description: A SECURITY DEFINER function omits a pinned SET search_path, allowing search_path hijack / privilege escalation
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.sql"
|
|
7
|
+
- "**/*.sql"
|
|
8
|
+
hunk_regex: 'SECURITY\s+DEFINER'
|
|
9
|
+
confidence_floor: 0.7
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
A `SECURITY DEFINER` function runs with the privileges of its owner, not
|
|
13
|
+
the caller. If it does not pin its `search_path`, a caller can set their
|
|
14
|
+
own `search_path` so that unqualified object references inside the
|
|
15
|
+
function resolve to attacker-controlled objects (a shadowing table,
|
|
16
|
+
function, or operator) in a schema the caller can write, executed with
|
|
17
|
+
the owner's elevated rights. This is the Postgres privilege-escalation
|
|
18
|
+
footgun that Supabase's own linter flags as
|
|
19
|
+
`function_search_path_mutable`.
|
|
20
|
+
|
|
21
|
+
Flag a `CREATE [OR REPLACE] FUNCTION ... SECURITY DEFINER` added or
|
|
22
|
+
modified in this diff that does NOT include a fixed
|
|
23
|
+
`SET search_path = ...` (e.g. `SET search_path = pg_catalog, public` or
|
|
24
|
+
`= ''` with fully-qualified references). Also flag when `search_path` is
|
|
25
|
+
set to something a caller can still influence.
|
|
26
|
+
|
|
27
|
+
Suppress when:
|
|
28
|
+
- the function pins `SET search_path` to a fixed value, OR
|
|
29
|
+
- every object reference in the body is already fully schema-qualified
|
|
30
|
+
AND `search_path` is set, OR
|
|
31
|
+
- the function is `SECURITY INVOKER` (the default); invoker functions
|
|
32
|
+
run as the caller, so this escalation doesn't apply.
|
|
33
|
+
|
|
34
|
+
Severity: `major` (privilege escalation on a definer function).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: supabase-single-500-on-no-match
|
|
3
|
+
description: Supabase `.single()` on a query that can legitimately return zero rows, throws PGRST116 and bubbles up as a 500
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.ts"
|
|
7
|
+
- "*.tsx"
|
|
8
|
+
- "*.js"
|
|
9
|
+
- "*.jsx"
|
|
10
|
+
- "*.mjs"
|
|
11
|
+
- "*.cjs"
|
|
12
|
+
- "**/*.ts"
|
|
13
|
+
- "**/*.tsx"
|
|
14
|
+
- "**/*.js"
|
|
15
|
+
- "**/*.jsx"
|
|
16
|
+
- "**/*.mjs"
|
|
17
|
+
- "**/*.cjs"
|
|
18
|
+
hunk_regex: "\\.single\\(\\)|\\bfrom\\([^)]*\\)\\s*\\.|supabase\\.from"
|
|
19
|
+
confidence_floor: 0.8
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
A Supabase query chain ends in `.single()` against a row that may
|
|
23
|
+
not exist (lookup by `id` from a request param, `.eq("user_id",
|
|
24
|
+
…)` where the caller may pass an unknown id, etc.). `.single()`
|
|
25
|
+
throws PostgREST error `PGRST116` ("Cannot coerce the result to
|
|
26
|
+
a single JSON object") when the row count is anything other than
|
|
27
|
+
exactly 1. In an edge function, that error bubbles into a 500
|
|
28
|
+
when the correct response is a 404, and the caller can't distinguish
|
|
29
|
+
"the thing you asked for doesn't exist" from "the server broke."
|
|
30
|
+
|
|
31
|
+
Flag when this diff adds (or modifies into) a Supabase query that:
|
|
32
|
+
- ends in `.single()`,
|
|
33
|
+
- selects / updates / deletes by an identifier (`eq("id", …)`,
|
|
34
|
+
`eq("user_id", …)`, `match({...})`),
|
|
35
|
+
- where the identifier traces back to a request parameter
|
|
36
|
+
(`req.params`, `req.query`, `req.body`, `req.url`, the parsed
|
|
37
|
+
payload of an edge function),
|
|
38
|
+
- AND the surrounding code does NOT catch `PGRST116` / does NOT
|
|
39
|
+
check `error.code === "PGRST116"` to map to a 404.
|
|
40
|
+
|
|
41
|
+
Examples that should fire:
|
|
42
|
+
- `await supabase.from("activities").update({...}).eq("id", id).single()`
|
|
43
|
+
with no PGRST116 → 404 handling
|
|
44
|
+
- `await supabase.from("users").select("*").eq("email", req.body.email).single()`
|
|
45
|
+
- `await supabase.from("teams").delete().eq("id", req.params.teamId).single()`
|
|
46
|
+
|
|
47
|
+
Suggested fix in the description:
|
|
48
|
+
- Swap `.single()` for `.maybeSingle()` (returns `{data: null,
|
|
49
|
+
error: null}` on zero rows) and have the handler return 404
|
|
50
|
+
when `data === null`, OR
|
|
51
|
+
- Keep `.single()` and catch `error.code === "PGRST116"`
|
|
52
|
+
explicitly, mapping to a 404 response.
|
|
53
|
+
|
|
54
|
+
Suppress when:
|
|
55
|
+
- the query is filtered by a column with a unique constraint AND
|
|
56
|
+
the caller has already proven the row exists earlier in the
|
|
57
|
+
request (e.g. an auth middleware fetched the user row by id),
|
|
58
|
+
- the surrounding code already handles `PGRST116` (look for the
|
|
59
|
+
literal `"PGRST116"` string in the catch or in an
|
|
60
|
+
`error.code ===` check),
|
|
61
|
+
- the function uses `.maybeSingle()` instead.
|
|
62
|
+
|
|
63
|
+
Severity: `major`: a 500 on a missing row is a real user-facing
|
|
64
|
+
bug (looks like an outage) and a real auditing problem (alerts
|
|
65
|
+
fire on 5xx, not on 404).
|