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,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