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,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: id-enumeration-sequential
|
|
3
|
+
description: Sequential / integer IDs used as URL parameters for access-controlled resources
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.ts"
|
|
7
|
+
- "*.tsx"
|
|
8
|
+
- "*.js"
|
|
9
|
+
- "*.jsx"
|
|
10
|
+
- "*.mjs"
|
|
11
|
+
- "*.cjs"
|
|
12
|
+
- "*.py"
|
|
13
|
+
- "*.go"
|
|
14
|
+
- "*.rb"
|
|
15
|
+
- "**/*.ts"
|
|
16
|
+
- "**/*.tsx"
|
|
17
|
+
- "**/*.js"
|
|
18
|
+
- "**/*.jsx"
|
|
19
|
+
- "**/*.mjs"
|
|
20
|
+
- "**/*.cjs"
|
|
21
|
+
- "**/*.py"
|
|
22
|
+
- "**/*.go"
|
|
23
|
+
- "**/*.rb"
|
|
24
|
+
hunk_regex: "(req\\.params|c\\.req\\.param|request\\.match_info|path_params).{0,30}(id|uid|user|order|invoice)"
|
|
25
|
+
security: true
|
|
26
|
+
confidence_floor: 0.7
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
A handler accepts a sequential integer ID from the URL
|
|
30
|
+
(`/orders/:id`, `/users/:id`) and looks up the row by primary key
|
|
31
|
+
without verifying that the authenticated principal OWNS that row.
|
|
32
|
+
This is IDOR (Insecure Direct Object Reference): an attacker just
|
|
33
|
+
increments / decrements the ID to enumerate other users' data.
|
|
34
|
+
|
|
35
|
+
Even when the ID type is opaque (UUID), the missing-authz check is
|
|
36
|
+
still a bug; the attacker may have obtained a leaked link.
|
|
37
|
+
|
|
38
|
+
Flag when:
|
|
39
|
+
- the handler resolves a record by `id` from the URL
|
|
40
|
+
- the subsequent query has no `where: { ownerId: ctx.user.id }`
|
|
41
|
+
(or equivalent) clause
|
|
42
|
+
- there's no permission check / policy call between the lookup and
|
|
43
|
+
the response
|
|
44
|
+
|
|
45
|
+
Suppress when:
|
|
46
|
+
- the row IS scoped to the authenticated user in the query
|
|
47
|
+
- a policy / RBAC / row-level-security check runs explicitly
|
|
48
|
+
- the resource is public by design (a blog post, a published
|
|
49
|
+
document) and the handler is the public endpoint
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: interactive-state-decoupled-from-output
|
|
3
|
+
description: New user-mutable state is introduced but an output that should depend on it reads a static or duplicate source instead
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.tsx"
|
|
7
|
+
- "*.jsx"
|
|
8
|
+
- "*.vue"
|
|
9
|
+
- "*.svelte"
|
|
10
|
+
- "**/*.tsx"
|
|
11
|
+
- "**/*.jsx"
|
|
12
|
+
- "**/*.vue"
|
|
13
|
+
- "**/*.svelte"
|
|
14
|
+
hunk_regex: "useState|useReducer|writable\\(|ref\\(|reactive\\(|toggle|setSelected|setChecked|setEnabled|setWatched|setFilter"
|
|
15
|
+
confidence_floor: 0.72
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
This diff adds a piece of user-mutable state (a `useState` /
|
|
19
|
+
`useReducer` / store `writable` / `ref` / a `toggle*` handler that
|
|
20
|
+
flips a Set or boolean) AND there is an output in the same component
|
|
21
|
+
whose meaning DEPENDS on that state but which is computed from a
|
|
22
|
+
different, static, or duplicate source, so toggling the control in
|
|
23
|
+
the UI has no effect on the output it appears to govern.
|
|
24
|
+
|
|
25
|
+
The classic shape: a user can star/unstar, select/deselect, enable/
|
|
26
|
+
disable, or filter items, but a derived view (an alert banner, a
|
|
27
|
+
count, a summary, a "what matches" list, a submit payload) is built
|
|
28
|
+
from a constant default list, an unrelated prop, or a separately
|
|
29
|
+
hardcoded copy of the same data, not from the live state the user
|
|
30
|
+
just changed. Each line reads correctly on its own; the bug is the
|
|
31
|
+
MISSING wire between the state and the consumer.
|
|
32
|
+
|
|
33
|
+
Flag when, in this diff:
|
|
34
|
+
- new interactive state `S` is introduced (and a handler mutates it),
|
|
35
|
+
AND
|
|
36
|
+
- there is a rendered output or computed value `O` whose
|
|
37
|
+
semantics clearly should reflect `S` (it is "about" the same thing
|
|
38
|
+
the user is toggling), BUT
|
|
39
|
+
- `O` is derived from a static constant, a duplicated literal, or a
|
|
40
|
+
different source, never reading `S`.
|
|
41
|
+
|
|
42
|
+
Trace it: find every place the new state SHOULD be read (the outputs
|
|
43
|
+
that semantically depend on it) and check each actually reads it. An
|
|
44
|
+
output that reads a static/duplicate source instead of `S` is the
|
|
45
|
+
finding. This also surfaces nearby dead code: a status/variant value
|
|
46
|
+
that no branch ever produces, or the same metric rendered from two
|
|
47
|
+
different sources that can disagree.
|
|
48
|
+
|
|
49
|
+
Suppress when:
|
|
50
|
+
- the static source is intentional and documented (e.g. the default
|
|
51
|
+
list is the seed and the toggle layers on top, and the output
|
|
52
|
+
correctly merges both),
|
|
53
|
+
- the output genuinely should not depend on the state (the
|
|
54
|
+
resemblance is coincidental),
|
|
55
|
+
- the state is purely presentational (drives only its own control's
|
|
56
|
+
appearance, like a star's fill) and no other output is expected to
|
|
57
|
+
track it.
|
|
58
|
+
|
|
59
|
+
Severity: `major` when the decoupling makes a user-facing control a
|
|
60
|
+
no-op for the thing it implies it controls (e.g. unwatching a
|
|
61
|
+
category in the UI but the alert still fires for it); `minor` for
|
|
62
|
+
cosmetic-only mismatches.
|
|
63
|
+
|
|
64
|
+
Example: a "watched categories" toggle drove only
|
|
65
|
+
the star icon while the threshold-breach alert banner was computed
|
|
66
|
+
from a separate static `DEFAULT_WATCHES` constant, so starring /
|
|
67
|
+
unstarring had no effect on which categories tripped the alert.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: json-parse-no-try-catch
|
|
3
|
+
description: JSON.parse over untrusted input without try/catch, crashes the request
|
|
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: "JSON\\.parse\\s*\\("
|
|
19
|
+
confidence_floor: 0.7
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
`JSON.parse(input)` throws on invalid input. When the input is a
|
|
23
|
+
request body, query string, cookie, or external API response, an
|
|
24
|
+
attacker can crash the handler by sending non-JSON. In Express 4
|
|
25
|
+
this surfaces as a 500 + log spam; in async handlers without an
|
|
26
|
+
async error boundary it can become an unhandled rejection.
|
|
27
|
+
|
|
28
|
+
Flag when `JSON.parse(...)` is called on a value sourced from
|
|
29
|
+
`req.body|req.query|req.cookies|req.headers|response|fetch result|
|
|
30
|
+
file read|process.argv` and the call is NOT inside a `try { ... }
|
|
31
|
+
catch` (or wrapped by a parser like `Zod.safeParse(JSON.parse(...))`
|
|
32
|
+
that itself doesn't catch the parse phase).
|
|
33
|
+
|
|
34
|
+
Suppress when:
|
|
35
|
+
- a `try/catch` surrounds the call and returns a sensible response
|
|
36
|
+
- the body parser of the framework already runs `JSON.parse` and
|
|
37
|
+
surfaces a typed error (`express.json()`, Hono's `await c.req.json()`
|
|
38
|
+
inside an error-handling app)
|
|
39
|
+
- the value is provably valid JSON from a controlled internal
|
|
40
|
+
source (a value the SAME service wrote moments ago and the parse
|
|
41
|
+
is the reverse trip)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: missing-rate-limit-on-auth
|
|
3
|
+
description: Login / password-reset / OTP / signup endpoint without rate limiting visible
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.ts"
|
|
7
|
+
- "*.tsx"
|
|
8
|
+
- "*.js"
|
|
9
|
+
- "*.jsx"
|
|
10
|
+
- "*.mjs"
|
|
11
|
+
- "*.cjs"
|
|
12
|
+
- "*.py"
|
|
13
|
+
- "*.go"
|
|
14
|
+
- "*.rb"
|
|
15
|
+
- "**/*.ts"
|
|
16
|
+
- "**/*.tsx"
|
|
17
|
+
- "**/*.js"
|
|
18
|
+
- "**/*.jsx"
|
|
19
|
+
- "**/*.mjs"
|
|
20
|
+
- "**/*.cjs"
|
|
21
|
+
- "**/*.py"
|
|
22
|
+
- "**/*.go"
|
|
23
|
+
- "**/*.rb"
|
|
24
|
+
hunk_regex: "(login|signin|sign-in|signup|register|forgot[-_]?password|reset[-_]?password|verify[-_]?otp|send[-_]?otp|magic[-_]?link)"
|
|
25
|
+
security: true
|
|
26
|
+
confidence_floor: 0.7
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
A login / signup / password-reset / OTP-verify / magic-link
|
|
30
|
+
endpoint is registered without a rate-limit middleware visible.
|
|
31
|
+
Without one: credential stuffing trivially scales, password-reset
|
|
32
|
+
email pumps spam any address, OTP-verify allows brute force, and
|
|
33
|
+
signup bots burn through plan limits.
|
|
34
|
+
|
|
35
|
+
Flag when:
|
|
36
|
+
- the new route handles auth-flow input (password, OTP code, email
|
|
37
|
+
send) and the diff doesn't show a rate-limiter on the route OR
|
|
38
|
+
a `rateLimit` / `limiter.consume` call inside the body
|
|
39
|
+
|
|
40
|
+
Suppress when:
|
|
41
|
+
- a `limiter` / `rateLimit` middleware is chained on the route
|
|
42
|
+
- the framework / platform applies per-IP throttling at the edge
|
|
43
|
+
(Vercel, Cloudflare, AWS WAF) and that's documented in the
|
|
44
|
+
repo (CLAUDE.md, README, infra notes)
|
|
45
|
+
- the endpoint is a webhook with HMAC signature (different threat
|
|
46
|
+
model: bot calls are rejected by signature check)
|
|
47
|
+
- the project explicitly uses a 3rd party (Clerk / Auth0 / WorkOS
|
|
48
|
+
/ Supabase Auth) that owns auth flows; the route is a thin
|
|
49
|
+
pass-through and the provider rate-limits
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: oauth-scope-wider-than-use
|
|
3
|
+
description: Connector requests an OAuth scope no code path in it actually uses
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.ts"
|
|
7
|
+
- "*.tsx"
|
|
8
|
+
- "*.js"
|
|
9
|
+
- "*.mjs"
|
|
10
|
+
- "*.py"
|
|
11
|
+
- "*.rb"
|
|
12
|
+
- "*.go"
|
|
13
|
+
- "*.java"
|
|
14
|
+
- "*.kt"
|
|
15
|
+
- "*.json"
|
|
16
|
+
- "*.yaml"
|
|
17
|
+
- "*.yml"
|
|
18
|
+
- "**/*.ts"
|
|
19
|
+
- "**/*.tsx"
|
|
20
|
+
- "**/*.js"
|
|
21
|
+
- "**/*.mjs"
|
|
22
|
+
- "**/*.py"
|
|
23
|
+
- "**/*.rb"
|
|
24
|
+
- "**/*.go"
|
|
25
|
+
- "**/*.java"
|
|
26
|
+
- "**/*.kt"
|
|
27
|
+
- "**/*.json"
|
|
28
|
+
- "**/*.yaml"
|
|
29
|
+
- "**/*.yml"
|
|
30
|
+
hunk_regex: "scope=|scopes?\\s*[:=]\\s*[\\[\\(\"']|\\.scope\\(|setScope\\(|\"scopes\"\\s*:"
|
|
31
|
+
security: true
|
|
32
|
+
confidence_floor: 0.75
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
A connector asks the provider for a scope, and nothing in the connector
|
|
36
|
+
ever calls an endpoint that needs it. The declaration is usually one
|
|
37
|
+
string in an authorize URL, a client constructor, or a manifest, written
|
|
38
|
+
once when the integration was built and never narrowed as the feature
|
|
39
|
+
shrank. The cost is real: the token the user grants can do more than
|
|
40
|
+
the product does, so a leaked token, a compromised server, or a bug in
|
|
41
|
+
the connector reaches data and writes the product never intended to
|
|
42
|
+
touch. A `full` scope on a connector that only reads one record is the
|
|
43
|
+
shape.
|
|
44
|
+
|
|
45
|
+
How to check, before you flag anything:
|
|
46
|
+
|
|
47
|
+
- Find the scope declaration. Read the whole line and the call it sits
|
|
48
|
+
in, so you can quote it and say which scopes are requested.
|
|
49
|
+
- Enumerate every provider call the connector makes. Search the
|
|
50
|
+
connector's own directory for the provider's base URL, its client
|
|
51
|
+
class, or the SDK method prefix (search for callers of the client,
|
|
52
|
+
then for the base URL) and list the endpoints you found.
|
|
53
|
+
- Map each requested scope to the calls that need it, using the
|
|
54
|
+
provider's own scope documentation as the connector states it, not a
|
|
55
|
+
guess about naming.
|
|
56
|
+
- Report every scope with no call behind it. One finding per unused
|
|
57
|
+
scope, not one per connector.
|
|
58
|
+
|
|
59
|
+
What to cite:
|
|
60
|
+
|
|
61
|
+
- The scope declaration line, with file and line, quoted whole.
|
|
62
|
+
- The provider calls you found, each as a supporting quote with its file
|
|
63
|
+
and line. This is what makes the finding checkable: without the list,
|
|
64
|
+
"no call needs this" is an assertion, not evidence.
|
|
65
|
+
|
|
66
|
+
Suppress when:
|
|
67
|
+
|
|
68
|
+
- the provider only grants the scope as part of a bundle (some providers
|
|
69
|
+
have no finer grain than `read`), and the connector says so
|
|
70
|
+
- the scope is used on a path behind a feature flag or an unreleased
|
|
71
|
+
code path that is still in the tree
|
|
72
|
+
- a sibling connector or a shared client in the same repository uses the
|
|
73
|
+
same credential, so the calls live outside this directory; widen the
|
|
74
|
+
search before flagging
|
|
75
|
+
- the connector is a thin proxy whose callers make the provider calls
|
|
76
|
+
|
|
77
|
+
Severity: medium by default. High when the unused scope grants write,
|
|
78
|
+
delete, or admin access, since the blast radius of the extra grant is
|
|
79
|
+
then the user's data rather than a wider read.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: object-spread-clobber
|
|
3
|
+
description: "{ ...existing, ...incoming } overwrites server-controlled fields with user input"
|
|
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: "\\{\\s*\\.\\.\\."
|
|
19
|
+
confidence_floor: 0.7
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
`{ ...existing, ...input }` spreads a user-supplied object over a
|
|
23
|
+
server-controlled one. Any field name in `input` that overlaps
|
|
24
|
+
`existing` wins, including fields the caller wasn't supposed to
|
|
25
|
+
write (`id`, `userId`, `role`, `tier`, `isAdmin`, `createdAt`,
|
|
26
|
+
`stripeCustomerId`).
|
|
27
|
+
|
|
28
|
+
Mass-assignment is the classic Rails / Express / Hono shape:
|
|
29
|
+
`db.update({ where, data: { ...req.body } })`.
|
|
30
|
+
|
|
31
|
+
Flag when:
|
|
32
|
+
- the spread merges a request-sourced object into a DB write
|
|
33
|
+
payload / state object
|
|
34
|
+
- there's no explicit allow-list pick (`pick(input, [...])` /
|
|
35
|
+
`{ name: input.name, email: input.email }`) before the spread
|
|
36
|
+
- the model has any field that an authenticated user shouldn't be
|
|
37
|
+
able to set (role, owner, billing)
|
|
38
|
+
|
|
39
|
+
Suppress when:
|
|
40
|
+
- the spread is preceded by an allow-list pick / Zod parse with a
|
|
41
|
+
strict schema (`schema.parse(input)` rejecting unknown keys)
|
|
42
|
+
- the spread is between two server-controlled objects only
|
|
43
|
+
- the destination is a transient payload (not persisted), and the
|
|
44
|
+
extra fields are deliberately allowed
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: open-redirect-from-untrusted-host
|
|
3
|
+
description: HTTP redirect using a URL/host taken from query / body without allow-list
|
|
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: "redirect\\s*\\(|res\\.redirect\\b|Location:|http\\.Redirect\\b|return\\s+redirect|RedirectView\\b|return\\s+[\"']redirect:"
|
|
29
|
+
security: true
|
|
30
|
+
confidence_floor: 0.7
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
A redirect target is constructed from request input (`?next=...`,
|
|
34
|
+
`?returnTo=...`, form field, JSON body) without validating that the
|
|
35
|
+
host belongs to a known allow-list. Phishing attacks chain
|
|
36
|
+
`/login?next=https://attacker.example/fake-login` so the trusted
|
|
37
|
+
host is the first thing the user sees in the URL bar.
|
|
38
|
+
|
|
39
|
+
Flag when a `redirect(url)` / `res.redirect(url)` / `Location:`
|
|
40
|
+
header is built from a request-derived URL string that isn't
|
|
41
|
+
validated against a list of allowed hosts or constrained to a
|
|
42
|
+
relative path.
|
|
43
|
+
|
|
44
|
+
Suppress when:
|
|
45
|
+
- the redirect target is constrained to a same-origin pathname
|
|
46
|
+
(starts with `/`, no scheme / host)
|
|
47
|
+
- the URL is parsed and checked against an allow-list of hosts
|
|
48
|
+
before redirect
|
|
49
|
+
- the value is the OUTPUT of a server-controlled flow (OAuth state
|
|
50
|
+
recovered from session, post-payment URL from a payment provider
|
|
51
|
+
the project controls)
|
|
52
|
+
|
|
53
|
+
In Java and Kotlin the idiom is `response.sendRedirect(target)`, a
|
|
54
|
+
Spring `RedirectView`, or a controller returning the string
|
|
55
|
+
`"redirect:"` with a request value appended.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: orm-drizzle-on-conflict-clobber
|
|
3
|
+
description: Drizzle / Kysely onConflictDoUpdate that updates every column, clobbers downstream writes
|
|
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: "onConflictDoUpdate|onConflict\\s*\\(|insertOrUpdate"
|
|
19
|
+
confidence_floor: 0.7
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
A Drizzle `onConflictDoUpdate({ set: { ... } })` (or Kysely
|
|
23
|
+
`onConflict(...).doUpdateSet`) with a `set:` block that updates
|
|
24
|
+
**every column** the insert tried to write overwrites columns the
|
|
25
|
+
caller didn't mean to touch. Webhook replays, idempotent retries,
|
|
26
|
+
and "create or update" code paths regress columns that downstream
|
|
27
|
+
handlers have already advanced (status, tier, role, lastSeenAt,
|
|
28
|
+
updatedAt).
|
|
29
|
+
|
|
30
|
+
Closely related to the existing `upsert-state-column` lens, but
|
|
31
|
+
focused on the Drizzle / Kysely shape where the `set:` block is
|
|
32
|
+
copied directly from the insert values without a deliberate column
|
|
33
|
+
filter.
|
|
34
|
+
|
|
35
|
+
Flag when the `set:` block contains the same columns as the insert
|
|
36
|
+
AND any of those columns are state-bearing (status, stage, role,
|
|
37
|
+
plan, tier, version, updatedAt) or are commonly written by other
|
|
38
|
+
code paths (preferences, profile fields).
|
|
39
|
+
|
|
40
|
+
Suppress when:
|
|
41
|
+
- the `set:` block explicitly OMITS state-bearing columns
|
|
42
|
+
- the conflict clause uses `target: ...` with `setWhere` /
|
|
43
|
+
`where: ...` that constrains the update to rows in a known state
|
|
44
|
+
- `onConflictDoNothing` (no clobber)
|
|
45
|
+
- the conflict target is a uniqueness column whose only path to
|
|
46
|
+
conflict is a literal duplicate of the same write (idempotent
|
|
47
|
+
semantic is the actual goal)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: path-traversal-in-fs-access
|
|
3
|
+
description: File system read / write using a path built from user 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
|
+
- "**/*.ts"
|
|
18
|
+
- "**/*.tsx"
|
|
19
|
+
- "**/*.js"
|
|
20
|
+
- "**/*.jsx"
|
|
21
|
+
- "**/*.mjs"
|
|
22
|
+
- "**/*.cjs"
|
|
23
|
+
- "**/*.py"
|
|
24
|
+
- "**/*.go"
|
|
25
|
+
- "**/*.rb"
|
|
26
|
+
- "**/*.java"
|
|
27
|
+
- "**/*.kt"
|
|
28
|
+
hunk_regex: "(readFile|writeFile|createReadStream|createWriteStream|open\\s*\\(|os\\.path\\.join|filepath\\.Join|File\\.open|fs\\.|\\bFile\\s*\\(|Paths\\.get\\s*\\(|Files\\.(readAllBytes|newInputStream|copy)\\s*\\()"
|
|
29
|
+
security: true
|
|
30
|
+
confidence_floor: 0.7
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
A filesystem call (`fs.readFile`, `fs.writeFile`, `open`, etc.)
|
|
34
|
+
uses a path built by joining a base directory with a value sourced
|
|
35
|
+
from request input. `../../../etc/passwd` and absolute-path
|
|
36
|
+
substitution bypass the base. Reads leak arbitrary files; writes
|
|
37
|
+
let an attacker plant malicious content where the server will
|
|
38
|
+
serve / execute it.
|
|
39
|
+
|
|
40
|
+
Flag when the path argument is a join of:
|
|
41
|
+
- a server-controlled prefix (`./uploads/`, `path.join(BASE, ...)`)
|
|
42
|
+
- AND a value from `req.body`, `req.query`, `req.params`, form
|
|
43
|
+
upload, parsed JSON, command-line args, or env
|
|
44
|
+
|
|
45
|
+
Suppress when:
|
|
46
|
+
- the user input is run through a sanitizer that strips `..` /
|
|
47
|
+
`/` / null bytes
|
|
48
|
+
- the final path is `path.resolve`d and then verified to live
|
|
49
|
+
beneath the base directory (`resolved.startsWith(base + sep)`)
|
|
50
|
+
- the input is a whitelisted enum / UUID lookup that the path
|
|
51
|
+
derives from an internal mapping (not used as the filename
|
|
52
|
+
directly)
|
|
53
|
+
- the file is served via a CDN / object store with its own access
|
|
54
|
+
control (no FS at all)
|
|
55
|
+
|
|
56
|
+
In Java and Kotlin the idiom is `new File(base + userInput)` (Kotlin
|
|
57
|
+
drops the `new`),
|
|
58
|
+
`Paths.get(...)` or `Files.readAllBytes(...)` on a path segment taken
|
|
59
|
+
from a request parameter or a multipart upload's filename.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pii-in-url-or-log
|
|
3
|
+
description: PII (email, phone, SSN, full name) in URLs or unredacted log statements
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.ts"
|
|
7
|
+
- "*.tsx"
|
|
8
|
+
- "*.js"
|
|
9
|
+
- "*.jsx"
|
|
10
|
+
- "*.mjs"
|
|
11
|
+
- "*.cjs"
|
|
12
|
+
- "*.py"
|
|
13
|
+
- "*.go"
|
|
14
|
+
- "*.rb"
|
|
15
|
+
- "**/*.ts"
|
|
16
|
+
- "**/*.tsx"
|
|
17
|
+
- "**/*.js"
|
|
18
|
+
- "**/*.jsx"
|
|
19
|
+
- "**/*.mjs"
|
|
20
|
+
- "**/*.cjs"
|
|
21
|
+
- "**/*.py"
|
|
22
|
+
- "**/*.go"
|
|
23
|
+
- "**/*.rb"
|
|
24
|
+
hunk_regex: "(email|phone|ssn|password|address|fullName|first_name|last_name|dob|date_of_birth)\\b"
|
|
25
|
+
security: true
|
|
26
|
+
confidence_floor: 0.7
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
A PII field (email, phone, SSN, full name, address, DOB) is
|
|
30
|
+
embedded in a URL query string OR logged unredacted. URL params
|
|
31
|
+
land in CDN logs, browser history, referrer headers to third
|
|
32
|
+
parties, and analytics services. Logs land in aggregators that
|
|
33
|
+
many engineers + vendors can read.
|
|
34
|
+
|
|
35
|
+
Flag when:
|
|
36
|
+
- a PII field is concatenated into a URL string for a redirect /
|
|
37
|
+
fetch / external API
|
|
38
|
+
- a log call passes a record / object that contains a PII field
|
|
39
|
+
without going through a redaction layer
|
|
40
|
+
|
|
41
|
+
Suppress when:
|
|
42
|
+
- the PII goes in the request BODY (POST/PUT) rather than the URL
|
|
43
|
+
- the log call explicitly uses a redactor / pino's `redact` /
|
|
44
|
+
custom `scrubPii` helper
|
|
45
|
+
- the value being logged is an internal ID / hash of the PII, not
|
|
46
|
+
the PII itself
|
|
47
|
+
- the URL is internal-only (no proxy / CDN / referrer leak) AND
|
|
48
|
+
there's a documented reason (still recommend moving it to the body)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: race-check-then-act
|
|
3
|
+
description: Check-then-act on a database row without a transaction / unique constraint
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.ts"
|
|
7
|
+
- "*.tsx"
|
|
8
|
+
- "*.js"
|
|
9
|
+
- "*.jsx"
|
|
10
|
+
- "*.mjs"
|
|
11
|
+
- "*.cjs"
|
|
12
|
+
- "*.py"
|
|
13
|
+
- "*.go"
|
|
14
|
+
- "*.rb"
|
|
15
|
+
- "**/*.ts"
|
|
16
|
+
- "**/*.tsx"
|
|
17
|
+
- "**/*.js"
|
|
18
|
+
- "**/*.jsx"
|
|
19
|
+
- "**/*.mjs"
|
|
20
|
+
- "**/*.cjs"
|
|
21
|
+
- "**/*.py"
|
|
22
|
+
- "**/*.go"
|
|
23
|
+
- "**/*.rb"
|
|
24
|
+
hunk_regex: "\\b(findFirst|findOne|findUnique|select|exists|count)\\b.{0,300}\\b(insert|create|update|save|delete)\\b"
|
|
25
|
+
confidence_floor: 0.7
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
A handler reads a row to decide whether to insert / update / delete
|
|
29
|
+
(check-then-act), but the read and the write are NOT in the same
|
|
30
|
+
transaction AND the table has no unique constraint backing the
|
|
31
|
+
invariant. Concurrent requests both pass the check and both
|
|
32
|
+
proceed: two users get the same username, two orders against the
|
|
33
|
+
same inventory unit, two emails for "first signup" bonus.
|
|
34
|
+
|
|
35
|
+
Flag when:
|
|
36
|
+
- a `findOne` / `findFirst` / `count` / `exists` is followed by an
|
|
37
|
+
`insert` / `update` / `delete` keyed on the same predicate
|
|
38
|
+
- the two operations are NOT wrapped in a single `transaction(...)`
|
|
39
|
+
/ `db.tx(...)`
|
|
40
|
+
- the predicate column is NOT a primary key / unique index
|
|
41
|
+
|
|
42
|
+
Suppress when:
|
|
43
|
+
- the operations run inside a single transaction with an explicit
|
|
44
|
+
isolation level that prevents the race (serializable, or
|
|
45
|
+
`SELECT ... FOR UPDATE`)
|
|
46
|
+
- the write uses an idempotent shape that survives the race
|
|
47
|
+
(`INSERT ... ON CONFLICT DO NOTHING`, `UPDATE ... WHERE col = old`)
|
|
48
|
+
- a unique constraint / partial index on the table guarantees the
|
|
49
|
+
invariant at the DB layer regardless of the app-level check
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: react-dangerously-set-inner-html
|
|
3
|
+
description: dangerouslySetInnerHTML fed by untrusted / unsanitized input
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.tsx"
|
|
7
|
+
- "*.jsx"
|
|
8
|
+
- "**/*.tsx"
|
|
9
|
+
- "**/*.jsx"
|
|
10
|
+
hunk_regex: "dangerouslySetInnerHTML"
|
|
11
|
+
security: true
|
|
12
|
+
confidence_floor: 0.7
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
`dangerouslySetInnerHTML` bypasses React's HTML escaping. Any value
|
|
16
|
+
that traces back to user input, a network response, or
|
|
17
|
+
markdown/HTML rendered without sanitization is a stored or reflected
|
|
18
|
+
XSS sink.
|
|
19
|
+
|
|
20
|
+
Flag when the `__html` value's provenance is unsanitized: a prop or
|
|
21
|
+
state variable that came from `fetch`, `URLSearchParams`, `params`,
|
|
22
|
+
form input, comment / post / message content, or any markdown
|
|
23
|
+
rendered without an escaping pipeline.
|
|
24
|
+
|
|
25
|
+
Suppress when:
|
|
26
|
+
- the value is run through `DOMPurify.sanitize` /
|
|
27
|
+
`sanitize-html` / equivalent immediately before the assignment
|
|
28
|
+
- the value is a literal string from the component itself (build-time
|
|
29
|
+
constant, not user-derived)
|
|
30
|
+
- the value is the output of a markdown library configured with
|
|
31
|
+
HTML disabled (`marked` with `sanitize: true`, `remark-html` with
|
|
32
|
+
`sanitize` plugin, `markdown-it` without `html: true`)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: react-fetch-in-effect-without-abort
|
|
3
|
+
description: useEffect fires a fetch but doesn't abort it on cleanup / dep change
|
|
4
|
+
triggers:
|
|
5
|
+
files:
|
|
6
|
+
- "*.tsx"
|
|
7
|
+
- "*.jsx"
|
|
8
|
+
- "**/*.tsx"
|
|
9
|
+
- "**/*.jsx"
|
|
10
|
+
hunk_regex: "useEffect\\s*\\([\\s\\S]{0,300}?\\bfetch\\s*\\("
|
|
11
|
+
confidence_floor: 0.7
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
A `useEffect` issues a `fetch` (or axios / ky / undici) but doesn't
|
|
15
|
+
hand the request an `AbortSignal` whose controller is aborted in the
|
|
16
|
+
cleanup. When the component unmounts before the response arrives,
|
|
17
|
+
or the deps change and the effect re-runs, the in-flight response
|
|
18
|
+
still resolves and calls `setState` on an unmounted component (or
|
|
19
|
+
clobbers fresher state with stale data from the previous request).
|
|
20
|
+
|
|
21
|
+
Flag when the fetch is started inside a `useEffect` and the cleanup
|
|
22
|
+
returned from the effect doesn't call `controller.abort()` (or the
|
|
23
|
+
fetch isn't passed a `signal` at all).
|
|
24
|
+
|
|
25
|
+
Suppress when:
|
|
26
|
+
- the body uses `react-query` / `swr` / TanStack Query (those
|
|
27
|
+
handle abort + race themselves)
|
|
28
|
+
- the cleanup uses an `ignore` boolean flag inside the
|
|
29
|
+
`.then(data => { if (!ignore) setState(data) })` pattern (slightly
|
|
30
|
+
worse than abort but still correct)
|
|
31
|
+
- the request is fire-and-forget with no `setState` in the chain
|