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.
Files changed (90) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +35 -0
  3. package/README.md +160 -0
  4. package/demo/baseline/.github/workflows/ci.yml +18 -0
  5. package/demo/baseline/Dockerfile +12 -0
  6. package/demo/baseline/app/__init__.py +0 -0
  7. package/demo/baseline/app/server.py +37 -0
  8. package/demo/baseline/package-lock.json +21 -0
  9. package/demo/baseline/package.json +9 -0
  10. package/demo/baseline/requirements.txt +1 -0
  11. package/demo/baseline/scripts/deploy.sh +13 -0
  12. package/demo/expected.json +126 -0
  13. package/demo/planted/.github/workflows/ci.yml +20 -0
  14. package/demo/planted/Dockerfile +11 -0
  15. package/demo/planted/app/config.py +3 -0
  16. package/demo/planted/app/search.py +17 -0
  17. package/demo/planted/app/server.py +40 -0
  18. package/demo/planted/package-lock.json +21 -0
  19. package/demo/planted/package.json +9 -0
  20. package/demo/planted/scripts/deploy.sh +13 -0
  21. package/dist/bin.js +42546 -0
  22. package/docs/agents.md +121 -0
  23. package/docs/cli.md +161 -0
  24. package/docs/config.md +165 -0
  25. package/docs/custom-scanners.md +164 -0
  26. package/docs/faq.md +54 -0
  27. package/docs/github-action.md +73 -0
  28. package/docs/index.md +29 -0
  29. package/docs/llms.txt +13 -0
  30. package/docs/quickstart.md +82 -0
  31. package/docs/scanners.md +148 -0
  32. package/docs/security.md +74 -0
  33. package/docs/telemetry.md +9 -0
  34. package/lenses/a11y-icon-only-button-no-aria-label.md +51 -0
  35. package/lenses/array-iteration-missing-key-prop.md +31 -0
  36. package/lenses/async-await-in-loop-n-plus-one.md +42 -0
  37. package/lenses/async-click-double-fire-race.md +59 -0
  38. package/lenses/async-floating-promise.md +40 -0
  39. package/lenses/async-promise-all-swallows-errors.md +42 -0
  40. package/lenses/async-unhandled-rejection-in-handler.md +42 -0
  41. package/lenses/auth-missing-on-state-change-route.md +60 -0
  42. package/lenses/auth-role-from-user-input.md +49 -0
  43. package/lenses/auth-timing-attack-password-compare.md +59 -0
  44. package/lenses/cookie-missing-secure-httponly.md +46 -0
  45. package/lenses/cors-wildcard-with-credentials.md +47 -0
  46. package/lenses/crypto-jwt-verify-without-algo-allowlist.md +41 -0
  47. package/lenses/crypto-math-random-for-tokens.md +53 -0
  48. package/lenses/crypto-md5-sha1-for-secrets.md +60 -0
  49. package/lenses/env-vars-read-at-module-top.md +42 -0
  50. package/lenses/eval-on-user-input.md +51 -0
  51. package/lenses/fetch-without-timeout.md +41 -0
  52. package/lenses/id-enumeration-sequential.md +49 -0
  53. package/lenses/interactive-state-decoupled-from-output.md +67 -0
  54. package/lenses/json-parse-no-try-catch.md +41 -0
  55. package/lenses/missing-rate-limit-on-auth.md +49 -0
  56. package/lenses/oauth-scope-wider-than-use.md +79 -0
  57. package/lenses/object-spread-clobber.md +44 -0
  58. package/lenses/open-redirect-from-untrusted-host.md +55 -0
  59. package/lenses/orm-drizzle-on-conflict-clobber.md +47 -0
  60. package/lenses/path-traversal-in-fs-access.md +59 -0
  61. package/lenses/pii-in-url-or-log.md +48 -0
  62. package/lenses/race-check-then-act.md +49 -0
  63. package/lenses/react-dangerously-set-inner-html.md +32 -0
  64. package/lenses/react-fetch-in-effect-without-abort.md +31 -0
  65. package/lenses/react-stale-closure-in-callback.md +28 -0
  66. package/lenses/react-state-set-in-render.md +33 -0
  67. package/lenses/react-use-effect-missing-cleanup.md +29 -0
  68. package/lenses/react-use-effect-missing-deps.md +30 -0
  69. package/lenses/regexp-from-user-input.md +43 -0
  70. package/lenses/return-shape-contract-break.md +71 -0
  71. package/lenses/secrets-logged-in-error-path.md +60 -0
  72. package/lenses/sql-migration-references-later-object.md +47 -0
  73. package/lenses/sql-string-concatenation.md +63 -0
  74. package/lenses/ssrf-server-side-fetch.md +69 -0
  75. package/lenses/supabase-comment-on-function-unqualified.md +37 -0
  76. package/lenses/supabase-function-default-public-execute.md +50 -0
  77. package/lenses/supabase-security-definer-no-search-path.md +34 -0
  78. package/lenses/supabase-single-500-on-no-match.md +65 -0
  79. package/lenses/upsert-state-column.md +57 -0
  80. package/lenses/url-not-encoded-for-user-id.md +78 -0
  81. package/lenses/use-state-default-not-functional.md +30 -0
  82. package/package.json +57 -0
  83. package/skills/openqodex/SKILL.md +141 -0
  84. package/templates/README.md +67 -0
  85. package/templates/claude-code/settings-hook.json +16 -0
  86. package/templates/cline/openqodex.md +9 -0
  87. package/templates/codex/AGENTS-section.md +8 -0
  88. package/templates/codex/hooks.json +16 -0
  89. package/templates/cursor/openqodex.mdc +15 -0
  90. 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).