Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions .claude/skills/deep-code-review/templates/semgrep-taint/js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
# Also date-parse-rollover (INFO: a date accepted only because Number.isFinite/isNaN of an inline Date.parse(x) or new Date(x) passed, with no .toISOString() round trip in the enclosing function; V8 reads 2026-02-30 as 2 March; test paths are skipped) and gcm-no-auth-tag-length (WARNING: createDecipheriv with an *-gcm cipher and no authTagLength option, so a truncated tag is accepted, a forgery risk only when the tag length is attacker-controlled). ponytail: any .toISOString() in the enclosing function counts as the round trip, component compares such as getUTCDate are not recognised, a numeric-timestamp new Date(ts) check can report, and a check on a variable bound first (const d = new Date(x); isNaN(d)) is not flagged because those hits were mostly noise; a cipher name that is not a string literal (or a const bound to one) and an options argument that is not an object literal are skipped.
# Also body-cap-after-buffer (INFO: await req.formData()/json()/text()/arrayBuffer() on a receiver named like req or request (req, request, c.req, event.request) followed later in the same block by a .size/.length/.byteLength > cap on the result or on a .get(...) entry of it, or preceded only by a content-length > cap check; a comparison with the literal 0 is an emptiness check, not a cap, and a counted getReader() read passes; test, fixture and mock paths are skipped). ponytail: the receiver is matched by name (one ending in req or request), so a request bound to another name (r) or read through a call (req.clone().json()) is missed, only a > comparison with the result on the left is recognised, a result bound by destructuring or read inline (await req.formData()).get(...) is not tied to a later check, a ?. or ! on the result or on its .get(...) entry (body?.length, body!.length, form.get("f")!.size) is not matched, a deeper field path (body.items.length) is not the result, a read and its check in different blocks (the read inside a try, the check after it) are not tied, and a cap that lives in a middleware or framework option is invisible.
# Also limiter-key-forwarded-header (INFO: an x-forwarded-for / x-real-ip read inside a function named like clientKey, limiterKey, rateKey, deriveKey or getIp; test, fixture and mock paths are skipped). ponytail: the function name is a heuristic, so a key built in an anonymous or differently named function (getClientIp, for one) is missed and any function with a matching name is reported.
# Also fetch-no-timeout (INFO: fetch(url) or fetch(url, {...}) with no signal key inside a Next route handler (export async function GET|POST|PUT|PATCH|DELETE), a pages API export default async function handler or an app.get|post|put|patch|delete|all("/path", ..., fn) callback; options with any signal value or a spread are skipped, as are test paths). ponytail: handlers are matched by shape, so a const-arrow export, a handler passed by name and a route path that is not a literal starting with / or * are not seen, and options held in a variable are read as unknown, not as missing a signal.
# Also auth-cookie-flags (WARNING: a cookie whose literal name has session, sess, sessid, sessionid, phpsessid, sid, token, auth, authtoken or jwt as a whole word (the name is split at punctuation and at lower-to-upper camelCase boundaries, and a name holding csrf or xsrf is skipped because double-submit cookies must stay readable by scripts), set positionally or as one options object by <x>.cookie, <x>.setCookie, cookies().set, (await cookies()).set, a variable bound by X = await cookies() or X = cookies() in an enclosing block (const jar = await cookies(); jar.set(...)) or <x>.cookies.set on any receiver not named below, with httpOnly missing or the literal false, or with secure missing or the literal false; any other httpOnly or secure value passes; ctx.cookies.set, context.cookies.set, event.cookies.set, this.cookies.set and a bare cookies.set are reported only for a literal httpOnly false or secure false, because the cookies library behind Koa's ctx.cookies documents httpOnly as true by default and secure as true by default over HTTPS, and SvelteKit documents both as true by default (secure false in development); a receiver ending in req or request is a request-side cookie and is skipped, and a cookie cleared with an empty value, maxAge: 0 or expires: new Date(0) is skipped; test paths are skipped). ponytail: options must be an object literal (options held in a variable are not read and a spread is skipped), the browser cookieStore (a global, not bound from cookies()) is not seen, a bare cookies.set also covers other frameworks whose defaults were not checked, so their missing flags are not reported, and sameSite is not checked.
# Also cors-credentials-any-origin (WARNING: a cors(...) or register(cors, ...) call with credentials true and an origin of true, the regex /.*/ or a callback that always allows ((o, cb) => cb(null, true), cb(null, o) or cb(null, !!o), written as an arrow expression, an arrow block or a function); or a function that copies req.get|header("origin") or headers.get("origin"), directly or through a variable, into Access-Control-Allow-Origin while it also sets Access-Control-Allow-Credentials to "true" or true, unless an if on a .has, .includes or .test call encloses or precedes the set call in the same block (an allowlist check); origin "*" is not reported because the cors packages send a literal * and browsers refuse it on credentialed requests; req.headers.origin is left to the registry cors-misconfiguration rule; test paths are skipped). ponytail: options held in a variable and a credentials header given as an object are not seen, any such if counts as an allowlist check whatever it tests and a lookup written another way (indexOf, some) is not read, and a callback with other statements or a conditional is not matched.
rules:
- id: perun.js.sqli
mode: taint
Expand Down Expand Up @@ -818,3 +821,161 @@ rules:
regex: (?i).*(?:(?:client|limiter|rate)key|derivekey|getip)
paths:
exclude: ["test", "tests", "__tests__", "spec", "fixtures", "mocks", "__mocks__", "e2e", "*.test.*", "*.spec.*"]
- id: perun.js.fetch-no-timeout
languages: [javascript, typescript]
severity: INFO
message: A server request handler calls fetch() with no signal option, so the call has no deadline of its own and a stalled upstream holds the request, its connection and its memory until a runtime or platform limit ends it. Pass a signal such as AbortSignal.timeout(ms) and handle the abort.
patterns:
- pattern-either:
- pattern: fetch($U)
- pattern: fetch($U, {...})
- pattern-not: 'fetch($U, {..., signal: $S, ...})'
- pattern-not: 'fetch($U, {..., ...$X, ...})'
- pattern-either:
- patterns:
- pattern-inside: |
export async function $M(...) {...}
- metavariable-regex:
metavariable: $M
regex: ^(?:GET|POST|PUT|PATCH|DELETE)$
- pattern-inside: |
export default async function handler(...) {...}
- patterns:
- pattern-either:
- pattern-inside: $A.$H("$P", ..., async (...) => {...})
- pattern-inside: $A.$H("$P", ..., async function (...) {...})
- pattern-inside: $A.$H("$P", ..., (...) => {...})
- pattern-inside: $A.$H("$P", ..., function (...) {...})
- metavariable-regex:
metavariable: $H
regex: ^(?:get|post|put|patch|delete|all)$
- metavariable-regex:
metavariable: $P
regex: ^[/*]
paths:
exclude: ["test", "tests", "__tests__", "spec", "fixtures", "mocks", "__mocks__", "e2e", "*.test.*", "*.spec.*"]
- id: perun.js.auth-cookie-flags
languages: [javascript, typescript]
severity: WARNING
message: An authentication cookie (its name has session, sess, sessid, sessionid, phpsessid, sid, token, auth, authtoken or jwt as a whole word) is set without httpOnly, with httpOnly false, with no secure option or with secure false. Without httpOnly a script can read it, so one XSS steals the session, and without secure it also travels over plain HTTP. Set httpOnly true, secure true (or an environment check) and a sameSite value.
patterns:
- pattern-either:
- patterns:
- pattern-either:
- pattern: $C("$N", $V)
- pattern: $C("$N", $V, {...})
- pattern: '$C({..., name: "$N", ...})'
- metavariable-regex:
metavariable: $C
regex: ^(?!(?:.*\.)?(?:req|request)\.cookies\.set$)(?:[\w.]+\.(?:cookie|setCookie)|(?:\(await )?cookies\(\)\)?\.set|(?:[\w.]+\.)?cookies\.set)$
- patterns:
- pattern-either:
- pattern: $STORE.set("$N", $V)
- pattern: $STORE.set("$N", $V, {...})
- pattern: '$STORE.set({..., name: "$N", ...})'
- pattern-either:
- pattern-inside: |
$STORE = await cookies();
...
- pattern-inside: |
$STORE = cookies();
...
- metavariable-regex:
metavariable: $N
regex: ^.*?(?:(?<![A-Za-z0-9])|(?<=[a-z])(?=[A-Z]))(?i:session|sess|sessid|sessionid|phpsessid|sid|token|auth|authtoken|jwt)(?:(?![A-Za-z0-9])|(?<=[a-z])(?=[A-Z]))
- metavariable-regex:
metavariable: $N
regex: ^(?!.*(?i:csrf|xsrf))
- pattern-not: '$C(..., {..., ...$X, ...})'
- pattern-not: '$C("$N", "", ...)'
- pattern-not: '$C({..., value: "", ...})'
- pattern-not: '$C(..., {..., maxAge: 0, ...})'
- pattern-not: '$C(..., {..., expires: new Date(0), ...})'
- pattern-not:
patterns:
- pattern: '$C(..., {..., httpOnly: $H, ..., secure: $S, ...})'
- metavariable-regex:
metavariable: $H
regex: ^(?!false$)
- metavariable-regex:
metavariable: $S
regex: ^(?!false$)
- pattern-not:
patterns:
- pattern: $C(...)
- metavariable-regex:
metavariable: $C
regex: ^(?:(?:ctx|context|event|this)\.)?cookies\.set$
- pattern-not: '$C(..., {..., httpOnly: false, ...})'
- pattern-not: '$C(..., {..., secure: false, ...})'
paths:
exclude: ["test", "tests", "__tests__", "spec", "fixtures", "mocks", "__mocks__", "e2e", "*.test.*", "*.spec.*"]
- id: perun.js.cors-credentials-any-origin
languages: [javascript, typescript]
severity: WARNING
message: CORS reflects any request origin and allows credentials. The config (origin true, a regex of /.*/ or a callback that always allows) or the handler (it copies the Origin header into Access-Control-Allow-Origin next to Access-Control-Allow-Credentials true) lets any origin read responses to credentialed requests, so another site can read the data of a signed-in user wherever the browser attaches the credentials, for example cookies marked SameSite=None or requests from a sibling subdomain. Allow only origins from a fixed allowlist.
pattern-either:
- patterns:
- pattern-either:
- pattern: cors(...)
- patterns:
- pattern: $APP.register($CORS, ...)
- metavariable-regex:
metavariable: $CORS
regex: (?i).*cors
- pattern: '$F(..., {..., credentials: true, ...})'
- pattern-either:
- pattern: '$F(..., {..., origin: true, ...})'
- pattern: '$F(..., {..., origin: /.*/, ...})'
- pattern: '$F(..., {..., origin: ($O, $CB) => { $CB(null, true); }, ...})'
- pattern: '$F(..., {..., origin: ($O, $CB) => { $CB(null, $O); }, ...})'
- pattern: '$F(..., {..., origin: ($O, $CB) => { $CB(null, !!$O); }, ...})'
- patterns:
- pattern-either:
- pattern: $R.$M("$A", <... $REQ.$G("$O") ...>)
- patterns:
- pattern: $R.$M("$A", $V)
- pattern-inside: |
$V = <... $REQ.$G("$O") ...>;
...
- metavariable-regex:
metavariable: $M
regex: ^(?:set|header|setHeader|append)$
- metavariable-regex:
metavariable: $A
regex: (?i)^access-control-allow-origin$
- metavariable-regex:
metavariable: $G
regex: ^(?:get|header)$
- metavariable-regex:
metavariable: $O
regex: (?i)^origin$
- pattern-either:
- pattern-inside: |
function $F(...) {
...
<... $R2.$M2("$B", $T) ...>
...
}
- pattern-inside: |
($...P) => {
...
<... $R2.$M2("$B", $T) ...>
...
}
- metavariable-regex:
metavariable: $B
regex: (?i)^access-control-allow-credentials$
- metavariable-regex:
metavariable: $T
regex: ^(?:true|["'`]true["'`])$
- pattern-not-inside:
patterns:
- pattern: |
if (<... $X.$K(...) ...>) $S
...
- metavariable-regex:
metavariable: $K
regex: ^(?:has|includes|test)$
paths:
exclude: ["test", "tests", "__tests__", "spec", "fixtures", "mocks", "__mocks__", "e2e", "*.test.*", "*.spec.*"]
6 changes: 6 additions & 0 deletions changelog.d/semgrep-js-gap-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Added
- Three bundled semgrep rules in `.claude/skills/deep-code-review/templates/semgrep-taint/js.yml` for defect classes the taint rules leave unflagged. Each carries the same test-path exclusions as `hardcoded-credential-compare`.
- `perun.js.fetch-no-timeout` (INFO): `fetch(url)` or `fetch(url, {...})` with no `signal` key inside a Next.js route handler (`export async function GET|POST|PUT|PATCH|DELETE`), a pages API `export default async function handler`, or an `app.get|post|put|patch|delete|all("/path", ..., fn)` callback. Any `signal:` value, an options spread, a fetch outside a handler and a `cache.get("key", fn)` callback stay clean. Ceiling: handlers are matched by shape, so a const-arrow export or a handler passed by name is not seen.
- `perun.js.auth-cookie-flags` (WARNING): a cookie whose literal name has session, sess, sessid, sessionid, phpsessid, sid, token, auth, authtoken or jwt as a whole word (split at punctuation and camelCase boundaries, so `authToken`, `connect.sid` and `next-auth.session-token` match and `authorized`, `tokenizer_mode` and `assessment` do not; names holding csrf or xsrf are skipped), set positionally or as one options object through `<x>.cookie`, `<x>.setCookie`, `cookies().set`, `(await cookies()).set`, a variable bound by `X = await cookies()` or `X = cookies()` in an enclosing block (`const jar = await cookies(); jar.set(...)`) or `<x>.cookies.set` on any receiver not named below, with `httpOnly` missing or the literal `false`, or `secure` missing or the literal `false`. `ctx.cookies.set`, `context.cookies.set`, `event.cookies.set`, `this.cookies.set` and a bare `cookies.set` are reported only for a literal `httpOnly: false` or `secure: false`: the cookies library behind Koa's `ctx.cookies` documents `httpOnly` as true by default and `secure` as true by default over HTTPS, and SvelteKit documents both as true by default (`secure` false in development). Any other `httpOnly` or `secure` value passes, and a clear (an empty value, `maxAge: 0` or `expires: new Date(0)`) and a `req.cookies.set` or `request.cookies.set` call are skipped. Ceiling: options must be an object literal, and a bare `cookies.set` also covers other frameworks whose defaults were not checked, so their missing flags are not reported.
- `perun.js.cors-credentials-any-origin` (WARNING): `cors({...})` or `register(cors, {...})` with `credentials: true` and `origin: true`, `origin: /.*/` or a callback that always allows (`cb(null, true)`, `cb(null, origin)` or `cb(null, !!origin)`), or a function that copies `req.get|header("origin")` or `headers.get("origin")` (directly or through a variable) into `Access-Control-Allow-Origin` while it sets `Access-Control-Allow-Credentials` to `"true"` or `true`, unless an `if` on a `.has`, `.includes` or `.test` call encloses or precedes the set call. A literal `origin: "*"` is not reported, because the cors packages send a literal `*` and browsers refuse it on credentialed requests. The `req.headers.origin` handler shape is left to the default pack's express `cors-misconfiguration` rule, which already flags it. Ceiling: any such `if` counts as an allowlist check whatever it tests.
- Test: `scripts/test_semgrep_js_gaps.py`, registered in `scripts/ci-tests.d/semgrep-js-gaps.sh`, runs annotated fictional fixtures (a `ruleid` or `ok` comment marks the next line) through semgrep with the bundled rules only, plus copies under test paths that must stay clean. Offline checks of severity, exclusions and the key regexes run where semgrep is absent.
7 changes: 7 additions & 0 deletions docs/standards-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1918,3 +1918,10 @@ Not fetched, by name only: the Homebrew formulae `gitleaks`, `trivy`, `osv-scann
| ossf/scorecard — checks, Branch-Protection | https://raw.githubusercontent.com/ossf/scorecard/main/docs/checks.md | Fetched 2026-10-30: risk High; five settings (`DismissStaleReviews`, `EnforceAdmins`, `RequireLastPushApproval`, `RequiresStatusChecks`, `UpToDateBeforeMerge`) need an admin token, and with a non-admin token the check scores only the settings a non-admin can read; `EnforceAdmins` is false when any bypass actor is defined on any rule; rulesets are covered under "Repo Rules". Basis for "some Branch-Protection settings need an admin token" in `tool-hub.md`. |

Not fetched, no URL: a community report that a token without admin can get a 404 from the branch-protection endpoint for a branch that is protected (seen in a search result, the page was not opened). The script therefore reports `Branch not protected` as a finding only when the repository object reports `permissions.admin` as true. `repo_settings_audit.py` was run offline against fixtures only; no live GitHub call was made when it was written, so the exact 403 and 404 bodies of each endpoint are the documented statuses, not observed ones.

## Verified by direct fetch (2026-10-31) — cookie flag defaults (`templates/semgrep-taint/js.yml`, `perun.js.auth-cookie-flags`)

| Source | URL | What it grounds |
|---|---|---|
| sveltejs/kit — @sveltejs/kit reference, Cookies | https://svelte.dev/docs/kit/@sveltejs-kit | Fetched 2026-10-31: the `Cookies` reference says "The `httpOnly` is `true` by default, as is `secure`, except during development, when it defaults to `false`." Basis for `event.cookies.set` being a receiver of `perun.js.auth-cookie-flags` that is reported only for a literal `httpOnly: false` or `secure: false`: a missing flag is the documented default there, not a defect. |
| pillarjs/cookies — README | https://github.com/pillarjs/cookies | Fetched 2026-10-31: `httpOnly` is "true by default"; `secure` is "false by default for HTTP, true by default for HTTPS". This is the cookies object behind Koa's `ctx.cookies`. Basis for `ctx.cookies.set`, `context.cookies.set`, `this.cookies.set` and a bare `cookies.set` being receivers of `perun.js.auth-cookie-flags` that are reported only for a literal `httpOnly: false` or `secure: false`. The defaults of other frameworks that expose a bare `cookies.set` were not checked, so their missing flags are not reported. |
2 changes: 2 additions & 0 deletions scripts/ci-tests.d/semgrep-js-gaps.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Bundled semgrep rules perun.js.fetch-no-timeout, perun.js.auth-cookie-flags and perun.js.cors-credentials-any-origin: annotated ruleid/ok fictional fixtures (live when semgrep is installed, SKIP otherwise) plus offline checks of the rule text (severity, test-path exclusion, cookie-name, receiver and allowlist-guard regexes, signal and spread exemptions) that still run where semgrep is absent. Cookie receivers: a missing or literal-false httpOnly or secure is reported for res.cookie, reply.setCookie, cookies().set, a variable bound from cookies() and other <x>.cookies.set receivers; for ctx, context, event, this and a bare cookies.set only a literal false is.
python3 scripts/test_semgrep_js_gaps.py
Loading
Loading