@lunora/advisor 1.0.0-alpha.206 → 1.0.0-alpha.208

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 (59) hide show
  1. package/dist/index.d.mts +135 -11
  2. package/dist/index.d.ts +135 -11
  3. package/dist/index.mjs +1 -1
  4. package/dist/packem_shared/{actionFetchSsrf-B4ZaOBlW.mjs → actionFetchSsrf-BK-kvOPO.mjs} +1 -1
  5. package/dist/packem_shared/{aiRawRunEscapeHatch-B5980eT9.mjs → aiRawRunEscapeHatch-BW7a8kZC.mjs} +1 -1
  6. package/dist/packem_shared/{aiToolSideEffectPromptInjection-_3MrX9Ul.mjs → aiToolSideEffectPromptInjection-9eJXRlKt.mjs} +1 -1
  7. package/dist/packem_shared/{authApiCallWithoutHeaders-D8U1hv8s.mjs → authApiCallWithoutHeaders-CReJRsGW.mjs} +1 -1
  8. package/dist/packem_shared/{authCsrfCheckDisabled-C6bxME5y.mjs → authCsrfCheckDisabled-CRIVsvb9.mjs} +1 -1
  9. package/dist/packem_shared/{authEmailVerificationDisabled-yROPDgn-.mjs → authEmailVerificationDisabled-BjFlnHr2.mjs} +1 -1
  10. package/dist/packem_shared/{authScimWithoutTransactions-w9qb3iBe.mjs → authScimWithoutTransactions-B5ATiz5A.mjs} +1 -1
  11. package/dist/packem_shared/{authSecureCookiesDisabled-qrCktlch.mjs → authSecureCookiesDisabled-By8PVum3.mjs} +1 -1
  12. package/dist/packem_shared/{authSessionFreshageZero-BMisjIIS.mjs → authSessionFreshageZero-DuxX3Ln9.mjs} +1 -1
  13. package/dist/packem_shared/{authTrustedOriginsWildcard-DFDpfN84.mjs → authTrustedOriginsWildcard-BKIh6788.mjs} +1 -1
  14. package/dist/packem_shared/{browserUserUrlWithoutAllowlist-CcK27rvt.mjs → browserUserUrlWithoutAllowlist-CzDEraJB.mjs} +1 -1
  15. package/dist/packem_shared/{containerInstanceKeyFromUserInput-DBdjUnQb.mjs → containerInstanceKeyFromUserInput-Bl-tAzKV.mjs} +1 -1
  16. package/dist/packem_shared/{containerRuntimeEgressRelaxation-CioCA2zY.mjs → containerRuntimeEgressRelaxation-4tMrhq1k.mjs} +1 -1
  17. package/dist/packem_shared/{containerStartEnableInternetOverride-C6dbznku.mjs → containerStartEnableInternetOverride-mN_kuRtk.mjs} +1 -1
  18. package/dist/packem_shared/filterAfterIndexScan-CV8aPq7v.mjs +1 -0
  19. package/dist/packem_shared/{filterOnPrimaryKey-DbFriOZA.mjs → filterOnPrimaryKey-BeHVg4w3.mjs} +1 -1
  20. package/dist/packem_shared/{filterWithoutIndex-Dc7Nn0y2.mjs → filterWithoutIndex-BGv3jbgg.mjs} +1 -1
  21. package/dist/packem_shared/{flagGatesSecurityWithUnsafeDefault-DB9M6hLM.mjs → flagGatesSecurityWithUnsafeDefault-B5FBDF8c.mjs} +1 -1
  22. package/dist/packem_shared/{geoIndexFieldNotGeopoint-CDMNbAfp.mjs → geoIndexFieldNotGeopoint-BMQuok2-.mjs} +1 -1
  23. package/dist/packem_shared/helpers-DdZ_pyR3.mjs +1 -0
  24. package/dist/packem_shared/{httpActionMissingAuthGuard-BhvcmkwW.mjs → httpActionMissingAuthGuard-DhMIs1EI.mjs} +1 -1
  25. package/dist/packem_shared/{httpActionResponseHeaderInjection-DKz2SlCa.mjs → httpActionResponseHeaderInjection-mcRYd3Zw.mjs} +1 -1
  26. package/dist/packem_shared/{identityUndeclaredClaimTrusted-CAUagXkJ.mjs → identityUndeclaredClaimTrusted-D1sEd5lr.mjs} +1 -1
  27. package/dist/packem_shared/{imagesUrlSourceFromUserInput-C2CJTTCI.mjs → imagesUrlSourceFromUserInput-BJXotuyO.mjs} +1 -1
  28. package/dist/packem_shared/{indexReferencesUnknownField-Dm8SL4Tp.mjs → indexReferencesUnknownField-DKz4-bla.mjs} +1 -1
  29. package/dist/packem_shared/{kvUnscopedUserKeyIdor-CXFKAyM2.mjs → kvUnscopedUserKeyIdor-DhymglIq.mjs} +1 -1
  30. package/dist/packem_shared/{mailRecipientFromRequestInput-CQZeOsP9.mjs → mailRecipientFromRequestInput-BVXsZ4Xf.mjs} +1 -1
  31. package/dist/packem_shared/{maskWeakHashStrategyOnPii-njstapQY.mjs → maskWeakHashStrategyOnPii-DJasagzX.mjs} +1 -1
  32. package/dist/packem_shared/{outputProjectionMissingOnPublicRead-DHivs_73.mjs → outputProjectionMissingOnPublicRead-DWSeo7oC.mjs} +1 -1
  33. package/dist/packem_shared/{ownerFieldFromArgsNotAuth-lgjS8mdd.mjs → ownerFieldFromArgsNotAuth-J8Xbz5uI.mjs} +1 -1
  34. package/dist/packem_shared/paymentCreateWithoutAuthorize-CdenWpXD.mjs +1 -0
  35. package/dist/packem_shared/{paymentWebhookWideTolerance-CtZb2AVA.mjs → paymentWebhookWideTolerance-DVhJB0Ip.mjs} +1 -1
  36. package/dist/packem_shared/{privilegedDispatchUnvalidatedPayload-DlJovHrt.mjs → privilegedDispatchUnvalidatedPayload-BM3NtT57.mjs} +1 -1
  37. package/dist/packem_shared/{publicMutationWithoutRatelimit-BeYapu1F.mjs → publicMutationWithoutRatelimit-Bd-rRxnz.mjs} +1 -1
  38. package/dist/packem_shared/{publicTableRlsOptoutConfusion-BFmh1kH0.mjs → publicTableRlsOptoutConfusion-CbNk75Xo.mjs} +1 -1
  39. package/dist/packem_shared/{ratelimitKeySpoofableOrGlobal-DlvJfSXs.mjs → ratelimitKeySpoofableOrGlobal-DHMl74hR.mjs} +1 -1
  40. package/dist/packem_shared/{ratelimitMiddlewareFailOpen-DGtyt-pc.mjs → ratelimitMiddlewareFailOpen-Di2t8pDc.mjs} +1 -1
  41. package/dist/packem_shared/{relationReferencesUnknownField-CqGEJD57.mjs → relationReferencesUnknownField-CJHOCSzs.mjs} +1 -1
  42. package/dist/packem_shared/selfRescheduleWithoutProgress-Be9d1bQ0.mjs +1 -0
  43. package/dist/packem_shared/{signupMutationWithoutDisposableGating-CFtFMJz8.mjs → signupMutationWithoutDisposableGating-BdKwtxDP.mjs} +1 -1
  44. package/dist/packem_shared/{sqlInjectionRisk-y-7c46LJ.mjs → sqlInjectionRisk-BYRllgPV.mjs} +1 -1
  45. package/dist/packem_shared/{storageGenerateUploadUrlNoContentTypePin-Bx9oGOjM.mjs → storageGenerateUploadUrlNoContentTypePin-CrwFuo83.mjs} +1 -1
  46. package/dist/packem_shared/{storageKeyFromUserArgs-BryKJJM-.mjs → storageKeyFromUserArgs-3vkxmI3v.mjs} +1 -1
  47. package/dist/packem_shared/{storagePresignedUrlForPrivateContent-lqE6LvNu.mjs → storagePresignedUrlForPrivateContent-C3KeV5tV.mjs} +1 -1
  48. package/dist/packem_shared/{storageUploadWithoutContentTypeAllowlist-DMr7w29r.mjs → storageUploadWithoutContentTypeAllowlist-BXoK_QWE.mjs} +1 -1
  49. package/dist/packem_shared/{storageUploadWithoutMaxSize-rSNFNb2l.mjs → storageUploadWithoutMaxSize-CW7uKvSi.mjs} +1 -1
  50. package/dist/packem_shared/sweeps-JWMwcakB.mjs +1 -0
  51. package/dist/packem_shared/{ttlFieldNotTimestamp-Dxa-6RfM.mjs → ttlFieldNotTimestamp-lnb5nmM6.mjs} +1 -1
  52. package/dist/packem_shared/unboundedCollectOnOpenRange-DHy2RUL7.mjs +1 -0
  53. package/dist/packem_shared/{unboundedLoop-BqbZ7boE.mjs → unboundedLoop-DxN3_apu.mjs} +1 -1
  54. package/dist/packem_shared/{userCreatingMutationWithoutCaptcha-C8yaqIIj.mjs → userCreatingMutationWithoutCaptcha-D4TZEpgz.mjs} +1 -1
  55. package/dist/packem_shared/{vectorsNamespaceFromUserInput-7I_Kwxvp.mjs → vectorsNamespaceFromUserInput-BWvGjJzp.mjs} +1 -1
  56. package/dist/packem_shared/{workflowUnknownTarget-Cm1hfTTs.mjs → workflowUnknownTarget-DEKB5H0g.mjs} +1 -1
  57. package/package.json +2 -2
  58. package/dist/packem_shared/helpers-BmppB5nL.mjs +0 -1
  59. package/dist/packem_shared/paymentCreateWithoutAuthorize-CICGmRFR.mjs +0 -1
@@ -1 +1 @@
1
- import{e as t}from"./finding-NrKO8idM.mjs";import{c as i,a as r}from"./helpers-BmppB5nL.mjs";const s={categories:["SECURITY"],description:"A `createAuth({...})` call sets `advanced.disableCSRFCheck: true`, turning off better-auth's origin validation for state-changing auth requests — a cross-site request riding the ambient auth cookie is processed like a same-origin one.",facing:"EXTERNAL",level:"ERROR",name:"auth_csrf_check_disabled",remediation:"Remove `advanced.disableCSRFCheck: true` from `createAuth({...})` and rely on `trustedOrigins` for origin validation instead of disabling the check.",run:a=>a.authConfigs===void 0?[]:a.authConfigs.filter(e=>e.analyzable&&e.disableCsrfCheck).map(e=>t(s,{cacheKey:`auth_csrf_check_disabled:${e.file}:${e.line.toString()}`,detail:`\`createAuth\` in ${r(e)} sets \`advanced.disableCSRFCheck: true\`, turning off origin validation for state-changing auth requests. Remove the flag and rely on \`trustedOrigins\` instead.`,metadata:{...i(e)}})),source:"static",title:"createAuth CSRF check disabled"};export{s as default};
1
+ import{e as t}from"./finding-NrKO8idM.mjs";import{c as i,a as r}from"./helpers-DdZ_pyR3.mjs";const s={categories:["SECURITY"],description:"A `createAuth({...})` call sets `advanced.disableCSRFCheck: true`, turning off better-auth's origin validation for state-changing auth requests — a cross-site request riding the ambient auth cookie is processed like a same-origin one.",facing:"EXTERNAL",level:"ERROR",name:"auth_csrf_check_disabled",remediation:"Remove `advanced.disableCSRFCheck: true` from `createAuth({...})` and rely on `trustedOrigins` for origin validation instead of disabling the check.",run:a=>a.authConfigs===void 0?[]:a.authConfigs.filter(e=>e.analyzable&&e.disableCsrfCheck).map(e=>t(s,{cacheKey:`auth_csrf_check_disabled:${e.file}:${e.line.toString()}`,detail:`\`createAuth\` in ${r(e)} sets \`advanced.disableCSRFCheck: true\`, turning off origin validation for state-changing auth requests. Remove the flag and rely on \`trustedOrigins\` instead.`,metadata:{...i(e)}})),source:"static",title:"createAuth CSRF check disabled"};export{s as default};
@@ -1 +1 @@
1
- import{e as i}from"./finding-NrKO8idM.mjs";import{c as t,a as r}from"./helpers-BmppB5nL.mjs";const l={categories:["SECURITY"],description:"A `createAuth({...})` call enables `emailAndPassword` with no `requireEmailVerification: true`. better-auth defaults email verification off, so an account is usable before the caller has proven ownership of the email address.",facing:"EXTERNAL",level:"WARN",name:"auth_email_verification_disabled",remediation:"Set `emailAndPassword.requireEmailVerification: true` on `createAuth({...})` so a new account can't act until its email address is verified.",run:a=>a.authConfigs===void 0?[]:a.authConfigs.filter(e=>e.analyzable&&e.emailPasswordEnabled&&!e.requireEmailVerification).map(e=>i(l,{cacheKey:`auth_email_verification_disabled:${e.file}:${e.line.toString()}`,detail:`\`createAuth\` in ${r(e)} enables \`emailAndPassword\` with no \`requireEmailVerification: true\`, so an account is usable before its email is proven. Set \`emailAndPassword.requireEmailVerification: true\`.`,metadata:{...t(e)}})),source:"static",title:"createAuth email verification disabled"};export{l as default};
1
+ import{e as i}from"./finding-NrKO8idM.mjs";import{c as t,a as r}from"./helpers-DdZ_pyR3.mjs";const l={categories:["SECURITY"],description:"A `createAuth({...})` call enables `emailAndPassword` with no `requireEmailVerification: true`. better-auth defaults email verification off, so an account is usable before the caller has proven ownership of the email address.",facing:"EXTERNAL",level:"WARN",name:"auth_email_verification_disabled",remediation:"Set `emailAndPassword.requireEmailVerification: true` on `createAuth({...})` so a new account can't act until its email address is verified.",run:a=>a.authConfigs===void 0?[]:a.authConfigs.filter(e=>e.analyzable&&e.emailPasswordEnabled&&!e.requireEmailVerification).map(e=>i(l,{cacheKey:`auth_email_verification_disabled:${e.file}:${e.line.toString()}`,detail:`\`createAuth\` in ${r(e)} enables \`emailAndPassword\` with no \`requireEmailVerification: true\`, so an account is usable before its email is proven. Set \`emailAndPassword.requireEmailVerification: true\`.`,metadata:{...t(e)}})),source:"static",title:"createAuth email verification disabled"};export{l as default};
@@ -1 +1 @@
1
- import{e}from"./finding-NrKO8idM.mjs";import{c as r,a as s}from"./helpers-BmppB5nL.mjs";const i={categories:["SECURITY"],description:"A `createAuth({...})` call loads `scim()` with a `database` that has no native transactions (`lunoraD1Adapter` / `lunoraAuthAdapter`). `@better-auth/scim` refuses to serve on such an adapter, so every SCIM request fails.",facing:"EXTERNAL",level:"ERROR",name:"auth_scim_without_transactions",remediation:"Move the auth tables somewhere with real transactions: `.auth({ namespace })` with a `LunoraAuthDO` (Durable Object storage), or a Postgres/MySQL kysely dialect over `@lunora/hyperdrive` with `transaction: true`. D1 cannot host SCIM.",run:t=>t.authConfigs===void 0?[]:t.authConfigs.filter(a=>a.analyzable&&a.scimOnNonTransactionalAdapter).map(a=>e(i,{cacheKey:`auth_scim_without_transactions:${a.file}:${a.line.toString()}`,detail:`\`createAuth\` in ${s(a)} loads \`scim()\` on an adapter without native transactions, so the SCIM plugin will refuse to serve and every provisioning request from your IdP fails. Host the auth tables in a Durable Object (\`.auth({ namespace })\`) or on Postgres/MySQL via \`@lunora/hyperdrive\`.`,metadata:{...r(a)}})),source:"static",title:"SCIM configured on an adapter without transactions"};export{i as default};
1
+ import{e}from"./finding-NrKO8idM.mjs";import{c as r,a as s}from"./helpers-DdZ_pyR3.mjs";const i={categories:["SECURITY"],description:"A `createAuth({...})` call loads `scim()` with a `database` that has no native transactions (`lunoraD1Adapter` / `lunoraAuthAdapter`). `@better-auth/scim` refuses to serve on such an adapter, so every SCIM request fails.",facing:"EXTERNAL",level:"ERROR",name:"auth_scim_without_transactions",remediation:"Move the auth tables somewhere with real transactions: `.auth({ namespace })` with a `LunoraAuthDO` (Durable Object storage), or a Postgres/MySQL kysely dialect over `@lunora/hyperdrive` with `transaction: true`. D1 cannot host SCIM.",run:t=>t.authConfigs===void 0?[]:t.authConfigs.filter(a=>a.analyzable&&a.scimOnNonTransactionalAdapter).map(a=>e(i,{cacheKey:`auth_scim_without_transactions:${a.file}:${a.line.toString()}`,detail:`\`createAuth\` in ${s(a)} loads \`scim()\` on an adapter without native transactions, so the SCIM plugin will refuse to serve and every provisioning request from your IdP fails. Host the auth tables in a Durable Object (\`.auth({ namespace })\`) or on Postgres/MySQL via \`@lunora/hyperdrive\`.`,metadata:{...r(a)}})),source:"static",title:"SCIM configured on an adapter without transactions"};export{i as default};
@@ -1 +1 @@
1
- import{e as s}from"./finding-NrKO8idM.mjs";import{c as a,a as o}from"./helpers-BmppB5nL.mjs";const i={categories:["SECURITY"],description:"A `createAuth({...})` call sets `advanced.useSecureCookies: false`, overriding Lunora's secure-by-default posture — the session cookie ships without `Secure` even on an HTTPS deployment, so it can be sent over a plaintext connection.",facing:"EXTERNAL",level:"ERROR",name:"auth_secure_cookies_disabled",remediation:"Remove `advanced.useSecureCookies: false` from `createAuth({...})` — let Lunora's secure-by-default posture set the `Secure` cookie attribute for an HTTPS deployment.",run:t=>t.authConfigs===void 0?[]:t.authConfigs.filter(e=>e.analyzable&&e.secureCookiesDisabled).map(e=>s(i,{cacheKey:`auth_secure_cookies_disabled:${e.file}:${e.line.toString()}`,detail:`\`createAuth\` in ${o(e)} sets \`advanced.useSecureCookies: false\`, so the session cookie ships without \`Secure\` even on an HTTPS deployment. Remove the override and let Lunora's secure-by-default posture apply.`,metadata:{...a(e)}})),source:"static",title:"createAuth secure cookies disabled"};export{i as default};
1
+ import{e as s}from"./finding-NrKO8idM.mjs";import{c as a,a as o}from"./helpers-DdZ_pyR3.mjs";const i={categories:["SECURITY"],description:"A `createAuth({...})` call sets `advanced.useSecureCookies: false`, overriding Lunora's secure-by-default posture — the session cookie ships without `Secure` even on an HTTPS deployment, so it can be sent over a plaintext connection.",facing:"EXTERNAL",level:"ERROR",name:"auth_secure_cookies_disabled",remediation:"Remove `advanced.useSecureCookies: false` from `createAuth({...})` — let Lunora's secure-by-default posture set the `Secure` cookie attribute for an HTTPS deployment.",run:t=>t.authConfigs===void 0?[]:t.authConfigs.filter(e=>e.analyzable&&e.secureCookiesDisabled).map(e=>s(i,{cacheKey:`auth_secure_cookies_disabled:${e.file}:${e.line.toString()}`,detail:`\`createAuth\` in ${o(e)} sets \`advanced.useSecureCookies: false\`, so the session cookie ships without \`Secure\` even on an HTTPS deployment. Remove the override and let Lunora's secure-by-default posture apply.`,metadata:{...a(e)}})),source:"static",title:"createAuth secure cookies disabled"};export{i as default};
@@ -1 +1 @@
1
- import{e as t}from"./finding-NrKO8idM.mjs";import{c as r,a}from"./helpers-BmppB5nL.mjs";const i={categories:["SECURITY"],description:"A `createAuth({...})` call sets `session.freshAge: 0`, disabling better-auth's recent-reauth check for sensitive operations — a long-lived stolen session can perform them without proving the caller still controls the credentials.",facing:"EXTERNAL",level:"WARN",name:"auth_session_freshage_zero",remediation:"Remove `session.freshAge: 0` from `createAuth({...})` (or set it to a real window) so sensitive operations require a recently re-authenticated session.",run:s=>s.authConfigs===void 0?[]:s.authConfigs.filter(e=>e.analyzable&&e.sessionFreshAgeZero).map(e=>t(i,{cacheKey:`auth_session_freshage_zero:${e.file}:${e.line.toString()}`,detail:`\`createAuth\` in ${a(e)} sets \`session.freshAge: 0\`, disabling the recent-reauth check for sensitive operations. Remove the override or set a real window.`,metadata:{...r(e)}})),source:"static",title:"createAuth session freshAge zero"};export{i as default};
1
+ import{e as t}from"./finding-NrKO8idM.mjs";import{c as r,a}from"./helpers-DdZ_pyR3.mjs";const i={categories:["SECURITY"],description:"A `createAuth({...})` call sets `session.freshAge: 0`, disabling better-auth's recent-reauth check for sensitive operations — a long-lived stolen session can perform them without proving the caller still controls the credentials.",facing:"EXTERNAL",level:"WARN",name:"auth_session_freshage_zero",remediation:"Remove `session.freshAge: 0` from `createAuth({...})` (or set it to a real window) so sensitive operations require a recently re-authenticated session.",run:s=>s.authConfigs===void 0?[]:s.authConfigs.filter(e=>e.analyzable&&e.sessionFreshAgeZero).map(e=>t(i,{cacheKey:`auth_session_freshage_zero:${e.file}:${e.line.toString()}`,detail:`\`createAuth\` in ${a(e)} sets \`session.freshAge: 0\`, disabling the recent-reauth check for sensitive operations. Remove the override or set a real window.`,metadata:{...r(e)}})),source:"static",title:"createAuth session freshAge zero"};export{i as default};
@@ -1 +1 @@
1
- import{e as t}from"./finding-NrKO8idM.mjs";import{c as r,a}from"./helpers-BmppB5nL.mjs";const s={categories:["SECURITY"],description:"A `createAuth({...})` call's `trustedOrigins` array contains a `\"*\"` entry, disabling better-auth's CSRF/origin validation entirely — any origin is accepted for state-changing auth requests.",facing:"EXTERNAL",level:"ERROR",name:"auth_trusted_origins_wildcard",remediation:'List the specific origins your deployment serves in `trustedOrigins` instead of `"*"` — a wildcard disables origin validation for every caller.',run:e=>e.authConfigs===void 0?[]:e.authConfigs.filter(i=>i.analyzable&&i.trustedOriginsWildcard).map(i=>t(s,{cacheKey:`auth_trusted_origins_wildcard:${i.file}:${i.line.toString()}`,detail:`\`createAuth\` in ${a(i)} sets \`trustedOrigins\` to include \`"*"\`, disabling CSRF/origin validation for every request. List the specific origins your deployment serves instead.`,metadata:{...r(i)}})),source:"static",title:"createAuth trustedOrigins wildcard"};export{s as default};
1
+ import{e as t}from"./finding-NrKO8idM.mjs";import{c as r,a}from"./helpers-DdZ_pyR3.mjs";const s={categories:["SECURITY"],description:"A `createAuth({...})` call's `trustedOrigins` array contains a `\"*\"` entry, disabling better-auth's CSRF/origin validation entirely — any origin is accepted for state-changing auth requests.",facing:"EXTERNAL",level:"ERROR",name:"auth_trusted_origins_wildcard",remediation:'List the specific origins your deployment serves in `trustedOrigins` instead of `"*"` — a wildcard disables origin validation for every caller.',run:e=>e.authConfigs===void 0?[]:e.authConfigs.filter(i=>i.analyzable&&i.trustedOriginsWildcard).map(i=>t(s,{cacheKey:`auth_trusted_origins_wildcard:${i.file}:${i.line.toString()}`,detail:`\`createAuth\` in ${a(i)} sets \`trustedOrigins\` to include \`"*"\`, disabling CSRF/origin validation for every request. List the specific origins your deployment serves instead.`,metadata:{...r(i)}})),source:"static",title:"createAuth trustedOrigins wildcard"};export{s as default};
@@ -1 +1 @@
1
- import{m as t}from"./argument-derived-sink-DLXFC79t.mjs";import{c as s,a as o}from"./helpers-BmppB5nL.mjs";const n=t({cacheKey:e=>`browser_user_url_without_allowlist:${e.file}:${e.line.toString()}`,categories:["SECURITY"],description:"A `ctx.browser.<method>(url, …)` call navigates to a URL derived from the handler's `args` with no server-side scoping, and no `createBrowser` allowlist contains it. The default guard blocks private targets but not arbitrary public URLs, so any caller can make the headless browser an open-proxy / SSRF tool (fetch arbitrary third-party URLs, DNS-rebind to internal hosts).",detail:e=>`\`ctx.browser.${e.method}\` in ${o(e)} navigates to a URL derived from \`args\` with no server-side scoping, and no \`createBrowser\` allowlist contains it — the default guard blocks private targets but not arbitrary public URLs, so any caller can turn the headless browser into an open-proxy / SSRF tool. Pin \`allowedHosts\` on \`createBrowser({...})\` (Browser Run enforces it on the session), and derive the URL from server-trusted state where possible.`,facing:"EXTERNAL",getAccesses:e=>e.browserUrlAccesses,level:"WARN",metadata:e=>({...s(e),method:e.method}),name:"browser_user_url_without_allowlist",remediation:"Pin the browser with an `allowedHosts` allowlist on `createBrowser({...})` — Browser Run enforces it on the session, which makes it the supported production posture — and derive the navigation URL from server-trusted state where possible rather than passing `args` straight to `ctx.browser`.",suppressWhen:e=>(e.configCalls??[]).some(r=>r.callee==="createBrowser"&&r.analyzable&&r.presentKeys.includes("allowedHosts")),title:"Browser navigates to arg-derived URL with no allowlist"});export{n as default};
1
+ import{m as t}from"./argument-derived-sink-DLXFC79t.mjs";import{c as s,a as o}from"./helpers-DdZ_pyR3.mjs";const n=t({cacheKey:e=>`browser_user_url_without_allowlist:${e.file}:${e.line.toString()}`,categories:["SECURITY"],description:"A `ctx.browser.<method>(url, …)` call navigates to a URL derived from the handler's `args` with no server-side scoping, and no `createBrowser` allowlist contains it. The default guard blocks private targets but not arbitrary public URLs, so any caller can make the headless browser an open-proxy / SSRF tool (fetch arbitrary third-party URLs, DNS-rebind to internal hosts).",detail:e=>`\`ctx.browser.${e.method}\` in ${o(e)} navigates to a URL derived from \`args\` with no server-side scoping, and no \`createBrowser\` allowlist contains it — the default guard blocks private targets but not arbitrary public URLs, so any caller can turn the headless browser into an open-proxy / SSRF tool. Pin \`allowedHosts\` on \`createBrowser({...})\` (Browser Run enforces it on the session), and derive the URL from server-trusted state where possible.`,facing:"EXTERNAL",getAccesses:e=>e.browserUrlAccesses,level:"WARN",metadata:e=>({...s(e),method:e.method}),name:"browser_user_url_without_allowlist",remediation:"Pin the browser with an `allowedHosts` allowlist on `createBrowser({...})` — Browser Run enforces it on the session, which makes it the supported production posture — and derive the navigation URL from server-trusted state where possible rather than passing `args` straight to `ctx.browser`.",suppressWhen:e=>(e.configCalls??[]).some(r=>r.callee==="createBrowser"&&r.analyzable&&r.presentKeys.includes("allowedHosts")),title:"Browser navigates to arg-derived URL with no allowlist"});export{n as default};
@@ -1 +1 @@
1
- import{m as t}from"./argument-derived-sink-DLXFC79t.mjs";import{c as n,a as r}from"./helpers-BmppB5nL.mjs";const s=t({cacheKey:e=>`container_instance_key_from_user_input:${e.file}:${e.line.toString()}`,categories:["SECURITY"],description:"A `ctx.containers.<name>.get` call routes to a container instance using a key derived from the handler's `args` with no server-side scoping. Any caller can supply another tenant's key and reach that tenant's container instance — a cross-tenant insecure direct object reference (IDOR).",detail:e=>`\`ctx.containers.*.${e.method}\` in ${r(e)} routes to a container instance using a key derived from \`args\` with no server-side scoping — any caller can reach another tenant's container (IDOR). Derive the key from a server-trusted identity (e.g. \`\${ctx.auth.userId}\`).`,facing:"EXTERNAL",getAccesses:e=>e.containerKeyAccesses,level:"WARN",metadata:e=>({...n(e),method:e.method}),name:"container_instance_key_from_user_input",remediation:"Derive the container instance key from a server-trusted identity (e.g. `${ctx.auth.userId}`) or a record the caller owns — never pass request input straight to `ctx.containers.<name>.get`.",title:"Possible cross-tenant IDOR from arg-derived unscoped container key"});export{s as default};
1
+ import{m as t}from"./argument-derived-sink-DLXFC79t.mjs";import{c as n,a as r}from"./helpers-DdZ_pyR3.mjs";const s=t({cacheKey:e=>`container_instance_key_from_user_input:${e.file}:${e.line.toString()}`,categories:["SECURITY"],description:"A `ctx.containers.<name>.get` call routes to a container instance using a key derived from the handler's `args` with no server-side scoping. Any caller can supply another tenant's key and reach that tenant's container instance — a cross-tenant insecure direct object reference (IDOR).",detail:e=>`\`ctx.containers.*.${e.method}\` in ${r(e)} routes to a container instance using a key derived from \`args\` with no server-side scoping — any caller can reach another tenant's container (IDOR). Derive the key from a server-trusted identity (e.g. \`\${ctx.auth.userId}\`).`,facing:"EXTERNAL",getAccesses:e=>e.containerKeyAccesses,level:"WARN",metadata:e=>({...n(e),method:e.method}),name:"container_instance_key_from_user_input",remediation:"Derive the container instance key from a server-trusted identity (e.g. `${ctx.auth.userId}`) or a record the caller owns — never pass request input straight to `ctx.containers.<name>.get`.",title:"Possible cross-tenant IDOR from arg-derived unscoped container key"});export{s as default};
@@ -1 +1 @@
1
- import{callSiteDescription as a}from"./callSiteCallers-DArHfQSU.mjs";import{e as i}from"./finding-NrKO8idM.mjs";import{c as n}from"./helpers-BmppB5nL.mjs";const r={categories:["SECURITY"],description:"A runtime `.egress.allow(...)` / `.egress.deny(...)` / `.egress.setAllowed(...)` call mutates the container's egress firewall at runtime, re-opening a network surface the static analysis assumes is locked to the `defineContainer` declaration.",facing:"EXTERNAL",level:"WARN",name:"container_runtime_egress_relaxation",remediation:"Prefer declaring the egress policy statically in `lunora/containers.ts` where it is auditable. When a runtime mutation is unavoidable, scope it behind a server-trusted check and keep the change narrow (a specific host, not a broad allow) rather than an unconditional relaxation.",run:t=>t.containerOverrides===void 0?[]:t.containerOverrides.filter(e=>e.kind==="egress_relaxation").map(e=>i(r,{cacheKey:`container_runtime_egress_relaxation:${e.file}:${e.line.toString()}`,detail:`${a(e.scope)} calls \`.egress.${e.detail}(...)\` at runtime, mutating the container's egress firewall.`,metadata:{...n(e),method:e.detail}})),source:"static",title:"Runtime egress mutation relaxes the container firewall"};export{r as default};
1
+ import{callSiteDescription as a}from"./callSiteCallers-DArHfQSU.mjs";import{e as i}from"./finding-NrKO8idM.mjs";import{c as n}from"./helpers-DdZ_pyR3.mjs";const r={categories:["SECURITY"],description:"A runtime `.egress.allow(...)` / `.egress.deny(...)` / `.egress.setAllowed(...)` call mutates the container's egress firewall at runtime, re-opening a network surface the static analysis assumes is locked to the `defineContainer` declaration.",facing:"EXTERNAL",level:"WARN",name:"container_runtime_egress_relaxation",remediation:"Prefer declaring the egress policy statically in `lunora/containers.ts` where it is auditable. When a runtime mutation is unavoidable, scope it behind a server-trusted check and keep the change narrow (a specific host, not a broad allow) rather than an unconditional relaxation.",run:t=>t.containerOverrides===void 0?[]:t.containerOverrides.filter(e=>e.kind==="egress_relaxation").map(e=>i(r,{cacheKey:`container_runtime_egress_relaxation:${e.file}:${e.line.toString()}`,detail:`${a(e.scope)} calls \`.egress.${e.detail}(...)\` at runtime, mutating the container's egress firewall.`,metadata:{...n(e),method:e.detail}})),source:"static",title:"Runtime egress mutation relaxes the container firewall"};export{r as default};
@@ -1 +1 @@
1
- import{callSiteDescription as n}from"./callSiteCallers-DArHfQSU.mjs";import{e as r}from"./finding-NrKO8idM.mjs";import{c as i}from"./helpers-BmppB5nL.mjs";const a={categories:["SECURITY"],description:"A runtime `.start({ enableInternet: true, … })` call overrides the static `defineContainer` declaration and re-opens public internet egress for that container instance, defeating the `container_public_internet` lint's assumption that the declaration is authoritative.",facing:"EXTERNAL",level:"WARN",name:"container_start_enable_internet_override",remediation:"Avoid overriding `enableInternet` at `.start()` time. Set it once on the `defineContainer` declaration in `lunora/containers.ts` so the static advisors can reason about it, and gate any runtime opt-in behind a server-trusted check rather than an unconditional literal.",run:t=>t.containerOverrides===void 0?[]:t.containerOverrides.filter(e=>e.kind==="enable_internet").map(e=>r(a,{cacheKey:`container_start_enable_internet_override:${e.file}:${e.line.toString()}`,detail:`${n(e.scope)} calls \`.start({ enableInternet: true })\`, overriding the container's static egress posture.`,metadata:{...i(e)}})),source:"static",title:"Runtime .start() override re-enables container internet access"};export{a as default};
1
+ import{callSiteDescription as n}from"./callSiteCallers-DArHfQSU.mjs";import{e as r}from"./finding-NrKO8idM.mjs";import{c as i}from"./helpers-DdZ_pyR3.mjs";const a={categories:["SECURITY"],description:"A runtime `.start({ enableInternet: true, … })` call overrides the static `defineContainer` declaration and re-opens public internet egress for that container instance, defeating the `container_public_internet` lint's assumption that the declaration is authoritative.",facing:"EXTERNAL",level:"WARN",name:"container_start_enable_internet_override",remediation:"Avoid overriding `enableInternet` at `.start()` time. Set it once on the `defineContainer` declaration in `lunora/containers.ts` so the static advisors can reason about it, and gate any runtime opt-in behind a server-trusted check rather than an unconditional literal.",run:t=>t.containerOverrides===void 0?[]:t.containerOverrides.filter(e=>e.kind==="enable_internet").map(e=>r(a,{cacheKey:`container_start_enable_internet_override:${e.file}:${e.line.toString()}`,detail:`${n(e.scope)} calls \`.start({ enableInternet: true })\`, overriding the container's static egress posture.`,metadata:{...i(e)}})),source:"static",title:"Runtime .start() override re-enables container internet access"};export{a as default};
@@ -0,0 +1 @@
1
+ import{e as o}from"./finding-NrKO8idM.mjs";import{i as l,c,q as f}from"./helpers-DdZ_pyR3.mjs";import{o as h,u as x}from"./sweeps-JWMwcakB.mjs";const m={categories:["PERFORMANCE"],description:"A query calls `.withIndex(…)` and then `.filter(…)` on a field that index does not cover. The index narrows the scan, and the filter discards most of the rows it read. Extending the index with the field lets the read seek instead of scanning.",facing:"EXTERNAL",level:"INFO",name:"filter_after_index_scan",remediation:"Add the filtered field to the index the read uses, so the range covers it, or confirm the predicate cannot be expressed as an index range and keep the filter.",run:t=>{const n=[],r=l(t.schema),d=new Set(h(t));for(const e of t.queries??[]){if(!e.hasFilter||e.table===""||e.index?.name===void 0||e.filterFields===void 0||d.has(e))continue;const a=r(e);if(a===void 0)continue;const i=x(e,a);i.length!==0&&n.push(o(m,{cacheKey:`filter_after_index_scan:${e.file}:${e.line.toString()}:${e.table}`,detail:`Query on "${e.table}" at ${f(e)} narrows with index "${e.index.name}" and then filters on ${i.map(s=>`"${s}"`).join(", ")}, which the index does not cover. The filter reads every row the index range returns and keeps only those that match.`,metadata:{...c(e),filteredFields:i,indexName:e.index.name,table:e.table}}))}return n},source:"static",title:"Filter after index scan"};export{m as default};
@@ -1 +1 @@
1
- import{e as r}from"./finding-NrKO8idM.mjs";import{q as a,c as s}from"./helpers-BmppB5nL.mjs";const l={categories:["PERFORMANCE"],description:"A query filters on `_id`, the primary key. That walks the table comparing every row's id, when `ctx.db.get(id)` fetches the row directly. There is no case where the scan is preferable.",facing:"EXTERNAL",level:"WARN",name:"filter_on_primary_key",remediation:'Replace `ctx.db.query("table").filter((d) => d._id === id).first()` with `ctx.db.get(id)`. Passing a typed `Id<"table">` also narrows the result to that table\'s `Doc`, where the scan form returns the shared row type.',run:t=>(t.queries??[]).filter(e=>e.filtersPrimaryKey===!0&&e.table!=="").map(e=>{const i=a(e);return r(l,{cacheKey:`filter_on_primary_key:${e.file}:${e.line.toString()}:${e.table}`,detail:`Query on "${e.table}" at ${i} filters on \`_id\` — it scans "${e.table}" to find a row \`ctx.db.get(id)\` addresses directly.`,metadata:{...s(e),table:e.table}})}),source:"static",title:"Filter on primary key instead of ctx.db.get"};export{l as default};
1
+ import{e as r}from"./finding-NrKO8idM.mjs";import{q as a,c as s}from"./helpers-DdZ_pyR3.mjs";const l={categories:["PERFORMANCE"],description:"A query filters on `_id`, the primary key. That walks the table comparing every row's id, when `ctx.db.get(id)` fetches the row directly. There is no case where the scan is preferable.",facing:"EXTERNAL",level:"WARN",name:"filter_on_primary_key",remediation:'Replace `ctx.db.query("table").filter((d) => d._id === id).first()` with `ctx.db.get(id)`. Passing a typed `Id<"table">` also narrows the result to that table\'s `Doc`, where the scan form returns the shared row type.',run:t=>(t.queries??[]).filter(e=>e.filtersPrimaryKey===!0&&e.table!=="").map(e=>{const i=a(e);return r(l,{cacheKey:`filter_on_primary_key:${e.file}:${e.line.toString()}:${e.table}`,detail:`Query on "${e.table}" at ${i} filters on \`_id\` — it scans "${e.table}" to find a row \`ctx.db.get(id)\` addresses directly.`,metadata:{...s(e),table:e.table}})}),source:"static",title:"Filter on primary key instead of ctx.db.get"};export{l as default};
@@ -1 +1 @@
1
- import{e as s}from"./finding-NrKO8idM.mjs";import{s as c,q as u,c as f}from"./helpers-BmppB5nL.mjs";const l={categories:["PERFORMANCE"],description:"A query calls `.filter()` without a `.withIndex()` / `.withSearchIndex()`, so it loads every row in the table and filters in memory — a full table scan that gets linearly slower as the table grows.",facing:"EXTERNAL",level:"WARN",name:"filter_without_index",remediation:'Narrow the read with `.withIndex("name", (q) => q.eq(...))` first, then `.filter()` only for what the index cannot express.',run:i=>{const t=[],d=c(i.schema);for(const e of i.queries??[]){if(!e.hasFilter||e.hasIndex||e.table===""||e.filtersPrimaryKey===!0)continue;const n=u(e),a=d.get(e.table),o={...f(e),shardKind:a??"unknown",table:e.table},r=`filter_without_index:${e.file}:${e.line.toString()}:${e.table}`;if(a==="shardBy"){t.push(s(l,{cacheKey:r,detail:`Query on "${e.table}" at ${n} calls .filter() without an index. "${e.table}" is \`.shardBy()\`, so the read is already scoped to one shard rather than the whole table — this is bounded by one tenant's row count, not the dataset. Add an index if that per-shard count grows large.`,level:"INFO",metadata:o}));continue}const h=new Map([["global",`it scans the whole D1 table "${e.table}" — unbounded, and the cost is a cross-region round trip`],["root",`it loads every row of "${e.table}" from the root Durable Object's SQLite and filters in memory`]]).get(a??"")??`it loads every row of "${e.table}" and filters in memory`;t.push(s(l,{cacheKey:r,detail:`Query on "${e.table}" at ${n} calls .filter() without an index — ${h}.`,metadata:o}))}return t},source:"static",title:"Filter without index"};export{l as default};
1
+ import{e as s}from"./finding-NrKO8idM.mjs";import{s as c,q as u,c as f}from"./helpers-DdZ_pyR3.mjs";const l={categories:["PERFORMANCE"],description:"A query calls `.filter()` without a `.withIndex()` / `.withSearchIndex()`, so it loads every row in the table and filters in memory — a full table scan that gets linearly slower as the table grows.",facing:"EXTERNAL",level:"WARN",name:"filter_without_index",remediation:'Narrow the read with `.withIndex("name", (q) => q.eq(...))` first, then `.filter()` only for what the index cannot express.',run:i=>{const t=[],d=c(i.schema);for(const e of i.queries??[]){if(!e.hasFilter||e.hasIndex||e.table===""||e.filtersPrimaryKey===!0)continue;const n=u(e),a=d.get(e.table),o={...f(e),shardKind:a??"unknown",table:e.table},r=`filter_without_index:${e.file}:${e.line.toString()}:${e.table}`;if(a==="shardBy"){t.push(s(l,{cacheKey:r,detail:`Query on "${e.table}" at ${n} calls .filter() without an index. "${e.table}" is \`.shardBy()\`, so the read is already scoped to one shard rather than the whole table — this is bounded by one tenant's row count, not the dataset. Add an index if that per-shard count grows large.`,level:"INFO",metadata:o}));continue}const h=new Map([["global",`it scans the whole D1 table "${e.table}" — unbounded, and the cost is a cross-region round trip`],["root",`it loads every row of "${e.table}" from the root Durable Object's SQLite and filters in memory`]]).get(a??"")??`it loads every row of "${e.table}" and filters in memory`;t.push(s(l,{cacheKey:r,detail:`Query on "${e.table}" at ${n} calls .filter() without an index — ${h}.`,metadata:o}))}return t},source:"static",title:"Filter without index"};export{l as default};
@@ -1 +1 @@
1
- import{e as n}from"./finding-NrKO8idM.mjs";import{c as r,a as o}from"./helpers-BmppB5nL.mjs";const f=new Set(["disallow","disallowed","enforce","enforced","enforcement","enforcing","gate","gated","gating","lockdown","rls"]),d=new Set(["allow","allowed","bypass","bypassed","permit","permitted"]),i=new Set(["disable","disabled","no","off","skip","skipped","without"]),u=/(?<=[A-Z])(?=[A-Z][a-z])/gu,c=/(?<=[a-z0-9])(?=[A-Z])/gu,g=/[^a-z0-9]+/u,h=a=>a.replaceAll(u," ").replaceAll(c," ").toLowerCase().split(g).filter(e=>e.length>0),p=a=>{let e=0;for(;e<a.length&&i.has(a[e]);)e+=1;if(e===a.length)return e;let t=0;for(;t<a.length-e&&i.has(a[a.length-1-t]);)t+=1;return e+t},y=a=>{const e=h(a),t=e.some(s=>f.has(s)),l=e.some(s=>d.has(s));if(t!==l)return{protects:t,safeDefault:p(e)%2===1?!t:t}},m={categories:["SECURITY"],description:"A `ctx.flags.boolean(key, default)` read on a security-shaped key has a fail-open default that selects the permissive branch. OpenFeature returns the default when the provider errors, so an outage silently disables a protection (a `false` default on an `enforce`/`rls`/`gate`/`lockdown` key) or grants a permission (a `true` default on an `allow`/`permit`/`bypass` key). A negating token in the key (`disableRls`, `rlsDisabled`, `skipEnforcement`) inverts which default is the safe one.",facing:"EXTERNAL",level:"WARN",name:"flag_gates_security_with_unsafe_default",remediation:"Flip the default so a provider outage fails closed — the finding's own detail names the value to write. The safe default is always the RESTRICTIVE branch, which is not a fixed value: a protection flag (`enforce*`/`rls*`/`gate*`/`lockdown*`/`disallow*`) defaults `true` and a permission/bypass flag (`allow*`/`permit*`/`bypass*`) defaults `false`, but a negated key (`disableRls`, `rlsDisabled`, `skipEnforcement`) inverts both. Never let an unreachable flag backend open access.",run:a=>a.flagSecurityDefaults===void 0?[]:a.flagSecurityDefaults.flatMap(e=>{const t=y(e.key);if(t===void 0||t.safeDefault===e.defaultValue)return[];const l=t.protects?"disabling the guarded protection":"granting the guarded permission";return[n(m,{cacheKey:`flag_gates_security_with_unsafe_default:${e.file}:${e.line.toString()}`,detail:`\`ctx.flags.boolean("${e.key}", ${String(e.defaultValue)})\` in ${o(e)} fails open to the permissive branch — a provider outage returns \`${String(e.defaultValue)}\`, ${l}. Fail closed: default it to \`${String(t.safeDefault)}\`.`,metadata:{defaultValue:e.defaultValue,...r(e),key:e.key}})]}),source:"static",title:"Security flag fails open to the permissive branch"};export{m as default};
1
+ import{e as n}from"./finding-NrKO8idM.mjs";import{c as r,a as o}from"./helpers-DdZ_pyR3.mjs";const f=new Set(["disallow","disallowed","enforce","enforced","enforcement","enforcing","gate","gated","gating","lockdown","rls"]),d=new Set(["allow","allowed","bypass","bypassed","permit","permitted"]),i=new Set(["disable","disabled","no","off","skip","skipped","without"]),u=/(?<=[A-Z])(?=[A-Z][a-z])/gu,c=/(?<=[a-z0-9])(?=[A-Z])/gu,g=/[^a-z0-9]+/u,h=a=>a.replaceAll(u," ").replaceAll(c," ").toLowerCase().split(g).filter(e=>e.length>0),p=a=>{let e=0;for(;e<a.length&&i.has(a[e]);)e+=1;if(e===a.length)return e;let t=0;for(;t<a.length-e&&i.has(a[a.length-1-t]);)t+=1;return e+t},y=a=>{const e=h(a),t=e.some(s=>f.has(s)),l=e.some(s=>d.has(s));if(t!==l)return{protects:t,safeDefault:p(e)%2===1?!t:t}},m={categories:["SECURITY"],description:"A `ctx.flags.boolean(key, default)` read on a security-shaped key has a fail-open default that selects the permissive branch. OpenFeature returns the default when the provider errors, so an outage silently disables a protection (a `false` default on an `enforce`/`rls`/`gate`/`lockdown` key) or grants a permission (a `true` default on an `allow`/`permit`/`bypass` key). A negating token in the key (`disableRls`, `rlsDisabled`, `skipEnforcement`) inverts which default is the safe one.",facing:"EXTERNAL",level:"WARN",name:"flag_gates_security_with_unsafe_default",remediation:"Flip the default so a provider outage fails closed — the finding's own detail names the value to write. The safe default is always the RESTRICTIVE branch, which is not a fixed value: a protection flag (`enforce*`/`rls*`/`gate*`/`lockdown*`/`disallow*`) defaults `true` and a permission/bypass flag (`allow*`/`permit*`/`bypass*`) defaults `false`, but a negated key (`disableRls`, `rlsDisabled`, `skipEnforcement`) inverts both. Never let an unreachable flag backend open access.",run:a=>a.flagSecurityDefaults===void 0?[]:a.flagSecurityDefaults.flatMap(e=>{const t=y(e.key);if(t===void 0||t.safeDefault===e.defaultValue)return[];const l=t.protects?"disabling the guarded protection":"granting the guarded permission";return[n(m,{cacheKey:`flag_gates_security_with_unsafe_default:${e.file}:${e.line.toString()}`,detail:`\`ctx.flags.boolean("${e.key}", ${String(e.defaultValue)})\` in ${o(e)} fails open to the permissive branch — a provider outage returns \`${String(e.defaultValue)}\`, ${l}. Fail closed: default it to \`${String(t.safeDefault)}\`.`,metadata:{defaultValue:e.defaultValue,...r(e),key:e.key}})]}),source:"static",title:"Security flag fails open to the permissive branch"};export{m as default};
@@ -1 +1 @@
1
- import{e as d}from"./finding-NrKO8idM.mjs";import{b as c}from"./helpers-BmppB5nL.mjs";const s={categories:["SCHEMA"],description:"A `.geoIndex(name, { field })` points at a column that is not a `v.geoPoint()`, so its geohash companion can't be built and `withGeoIndex(...)` can never match.",facing:"INTERNAL",level:"ERROR",name:"geo_index_field_not_geopoint",remediation:"Point the geo index at a `v.geoPoint()` column, or change the column's type to `v.geoPoint()`.",run:a=>{const t=[];for(const e of a.schema.tables)for(const n of e.indexes){if(n.kind!=="geo")continue;const o=n.fields[0];if(o===void 0)continue;const i=c(e,o);i===void 0||i==="geoPoint"||t.push(d(s,{cacheKey:`geo_index_field_not_geopoint:${e.name}:${n.name}:${o}`,detail:`Geo index "${n.name}" on table "${e.name}" indexes column "${o}", which is a ${i}, not a v.geoPoint().`,metadata:{field:o,index:n.name,kind:i,table:e.name}}))}return t},source:"static",title:"Geo index field is not a geoPoint"};export{s as default};
1
+ import{e as d}from"./finding-NrKO8idM.mjs";import{b as c}from"./helpers-DdZ_pyR3.mjs";const s={categories:["SCHEMA"],description:"A `.geoIndex(name, { field })` points at a column that is not a `v.geoPoint()`, so its geohash companion can't be built and `withGeoIndex(...)` can never match.",facing:"INTERNAL",level:"ERROR",name:"geo_index_field_not_geopoint",remediation:"Point the geo index at a `v.geoPoint()` column, or change the column's type to `v.geoPoint()`.",run:a=>{const t=[];for(const e of a.schema.tables)for(const n of e.indexes){if(n.kind!=="geo")continue;const o=n.fields[0];if(o===void 0)continue;const i=c(e,o);i===void 0||i==="geoPoint"||t.push(d(s,{cacheKey:`geo_index_field_not_geopoint:${e.name}:${n.name}:${o}`,detail:`Geo index "${n.name}" on table "${e.name}" indexes column "${o}", which is a ${i}, not a v.geoPoint().`,metadata:{field:o,index:n.name,kind:i,table:e.name}}))}return t},source:"static",title:"Geo index field is not a geoPoint"};export{s as default};
@@ -0,0 +1 @@
1
+ import{s as h}from"./dispatch-namespace-BE_LxN7Z.mjs";import{callSiteMetadata as I,helperRole as S,callSiteCallers as d}from"./callSiteCallers-DArHfQSU.mjs";const r=new Set(["delete","deleteMany","hardDelete","patch","patchIf","patchMany","patchWhere","replace"]),$=(e,t)=>e.method!=="patch"&&e.method!=="patchMany"||t===void 0||e.fields===void 0?!0:e.fields.some(n=>t.includes(n)),m=["address","birthdate","creditCard","dateOfBirth","dob","driversLicense","email","firstName","fullName","lastName","nationalId","passport","phone","phoneNumber","socialSecurity","socialSecurityNumber","ssn","taxId"],N=/[^a-z0-9]/giu,_=/([a-z0-9])([A-Z])/gu,w=/[^a-zA-Z0-9]+/u,i=e=>e.replaceAll(N,"").toLowerCase(),c=e=>e.replaceAll(_,"$1_$2").split(w).filter(t=>t.length>0).map(t=>t.toLowerCase()),M=new Set(m.map(e=>i(e))),g=new Set(m.filter(e=>c(e).length===1).map(e=>e.toLowerCase())),A=new Set(["accountId","authorId","createdBy","createdById","organizationId","orgId","ownerId","tenantId","updatedBy","userId","workspaceId"]),E=e=>M.has(i(e))||c(e).some(t=>g.has(t)),L=(e,t)=>{const n=c(e),o=i(e);return t.some(s=>{if(o===i(s))return!0;const a=c(s);return a.length>0&&n.some((l,u)=>a.every((f,p)=>n[u+p]===f))})},O=(e,t)=>e.columnKinds!==void 0&&Object.hasOwn(e.columnKinds,t)?e.columnKinds[t]:void 0,b=new Set(["_creationTime","_id"]),R=e=>e.fields.filter(t=>A.has(t)||E(t)),v=e=>new Set([...b,...e.fields]),P=e=>e.visibility==="public"&&(e.kind==="mutation"||e.kind==="action"),K=e=>e.line>0?`${e.file}:${e.line.toString()}`:e.file,k=e=>{const t=`${e.file}:${e.line.toString()}`;switch(e.scope.kind){case"export":return`\`${e.scope.name}\` (${t})`;case"helper":return`\`${e.scope.name}\` (${t}; ${S(e.scope)})`;default:return`module scope (${t})`}},x=e=>({...I(e.scope),file:e.file,line:e.line}),z=e=>new Map(e.tables.map(t=>[t.name,t.shardKind])),T=(e,t)=>`${h(e)}:${t}`,D=(e,t)=>new Set([...(e??[]).flatMap(n=>n.kind==="schedule"&&n.target!==void 0?[n.target]:[]),...t??[]]),W=(e,t)=>{const n=new Map,o=(s,a)=>{const l=n.get(s);l===void 0?n.set(s,[a]):l.push(a)};for(const s of e??[])if(r.has(s.method))for(const a of d(s.scope))o(`${s.file}:${a}:${s.table}`,{fields:s.fields,method:s.method});for(const s of t??[])r.has(s.method)&&o(`${s.file}:${s.exportName}:${s.table}`,{fields:s.fields,method:s.method});return n},B=e=>{const t=new Map(e.tables.map(n=>[n.name,new Map(n.indexes.filter(o=>o.kind==="index").map(o=>[o.name,o.fields]))]));return n=>{const o=n.index?.name;return o===void 0?void 0:t.get(n.table)?.get(o)}},F=(e,t,n,o=d(t.scope))=>o.some(s=>(e.get(`${t.file}:${s}:${t.table}`)??[]).some(a=>$(a,n)));export{k as a,O as b,x as c,E as d,P as e,T as f,F as g,D as h,B as i,L as m,R as o,K as q,W as r,z as s,v as t};
@@ -1 +1 @@
1
- import{e as i}from"./finding-NrKO8idM.mjs";import{c as n,a as c}from"./helpers-BmppB5nL.mjs";const r=(e,t)=>e==="httpRoute"&&t?`\`httpRoute\` ${t} handler`:"`httpAction` handler",h={categories:["SECURITY"],description:"An `httpAction`/`httpRoute` handler performs a side effect (`ctx.runMutation`/`ctx.runAction`/a `ctx.db` write) but never reads `ctx.auth` — an unauthenticated HTTP endpoint driving a state change, bypassing the identity/RLS checks that guard the rest of the app.",facing:"EXTERNAL",level:"WARN",name:"http_action_missing_auth_guard",remediation:"Read `ctx.auth` in the handler before the side effect — call `await ctx.auth.getIdentity()` (or check `ctx.auth.userId`) and reject unauthenticated/unauthorized requests, or forward through a `mutation`/`action` whose RLS policies enforce access. If the endpoint is intentionally public (e.g. a signed webhook), verify the provider signature before the write.",run:e=>e.httpActionGuards===void 0?[]:e.httpActionGuards.filter(t=>!t.readsAuth).map(t=>{const a=r(t.kind,t.method);return i(h,{cacheKey:`http_action_missing_auth_guard:${t.file}:${t.line.toString()}`,detail:`${a} ${c(t)} calls \`ctx.${t.sideEffect}\` but never reads \`ctx.auth\` — an anonymous caller can drive this write. Authenticate the request before the side effect.`,metadata:{...n(t),kind:t.kind,sideEffect:t.sideEffect,...t.method?{method:t.method}:{}}})}),source:"static",title:"Unauthenticated HTTP handler performs a side effect"};export{h as default};
1
+ import{e as i}from"./finding-NrKO8idM.mjs";import{c as n,a as c}from"./helpers-DdZ_pyR3.mjs";const r=(e,t)=>e==="httpRoute"&&t?`\`httpRoute\` ${t} handler`:"`httpAction` handler",h={categories:["SECURITY"],description:"An `httpAction`/`httpRoute` handler performs a side effect (`ctx.runMutation`/`ctx.runAction`/a `ctx.db` write) but never reads `ctx.auth` — an unauthenticated HTTP endpoint driving a state change, bypassing the identity/RLS checks that guard the rest of the app.",facing:"EXTERNAL",level:"WARN",name:"http_action_missing_auth_guard",remediation:"Read `ctx.auth` in the handler before the side effect — call `await ctx.auth.getIdentity()` (or check `ctx.auth.userId`) and reject unauthenticated/unauthorized requests, or forward through a `mutation`/`action` whose RLS policies enforce access. If the endpoint is intentionally public (e.g. a signed webhook), verify the provider signature before the write.",run:e=>e.httpActionGuards===void 0?[]:e.httpActionGuards.filter(t=>!t.readsAuth).map(t=>{const a=r(t.kind,t.method);return i(h,{cacheKey:`http_action_missing_auth_guard:${t.file}:${t.line.toString()}`,detail:`${a} ${c(t)} calls \`ctx.${t.sideEffect}\` but never reads \`ctx.auth\` — an anonymous caller can drive this write. Authenticate the request before the side effect.`,metadata:{...n(t),kind:t.kind,sideEffect:t.sideEffect,...t.method?{method:t.method}:{}}})}),source:"static",title:"Unauthenticated HTTP handler performs a side effect"};export{h as default};
@@ -1 +1 @@
1
- import{e as r}from"./finding-NrKO8idM.mjs";import{c as i,a as n}from"./helpers-BmppB5nL.mjs";const s={"headers-append":"a `headers.append(...)` call","headers-ctor":"a `new Headers({...})` initializer","headers-set":"a `headers.set(...)` call","response-init":"a `Response` init `headers` object"},o={categories:["SECURITY"],description:"An `httpAction` handler writes a response-header value derived from raw request input (`request.headers`/URL/query/body) with no CR/LF sanitizer. A `Request`-derived string placed verbatim into a response header lets a caller smuggle `\\r\\n` and inject extra headers or split the response (HTTP response splitting / header injection).",facing:"EXTERNAL",level:"WARN",name:"http_action_response_header_injection",remediation:"Never place a raw request value into a response header. Route it through a CR/LF guard (`isSafeHeaderValue` from `@lunora/server`, rejecting values containing `\\r`/`\\n`/`\\0`), URL-encode it (`encodeURIComponent`), or coerce it to a number — before writing the header. `String(...)` / `.toString()` do NOT strip CR/LF and are not sufficient.",run:t=>t.httpHeaderWrites===void 0?[]:t.httpHeaderWrites.map(e=>{const a=e.headerName===""?"a response header":`the \`${e.headerName}\` response header`;return r(o,{cacheKey:`http_action_response_header_injection:${e.file}:${e.line.toString()}`,detail:`${n(e)} writes ${a} from raw request input via ${s[e.via]} with no CR/LF guard — a caller can smuggle \`\\r\\n\` to inject headers or split the response. Route the value through \`isSafeHeaderValue\` or \`encodeURIComponent\` first.`,metadata:{...i(e),headerName:e.headerName,via:e.via}})}),source:"static",title:"Response header written from unsanitized request input"};export{o as default};
1
+ import{e as r}from"./finding-NrKO8idM.mjs";import{c as i,a as n}from"./helpers-DdZ_pyR3.mjs";const s={"headers-append":"a `headers.append(...)` call","headers-ctor":"a `new Headers({...})` initializer","headers-set":"a `headers.set(...)` call","response-init":"a `Response` init `headers` object"},o={categories:["SECURITY"],description:"An `httpAction` handler writes a response-header value derived from raw request input (`request.headers`/URL/query/body) with no CR/LF sanitizer. A `Request`-derived string placed verbatim into a response header lets a caller smuggle `\\r\\n` and inject extra headers or split the response (HTTP response splitting / header injection).",facing:"EXTERNAL",level:"WARN",name:"http_action_response_header_injection",remediation:"Never place a raw request value into a response header. Route it through a CR/LF guard (`isSafeHeaderValue` from `@lunora/server`, rejecting values containing `\\r`/`\\n`/`\\0`), URL-encode it (`encodeURIComponent`), or coerce it to a number — before writing the header. `String(...)` / `.toString()` do NOT strip CR/LF and are not sufficient.",run:t=>t.httpHeaderWrites===void 0?[]:t.httpHeaderWrites.map(e=>{const a=e.headerName===""?"a response header":`the \`${e.headerName}\` response header`;return r(o,{cacheKey:`http_action_response_header_injection:${e.file}:${e.line.toString()}`,detail:`${n(e)} writes ${a} from raw request input via ${s[e.via]} with no CR/LF guard — a caller can smuggle \`\\r\\n\` to inject headers or split the response. Route the value through \`isSafeHeaderValue\` or \`encodeURIComponent\` first.`,metadata:{...i(e),headerName:e.headerName,via:e.via}})}),source:"static",title:"Response header written from unsanitized request input"};export{o as default};
@@ -1 +1 @@
1
- import{e as i}from"./finding-NrKO8idM.mjs";import{a as d,c as r}from"./helpers-BmppB5nL.mjs";const n={categories:["SECURITY"],description:"An authorization read of `auth.identity.<key>` for a claim outside the declared `defineIdentity({ ... })` contract. `defineIdentity` validates only declared claims and forwards undeclared ones verbatim, so the value the policy trusts was never validated — an attacker's token can forge it.",facing:"EXTERNAL",level:"WARN",name:"identity_undeclared_claim_trusted",remediation:"Declare the claim in `defineIdentity({ ... })` (e.g. `role: v.optional(v.string())`) so the worker validates it at the trust boundary before a policy reads it, or stop trusting it for authorization. Declared claims (and `userId`) are validated; undeclared claims are forwarded unchecked.",run:t=>t.identityClaimReads===void 0?[]:t.identityClaimReads.filter(e=>!e.declared).map(e=>{const a=d(e);return i(n,{cacheKey:`identity_undeclared_claim_trusted:${e.file}:${e.line.toString()}:${e.key}`,detail:`\`${e.key}\` in ${a} is read off \`identity\` but is not declared in \`defineIdentity({ ... })\`. Undeclared claims are forwarded unvalidated, so this authorization decision trusts a forgeable value.`,metadata:{...r(e),key:e.key}})}),source:"static",title:"Authorization trusts an undeclared identity claim"};export{n as default};
1
+ import{e as i}from"./finding-NrKO8idM.mjs";import{a as d,c as r}from"./helpers-DdZ_pyR3.mjs";const n={categories:["SECURITY"],description:"An authorization read of `auth.identity.<key>` for a claim outside the declared `defineIdentity({ ... })` contract. `defineIdentity` validates only declared claims and forwards undeclared ones verbatim, so the value the policy trusts was never validated — an attacker's token can forge it.",facing:"EXTERNAL",level:"WARN",name:"identity_undeclared_claim_trusted",remediation:"Declare the claim in `defineIdentity({ ... })` (e.g. `role: v.optional(v.string())`) so the worker validates it at the trust boundary before a policy reads it, or stop trusting it for authorization. Declared claims (and `userId`) are validated; undeclared claims are forwarded unchecked.",run:t=>t.identityClaimReads===void 0?[]:t.identityClaimReads.filter(e=>!e.declared).map(e=>{const a=d(e);return i(n,{cacheKey:`identity_undeclared_claim_trusted:${e.file}:${e.line.toString()}:${e.key}`,detail:`\`${e.key}\` in ${a} is read off \`identity\` but is not declared in \`defineIdentity({ ... })\`. Undeclared claims are forwarded unvalidated, so this authorization decision trusts a forgeable value.`,metadata:{...r(e),key:e.key}})}),source:"static",title:"Authorization trusts an undeclared identity claim"};export{n as default};
@@ -1 +1 @@
1
- import{m as r}from"./argument-derived-sink-DLXFC79t.mjs";import{c as a,a as i}from"./helpers-BmppB5nL.mjs";const o=r({cacheKey:e=>`images_url_source_from_user_input:${e.file}:${e.line.toString()}`,categories:["SECURITY"],description:"A `buildImageDeliveryUrl({ key, … })` call's `key` — the CDN transform's source image, an absolute URL or an origin-relative key — is derived from the handler's `args` with no server-side scoping, so any caller can point the CDN's on-the-fly transform at an attacker-chosen origin (SSRF / open proxy) or an arbitrary key under the account's own store.",detail:e=>`\`buildImageDeliveryUrl\` in ${i(e)} builds its delivery URL from a \`key\` derived from \`args\` with no server-side scoping — any caller can point the CDN's on-the-fly transform at an attacker-chosen origin or store key. Validate \`key\` against an allowlist, or derive it from server-trusted state.`,facing:"EXTERNAL",getAccesses:e=>e.imageDeliveryUrlAccesses,level:"WARN",metadata:e=>({...a(e)}),name:"images_url_source_from_user_input",remediation:"Validate `key` against an allowlist of known origins/prefixes before calling `buildImageDeliveryUrl`, or derive it from server-trusted state (a stored record's own image key) rather than passing `args` straight through.",title:"Image delivery URL built from unscoped user input"});export{o as default};
1
+ import{m as r}from"./argument-derived-sink-DLXFC79t.mjs";import{c as a,a as i}from"./helpers-DdZ_pyR3.mjs";const o=r({cacheKey:e=>`images_url_source_from_user_input:${e.file}:${e.line.toString()}`,categories:["SECURITY"],description:"A `buildImageDeliveryUrl({ key, … })` call's `key` — the CDN transform's source image, an absolute URL or an origin-relative key — is derived from the handler's `args` with no server-side scoping, so any caller can point the CDN's on-the-fly transform at an attacker-chosen origin (SSRF / open proxy) or an arbitrary key under the account's own store.",detail:e=>`\`buildImageDeliveryUrl\` in ${i(e)} builds its delivery URL from a \`key\` derived from \`args\` with no server-side scoping — any caller can point the CDN's on-the-fly transform at an attacker-chosen origin or store key. Validate \`key\` against an allowlist, or derive it from server-trusted state.`,facing:"EXTERNAL",getAccesses:e=>e.imageDeliveryUrlAccesses,level:"WARN",metadata:e=>({...a(e)}),name:"images_url_source_from_user_input",remediation:"Validate `key` against an allowlist of known origins/prefixes before calling `buildImageDeliveryUrl`, or derive it from server-trusted state (a stored record's own image key) rather than passing `args` straight through.",title:"Image delivery URL built from unscoped user input"});export{o as default};
@@ -1 +1 @@
1
- import{e as i}from"./finding-NrKO8idM.mjs";import{t as r}from"./helpers-BmppB5nL.mjs";const c={categories:["SCHEMA"],description:"An index references a column that is not declared on its table. The index can never match, and the typo would otherwise surface only at runtime.",facing:"INTERNAL",level:"ERROR",name:"index_references_unknown_field",remediation:"Fix the column name in the index declaration, or add the column to the table.",run:a=>a.schema.tables.flatMap(e=>{const o=r(e);return e.indexes.flatMap(n=>n.fields.filter(t=>!o.has(t)).map(t=>i(c,{cacheKey:`index_references_unknown_field:${e.name}:${n.name}:${t}`,detail:`Index "${n.name}" on table "${e.name}" references column "${t}", which is not declared on the table.`,metadata:{field:t,index:n.name,indexKind:n.kind,table:e.name}})))}),source:"static",title:"Index references unknown field"};export{c as default};
1
+ import{e as i}from"./finding-NrKO8idM.mjs";import{t as r}from"./helpers-DdZ_pyR3.mjs";const c={categories:["SCHEMA"],description:"An index references a column that is not declared on its table. The index can never match, and the typo would otherwise surface only at runtime.",facing:"INTERNAL",level:"ERROR",name:"index_references_unknown_field",remediation:"Fix the column name in the index declaration, or add the column to the table.",run:a=>a.schema.tables.flatMap(e=>{const o=r(e);return e.indexes.flatMap(n=>n.fields.filter(t=>!o.has(t)).map(t=>i(c,{cacheKey:`index_references_unknown_field:${e.name}:${n.name}:${t}`,detail:`Index "${n.name}" on table "${e.name}" references column "${t}", which is not declared on the table.`,metadata:{field:t,index:n.name,indexKind:n.kind,table:e.name}})))}),source:"static",title:"Index references unknown field"};export{c as default};
@@ -1 +1 @@
1
- import{e as s}from"./finding-NrKO8idM.mjs";import{a as o,c as a}from"./helpers-BmppB5nL.mjs";const n={categories:["SECURITY"],description:"A `ctx.kv` read/write uses a namespace key derived from the handler's `args` with no server-side scoping. Workers KV is a flat namespace, so an unscoped key lets any caller read, overwrite, or delete another user's entry — an insecure direct object reference (IDOR).",facing:"EXTERNAL",level:"ERROR",name:"kv_unscoped_user_key_idor",remediation:"Prefix every `ctx.kv` key with a server-trusted identity (e.g. `${ctx.auth.userId}:${args.id}`) so a caller can only address their own entries. Never pass request input straight to `ctx.kv.get`/`put`/`delete`.",run:r=>r.kvKeyAccesses===void 0?[]:r.kvKeyAccesses.map(e=>{const t=`\`ctx.kv.${e.method}\` in ${o(e)}`,i={...a(e),method:e.method,visibility:e.visibility??"unknown"};return e.visibility==="internal"?s(n,{cacheKey:`kv_unscoped_user_key_idor:${e.file}:${e.line.toString()}`,detail:`${t} uses a KV key derived from \`args\` with no server-side scoping. This is expected for an \`internal\` procedure — no caller can reach it directly, so the key is only ever supplied by trusted server code. Audit the PUBLIC procedures that dispatch to it: if one forwards \`args\` straight through, the IDOR is there.`,facing:"INTERNAL",level:"INFO",metadata:i}):s(n,{cacheKey:`kv_unscoped_user_key_idor:${e.file}:${e.line.toString()}`,detail:`${t} uses a KV key derived from \`args\` with no server-side scoping — any caller can read/overwrite/delete another user's entry (IDOR). Prefix the key with a server-trusted identity (e.g. \`\${ctx.auth.userId}:…\`).`,metadata:i})}),source:"static",title:"Possible IDOR from arg-derived unscoped KV key"};export{n as default};
1
+ import{e as s}from"./finding-NrKO8idM.mjs";import{a as o,c as a}from"./helpers-DdZ_pyR3.mjs";const n={categories:["SECURITY"],description:"A `ctx.kv` read/write uses a namespace key derived from the handler's `args` with no server-side scoping. Workers KV is a flat namespace, so an unscoped key lets any caller read, overwrite, or delete another user's entry — an insecure direct object reference (IDOR).",facing:"EXTERNAL",level:"ERROR",name:"kv_unscoped_user_key_idor",remediation:"Prefix every `ctx.kv` key with a server-trusted identity (e.g. `${ctx.auth.userId}:${args.id}`) so a caller can only address their own entries. Never pass request input straight to `ctx.kv.get`/`put`/`delete`.",run:r=>r.kvKeyAccesses===void 0?[]:r.kvKeyAccesses.map(e=>{const t=`\`ctx.kv.${e.method}\` in ${o(e)}`,i={...a(e),method:e.method,visibility:e.visibility??"unknown"};return e.visibility==="internal"?s(n,{cacheKey:`kv_unscoped_user_key_idor:${e.file}:${e.line.toString()}`,detail:`${t} uses a KV key derived from \`args\` with no server-side scoping. This is expected for an \`internal\` procedure — no caller can reach it directly, so the key is only ever supplied by trusted server code. Audit the PUBLIC procedures that dispatch to it: if one forwards \`args\` straight through, the IDOR is there.`,facing:"INTERNAL",level:"INFO",metadata:i}):s(n,{cacheKey:`kv_unscoped_user_key_idor:${e.file}:${e.line.toString()}`,detail:`${t} uses a KV key derived from \`args\` with no server-side scoping — any caller can read/overwrite/delete another user's entry (IDOR). Prefix the key with a server-trusted identity (e.g. \`\${ctx.auth.userId}:…\`).`,metadata:i})}),source:"static",title:"Possible IDOR from arg-derived unscoped KV key"};export{n as default};
@@ -1 +1 @@
1
- import{e as i}from"./finding-NrKO8idM.mjs";import{c as r,a}from"./helpers-BmppB5nL.mjs";const n={categories:["SECURITY"],description:"A `ctx.mail`/`ctx.email` `send`/`queue` call sets a recipient field (to/cc/bcc) from the handler's `args` with no server-side scoping. Sending to an address taken straight from request input turns the deployment into an open relay / spam amplifier.",facing:"EXTERNAL",level:"WARN",name:"mail_recipient_from_request_input",remediation:"Derive recipients from server-trusted state (e.g. the authenticated user's own record via `ctx.auth`), never straight from `args`. If user-chosen recipients are intended, gate the path behind authentication and rate limiting.",run:t=>t.mailRecipientAccesses===void 0?[]:t.mailRecipientAccesses.map(e=>i(n,{cacheKey:`mail_recipient_from_request_input:${e.file}:${e.line.toString()}`,detail:`\`ctx.mail.${e.method}\` in ${a(e)} sets a recipient field (to/cc/bcc) derived from \`args\` with no server-side scoping — any caller can direct mail to an arbitrary address (open relay / spam amplifier). Derive the recipient from server-trusted state instead (e.g. \`ctx.auth.user.email\`).`,metadata:{...r(e),method:e.method}})),source:"static",title:"Mail recipient derived from request input"};export{n as default};
1
+ import{e as i}from"./finding-NrKO8idM.mjs";import{c as r,a}from"./helpers-DdZ_pyR3.mjs";const n={categories:["SECURITY"],description:"A `ctx.mail`/`ctx.email` `send`/`queue` call sets a recipient field (to/cc/bcc) from the handler's `args` with no server-side scoping. Sending to an address taken straight from request input turns the deployment into an open relay / spam amplifier.",facing:"EXTERNAL",level:"WARN",name:"mail_recipient_from_request_input",remediation:"Derive recipients from server-trusted state (e.g. the authenticated user's own record via `ctx.auth`), never straight from `args`. If user-chosen recipients are intended, gate the path behind authentication and rate limiting.",run:t=>t.mailRecipientAccesses===void 0?[]:t.mailRecipientAccesses.map(e=>i(n,{cacheKey:`mail_recipient_from_request_input:${e.file}:${e.line.toString()}`,detail:`\`ctx.mail.${e.method}\` in ${a(e)} sets a recipient field (to/cc/bcc) derived from \`args\` with no server-side scoping — any caller can direct mail to an arbitrary address (open relay / spam amplifier). Derive the recipient from server-trusted state instead (e.g. \`ctx.auth.user.email\`).`,metadata:{...r(e),method:e.method}})),source:"static",title:"Mail recipient derived from request input"};export{n as default};
@@ -1 +1 @@
1
- import{e as i}from"./finding-NrKO8idM.mjs";import{i as s}from"./helpers-BmppB5nL.mjs";const o={categories:["SECURITY"],description:'A `mask(policies)` column named like PII (email, SSN, phone, …) uses the `"hash"` strategy. `"hash"` is an unsalted 32-bit FNV-1a digest — brute-force-recoverable and cross-row-correlatable — not a confidentiality control, so it does not actually hide the value.',facing:"EXTERNAL",level:"WARN",name:"mask_weak_hash_strategy_on_pii",remediation:'Use `"redact"` instead of `"hash"` for this column — e.g. `mask({ <table>: { <column>: "redact" } })`. Reserve `"hash"` for columns where a stable, joinable pseudonym is the goal and the value itself is not sensitive.',run:a=>a.maskStrategies===void 0?[]:a.maskStrategies.filter(e=>e.strategy==="hash"&&s(e.column)).map(e=>i(o,{cacheKey:`mask_weak_hash_strategy_on_pii:${e.file}:${e.line.toString()}`,detail:`\`${e.exportName}\` in ${e.file} masks \`${e.table===""?e.column:`${e.table}.${e.column}`}\` with \`"hash"\` — its name suggests PII, and \`"hash"\` is brute-force-recoverable and leaks cross-row equality. Use \`"redact"\` instead.`,metadata:{column:e.column,exportName:e.exportName,file:e.file,table:e.table}})),source:"static",title:'Weak "hash" mask strategy on a PII column'};export{o as default};
1
+ import{e as i}from"./finding-NrKO8idM.mjs";import{d as s}from"./helpers-DdZ_pyR3.mjs";const o={categories:["SECURITY"],description:'A `mask(policies)` column named like PII (email, SSN, phone, …) uses the `"hash"` strategy. `"hash"` is an unsalted 32-bit FNV-1a digest — brute-force-recoverable and cross-row-correlatable — not a confidentiality control, so it does not actually hide the value.',facing:"EXTERNAL",level:"WARN",name:"mask_weak_hash_strategy_on_pii",remediation:'Use `"redact"` instead of `"hash"` for this column — e.g. `mask({ <table>: { <column>: "redact" } })`. Reserve `"hash"` for columns where a stable, joinable pseudonym is the goal and the value itself is not sensitive.',run:a=>a.maskStrategies===void 0?[]:a.maskStrategies.filter(e=>e.strategy==="hash"&&s(e.column)).map(e=>i(o,{cacheKey:`mask_weak_hash_strategy_on_pii:${e.file}:${e.line.toString()}`,detail:`\`${e.exportName}\` in ${e.file} masks \`${e.table===""?e.column:`${e.table}.${e.column}`}\` with \`"hash"\` — its name suggests PII, and \`"hash"\` is brute-force-recoverable and leaks cross-row equality. Use \`"redact"\` instead.`,metadata:{column:e.column,exportName:e.exportName,file:e.file,table:e.table}})),source:"static",title:'Weak "hash" mask strategy on a PII column'};export{o as default};
@@ -1 +1 @@
1
- import{e as r}from"./finding-NrKO8idM.mjs";import{i as s}from"./helpers-BmppB5nL.mjs";const l={categories:["SECURITY"],description:"A `.public()` `query` returns raw table rows (no `.output(...)` projection, no `.use(mask(...))`) from a table carrying PII-named columns (`email`, `phone`, `ssn`, …). Every column ships to the caller, and a column added to the table later leaks by default.",facing:"EXTERNAL",level:"INFO",name:"output_projection_missing_on_public_read",remediation:"Add an explicit return projection to the public query — `.output(v.object({ … }).strip())` listing only the fields a client needs — or apply a `.use(mask(...))` policy to the PII columns. This makes the exposed shape intentional and stops a newly-added column from leaking through this query by default.",run:o=>{if(o.rawRowReturns===void 0)return[];const i=new Map;for(const e of o.schema.tables){const t=e.fields.filter(a=>s(a));t.length>0&&i.set(e.name,t)}const n=[];for(const e of o.rawRowReturns){if(e.visibility!=="public"||e.usesOutput||e.usesMask||e.table==="")continue;const t=i.get(e.table);t!==void 0&&n.push(r(l,{cacheKey:`output_projection_missing_on_public_read:${e.file}:${e.line.toString()}`,detail:`Public query \`${e.exportName}\` (${e.file}:${e.line.toString()}) returns raw \`${e.table}\` rows with no \`.output(...)\` projection — shipping PII column(s) ${t.join(", ")} to every caller, and any column added to \`${e.table}\` later leaks by default. Project the return with \`.output(v.object({ … }).strip())\` or mask the PII columns.`,metadata:{columns:t,exportName:e.exportName,file:e.file,line:e.line,table:e.table}}))}return n},source:"static",title:"Public query returns raw rows with PII and no output projection"};export{l as default};
1
+ import{e as r}from"./finding-NrKO8idM.mjs";import{d as s}from"./helpers-DdZ_pyR3.mjs";const l={categories:["SECURITY"],description:"A `.public()` `query` returns raw table rows (no `.output(...)` projection, no `.use(mask(...))`) from a table carrying PII-named columns (`email`, `phone`, `ssn`, …). Every column ships to the caller, and a column added to the table later leaks by default.",facing:"EXTERNAL",level:"INFO",name:"output_projection_missing_on_public_read",remediation:"Add an explicit return projection to the public query — `.output(v.object({ … }).strip())` listing only the fields a client needs — or apply a `.use(mask(...))` policy to the PII columns. This makes the exposed shape intentional and stops a newly-added column from leaking through this query by default.",run:o=>{if(o.rawRowReturns===void 0)return[];const i=new Map;for(const e of o.schema.tables){const t=e.fields.filter(a=>s(a));t.length>0&&i.set(e.name,t)}const n=[];for(const e of o.rawRowReturns){if(e.visibility!=="public"||e.usesOutput||e.usesMask||e.table==="")continue;const t=i.get(e.table);t!==void 0&&n.push(r(l,{cacheKey:`output_projection_missing_on_public_read:${e.file}:${e.line.toString()}`,detail:`Public query \`${e.exportName}\` (${e.file}:${e.line.toString()}) returns raw \`${e.table}\` rows with no \`.output(...)\` projection — shipping PII column(s) ${t.join(", ")} to every caller, and any column added to \`${e.table}\` later leaks by default. Project the return with \`.output(v.object({ … }).strip())\` or mask the PII columns.`,metadata:{columns:t,exportName:e.exportName,file:e.file,line:e.line,table:e.table}}))}return n},source:"static",title:"Public query returns raw rows with PII and no output projection"};export{l as default};
@@ -1 +1 @@
1
- import{e as n}from"./finding-NrKO8idM.mjs";import{a as o,c as s}from"./helpers-BmppB5nL.mjs";const a={categories:["SECURITY"],description:"A `ctx.db` write sets an ownership/identity column (`userId`, `ownerId`, `tenantId`, …) from the handler's `args`. The caller controls who the row belongs to, so any caller can write rows owned by another user or tenant — an act-as-any-user / cross-tenant IDOR.",facing:"EXTERNAL",level:"ERROR",name:"owner_field_from_args_not_auth",remediation:"Stamp the ownership column from the server-trusted identity (`ctx.auth.userId` / `ctx.identity`), never from request input. Drop the field from the accepted `args` so a caller cannot supply it. In an `owner`-scoped `defineMutator`, `args[owner]` is verified only while the `server` impl leaves `args` untouched: a `server` impl that uses `this` or refers to itself (its own name, or `<mutator>.server`) is reported, because it could re-enter with unverified args. Handing `args` to a function codegen cannot read (an imported validator) also counts as touching it; validate in a function declared in the same file that only reads its argument, or validate a copy (`{ ...args }`).",run:i=>i.ownerFieldWrites===void 0?[]:i.ownerFieldWrites.flatMap(e=>{if(e.ownerScoped)return[];if(e.guarded)return[];const t=`\`${e.method}\` in ${o(e)}`,r={...s(e),field:e.field,method:e.method,visibility:e.visibility??"unknown"};return e.adminOnly?[n(a,{cacheKey:`owner_field_from_args_not_auth:${e.file}:${e.line.toString()}:${e.field}`,detail:`${t} sets the ownership field \`${e.field}\` from \`args\`. Expected for a platform-admin procedure: only an admin can reach it, and the admin gate is the trust boundary, not the subject field.`,facing:"INTERNAL",level:"INFO",metadata:r})]:e.visibility==="internal"?[n(a,{cacheKey:`owner_field_from_args_not_auth:${e.file}:${e.line.toString()}:${e.field}`,detail:`${t} sets the ownership field \`${e.field}\` from \`args\`. This is expected for an \`internal\` procedure — no caller can reach it directly, and the trusted caller passes the subject along. Audit the PUBLIC procedures that dispatch to it: if one forwards \`args.${e.field}\` straight through, the IDOR is there.`,facing:"INTERNAL",level:"INFO",metadata:r})]:[n(a,{cacheKey:`owner_field_from_args_not_auth:${e.file}:${e.line.toString()}:${e.field}`,detail:`${t} sets the ownership field \`${e.field}\` from \`args\` instead of the server-trusted identity — any caller can write rows owned by another user/tenant (IDOR). Stamp \`${e.field}\` from \`ctx.auth\`/\`ctx.identity\`, never from request input.`,metadata:r})]}),source:"static",title:"Ownership field written from args, not server identity"};export{a as default};
1
+ import{e as n}from"./finding-NrKO8idM.mjs";import{a as o,c as s}from"./helpers-DdZ_pyR3.mjs";const a={categories:["SECURITY"],description:"A `ctx.db` write sets an ownership/identity column (`userId`, `ownerId`, `tenantId`, …) from the handler's `args`. The caller controls who the row belongs to, so any caller can write rows owned by another user or tenant — an act-as-any-user / cross-tenant IDOR.",facing:"EXTERNAL",level:"ERROR",name:"owner_field_from_args_not_auth",remediation:"Stamp the ownership column from the server-trusted identity (`ctx.auth.userId` / `ctx.identity`), never from request input. Drop the field from the accepted `args` so a caller cannot supply it. In an `owner`-scoped `defineMutator`, `args[owner]` is verified only while the `server` impl leaves `args` untouched: a `server` impl that uses `this` or refers to itself (its own name, or `<mutator>.server`) is reported, because it could re-enter with unverified args. Handing `args` to a function codegen cannot read (an imported validator) also counts as touching it; validate in a function declared in the same file that only reads its argument, or validate a copy (`{ ...args }`).",run:i=>i.ownerFieldWrites===void 0?[]:i.ownerFieldWrites.flatMap(e=>{if(e.ownerScoped)return[];if(e.guarded)return[];const t=`\`${e.method}\` in ${o(e)}`,r={...s(e),field:e.field,method:e.method,visibility:e.visibility??"unknown"};return e.adminOnly?[n(a,{cacheKey:`owner_field_from_args_not_auth:${e.file}:${e.line.toString()}:${e.field}`,detail:`${t} sets the ownership field \`${e.field}\` from \`args\`. Expected for a platform-admin procedure: only an admin can reach it, and the admin gate is the trust boundary, not the subject field.`,facing:"INTERNAL",level:"INFO",metadata:r})]:e.visibility==="internal"?[n(a,{cacheKey:`owner_field_from_args_not_auth:${e.file}:${e.line.toString()}:${e.field}`,detail:`${t} sets the ownership field \`${e.field}\` from \`args\`. This is expected for an \`internal\` procedure — no caller can reach it directly, and the trusted caller passes the subject along. Audit the PUBLIC procedures that dispatch to it: if one forwards \`args.${e.field}\` straight through, the IDOR is there.`,facing:"INTERNAL",level:"INFO",metadata:r})]:[n(a,{cacheKey:`owner_field_from_args_not_auth:${e.file}:${e.line.toString()}:${e.field}`,detail:`${t} sets the ownership field \`${e.field}\` from \`args\` instead of the server-trusted identity — any caller can write rows owned by another user/tenant (IDOR). Stamp \`${e.field}\` from \`ctx.auth\`/\`ctx.identity\`, never from request input.`,metadata:r})]}),source:"static",title:"Ownership field written from args, not server identity"};export{a as default};
@@ -0,0 +1 @@
1
+ import{e as a}from"./finding-NrKO8idM.mjs";const i={categories:["SECURITY"],description:"`createPayment({...})` is constructed without an `authorize` callback. The factory rejects that configuration at construction (`CONFIG_INVALID`), so every payment mutation on it is denied and the app fails at startup.",facing:"EXTERNAL",level:"ERROR",name:"payment_create_without_authorize",remediation:"Pass an `authorize(referenceId)` callback to `createPayment({...})` that verifies the current caller (via `ctx.auth`) owns the payment/subscription reference before the operation runs. Prefer the `ctx.payments` accessor, which is authorization-scoped by default.",run:t=>t.configCalls===void 0?[]:t.configCalls.filter(e=>e.callee==="createPayment"&&e.analyzable&&!e.presentKeys.includes("authorize")).map(e=>a(i,{cacheKey:`payment_create_without_authorize:${e.file}:${e.line.toString()}`,detail:`\`createPayment({...})\` in ${e.file}:${e.line.toString()} has no \`authorize\` callback, so the factory throws \`CONFIG_INVALID\` at construction and every payment mutation on it is denied. Add an \`authorize(referenceId)\` gate that checks \`ctx.auth\` ownership.`,metadata:{callee:e.callee,file:e.file,line:e.line}})),source:"static",title:"createPayment without an authorize gate"};export{i as default};
@@ -1 +1 @@
1
- import{e as n}from"./finding-NrKO8idM.mjs";import{a as i,c as l}from"./helpers-BmppB5nL.mjs";const r=3600,s={categories:["SECURITY"],description:"A payment webhook adapter configured with an implausibly wide `webhookToleranceSeconds` (over an hour). The timestamp tolerance is the webhook replay window; widening it far past the 300s default keeps captured signed events replayable long after capture, defeating the replay check.",facing:"EXTERNAL",level:"WARN",name:"payment_webhook_wide_tolerance",remediation:"Keep `webhookToleranceSeconds` at or near the 300s default — just enough for clock skew. If a wide window was set to work around retries or backfills, handle those with idempotency on the event id instead, not by loosening the replay-window guard.",run:t=>{if(t.paymentWebhooks===void 0)return[];const a=e=>e.toleranceSeconds!==void 0&&e.toleranceSeconds>=r;return t.paymentWebhooks.filter(e=>a(e)).map(e=>{const o=i(e);return n(s,{cacheKey:`payment_webhook_wide_tolerance:${e.file}:${e.line.toString()}`,detail:`\`${e.callee}\` in ${o} sets \`webhookToleranceSeconds\` to ${(e.toleranceSeconds??0).toString()}s (default 300s). That replay window keeps captured signed events valid long after capture.`,metadata:{callee:e.callee,...l(e),toleranceSeconds:e.toleranceSeconds}})})},source:"static",title:"Payment webhook replay-tolerance window is implausibly wide"};export{s as default};
1
+ import{e as n}from"./finding-NrKO8idM.mjs";import{a as i,c as l}from"./helpers-DdZ_pyR3.mjs";const r=3600,s={categories:["SECURITY"],description:"A payment webhook adapter configured with an implausibly wide `webhookToleranceSeconds` (over an hour). The timestamp tolerance is the webhook replay window; widening it far past the 300s default keeps captured signed events replayable long after capture, defeating the replay check.",facing:"EXTERNAL",level:"WARN",name:"payment_webhook_wide_tolerance",remediation:"Keep `webhookToleranceSeconds` at or near the 300s default — just enough for clock skew. If a wide window was set to work around retries or backfills, handle those with idempotency on the event id instead, not by loosening the replay-window guard.",run:t=>{if(t.paymentWebhooks===void 0)return[];const a=e=>e.toleranceSeconds!==void 0&&e.toleranceSeconds>=r;return t.paymentWebhooks.filter(e=>a(e)).map(e=>{const o=i(e);return n(s,{cacheKey:`payment_webhook_wide_tolerance:${e.file}:${e.line.toString()}`,detail:`\`${e.callee}\` in ${o} sets \`webhookToleranceSeconds\` to ${(e.toleranceSeconds??0).toString()}s (default 300s). That replay window keeps captured signed events valid long after capture.`,metadata:{callee:e.callee,...l(e),toleranceSeconds:e.toleranceSeconds}})})},source:"static",title:"Payment webhook replay-tolerance window is implausibly wide"};export{s as default};
@@ -1 +1 @@
1
- import{s as i}from"./dispatch-namespace-BE_LxN7Z.mjs";import{callSiteMetadata as n}from"./callSiteCallers-DArHfQSU.mjs";import{e as l}from"./finding-NrKO8idM.mjs";import{a as d}from"./helpers-BmppB5nL.mjs";const u={categories:["SECURITY"],description:"A `defineQueue`/`defineWorkflow` handler forwards its untrusted payload (queue body / workflow params) straight into a `ctx.run` dispatch whose target is RLS-gated. The handler runs under the system identity with RLS disabled, so the target's row policy — the only thing guarding it — is bypassed, letting a caller act as any user / write across tenants.",facing:"EXTERNAL",level:"ERROR",name:"privileged_dispatch_unvalidated_payload",remediation:"Don't forward an unchecked queue body / workflow param into an RLS-gated function. Re-derive the ownership/identity keys from server-trusted state (or an argument you validate against it) before dispatching, or route the work through a function that re-checks the caller's identity rather than relying on the row policy the privileged handler skips.",run:r=>{if(r.privilegedDispatches===void 0)return[];const s=r.rlsProcedures??[],o=(e,a)=>s.some(t=>t.usesRls&&i(t.file)===i(e)&&t.exportName===a);return r.privilegedDispatches.filter(e=>o(e.targetFile,e.targetExport)).map(e=>{const a=e.dispatchKind==="queue"?"queue":"workflow",t=e.dispatchKind==="queue"?"queue message body":"workflow params";return l(u,{cacheKey:`privileged_dispatch_unvalidated_payload:${e.file}:${e.line.toString()}`,detail:`The ${a} handler ${d(e)} forwards its untrusted ${t} straight into \`${e.targetFile}.${e.targetExport}\`, which is guarded by \`.use(rls(...))\`. The handler runs under the system identity with RLS disabled, so the target's row policy is bypassed — any ownership/identity field the payload controls lets a caller act as another user or write across tenants. Re-derive the identity keys from server-trusted state before dispatching, or route through a function that re-checks the caller.`,metadata:{dispatchKind:e.dispatchKind,file:e.file,...n(e.scope),line:e.line,targetExport:e.targetExport,targetFile:e.targetFile}})})},source:"static",title:"Privileged handler forwards untrusted payload into an RLS-gated function"};export{u as default};
1
+ import{s as i}from"./dispatch-namespace-BE_LxN7Z.mjs";import{callSiteMetadata as n}from"./callSiteCallers-DArHfQSU.mjs";import{e as l}from"./finding-NrKO8idM.mjs";import{a as d}from"./helpers-DdZ_pyR3.mjs";const u={categories:["SECURITY"],description:"A `defineQueue`/`defineWorkflow` handler forwards its untrusted payload (queue body / workflow params) straight into a `ctx.run` dispatch whose target is RLS-gated. The handler runs under the system identity with RLS disabled, so the target's row policy — the only thing guarding it — is bypassed, letting a caller act as any user / write across tenants.",facing:"EXTERNAL",level:"ERROR",name:"privileged_dispatch_unvalidated_payload",remediation:"Don't forward an unchecked queue body / workflow param into an RLS-gated function. Re-derive the ownership/identity keys from server-trusted state (or an argument you validate against it) before dispatching, or route the work through a function that re-checks the caller's identity rather than relying on the row policy the privileged handler skips.",run:r=>{if(r.privilegedDispatches===void 0)return[];const s=r.rlsProcedures??[],o=(e,a)=>s.some(t=>t.usesRls&&i(t.file)===i(e)&&t.exportName===a);return r.privilegedDispatches.filter(e=>o(e.targetFile,e.targetExport)).map(e=>{const a=e.dispatchKind==="queue"?"queue":"workflow",t=e.dispatchKind==="queue"?"queue message body":"workflow params";return l(u,{cacheKey:`privileged_dispatch_unvalidated_payload:${e.file}:${e.line.toString()}`,detail:`The ${a} handler ${d(e)} forwards its untrusted ${t} straight into \`${e.targetFile}.${e.targetExport}\`, which is guarded by \`.use(rls(...))\`. The handler runs under the system identity with RLS disabled, so the target's row policy is bypassed — any ownership/identity field the payload controls lets a caller act as another user or write across tenants. Re-derive the identity keys from server-trusted state before dispatching, or route through a function that re-checks the caller.`,metadata:{dispatchKind:e.dispatchKind,file:e.file,...n(e.scope),line:e.line,targetExport:e.targetExport,targetFile:e.targetFile}})})},source:"static",title:"Privileged handler forwards untrusted payload into an RLS-gated function"};export{u as default};
@@ -1 +1 @@
1
- import{e as a}from"./finding-NrKO8idM.mjs";import{d as r,m as s}from"./helpers-BmppB5nL.mjs";const n=["contact","forgot","login","logIn","magic","otp","register","reset","signin","signIn","signup","signUp","subscribe","verify"],o={categories:["SECURITY"],description:"A public `mutation`/`action` has no `rateLimit` middleware. Publicly-callable writes are flood and brute-force targets — an attacker can exhaust writes, mail quota, or credits, or guess credentials on auth-shaped endpoints.",facing:"EXTERNAL",level:"WARN",name:"public_mutation_without_ratelimit",remediation:'Attach a rate limit: `.use(rateLimit(limiter, "<bucket>"))` from `@lunora/ratelimit`, or wrap the recommended public-procedure guards with `.use(protectPublic({ rateLimit, captcha }))` from `@lunora/server`. Genuinely-open writes can be acknowledged by adding a permissive limiter.',run:i=>i.procedureProtections===void 0?[]:i.procedureProtections.filter(t=>r(t)&&!t.usesRateLimit).map(t=>{const e=s(t.exportName,n);return a(o,{cacheKey:`public_mutation_without_ratelimit:${t.file}:${t.exportName}`,detail:`Public ${t.kind} \`${t.exportName}\` (${t.file}) has no rate limit${e?" — its name suggests an auth/abuse-sensitive endpoint, so this is high-risk":""}. Add \`.use(rateLimit(...))\` or \`.use(protectPublic({ rateLimit }))\`.`,metadata:{exportName:t.exportName,file:t.file,kind:t.kind,sensitive:e}})}),source:"static",title:"Public write without a rate limit"};export{o as default};
1
+ import{e as a}from"./finding-NrKO8idM.mjs";import{e as r,m as s}from"./helpers-DdZ_pyR3.mjs";const n=["contact","forgot","login","logIn","magic","otp","register","reset","signin","signIn","signup","signUp","subscribe","verify"],o={categories:["SECURITY"],description:"A public `mutation`/`action` has no `rateLimit` middleware. Publicly-callable writes are flood and brute-force targets — an attacker can exhaust writes, mail quota, or credits, or guess credentials on auth-shaped endpoints.",facing:"EXTERNAL",level:"WARN",name:"public_mutation_without_ratelimit",remediation:'Attach a rate limit: `.use(rateLimit(limiter, "<bucket>"))` from `@lunora/ratelimit`, or wrap the recommended public-procedure guards with `.use(protectPublic({ rateLimit, captcha }))` from `@lunora/server`. Genuinely-open writes can be acknowledged by adding a permissive limiter.',run:i=>i.procedureProtections===void 0?[]:i.procedureProtections.filter(t=>r(t)&&!t.usesRateLimit).map(t=>{const e=s(t.exportName,n);return a(o,{cacheKey:`public_mutation_without_ratelimit:${t.file}:${t.exportName}`,detail:`Public ${t.kind} \`${t.exportName}\` (${t.file}) has no rate limit${e?" — its name suggests an auth/abuse-sensitive endpoint, so this is high-risk":""}. Add \`.use(rateLimit(...))\` or \`.use(protectPublic({ rateLimit }))\`.`,metadata:{exportName:t.exportName,file:t.file,kind:t.kind,sensitive:e}})}),source:"static",title:"Public write without a rate limit"};export{o as default};
@@ -1 +1 @@
1
- import{e as i}from"./finding-NrKO8idM.mjs";import{o as a}from"./helpers-BmppB5nL.mjs";const n={categories:["SECURITY"],description:'`.public()` opts a table OUT of the schema\'s `.rls("required")` enforcement — it does not mean the table holds public data. A `.public()` table whose columns look ownership- or PII-shaped (`userId`, `email`, `ssn`, …) is exempt from the row-security guard, not "safe to expose".',facing:"EXTERNAL",level:"WARN",name:"public_table_rls_optout_confusion",remediation:'Double-check this table actually needs the RLS opt-out. If it does not, remove `.public()` so `.rls("required")` covers it like every other table. If it genuinely must stay open (a shared lookup table, for instance), rename or comment it so the exemption is intentional rather than a name-implies-safety mistake, and make sure no procedure trusts `.public()` as an authorization boundary.',run:o=>{if(o.schema.rlsMode!=="required")return[];const s=[];for(const e of o.schema.tables){if(!e.isPublic)continue;const t=a(e);t.length!==0&&s.push(i(n,{cacheKey:`public_table_rls_optout_confusion:${e.name}`,detail:`Table "${e.name}" is \`.public()\` (opted OUT of the schema's \`.rls("required")\` enforcement) but carries ownership/PII-shaped column(s): ${t.join(", ")}. \`.public()\` means "exempt from RLS", not "safe to expose" — confirm this table really needs the opt-out.`,metadata:{columns:t,table:e.name}}))}return s},source:"static",title:"Public table opts out of RLS but carries sensitive columns"};export{n as default};
1
+ import{e as i}from"./finding-NrKO8idM.mjs";import{o as a}from"./helpers-DdZ_pyR3.mjs";const n={categories:["SECURITY"],description:'`.public()` opts a table OUT of the schema\'s `.rls("required")` enforcement — it does not mean the table holds public data. A `.public()` table whose columns look ownership- or PII-shaped (`userId`, `email`, `ssn`, …) is exempt from the row-security guard, not "safe to expose".',facing:"EXTERNAL",level:"WARN",name:"public_table_rls_optout_confusion",remediation:'Double-check this table actually needs the RLS opt-out. If it does not, remove `.public()` so `.rls("required")` covers it like every other table. If it genuinely must stay open (a shared lookup table, for instance), rename or comment it so the exemption is intentional rather than a name-implies-safety mistake, and make sure no procedure trusts `.public()` as an authorization boundary.',run:o=>{if(o.schema.rlsMode!=="required")return[];const s=[];for(const e of o.schema.tables){if(!e.isPublic)continue;const t=a(e);t.length!==0&&s.push(i(n,{cacheKey:`public_table_rls_optout_confusion:${e.name}`,detail:`Table "${e.name}" is \`.public()\` (opted OUT of the schema's \`.rls("required")\` enforcement) but carries ownership/PII-shaped column(s): ${t.join(", ")}. \`.public()\` means "exempt from RLS", not "safe to expose" — confirm this table really needs the opt-out.`,metadata:{columns:t,table:e.name}}))}return s},source:"static",title:"Public table opts out of RLS but carries sensitive columns"};export{n as default};
@@ -1 +1 @@
1
- import{e as a}from"./finding-NrKO8idM.mjs";import{c as i,a as r}from"./helpers-BmppB5nL.mjs";const l={categories:["SECURITY"],description:"A `rateLimit`/`dbRateLimit` middleware call's `key` selector is derived from the handler's `args` with no server-side scoping, so an attacker can rotate the key on every request (e.g. by resubmitting a different email/id) and never share a bucket with themselves — the limit never actually limits them.",facing:"EXTERNAL",level:"WARN",name:"ratelimit_key_spoofable_or_global",remediation:'Derive the rate-limit `key` from a server-trusted value — `ctx.auth.userId` for authenticated callers, `ctx.ip` for anonymous ones (e.g. `key: (ctx) => ctx.auth.userId ?? ctx.ip ?? "anon"`) — instead of an `args` field the caller controls.',run:t=>t.ratelimitKeySelectors===void 0?[]:t.ratelimitKeySelectors.map(e=>a(l,{cacheKey:`ratelimit_key_spoofable_or_global:${e.file}:${e.line.toString()}`,detail:`\`${e.callee}(…, "${e.limitName}", { key })\` in ${r(e)} derives its \`key\` from \`args\` with no server-side scoping — an attacker can rotate the key per request and bypass the limit. Derive \`key\` from \`ctx.auth.userId\` / \`ctx.ip\` instead.`,metadata:{callee:e.callee,...i(e),limitName:e.limitName}})),source:"static",title:"Rate-limit key derived from spoofable user input"};export{l as default};
1
+ import{e as a}from"./finding-NrKO8idM.mjs";import{c as i,a as r}from"./helpers-DdZ_pyR3.mjs";const l={categories:["SECURITY"],description:"A `rateLimit`/`dbRateLimit` middleware call's `key` selector is derived from the handler's `args` with no server-side scoping, so an attacker can rotate the key on every request (e.g. by resubmitting a different email/id) and never share a bucket with themselves — the limit never actually limits them.",facing:"EXTERNAL",level:"WARN",name:"ratelimit_key_spoofable_or_global",remediation:'Derive the rate-limit `key` from a server-trusted value — `ctx.auth.userId` for authenticated callers, `ctx.ip` for anonymous ones (e.g. `key: (ctx) => ctx.auth.userId ?? ctx.ip ?? "anon"`) — instead of an `args` field the caller controls.',run:t=>t.ratelimitKeySelectors===void 0?[]:t.ratelimitKeySelectors.map(e=>a(l,{cacheKey:`ratelimit_key_spoofable_or_global:${e.file}:${e.line.toString()}`,detail:`\`${e.callee}(…, "${e.limitName}", { key })\` in ${r(e)} derives its \`key\` from \`args\` with no server-side scoping — an attacker can rotate the key per request and bypass the limit. Derive \`key\` from \`ctx.auth.userId\` / \`ctx.ip\` instead.`,metadata:{callee:e.callee,...i(e),limitName:e.limitName}})),source:"static",title:"Rate-limit key derived from spoofable user input"};export{l as default};
@@ -1 +1 @@
1
- import{callSiteLabel as r,callSiteCallers as l}from"./callSiteCallers-DArHfQSU.mjs";import{e as n}from"./finding-NrKO8idM.mjs";import{c as s,a as o,m as t}from"./helpers-BmppB5nL.mjs";const a=["signin","signIn","signup","signUp","login","logIn","register","reset","password","otp","verify","payment","checkout"],m=i=>[r(i.scope),...l(i.scope)].some(e=>t(e,a))||t(i.limitName,a),u={categories:["SECURITY"],description:"A rate-limit or Turnstile middleware guarding a sensitive auth/payment procedure is configured `failOpen: true`, so a limiter store outage or a failed Turnstile siteverify silently admits every request — turning a transient failure into an unthrottled brute-force / abuse window.",facing:"EXTERNAL",level:"WARN",name:"ratelimit_middleware_fail_open",remediation:"Drop `failOpen: true` (the default is fail-closed) on the rate-limit / Turnstile guard for auth and payment flows, or move the escape hatch to a non-sensitive procedure. Prefer rejecting traffic with a 503 over admitting unlimited unauthenticated attempts when the limiter or siteverify is unavailable.",run:i=>i.failOpenGuards===void 0?[]:i.failOpenGuards.filter(e=>e.failOpen&&m(e)).map(e=>n(u,{cacheKey:`ratelimit_middleware_fail_open:${e.file}:${e.line.toString()}`,detail:`\`${e.callee}(...)\` in ${o(e)} sets \`failOpen: true\` while guarding a sensitive flow — a limiter/siteverify outage then admits every request instead of rejecting it.`,metadata:{callee:e.callee,...s(e),limitName:e.limitName}})),source:"static",title:"Fail-open rate-limit / CAPTCHA guard on a sensitive procedure"};export{u as default};
1
+ import{callSiteLabel as r,callSiteCallers as l}from"./callSiteCallers-DArHfQSU.mjs";import{e as n}from"./finding-NrKO8idM.mjs";import{c as s,a as o,m as t}from"./helpers-DdZ_pyR3.mjs";const a=["signin","signIn","signup","signUp","login","logIn","register","reset","password","otp","verify","payment","checkout"],m=i=>[r(i.scope),...l(i.scope)].some(e=>t(e,a))||t(i.limitName,a),u={categories:["SECURITY"],description:"A rate-limit or Turnstile middleware guarding a sensitive auth/payment procedure is configured `failOpen: true`, so a limiter store outage or a failed Turnstile siteverify silently admits every request — turning a transient failure into an unthrottled brute-force / abuse window.",facing:"EXTERNAL",level:"WARN",name:"ratelimit_middleware_fail_open",remediation:"Drop `failOpen: true` (the default is fail-closed) on the rate-limit / Turnstile guard for auth and payment flows, or move the escape hatch to a non-sensitive procedure. Prefer rejecting traffic with a 503 over admitting unlimited unauthenticated attempts when the limiter or siteverify is unavailable.",run:i=>i.failOpenGuards===void 0?[]:i.failOpenGuards.filter(e=>e.failOpen&&m(e)).map(e=>n(u,{cacheKey:`ratelimit_middleware_fail_open:${e.file}:${e.line.toString()}`,detail:`\`${e.callee}(...)\` in ${o(e)} sets \`failOpen: true\` while guarding a sensitive flow — a limiter/siteverify outage then admits every request instead of rejecting it.`,metadata:{callee:e.callee,...s(e),limitName:e.limitName}})),source:"static",title:"Fail-open rate-limit / CAPTCHA guard on a sensitive procedure"};export{u as default};
@@ -1 +1 @@
1
- import{e as c}from"./finding-NrKO8idM.mjs";import{t as m}from"./helpers-BmppB5nL.mjs";const f=(o,i,t,a)=>{const e=o.get(a.table);if(!e)return[];const n=a.kind==="one"?t:e,s=a.kind==="one"?e:t,r=[];return i(n).has(a.field)||r.push({column:a.field,owner:n.name,side:"field"}),i(s).has(a.references)||r.push({column:a.references,owner:s.name,side:"references"}),r},d={categories:["SCHEMA"],description:"A relation references a foreign-key or referenced column that is not declared on its table, so the join can never resolve.",facing:"INTERNAL",level:"ERROR",name:"relation_references_unknown_field",remediation:"Fix the `field` / `references` column name in the relation, or add the missing column.",run:o=>{const i=new Map(o.schema.tables.map(e=>[e.name,e])),t=new Map,a=e=>{let n=t.get(e);return n||(n=m(e),t.set(e,n)),n};return o.schema.tables.flatMap(e=>e.relations.flatMap(n=>f(i,a,e,n).map(s=>c(d,{cacheKey:`relation_references_unknown_field:${e.name}:${n.name}:${s.side}`,detail:`Relation "${n.name}" on table "${e.name}" uses ${s.side} "${s.column}", which is not declared on table "${s.owner}".`,metadata:{column:s.column,owner:s.owner,relation:n.name,side:s.side,table:e.name}}))))},source:"static",title:"Relation references unknown field"};export{d as default};
1
+ import{e as c}from"./finding-NrKO8idM.mjs";import{t as m}from"./helpers-DdZ_pyR3.mjs";const f=(o,i,t,a)=>{const e=o.get(a.table);if(!e)return[];const n=a.kind==="one"?t:e,s=a.kind==="one"?e:t,r=[];return i(n).has(a.field)||r.push({column:a.field,owner:n.name,side:"field"}),i(s).has(a.references)||r.push({column:a.references,owner:s.name,side:"references"}),r},d={categories:["SCHEMA"],description:"A relation references a foreign-key or referenced column that is not declared on its table, so the join can never resolve.",facing:"INTERNAL",level:"ERROR",name:"relation_references_unknown_field",remediation:"Fix the `field` / `references` column name in the relation, or add the missing column.",run:o=>{const i=new Map(o.schema.tables.map(e=>[e.name,e])),t=new Map,a=e=>{let n=t.get(e);return n||(n=m(e),t.set(e,n)),n};return o.schema.tables.flatMap(e=>e.relations.flatMap(n=>f(i,a,e,n).map(s=>c(d,{cacheKey:`relation_references_unknown_field:${e.name}:${n.name}:${s.side}`,detail:`Relation "${n.name}" on table "${e.name}" uses ${s.side} "${s.column}", which is not declared on table "${s.owner}".`,metadata:{column:s.column,owner:s.owner,relation:n.name,side:s.side,table:e.name}}))))},source:"static",title:"Relation references unknown field"};export{d as default};
@@ -0,0 +1 @@
1
+ import{callSiteCallers as d}from"./callSiteCallers-DArHfQSU.mjs";import{e as f}from"./finding-NrKO8idM.mjs";import{r as u,f as h,g}from"./helpers-DdZ_pyR3.mjs";const m=1e3,p=/^(?:continue|next|page)?_?cursor$/iu,w={categories:["PERFORMANCE"],description:"A function re-arms itself with `ctx.scheduler.runAfter` on a delay under one second, and the re-arming passes no cursor and nothing in the function removes the rows it reads. Each run finds the same rows and does the same work again.",facing:"EXTERNAL",level:"WARN",name:"self_reschedule_without_progress",remediation:"Carry the progress forward: pass a cursor (or the last processed id) in the dispatch args, or delete or update the rows the function handled so the next run selects only what is left. Re-arm only while work remains, and use a longer delay or a `cron` for periodic work.",run:o=>{if(o.callEdges===void 0)return[];const{callEdges:r}=o,c=u(o.tableWrites,o.rowEffects),i=[];for(const e of r){if(e.kind!=="schedule"||e.conditional!==!0||e.target===void 0||e.scope.kind!=="export")continue;const{name:t}=e.scope;if(e.target!==h(e.file,t))continue;const n=e.schedule?.delayMs,a=e.schedule?.argKeys;if(n===void 0||n>=m||a===void 0||a.some(s=>p.test(s))||r.some(s=>s.kind==="call"&&s.file===e.file&&s.scope.kind==="export"&&s.scope.name===t))continue;const l=(o.queries??[]).filter(s=>s.file===e.file&&s.table!==""&&d(s.scope).includes(t));l.length===0||l.some(s=>g(c,s,void 0,[t]))||i.push(f(w,{cacheKey:`self_reschedule_without_progress:${e.file}:${e.line.toString()}:${t}`,detail:`\`${t}\` (${e.file}:${e.line.toString()}) re-arms itself after ${n.toString()} ms with no cursor in its args, and it removes none of the rows it reads. The next run finds the same rows and starts the same work again.`,metadata:{delayMs:n,file:e.file,function:t,line:e.line}}))}return i},source:"static",title:"Self-reschedule without progress"};export{w as default};
@@ -1 +1 @@
1
- import{e as s}from"./finding-NrKO8idM.mjs";import{m as t}from"./procedure-protections-DRQotu9I.mjs";import{d as o}from"./helpers-BmppB5nL.mjs";const r={categories:["SECURITY"],description:"A public `mutation`/`action` that creates a user/session/account row has no disposable-email gate. Throwaway mailboxes farm free trials, evade bans, and pollute the user table.",facing:"EXTERNAL",level:"WARN",name:"signup_mutation_without_disposable_gating",remediation:"Add `.use(emailGateMiddleware({ email: (ctx) => ctx.args.email }))` from `@lunora/auth/email-guard` to reject disposable domains at signup, or gate better-auth's native signup with `withEmailGate(...)` / `emailGateDatabaseHooks(...)`.",run:a=>{if(a.procedureProtections===void 0)return[];const i=[];for(const e of a.procedureProtections)!o(e)||!t(e.writesUserTable)||e.usesEmailGate||t(e.hasEmailArg)&&e.hasEmailArg!==!1&&i.push(s(r,{cacheKey:`signup_mutation_without_disposable_gating:${e.file}:${e.exportName}`,detail:`Public ${e.kind} \`${e.exportName}\` (${e.file}) writes a user/session/account table but has no disposable-email gate. Add \`.use(emailGateMiddleware(...))\` from \`@lunora/auth/email-guard\`.`,metadata:{exportName:e.exportName,file:e.file,kind:e.kind,writesUserTable:e.writesUserTable}}));return i},source:"static",title:"Account-creating write without a disposable-email gate"};export{r as default};
1
+ import{e as s}from"./finding-NrKO8idM.mjs";import{m as t}from"./procedure-protections-DRQotu9I.mjs";import{e as o}from"./helpers-DdZ_pyR3.mjs";const r={categories:["SECURITY"],description:"A public `mutation`/`action` that creates a user/session/account row has no disposable-email gate. Throwaway mailboxes farm free trials, evade bans, and pollute the user table.",facing:"EXTERNAL",level:"WARN",name:"signup_mutation_without_disposable_gating",remediation:"Add `.use(emailGateMiddleware({ email: (ctx) => ctx.args.email }))` from `@lunora/auth/email-guard` to reject disposable domains at signup, or gate better-auth's native signup with `withEmailGate(...)` / `emailGateDatabaseHooks(...)`.",run:a=>{if(a.procedureProtections===void 0)return[];const i=[];for(const e of a.procedureProtections)!o(e)||!t(e.writesUserTable)||e.usesEmailGate||t(e.hasEmailArg)&&e.hasEmailArg!==!1&&i.push(s(r,{cacheKey:`signup_mutation_without_disposable_gating:${e.file}:${e.exportName}`,detail:`Public ${e.kind} \`${e.exportName}\` (${e.file}) writes a user/session/account table but has no disposable-email gate. Add \`.use(emailGateMiddleware(...))\` from \`@lunora/auth/email-guard\`.`,metadata:{exportName:e.exportName,file:e.file,kind:e.kind,writesUserTable:e.writesUserTable}}));return i},source:"static",title:"Account-creating write without a disposable-email gate"};export{r as default};
@@ -1 +1 @@
1
- import{e as i}from"./finding-NrKO8idM.mjs";import{c as n,a}from"./helpers-BmppB5nL.mjs";const s={categories:["SECURITY"],description:"A `ctx.sql` tagged-template interpolates an unparameterized string-building expression (concatenation or nested template) instead of a bound value — splicing raw text into the query and reopening SQL injection.",facing:"EXTERNAL",level:"ERROR",name:"sql_injection_risk",remediation:"Pass the value through a bound placeholder so the driver parameterizes it — keep request input inside a `ctx.sql` placeholder instead of concatenating it into the query string. Never build SQL text from request input by hand.",run:t=>t.sqlInterpolations===void 0?[]:t.sqlInterpolations.map(e=>i(s,{cacheKey:`sql_injection_risk:${e.file}:${e.line.toString()}`,detail:`\`ctx.sql\` in ${a(e)} interpolates a string-building expression instead of a bound value — a SQL-injection vector. Pass the value through a bound placeholder so the driver parameterizes it.`,metadata:{...n(e)}})),source:"static",title:"Possible SQL injection in ctx.sql interpolation"};export{s as default};
1
+ import{e as i}from"./finding-NrKO8idM.mjs";import{c as n,a}from"./helpers-DdZ_pyR3.mjs";const s={categories:["SECURITY"],description:"A `ctx.sql` tagged-template interpolates an unparameterized string-building expression (concatenation or nested template) instead of a bound value — splicing raw text into the query and reopening SQL injection.",facing:"EXTERNAL",level:"ERROR",name:"sql_injection_risk",remediation:"Pass the value through a bound placeholder so the driver parameterizes it — keep request input inside a `ctx.sql` placeholder instead of concatenating it into the query string. Never build SQL text from request input by hand.",run:t=>t.sqlInterpolations===void 0?[]:t.sqlInterpolations.map(e=>i(s,{cacheKey:`sql_injection_risk:${e.file}:${e.line.toString()}`,detail:`\`ctx.sql\` in ${a(e)} interpolates a string-building expression instead of a bound value — a SQL-injection vector. Pass the value through a bound placeholder so the driver parameterizes it.`,metadata:{...n(e)}})),source:"static",title:"Possible SQL injection in ctx.sql interpolation"};export{s as default};
@@ -1 +1 @@
1
- import{e as n}from"./finding-NrKO8idM.mjs";import{c as o,a}from"./helpers-BmppB5nL.mjs";const r={categories:["SECURITY"],description:"A `ctx.storage.generateUploadUrl(key, …)` call has no `contentType` pin, so the signed PUT URL it mints declares no expected content-type for the serving route to check — `upload()`'s `allowedContentTypes`/`maxSize` guards never run for a client-side PUT against this URL.",facing:"EXTERNAL",level:"WARN",name:"storage_generate_upload_url_no_content_type_pin",remediation:"Pass `contentType` to `ctx.storage.generateUploadUrl(key, { contentType })` so the signature is scoped to that content-type, and have your PUT route reject a request whose `Content-Type` differs from `verifySignedUrl`'s returned `contentType` — the pin is bound into the signature, but nothing in `@lunora/storage` compares it to the upload for you. If the size also needs bounding, prefer `upload()`/`store()` (with `allowedContentTypes`/`maxSize`) over a client-side signed PUT.",run:t=>t.storageUploads===void 0?[]:t.storageUploads.filter(e=>e.method==="generateUploadUrl"&&e.analyzable&&!e.presentKeys.includes("contentType")).map(e=>n(r,{cacheKey:`storage_generate_upload_url_no_content_type_pin:${e.file}:${e.line.toString()}`,detail:`\`ctx.storage.generateUploadUrl\` in ${a(e)} has no \`contentType\` pin — the signed PUT it mints declares no expected type for the serving route to check, and bypasses \`upload()\`'s \`allowedContentTypes\`/\`maxSize\` guards.`,metadata:{...o(e)}})),source:"static",title:"generateUploadUrl signed PUT with no content-type pin"};export{r as default};
1
+ import{e as n}from"./finding-NrKO8idM.mjs";import{c as o,a}from"./helpers-DdZ_pyR3.mjs";const r={categories:["SECURITY"],description:"A `ctx.storage.generateUploadUrl(key, …)` call has no `contentType` pin, so the signed PUT URL it mints declares no expected content-type for the serving route to check — `upload()`'s `allowedContentTypes`/`maxSize` guards never run for a client-side PUT against this URL.",facing:"EXTERNAL",level:"WARN",name:"storage_generate_upload_url_no_content_type_pin",remediation:"Pass `contentType` to `ctx.storage.generateUploadUrl(key, { contentType })` so the signature is scoped to that content-type, and have your PUT route reject a request whose `Content-Type` differs from `verifySignedUrl`'s returned `contentType` — the pin is bound into the signature, but nothing in `@lunora/storage` compares it to the upload for you. If the size also needs bounding, prefer `upload()`/`store()` (with `allowedContentTypes`/`maxSize`) over a client-side signed PUT.",run:t=>t.storageUploads===void 0?[]:t.storageUploads.filter(e=>e.method==="generateUploadUrl"&&e.analyzable&&!e.presentKeys.includes("contentType")).map(e=>n(r,{cacheKey:`storage_generate_upload_url_no_content_type_pin:${e.file}:${e.line.toString()}`,detail:`\`ctx.storage.generateUploadUrl\` in ${a(e)} has no \`contentType\` pin — the signed PUT it mints declares no expected type for the serving route to check, and bypasses \`upload()\`'s \`allowedContentTypes\`/\`maxSize\` guards.`,metadata:{...o(e)}})),source:"static",title:"generateUploadUrl signed PUT with no content-type pin"};export{r as default};
@@ -1 +1 @@
1
- import{e as i}from"./finding-NrKO8idM.mjs";import{a,c as n}from"./helpers-BmppB5nL.mjs";const s={categories:["SECURITY"],description:"A `ctx.storage.*` call uses an R2 object key taken directly from the handler's `args` with no server-side scoping. The bucket methods key by the caller-supplied string, so any caller can read, overwrite, or delete another user's object — object-level IDOR.",facing:"EXTERNAL",level:"ERROR",name:"storage_key_from_user_args",remediation:"Prefix the object key with a server-trusted identity (e.g. `${ctx.auth.userId}/…`) or resolve the object through a record the caller is known to own. Never pass request input straight through as an R2 object key.",run:r=>r.storageKeyAccesses===void 0?[]:r.storageKeyAccesses.map(e=>{const t=`\`ctx.storage.*.${e.method}\` in ${a(e)}`,o={...n(e),method:e.method,visibility:e.visibility??"unknown"};return e.visibility==="internal"?i(s,{cacheKey:`storage_key_from_user_args:${e.file}:${e.line.toString()}`,detail:`${t} uses an object key derived from \`args\` with no server-side scoping. This is expected for an \`internal\` procedure — no caller can reach it directly, so the key is only ever supplied by trusted server code. Audit the PUBLIC procedures that dispatch to it: if one forwards \`args\` straight through, the IDOR is there.`,facing:"INTERNAL",level:"INFO",metadata:o}):i(s,{cacheKey:`storage_key_from_user_args:${e.file}:${e.line.toString()}`,detail:`${t} uses an object key derived from \`args\` with no server-side scoping — any caller can read/overwrite/delete another user's object (IDOR). Prefix the key with a server-trusted identity (e.g. \`\${ctx.auth.userId}/…\`) or resolve the object through an owned record.`,metadata:o})}),source:"static",title:"R2 object key taken directly from user args (IDOR)"};export{s as default};
1
+ import{e as i}from"./finding-NrKO8idM.mjs";import{a,c as n}from"./helpers-DdZ_pyR3.mjs";const s={categories:["SECURITY"],description:"A `ctx.storage.*` call uses an R2 object key taken directly from the handler's `args` with no server-side scoping. The bucket methods key by the caller-supplied string, so any caller can read, overwrite, or delete another user's object — object-level IDOR.",facing:"EXTERNAL",level:"ERROR",name:"storage_key_from_user_args",remediation:"Prefix the object key with a server-trusted identity (e.g. `${ctx.auth.userId}/…`) or resolve the object through a record the caller is known to own. Never pass request input straight through as an R2 object key.",run:r=>r.storageKeyAccesses===void 0?[]:r.storageKeyAccesses.map(e=>{const t=`\`ctx.storage.*.${e.method}\` in ${a(e)}`,o={...n(e),method:e.method,visibility:e.visibility??"unknown"};return e.visibility==="internal"?i(s,{cacheKey:`storage_key_from_user_args:${e.file}:${e.line.toString()}`,detail:`${t} uses an object key derived from \`args\` with no server-side scoping. This is expected for an \`internal\` procedure — no caller can reach it directly, so the key is only ever supplied by trusted server code. Audit the PUBLIC procedures that dispatch to it: if one forwards \`args\` straight through, the IDOR is there.`,facing:"INTERNAL",level:"INFO",metadata:o}):i(s,{cacheKey:`storage_key_from_user_args:${e.file}:${e.line.toString()}`,detail:`${t} uses an object key derived from \`args\` with no server-side scoping — any caller can read/overwrite/delete another user's object (IDOR). Prefix the key with a server-trusted identity (e.g. \`\${ctx.auth.userId}/…\`) or resolve the object through an owned record.`,metadata:o})}),source:"static",title:"R2 object key taken directly from user args (IDOR)"};export{s as default};
@@ -1 +1 @@
1
- import{e as l}from"./finding-NrKO8idM.mjs";import{a as g,c as d}from"./helpers-BmppB5nL.mjs";const c=new Set(["getPresignedUrl","getSignedUrl"]),h=8640*60,s=t=>t.method==="getPresignedUrl",a=t=>c.has(t.method)&&t.expiresInSeconds!==void 0&&t.expiresInSeconds>=h,S={categories:["SECURITY"],description:"`ctx.storage.getPresignedUrl(...)` mints a native S3 SigV4 URL that resolves directly against R2, so the request never reaches the Worker and cannot be gated at all — the wrong choice for private or policy-gated content. Either signer minting an `expiresInSeconds` near the shared 7-day ceiling also hands out a long-lived bearer credential.",facing:"EXTERNAL",level:"WARN",name:"storage_presigned_url_for_private_content",remediation:"For private/per-user/policy-gated content, use `getSignedUrl` instead of `getPresignedUrl`: it mints the URL at your own `publicBaseUrl` origin, so the request reaches a route you control. You still have to write that route — validate the URL with `verifySignedUrl` and run your auth/policy/rate-limit checks there; the framework ships no serving handler. On either signer, keep `expiresInSeconds` as short as the use case allows — well under the 7-day ceiling — rather than minting a near-maximum-lifetime URL.",run:t=>t.storageUploads===void 0?[]:t.storageUploads.filter(e=>s(e)||a(e)).map(e=>{const r=s(e),o=a(e),i=g(e);let n;return r&&o?n=`\`ctx.storage.getPresignedUrl\` in ${i} mints a native S3 SigV4 URL — resolves straight off R2, so the request never reaches the Worker to be gated — with an \`expiresInSeconds\` of ${(e.expiresInSeconds??0).toString()}s, near the 7-day signing ceiling.`:r?n=`\`ctx.storage.getPresignedUrl\` in ${i} mints a native S3 SigV4 URL that resolves directly against R2, so the request never reaches the Worker and cannot be gated. Use \`getSignedUrl\` and serve it from a route that calls \`verifySignedUrl\` and runs your checks.`:n=`\`ctx.storage.${e.method}\` in ${i} requests an \`expiresInSeconds\` of ${(e.expiresInSeconds??0).toString()}s, near the 7-day signing ceiling — a leaked link stays valid for almost a week.`,l(S,{cacheKey:`storage_presigned_url_for_private_content:${e.file}:${e.line.toString()}`,detail:n,metadata:{expiresInSeconds:e.expiresInSeconds,...d(e),method:e.method}})}),source:"static",title:"Native presigned URL or near-max-TTL signed URL for private content"};export{S as default};
1
+ import{e as l}from"./finding-NrKO8idM.mjs";import{a as g,c as d}from"./helpers-DdZ_pyR3.mjs";const c=new Set(["getPresignedUrl","getSignedUrl"]),h=8640*60,s=t=>t.method==="getPresignedUrl",a=t=>c.has(t.method)&&t.expiresInSeconds!==void 0&&t.expiresInSeconds>=h,S={categories:["SECURITY"],description:"`ctx.storage.getPresignedUrl(...)` mints a native S3 SigV4 URL that resolves directly against R2, so the request never reaches the Worker and cannot be gated at all — the wrong choice for private or policy-gated content. Either signer minting an `expiresInSeconds` near the shared 7-day ceiling also hands out a long-lived bearer credential.",facing:"EXTERNAL",level:"WARN",name:"storage_presigned_url_for_private_content",remediation:"For private/per-user/policy-gated content, use `getSignedUrl` instead of `getPresignedUrl`: it mints the URL at your own `publicBaseUrl` origin, so the request reaches a route you control. You still have to write that route — validate the URL with `verifySignedUrl` and run your auth/policy/rate-limit checks there; the framework ships no serving handler. On either signer, keep `expiresInSeconds` as short as the use case allows — well under the 7-day ceiling — rather than minting a near-maximum-lifetime URL.",run:t=>t.storageUploads===void 0?[]:t.storageUploads.filter(e=>s(e)||a(e)).map(e=>{const r=s(e),o=a(e),i=g(e);let n;return r&&o?n=`\`ctx.storage.getPresignedUrl\` in ${i} mints a native S3 SigV4 URL — resolves straight off R2, so the request never reaches the Worker to be gated — with an \`expiresInSeconds\` of ${(e.expiresInSeconds??0).toString()}s, near the 7-day signing ceiling.`:r?n=`\`ctx.storage.getPresignedUrl\` in ${i} mints a native S3 SigV4 URL that resolves directly against R2, so the request never reaches the Worker and cannot be gated. Use \`getSignedUrl\` and serve it from a route that calls \`verifySignedUrl\` and runs your checks.`:n=`\`ctx.storage.${e.method}\` in ${i} requests an \`expiresInSeconds\` of ${(e.expiresInSeconds??0).toString()}s, near the 7-day signing ceiling — a leaked link stays valid for almost a week.`,l(S,{cacheKey:`storage_presigned_url_for_private_content:${e.file}:${e.line.toString()}`,detail:n,metadata:{expiresInSeconds:e.expiresInSeconds,...d(e),method:e.method}})}),source:"static",title:"Native presigned URL or near-max-TTL signed URL for private content"};export{S as default};
@@ -1 +1 @@
1
- import{e as a}from"./finding-NrKO8idM.mjs";import{c as o,a as l}from"./helpers-BmppB5nL.mjs";const s=new Set(["store","upload"]),n={categories:["SECURITY"],description:"A `ctx.storage.upload`/`store` call has no `allowedContentTypes` allowlist, so an uploader can store any content-type — including `text/html` or `image/svg+xml` — which, served back from `publicBaseUrl`, is a stored-XSS vector against your own origin.",facing:"EXTERNAL",level:"WARN",name:"storage_upload_without_content_type_allowlist",remediation:'Pass `allowedContentTypes: ["image/png", "image/jpeg", …]` to `ctx.storage.upload`/`store` so an uploaded object\'s `contentType` must match an approved list. Reject anything HTML/SVG/script-executable unless the app genuinely needs to serve it.',run:t=>t.storageUploads===void 0?[]:t.storageUploads.filter(e=>s.has(e.method)&&e.analyzable&&!e.presentKeys.includes("allowedContentTypes")).map(e=>a(n,{cacheKey:`storage_upload_without_content_type_allowlist:${e.file}:${e.line.toString()}`,detail:`\`ctx.storage.${e.method}\` in ${l(e)} has no \`allowedContentTypes\` allowlist — any content-type is accepted, including \`text/html\`/\`image/svg+xml\`, a stored-XSS risk if the object is served from \`publicBaseUrl\`.`,metadata:{...o(e),method:e.method}})),source:"static",title:"Storage upload with no content-type allowlist"};export{n as default};
1
+ import{e as a}from"./finding-NrKO8idM.mjs";import{c as o,a as l}from"./helpers-DdZ_pyR3.mjs";const s=new Set(["store","upload"]),n={categories:["SECURITY"],description:"A `ctx.storage.upload`/`store` call has no `allowedContentTypes` allowlist, so an uploader can store any content-type — including `text/html` or `image/svg+xml` — which, served back from `publicBaseUrl`, is a stored-XSS vector against your own origin.",facing:"EXTERNAL",level:"WARN",name:"storage_upload_without_content_type_allowlist",remediation:'Pass `allowedContentTypes: ["image/png", "image/jpeg", …]` to `ctx.storage.upload`/`store` so an uploaded object\'s `contentType` must match an approved list. Reject anything HTML/SVG/script-executable unless the app genuinely needs to serve it.',run:t=>t.storageUploads===void 0?[]:t.storageUploads.filter(e=>s.has(e.method)&&e.analyzable&&!e.presentKeys.includes("allowedContentTypes")).map(e=>a(n,{cacheKey:`storage_upload_without_content_type_allowlist:${e.file}:${e.line.toString()}`,detail:`\`ctx.storage.${e.method}\` in ${l(e)} has no \`allowedContentTypes\` allowlist — any content-type is accepted, including \`text/html\`/\`image/svg+xml\`, a stored-XSS risk if the object is served from \`publicBaseUrl\`.`,metadata:{...o(e),method:e.method}})),source:"static",title:"Storage upload with no content-type allowlist"};export{n as default};
@@ -1 +1 @@
1
- import{e as t}from"./finding-NrKO8idM.mjs";import{c as o,a as i}from"./helpers-BmppB5nL.mjs";const s=new Set(["store","upload"]),r={categories:["SECURITY"],description:"A `ctx.storage.upload`/`store` call has no `maxSize` cap, so a caller can push an unbounded body straight into R2 through the Worker — unbounded storage/billing exhaustion, and for a streamed body, unbounded worker time.",facing:"EXTERNAL",level:"WARN",name:"storage_upload_without_max_size",remediation:"Pass a `maxSize` (bytes) to `ctx.storage.upload`/`store` sized to the largest legitimate object the app accepts. `ArrayBuffer`/`Blob` bodies are rejected up front when oversized; a `ReadableStream` body is read only as far as the cap and refused once it is exceeded, so nothing oversized reaches the bucket. A streamed body must be buffered to be capped, so `maxSize` is also the per-request memory ceiling and is itself capped at 16 MiB on that path — if the app legitimately accepts objects larger than that, upload them with `ctx.storage.createMultipartUpload()` or a presigned `createUploadHandler()` (neither is flagged by this lint, and neither holds the whole object) rather than dropping the cap.",run:a=>a.storageUploads===void 0?[]:a.storageUploads.filter(e=>s.has(e.method)&&e.analyzable&&!e.presentKeys.includes("maxSize")).map(e=>t(r,{cacheKey:`storage_upload_without_max_size:${e.file}:${e.line.toString()}`,detail:`\`ctx.storage.${e.method}\` in ${i(e)} has no \`maxSize\` cap — a caller can push an unbounded body into R2, exhausting storage/billing.`,metadata:{...o(e),method:e.method}})),source:"static",title:"Storage upload with no size cap"};export{r as default};
1
+ import{e as t}from"./finding-NrKO8idM.mjs";import{c as o,a as i}from"./helpers-DdZ_pyR3.mjs";const s=new Set(["store","upload"]),r={categories:["SECURITY"],description:"A `ctx.storage.upload`/`store` call has no `maxSize` cap, so a caller can push an unbounded body straight into R2 through the Worker — unbounded storage/billing exhaustion, and for a streamed body, unbounded worker time.",facing:"EXTERNAL",level:"WARN",name:"storage_upload_without_max_size",remediation:"Pass a `maxSize` (bytes) to `ctx.storage.upload`/`store` sized to the largest legitimate object the app accepts. `ArrayBuffer`/`Blob` bodies are rejected up front when oversized; a `ReadableStream` body is read only as far as the cap and refused once it is exceeded, so nothing oversized reaches the bucket. A streamed body must be buffered to be capped, so `maxSize` is also the per-request memory ceiling and is itself capped at 16 MiB on that path — if the app legitimately accepts objects larger than that, upload them with `ctx.storage.createMultipartUpload()` or a presigned `createUploadHandler()` (neither is flagged by this lint, and neither holds the whole object) rather than dropping the cap.",run:a=>a.storageUploads===void 0?[]:a.storageUploads.filter(e=>s.has(e.method)&&e.analyzable&&!e.presentKeys.includes("maxSize")).map(e=>t(r,{cacheKey:`storage_upload_without_max_size:${e.file}:${e.line.toString()}`,detail:`\`ctx.storage.${e.method}\` in ${i(e)} has no \`maxSize\` cap — a caller can push an unbounded body into R2, exhausting storage/billing.`,metadata:{...o(e),method:e.method}})),source:"static",title:"Storage upload with no size cap"};export{r as default};
@@ -0,0 +1 @@
1
+ import{callSiteCallers as u}from"./callSiteCallers-DArHfQSU.mjs";import{h as d,r as f,i as h,g,f as m}from"./helpers-DdZ_pyR3.mjs";const p=new Set(["lt","lte"]),w=s=>{const o=d(s.callEdges,s.cronTargets),t=f(s.tableWrites,s.rowEffects),i=h(s.schema);return(s.queries??[]).filter(e=>{if(e.terminal!=="collect"||e.table===""||e.index?.bounds===void 0)return!1;const{bounds:l}=e.index;if(l.some(n=>p.has(n)))return!1;const r=l.length>0&&l.every(n=>n==="eq"),c=n=>o.has(m(e.file,n))||e.visibility==="internal"&&!r,a=i(e);return u(e.scope).some(n=>c(n)&&!g(t,e,a,[n]))})},b=(s,o)=>{const t=new Set(o);return(s.filterFields??[]).filter(i=>i!=="_id"&&!t.has(i))};export{w as o,b as u};
@@ -1 +1 @@
1
- import{e as o}from"./finding-NrKO8idM.mjs";import{b as l}from"./helpers-BmppB5nL.mjs";const m=new Set(["date","number","timestamp"]),s={categories:["SCHEMA"],description:"A `.ttl(field)` policy points at a column that is not an epoch-millisecond timestamp, so the alarm sweep's `field < now` comparison can't decide expiry correctly.",facing:"INTERNAL",level:"ERROR",name:"ttl_field_not_timestamp",remediation:"Point `.ttl(field)` at a `v.timestamp()` / `v.date()` / `v.number()` (epoch-ms) column, or add such a column.",run:n=>{const a=[];for(const e of n.schema.tables){const{ttl:t}=e;if(!t)continue;const i=l(e,t.field);i===void 0||m.has(i)||a.push(o(s,{cacheKey:`ttl_field_not_timestamp:${e.name}:${t.field}`,detail:`Table "${e.name}" declares \`.ttl("${t.field}")\`, but "${t.field}" is a ${i} column, not an epoch-millisecond timestamp.`,metadata:{field:t.field,kind:i,table:e.name}}))}return a},source:"static",title:"TTL field is not a timestamp"};export{s as default};
1
+ import{e as o}from"./finding-NrKO8idM.mjs";import{b as l}from"./helpers-DdZ_pyR3.mjs";const m=new Set(["date","number","timestamp"]),s={categories:["SCHEMA"],description:"A `.ttl(field)` policy points at a column that is not an epoch-millisecond timestamp, so the alarm sweep's `field < now` comparison can't decide expiry correctly.",facing:"INTERNAL",level:"ERROR",name:"ttl_field_not_timestamp",remediation:"Point `.ttl(field)` at a `v.timestamp()` / `v.date()` / `v.number()` (epoch-ms) column, or add such a column.",run:n=>{const a=[];for(const e of n.schema.tables){const{ttl:t}=e;if(!t)continue;const i=l(e,t.field);i===void 0||m.has(i)||a.push(o(s,{cacheKey:`ttl_field_not_timestamp:${e.name}:${t.field}`,detail:`Table "${e.name}" declares \`.ttl("${t.field}")\`, but "${t.field}" is a ${i} column, not an epoch-millisecond timestamp.`,metadata:{field:t.field,kind:i,table:e.name}}))}return a},source:"static",title:"TTL field is not a timestamp"};export{s as default};
@@ -0,0 +1 @@
1
+ import{e as s}from"./finding-NrKO8idM.mjs";import{i as l,c,q as d}from"./helpers-DdZ_pyR3.mjs";import{o as h,u}from"./sweeps-JWMwcakB.mjs";const p={categories:["PERFORMANCE"],description:"A server-driven function (an internal procedure, or one a scheduled dispatch or cron job runs) calls `.collect()` on an open index range, with no upper bound and no take/paginate, and nothing it runs removes the rows it selects. Each run reads the whole range again, and the cost grows with the table.",facing:"EXTERNAL",level:"WARN",name:"unbounded_collect_on_open_range",remediation:"Bound the range from above (`.withIndex(…, (q) => q.eq(…).lt(…, cutoff))`), cap it with `.take(n)`, or page it with `.paginate(…)` and carry the cursor to the next run. A sweep that never removes the rows it selects keeps selecting them, so delete or update the rows it has handled, or move the cursor past them.",run:t=>{const a=l(t.schema);return h(t).map(e=>{const o=e.index?.name??"(computed)",n=u(e,a(e)),r=n.length===0?"":` It also filters on ${n.map(i=>`"${i}"`).join(", ")}, which the index does not cover.`;return s(p,{cacheKey:`unbounded_collect_on_open_range:${e.file}:${e.line.toString()}:${e.table}`,detail:`Server-driven read of "${e.table}" at ${d(e)} collects the open range of index "${o}" with no upper bound. Nothing it runs removes the rows it selects, so every run reads the whole range again.${r}`,metadata:{...c(e),filteredFields:n,indexName:o,table:e.table}})})},source:"static",title:"Unbounded collect on an open index range"};export{p as default};
@@ -1 +1 @@
1
- import{isReachableSite as r,callSiteLabel as i}from"./callSiteCallers-DArHfQSU.mjs";import{e as n}from"./finding-NrKO8idM.mjs";import{c as o,a}from"./helpers-BmppB5nL.mjs";const l={do:"`do { … } while (true)`",for:"`for (;;)`",while:"`while (true)`"},d={categories:["PERFORMANCE"],description:"A literal-infinite loop (`while (true)`, `for (;;)`, `do … while (true)`) has no `break`, `return` or `throw` that can leave it. Once entered it never ends: inside a Durable Object it holds the object forever, and every storage read or write in its body is billed on every turn.",facing:"EXTERNAL",level:"ERROR",name:"unbounded_loop",remediation:"Give the loop an exit: loop on a real condition (`while (cursor !== null)`), `break` when the work runs out, or cap it with an iteration budget. An `await` is not an exit: a long-polling `while (true)` worker is rejected unless it can still leave — check `signal.throwIfAborted()` each turn, `break` when the source closes, or stop at a deadline. For recurring work, do one batch per invocation and re-arm with `ctx.scheduler.runAfter(…)` behind a guard that stops when nothing is left.",run:t=>t.unboundedLoops===void 0?[]:t.unboundedLoops.filter(e=>e.scope.kind==="module"||r(e.scope)).map(e=>n(d,{cacheKey:`unbounded_loop:${e.file}:${i(e.scope)}:${e.kind}`,detail:`${l[e.kind]} in ${a(e)} has no \`break\`, \`return\` or \`throw\` that can leave it — once entered it never ends, and every storage call in its body is billed on every turn.`,metadata:{...o(e),loop:e.kind}})),source:"static",title:"Infinite loop with no exit"};export{d as default};
1
+ import{isReachableSite as r,callSiteLabel as i}from"./callSiteCallers-DArHfQSU.mjs";import{e as n}from"./finding-NrKO8idM.mjs";import{c as o,a}from"./helpers-DdZ_pyR3.mjs";const l={do:"`do { … } while (true)`",for:"`for (;;)`",while:"`while (true)`"},d={categories:["PERFORMANCE"],description:"A literal-infinite loop (`while (true)`, `for (;;)`, `do … while (true)`) has no `break`, `return` or `throw` that can leave it. Once entered it never ends: inside a Durable Object it holds the object forever, and every storage read or write in its body is billed on every turn.",facing:"EXTERNAL",level:"ERROR",name:"unbounded_loop",remediation:"Give the loop an exit: loop on a real condition (`while (cursor !== null)`), `break` when the work runs out, or cap it with an iteration budget. An `await` is not an exit: a long-polling `while (true)` worker is rejected unless it can still leave — check `signal.throwIfAborted()` each turn, `break` when the source closes, or stop at a deadline. For recurring work, do one batch per invocation and re-arm with `ctx.scheduler.runAfter(…)` behind a guard that stops when nothing is left.",run:t=>t.unboundedLoops===void 0?[]:t.unboundedLoops.filter(e=>e.scope.kind==="module"||r(e.scope)).map(e=>n(d,{cacheKey:`unbounded_loop:${e.file}:${i(e.scope)}:${e.kind}`,detail:`${l[e.kind]} in ${a(e)} has no \`break\`, \`return\` or \`throw\` that can leave it — once entered it never ends, and every storage call in its body is billed on every turn.`,metadata:{...o(e),loop:e.kind}})),source:"static",title:"Infinite loop with no exit"};export{d as default};
@@ -1 +1 @@
1
- import{e as a}from"./finding-NrKO8idM.mjs";import{m as i}from"./procedure-protections-DRQotu9I.mjs";import{d as s}from"./helpers-BmppB5nL.mjs";const r=t=>t.writesUserTable===!0?"writes a user/session table":t.callsMail===!0?"sends mail":t.analyzableBody===!1?"may write a user/session table or send mail — its handler body could not be read":"may write a user/session table or send mail — at least one could not be determined",n={categories:["SECURITY"],description:"A public `mutation`/`action` that creates a user/session or sends mail has no CAPTCHA / bot check. Account-creating and mail-sending endpoints are prime automated-abuse targets (credential stuffing, mailbox flooding, disposable-account farming).",facing:"EXTERNAL",level:"WARN",name:"user_creating_mutation_without_captcha",remediation:"Add a server-verified human check: `.use(verifyTurnstileMiddleware({ secret, token: (c) => c.args.captchaToken }))` from `@lunora/auth`, or wrap it with `.use(protectPublic({ rateLimit, captcha }))` from `@lunora/server`. Pair with a rate limit for defense in depth. Note it is `verifyTurnstileMiddleware`, not the bare `verifyTurnstile` verdict function — that one returns a Promise and verifies nothing when placed in a `.use()` chain.",run:t=>t.procedureProtections===void 0?[]:t.procedureProtections.filter(e=>s(e)&&(i(e.writesUserTable)||i(e.callsMail))&&!e.usesCaptcha).map(e=>a(n,{cacheKey:`user_creating_mutation_without_captcha:${e.file}:${e.exportName}`,detail:`Public ${e.kind} \`${e.exportName}\` (${e.file}) ${r(e)} but has no CAPTCHA check. Add \`.use(verifyTurnstileMiddleware(...))\` or \`.use(protectPublic({ captcha }))\`.`,metadata:{callsMail:e.callsMail,exportName:e.exportName,file:e.file,kind:e.kind,writesUserTable:e.writesUserTable}})),source:"static",title:"Account-creating / mail-sending write without a CAPTCHA"};export{n as default};
1
+ import{e as a}from"./finding-NrKO8idM.mjs";import{m as i}from"./procedure-protections-DRQotu9I.mjs";import{e as s}from"./helpers-DdZ_pyR3.mjs";const r=t=>t.writesUserTable===!0?"writes a user/session table":t.callsMail===!0?"sends mail":t.analyzableBody===!1?"may write a user/session table or send mail — its handler body could not be read":"may write a user/session table or send mail — at least one could not be determined",n={categories:["SECURITY"],description:"A public `mutation`/`action` that creates a user/session or sends mail has no CAPTCHA / bot check. Account-creating and mail-sending endpoints are prime automated-abuse targets (credential stuffing, mailbox flooding, disposable-account farming).",facing:"EXTERNAL",level:"WARN",name:"user_creating_mutation_without_captcha",remediation:"Add a server-verified human check: `.use(verifyTurnstileMiddleware({ secret, token: (c) => c.args.captchaToken }))` from `@lunora/auth`, or wrap it with `.use(protectPublic({ rateLimit, captcha }))` from `@lunora/server`. Pair with a rate limit for defense in depth. Note it is `verifyTurnstileMiddleware`, not the bare `verifyTurnstile` verdict function — that one returns a Promise and verifies nothing when placed in a `.use()` chain.",run:t=>t.procedureProtections===void 0?[]:t.procedureProtections.filter(e=>s(e)&&(i(e.writesUserTable)||i(e.callsMail))&&!e.usesCaptcha).map(e=>a(n,{cacheKey:`user_creating_mutation_without_captcha:${e.file}:${e.exportName}`,detail:`Public ${e.kind} \`${e.exportName}\` (${e.file}) ${r(e)} but has no CAPTCHA check. Add \`.use(verifyTurnstileMiddleware(...))\` or \`.use(protectPublic({ captcha }))\`.`,metadata:{callsMail:e.callsMail,exportName:e.exportName,file:e.file,kind:e.kind,writesUserTable:e.writesUserTable}})),source:"static",title:"Account-creating / mail-sending write without a CAPTCHA"};export{n as default};
@@ -1 +1 @@
1
- import{m as r}from"./argument-derived-sink-DLXFC79t.mjs";import{c as t,a}from"./helpers-BmppB5nL.mjs";const n=r({cacheKey:e=>`vectors_namespace_from_user_input:${e.file}:${e.line.toString()}`,categories:["SECURITY"],description:"A `ctx.vectors.query`/`upsert`/`upsertNow` call uses a `namespace` derived from the handler's `args` with no server-side scoping. A Vectorize namespace partitions one index into isolated sub-collections, so an unscoped namespace lets any caller read or poison another tenant's vectors.",detail:e=>`\`ctx.vectors.${e.method}\` in ${a(e)} uses a Vectorize namespace derived from \`args\` with no server-side scoping — any caller can read or poison another tenant's vectors. Derive the namespace from a server-trusted identity (e.g. \`\${ctx.auth.orgId}\`), never from \`args\`.`,facing:"EXTERNAL",getAccesses:e=>e.vectorNamespaceAccesses,level:"WARN",metadata:e=>({...t(e),method:e.method}),name:"vectors_namespace_from_user_input",remediation:"Derive the `namespace` from a server-trusted identity (e.g. `${ctx.auth.orgId}`), never from `args`.",title:"Vectorize namespace derived from unscoped user input"});export{n as default};
1
+ import{m as r}from"./argument-derived-sink-DLXFC79t.mjs";import{c as t,a}from"./helpers-DdZ_pyR3.mjs";const n=r({cacheKey:e=>`vectors_namespace_from_user_input:${e.file}:${e.line.toString()}`,categories:["SECURITY"],description:"A `ctx.vectors.query`/`upsert`/`upsertNow` call uses a `namespace` derived from the handler's `args` with no server-side scoping. A Vectorize namespace partitions one index into isolated sub-collections, so an unscoped namespace lets any caller read or poison another tenant's vectors.",detail:e=>`\`ctx.vectors.${e.method}\` in ${a(e)} uses a Vectorize namespace derived from \`args\` with no server-side scoping — any caller can read or poison another tenant's vectors. Derive the namespace from a server-trusted identity (e.g. \`\${ctx.auth.orgId}\`), never from \`args\`.`,facing:"EXTERNAL",getAccesses:e=>e.vectorNamespaceAccesses,level:"WARN",metadata:e=>({...t(e),method:e.method}),name:"vectors_namespace_from_user_input",remediation:"Derive the `namespace` from a server-trusted identity (e.g. `${ctx.auth.orgId}`), never from `args`.",title:"Vectorize namespace derived from unscoped user input"});export{n as default};
@@ -1 +1 @@
1
- import{callSiteDescription as w,callSiteLabel as t}from"./callSiteCallers-DArHfQSU.mjs";import{e as l}from"./finding-NrKO8idM.mjs";import{c as f}from"./helpers-BmppB5nL.mjs";const n={categories:["SCHEMA"],description:'A `ctx.workflows.get("<name>")` call references a workflow that is not declared by any `defineWorkflow` export in `lunora/workflows.ts`. The name is a typo or points at a removed/renamed workflow — the call throws at runtime.',facing:"INTERNAL",level:"ERROR",name:"workflow_unknown_target",remediation:'Fix the workflow name in the `ctx.workflows.get("…")` call, or add the missing `defineWorkflow` export to `lunora/workflows.ts`.',run:e=>{if(e.workflows===void 0||e.workflowCalls===void 0)return[];const r=new Set(e.workflows.map(o=>o.exportName));return e.workflowCalls.filter(o=>o.workflow!==""&&!r.has(o.workflow)).map(o=>l(n,{cacheKey:`workflow_unknown_target:${o.file}:${t(o.scope)}:${o.workflow}`,detail:`\`ctx.workflows.get("${o.workflow}")\` in ${w(o.scope)} (${o.file}) references workflow "${o.workflow}", which is not declared in lunora/workflows.ts.`,metadata:{...f(o),workflow:o.workflow}}))},source:"static",title:"Workflow call references unknown workflow"};export{n as default};
1
+ import{callSiteDescription as w,callSiteLabel as t}from"./callSiteCallers-DArHfQSU.mjs";import{e as l}from"./finding-NrKO8idM.mjs";import{c as f}from"./helpers-DdZ_pyR3.mjs";const n={categories:["SCHEMA"],description:'A `ctx.workflows.get("<name>")` call references a workflow that is not declared by any `defineWorkflow` export in `lunora/workflows.ts`. The name is a typo or points at a removed/renamed workflow — the call throws at runtime.',facing:"INTERNAL",level:"ERROR",name:"workflow_unknown_target",remediation:'Fix the workflow name in the `ctx.workflows.get("…")` call, or add the missing `defineWorkflow` export to `lunora/workflows.ts`.',run:e=>{if(e.workflows===void 0||e.workflowCalls===void 0)return[];const r=new Set(e.workflows.map(o=>o.exportName));return e.workflowCalls.filter(o=>o.workflow!==""&&!r.has(o.workflow)).map(o=>l(n,{cacheKey:`workflow_unknown_target:${o.file}:${t(o.scope)}:${o.workflow}`,detail:`\`ctx.workflows.get("${o.workflow}")\` in ${w(o.scope)} (${o.file}) references workflow "${o.workflow}", which is not declared in lunora/workflows.ts.`,metadata:{...f(o),workflow:o.workflow}}))},source:"static",title:"Workflow call references unknown workflow"};export{n as default};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/advisor",
3
- "version": "1.0.0-alpha.206",
3
+ "version": "1.0.0-alpha.208",
4
4
  "description": "Schema & query lints (splinter-style advisors) for Lunora, feeding the Studio Advisors view",
5
5
  "keywords": [
6
6
  "advisor",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@lunora/errors": ">=1.0.0-alpha.51 <2.0.0-0",
50
- "@lunora/server": "1.0.0-alpha.190"
50
+ "@lunora/server": "1.0.0-alpha.192"
51
51
  },
52
52
  "engines": {
53
53
  "node": "^22.15.0 || >=24.11.0"
@@ -1 +0,0 @@
1
- import{callSiteMetadata as u,helperRole as p}from"./callSiteCallers-DArHfQSU.mjs";const c=["address","birthdate","creditCard","dateOfBirth","dob","driversLicense","email","firstName","fullName","lastName","nationalId","passport","phone","phoneNumber","socialSecurity","socialSecurityNumber","ssn","taxId"],I=/[^a-z0-9]/giu,S=/([a-z0-9])([A-Z])/gu,h=/[^a-zA-Z0-9]+/u,s=e=>e.replaceAll(I,"").toLowerCase(),n=e=>e.replaceAll(S,"$1_$2").split(h).filter(t=>t.length>0).map(t=>t.toLowerCase()),N=new Set(c.map(e=>s(e))),_=new Set(c.filter(e=>n(e).length===1).map(e=>e.toLowerCase())),f=new Set(["accountId","authorId","createdBy","createdById","organizationId","orgId","ownerId","tenantId","updatedBy","userId","workspaceId"]),A=e=>N.has(s(e))||n(e).some(t=>_.has(t)),O=(e,t)=>{const a=n(e),l=s(e);return t.some(i=>{if(l===s(i))return!0;const o=n(i);return o.length>0&&a.some((L,r)=>o.every((d,m)=>a[r+m]===d))})},P=(e,t)=>e.columnKinds!==void 0&&Object.hasOwn(e.columnKinds,t)?e.columnKinds[t]:void 0,E=new Set(["_creationTime","_id"]),b=e=>e.fields.filter(t=>f.has(t)||A(t)),g=e=>new Set([...E,...e.fields]),y=e=>e.visibility==="public"&&(e.kind==="mutation"||e.kind==="action"),C=e=>e.line>0?`${e.file}:${e.line.toString()}`:e.file,M=e=>{const t=`${e.file}:${e.line.toString()}`;switch(e.scope.kind){case"export":return`\`${e.scope.name}\` (${t})`;case"helper":return`\`${e.scope.name}\` (${t}; ${p(e.scope)})`;default:return`module scope (${t})`}},R=e=>({...u(e.scope),file:e.file,line:e.line}),w=e=>new Map(e.tables.map(t=>[t.name,t.shardKind]));export{M as a,P as b,R as c,y as d,A as i,O as m,b as o,C as q,w as s,g as t};
@@ -1 +0,0 @@
1
- import{e as a}from"./finding-NrKO8idM.mjs";const r={categories:["SECURITY"],description:"`createPayment({...})` was constructed without an `authorize` callback, so its access-control gate never runs — any caller that reaches a payment reference id can drive a charge, refund, or subscription change against it (a broken-access-control / IDOR risk on money movement).",facing:"EXTERNAL",level:"ERROR",name:"payment_create_without_authorize",remediation:"Pass an `authorize(referenceId)` callback to `createPayment({...})` that verifies the current caller (via `ctx.auth`) owns the payment/subscription reference before the operation runs. Prefer the `ctx.payments` accessor, which is authorization-scoped by default.",run:t=>t.configCalls===void 0?[]:t.configCalls.filter(e=>e.callee==="createPayment"&&e.analyzable&&!e.presentKeys.includes("authorize")).map(e=>a(r,{cacheKey:`payment_create_without_authorize:${e.file}:${e.line.toString()}`,detail:`\`createPayment({...})\` in ${e.file}:${e.line.toString()} has no \`authorize\` callback — its access-control gate never runs, so any caller reaching the payment reference can move money against it. Add an \`authorize(referenceId)\` gate that checks \`ctx.auth\` ownership before the operation.`,metadata:{callee:e.callee,file:e.file,line:e.line}})),source:"static",title:"createPayment without an authorize gate"};export{r as default};