@lunora/advisor 1.0.0-alpha.21 → 1.0.0-alpha.211
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -7
- package/dist/index.d.mts +4107 -2575
- package/dist/index.d.ts +4107 -2575
- package/dist/index.mjs +1 -181
- package/dist/packem_shared/MAP_VERSION-MQ5W2B6n.mjs +1 -0
- package/dist/packem_shared/actionFetchSsrf-BK-kvOPO.mjs +1 -0
- package/dist/packem_shared/actionWithoutErrorHandling-4GCBT0_z.mjs +1 -0
- package/dist/packem_shared/adminRouteWithoutGuard-DPE7LuNh.mjs +1 -0
- package/dist/packem_shared/aiRawRunEscapeHatch-BW7a8kZC.mjs +1 -0
- package/dist/packem_shared/aiRunWithoutLogging-CvHHtEN9.mjs +1 -0
- package/dist/packem_shared/aiToolSideEffectPromptInjection-9eJXRlKt.mjs +1 -0
- package/dist/packem_shared/aiUnboundedGenerationPublic-C17h6sMV.mjs +1 -0
- package/dist/packem_shared/allowUnauthenticatedShardAccessEnabled-BQufF5Kb.mjs +1 -0
- package/dist/packem_shared/argument-derived-sink-DLXFC79t.mjs +1 -0
- package/dist/packem_shared/authApiCallWithoutHeaders-CReJRsGW.mjs +1 -0
- package/dist/packem_shared/authCsrfCheckDisabled-CRIVsvb9.mjs +1 -0
- package/dist/packem_shared/authEmailVerificationDisabled-BjFlnHr2.mjs +1 -0
- package/dist/packem_shared/authScimWithoutTransactions-B5ATiz5A.mjs +1 -0
- package/dist/packem_shared/authSecureCookiesDisabled-By8PVum3.mjs +1 -0
- package/dist/packem_shared/authSessionFreshageZero-DuxX3Ln9.mjs +1 -0
- package/dist/packem_shared/authTrustedOriginsWildcard-BKIh6788.mjs +1 -0
- package/dist/packem_shared/browserAllowPrivateTargets-Cj5sizhv.mjs +1 -0
- package/dist/packem_shared/browserUserUrlWithoutAllowlist-CzDEraJB.mjs +1 -0
- package/dist/packem_shared/callSiteCallers-DArHfQSU.mjs +1 -0
- package/dist/packem_shared/circularFk-DtcWFJxK.mjs +1 -0
- package/dist/packem_shared/classifySensitivity-JnjaTGYi.mjs +1 -0
- package/dist/packem_shared/commitOrderedHardDelete-BPdwOKA7.mjs +1 -0
- package/dist/packem_shared/compareToBaseline-CUG59IOy.mjs +1 -0
- package/dist/packem_shared/containerInstanceKeyFromUserInput-Bl-tAzKV.mjs +1 -0
- package/dist/packem_shared/containerOversizedInstance-Bx89uR7E.mjs +1 -0
- package/dist/packem_shared/containerPublicInternet-BFfZf_P4.mjs +1 -0
- package/dist/packem_shared/containerRuntimeEgressRelaxation-4tMrhq1k.mjs +1 -0
- package/dist/packem_shared/containerStartEnableInternetOverride-mN_kuRtk.mjs +1 -0
- package/dist/packem_shared/crossModuleTableWrite-DjiVTAwK.mjs +1 -0
- package/dist/packem_shared/dedupeCacheKeys-DtBOHffV.mjs +1 -0
- package/dist/packem_shared/dispatch-namespace-BE_LxN7Z.mjs +1 -0
- package/dist/packem_shared/dispatchCycle-CWc5Q0cd.mjs +1 -0
- package/dist/packem_shared/duplicateIndex-Cip6-Rpu.mjs +1 -0
- package/dist/packem_shared/emptyIndex-BnHDcXza.mjs +1 -0
- package/dist/packem_shared/errorWithoutCatalog-BTfvaXHR.mjs +1 -0
- package/dist/packem_shared/exportSinkMisconfigured-JfbAx9AI.mjs +1 -0
- package/dist/packem_shared/externalSourceIncrementalNoDeletePath-BCzm3HzF.mjs +1 -0
- package/dist/packem_shared/externalSourceOnGlobal-CH7xbJ49.mjs +1 -0
- package/dist/packem_shared/externalSourceUnscoped-BxU2uSXk.mjs +1 -0
- package/dist/packem_shared/fanOutBreadth-CBtmZnoh.mjs +1 -0
- package/dist/packem_shared/filterAfterIndexScan-CV8aPq7v.mjs +1 -0
- package/dist/packem_shared/filterOnPrimaryKey-BeHVg4w3.mjs +1 -0
- package/dist/packem_shared/filterWithoutIndex-BGv3jbgg.mjs +1 -0
- package/dist/packem_shared/finding-NrKO8idM.mjs +1 -0
- package/dist/packem_shared/fk-index-DXYS17cc.mjs +1 -0
- package/dist/packem_shared/flagGatesSecurityWithUnsafeDefault-B5FBDF8c.mjs +1 -0
- package/dist/packem_shared/flagReadInSubscription-DabIhYGD.mjs +1 -0
- package/dist/packem_shared/fromServerSchema-KWJd4PLE.mjs +1 -0
- package/dist/packem_shared/geoIndexFieldNotGeopoint-BMQuok2-.mjs +1 -0
- package/dist/packem_shared/geoIndexUnused-D7C9Qr4U.mjs +1 -0
- package/dist/packem_shared/globalTableNearColumnLimit-BFbBBd6A.mjs +1 -0
- package/dist/packem_shared/gradeFromScore-KSt58rj1.mjs +1 -0
- package/dist/packem_shared/hardcodedSecret-Bw_4FYrs.mjs +1 -0
- package/dist/packem_shared/helpers-DdZ_pyR3.mjs +1 -0
- package/dist/packem_shared/hotShard-BwGYZ3Tq.mjs +1 -0
- package/dist/packem_shared/httpActionMissingAuthGuard-DhMIs1EI.mjs +1 -0
- package/dist/packem_shared/httpActionResponseHeaderInjection-mcRYd3Zw.mjs +1 -0
- package/dist/packem_shared/hyperdriveOutsideAction-CPDdGP2g.mjs +1 -0
- package/dist/packem_shared/identityUndeclaredClaimTrusted-D1sEd5lr.mjs +1 -0
- package/dist/packem_shared/imagesUrlSourceFromUserInput-BJXotuyO.mjs +1 -0
- package/dist/packem_shared/indexReferencesUnknownField-DKz4-bla.mjs +1 -0
- package/dist/packem_shared/indexUtilization-CkVPZcVe.mjs +1 -0
- package/dist/packem_shared/insertManyUnsafeUserData-DWE8d_DF.mjs +1 -0
- package/dist/packem_shared/kvUnscopedUserKeyIdor-DhymglIq.mjs +1 -0
- package/dist/packem_shared/mailInboundDispatchWithoutVerify-CWwqXyPX.mjs +1 -0
- package/dist/packem_shared/mailRecipientFromRequestInput-BVXsZ4Xf.mjs +1 -0
- package/dist/packem_shared/maskUncoveredPiiColumn-axEka4nd.mjs +1 -0
- package/dist/packem_shared/maskWeakHashStrategyOnPii-DJasagzX.mjs +1 -0
- package/dist/packem_shared/maskedRelationLeakViaWith-CIFij6vE.mjs +1 -0
- package/dist/packem_shared/mutatorFullRowReplace-BzRpZ47r.mjs +1 -0
- package/dist/packem_shared/mutatorWithoutOwnerScope-BoS81ns0.mjs +1 -0
- package/dist/packem_shared/nondeterministicQueryMutation-Bbb-AGOS.mjs +1 -0
- package/dist/packem_shared/normalizeIdUsedAsAuthorization-BXN-Can6.mjs +1 -0
- package/dist/packem_shared/notifyMissingPushConfig-DeQMoNwm.mjs +1 -0
- package/dist/packem_shared/notifySendOutsideAction-BJrDkr4h.mjs +1 -0
- package/dist/packem_shared/outputProjectionMissingOnPublicRead-DWSeo7oC.mjs +1 -0
- package/dist/packem_shared/ownerFieldFromArgsNotAuth-J8Xbz5uI.mjs +1 -0
- package/dist/packem_shared/paymentCreateWithoutAuthorize-CdenWpXD.mjs +1 -0
- package/dist/packem_shared/paymentWebhookWideTolerance-DVhJB0Ip.mjs +1 -0
- package/dist/packem_shared/plaintextSecretInWranglerVariables-bNkqmNqV.mjs +1 -0
- package/dist/packem_shared/policyReferencesUnknownTable-CIKuRZ5Y.mjs +1 -0
- package/dist/packem_shared/privilegedDispatchUnvalidatedPayload-BM3NtT57.mjs +1 -0
- package/dist/packem_shared/privilegedFanoutFromPublicProcedure-DV188gHQ.mjs +1 -0
- package/dist/packem_shared/procedure-protections-DRQotu9I.mjs +1 -0
- package/dist/packem_shared/procedureWithoutStructuredEvent-CZ_B23nM.mjs +1 -0
- package/dist/packem_shared/publicArgumentUsesAny-BDEvMA8Q.mjs +1 -0
- package/dist/packem_shared/publicMutationWithoutRatelimit-Bd-rRxnz.mjs +1 -0
- package/dist/packem_shared/publicTableRlsOptoutConfusion-CbNk75Xo.mjs +1 -0
- package/dist/packem_shared/queueWithoutDlq-BvHz3Opg.mjs +1 -0
- package/dist/packem_shared/r2sqlOutsideAction-BdLiLOAX.mjs +1 -0
- package/dist/packem_shared/ratelimitDefaultMemoryStore-Dj0Kk7t9.mjs +1 -0
- package/dist/packem_shared/ratelimitKeySpoofableOrGlobal-DHMl74hR.mjs +1 -0
- package/dist/packem_shared/ratelimitMiddlewareFailOpen-Di2t8pDc.mjs +1 -0
- package/dist/packem_shared/relationReferencesUnknownField-Cl6mik8w.mjs +1 -0
- package/dist/packem_shared/relationReferencesUnknownTable-vYjirSxf.mjs +1 -0
- package/dist/packem_shared/rlsUncoveredTable-CZ4ie4gX.mjs +1 -0
- package/dist/packem_shared/selfRescheduleWithoutProgress-Be9d1bQ0.mjs +1 -0
- package/dist/packem_shared/shapeTargetsGlobalTable-Bu3eEDic.mjs +1 -0
- package/dist/packem_shared/shapeUnknownTable-CREfNnWi.mjs +1 -0
- package/dist/packem_shared/signupMutationWithoutDisposableGating-BdKwtxDP.mjs +1 -0
- package/dist/packem_shared/softDeleteIncludeDeletedFromArgs-C7Ugg4zb.mjs +1 -0
- package/dist/packem_shared/sqlInjectionRisk-BYRllgPV.mjs +1 -0
- package/dist/packem_shared/storageGenerateUploadUrlNoContentTypePin-CrwFuo83.mjs +1 -0
- package/dist/packem_shared/storageKeyFromUserArgs-3vkxmI3v.mjs +1 -0
- package/dist/packem_shared/storagePresignedUrlForPrivateContent-C3KeV5tV.mjs +1 -0
- package/dist/packem_shared/storageUploadWithoutContentTypeAllowlist-BXoK_QWE.mjs +1 -0
- package/dist/packem_shared/storageUploadWithoutMaxSize-CW7uKvSi.mjs +1 -0
- package/dist/packem_shared/sweeps-JWMwcakB.mjs +1 -0
- package/dist/packem_shared/tableWithoutInsert-ClJp_jDr.mjs +1 -0
- package/dist/packem_shared/throughRelationMissingUniqueIndex-Cg5e3GkS.mjs +1 -0
- package/dist/packem_shared/topicTooManySubscriptions-CiZhFm9r.mjs +1 -0
- package/dist/packem_shared/ttlFieldNotTimestamp-lnb5nmM6.mjs +1 -0
- package/dist/packem_shared/unboundedCollectOnOpenRange-DHy2RUL7.mjs +1 -0
- package/dist/packem_shared/unboundedLoop-DxN3_apu.mjs +1 -0
- package/dist/packem_shared/unboundedStringArgument-jZnCbwB0.mjs +1 -0
- package/dist/packem_shared/unindexedForeignKey-Dypgn8uH.mjs +1 -0
- package/dist/packem_shared/unindexedRelationTarget-Dj2_NOCm.mjs +1 -0
- package/dist/packem_shared/unrestrictedWhereBranch-CcIBmHik.mjs +1 -0
- package/dist/packem_shared/userCreatingMutationWithoutCaptcha-D4TZEpgz.mjs +1 -0
- package/dist/packem_shared/vectorsNamespaceFromUserInput-BWvGjJzp.mjs +1 -0
- package/dist/packem_shared/workflowDuplicateStepName-B6eAJzwA.mjs +1 -0
- package/dist/packem_shared/workflowUnknownTarget-DEKB5H0g.mjs +1 -0
- package/dist/packem_shared/workflowUnused-DHwwE-1P.mjs +1 -0
- package/package.json +3 -3
- package/dist/packem_shared/AE_METRIC_EVENTS-BM14d0lm.mjs +0 -87
- package/dist/packem_shared/actionFetchSsrf-wbmYgzmz.mjs +0 -26
- package/dist/packem_shared/adminRouteWithoutGuard-UUGBkAjU.mjs +0 -33
- package/dist/packem_shared/aiRawRunEscapeHatch-C29jd32J.mjs +0 -26
- package/dist/packem_shared/aiToolSideEffectPromptInjection-BtD3alqB.mjs +0 -33
- package/dist/packem_shared/aiUnboundedGenerationPublic-CbAcSxLB.mjs +0 -26
- package/dist/packem_shared/allowUnauthenticatedShardAccessEnabled-BcASq-jP.mjs +0 -30
- package/dist/packem_shared/argument-derived-sink-C1xTTqAt.mjs +0 -30
- package/dist/packem_shared/authApiCallWithoutHeaders-BeJhCZaf.mjs +0 -38
- package/dist/packem_shared/authCsrfCheckDisabled-DCf9FSoD.mjs +0 -26
- package/dist/packem_shared/authEmailVerificationDisabled-Dk-Tc5oU.mjs +0 -26
- package/dist/packem_shared/authSecureCookiesDisabled-osJrHW9Y.mjs +0 -26
- package/dist/packem_shared/authSessionFreshageZero-CjufCc_Q.mjs +0 -26
- package/dist/packem_shared/authTrustedOriginsWildcard-ylv4PKzC.mjs +0 -26
- package/dist/packem_shared/browserAllowPrivateTargets-CBvQxLAM.mjs +0 -26
- package/dist/packem_shared/browserUserUrlWithoutAllowlist-CvhA6w59.mjs +0 -26
- package/dist/packem_shared/circularFk-B2freHrP.mjs +0 -84
- package/dist/packem_shared/constraintValidator-Dr9Py3FD.mjs +0 -186
- package/dist/packem_shared/containerInstanceKeyFromUserInput-BUXj2J4w.mjs +0 -19
- package/dist/packem_shared/containerOversizedInstance-5U1VKPRM.mjs +0 -36
- package/dist/packem_shared/containerPublicInternet-CuNerJE5.mjs +0 -30
- package/dist/packem_shared/containerRuntimeEgressRelaxation-B0LlfmDA.mjs +0 -26
- package/dist/packem_shared/containerStartEnableInternetOverride-DDaHZQ1L.mjs +0 -26
- package/dist/packem_shared/duplicateIndex-BOublMSt.mjs +0 -57
- package/dist/packem_shared/emptyIndex-BX8EuEY7.mjs +0 -32
- package/dist/packem_shared/externalSourceOnGlobal-Bg-NfCX9.mjs +0 -30
- package/dist/packem_shared/externalSourceUnscoped-5vT-Bup3.mjs +0 -44
- package/dist/packem_shared/filterWithoutIndex-BYVeJaSs.mjs +0 -31
- package/dist/packem_shared/finding-Dm_zvzS1.mjs +0 -16
- package/dist/packem_shared/fk-index-IUK1ukgs.mjs +0 -7
- package/dist/packem_shared/flagGatesSecurityWithUnsafeDefault-BhIs0shr.mjs +0 -41
- package/dist/packem_shared/fromServerSchema-BiB6JSiW.mjs +0 -59
- package/dist/packem_shared/hardcodedSecret-W2pz1UZB.mjs +0 -35
- package/dist/packem_shared/helpers-BySnKhVB.mjs +0 -31
- package/dist/packem_shared/hotShard-Ir5D0B6J.mjs +0 -48
- package/dist/packem_shared/httpActionMissingAuthGuard-CxipddNx.mjs +0 -35
- package/dist/packem_shared/httpActionResponseHeaderInjection-DOFS7pFT.mjs +0 -39
- package/dist/packem_shared/hyperdriveOutsideAction-BgZqX7Xg.mjs +0 -30
- package/dist/packem_shared/identityUndeclaredClaimTrusted-D8nXV2dd.mjs +0 -32
- package/dist/packem_shared/imagesUrlSourceFromUserInput-YcDQ0b_Y.mjs +0 -19
- package/dist/packem_shared/indexReferencesUnknownField-BSWNngxX.mjs +0 -36
- package/dist/packem_shared/indexUtilization-B5DMQ3bI.mjs +0 -45
- package/dist/packem_shared/insertManyUnsafeUserData-Dn77XpmX.mjs +0 -26
- package/dist/packem_shared/kvUnscopedUserKeyIdor-YWwmfE8X.mjs +0 -19
- package/dist/packem_shared/mailInboundDispatchWithoutVerify-CHSRA8zz.mjs +0 -28
- package/dist/packem_shared/mailRecipientFromRequestInput-Cka2qu5J.mjs +0 -26
- package/dist/packem_shared/maskUncoveredPiiColumn-DjGIPG6M.mjs +0 -61
- package/dist/packem_shared/maskWeakHashStrategyOnPii-1c4q8Opf.mjs +0 -35
- package/dist/packem_shared/maskedRelationLeakViaWith-CPI4s0sl.mjs +0 -76
- package/dist/packem_shared/mutatorFullRowReplace-BJnNDaIV.mjs +0 -26
- package/dist/packem_shared/nondeterministicQueryMutation-GXES1fLp.mjs +0 -35
- package/dist/packem_shared/normalizeIdUsedAsAuthorization-BVPtCpzT.mjs +0 -50
- package/dist/packem_shared/outputProjectionMissingOnPublicRead-Bl5IMx0k.mjs +0 -51
- package/dist/packem_shared/ownerFieldFromArgsNotAuth-mOw3hE5z.mjs +0 -26
- package/dist/packem_shared/paymentCreateWithoutAuthorize-BYm4JLxo.mjs +0 -26
- package/dist/packem_shared/paymentWebhookWideTolerance-D9QQJMJq.mjs +0 -35
- package/dist/packem_shared/plaintextSecretInWranglerVariables-NKO4YkKf.mjs +0 -35
- package/dist/packem_shared/policyReferencesUnknownTable-DtaIEovd.mjs +0 -38
- package/dist/packem_shared/privilegedDispatchUnvalidatedPayload-5Forjckt.mjs +0 -37
- package/dist/packem_shared/privilegedFanoutFromPublicProcedure-D3dL01B8.mjs +0 -26
- package/dist/packem_shared/publicArgumentUsesAny-C71b2NCf.mjs +0 -32
- package/dist/packem_shared/publicMutationWithoutRatelimit-DbIhgi7j.mjs +0 -36
- package/dist/packem_shared/publicTableRlsOptoutConfusion-Bn71yoD4.mjs +0 -38
- package/dist/packem_shared/queueWithoutDlq-CSkGNb_0.mjs +0 -41
- package/dist/packem_shared/r2sqlOutsideAction-CtqxvMuV.mjs +0 -30
- package/dist/packem_shared/ratelimitDefaultMemoryStore-BISChG5C.mjs +0 -26
- package/dist/packem_shared/ratelimitKeySpoofableOrGlobal-DqlHYQQ3.mjs +0 -26
- package/dist/packem_shared/ratelimitMiddlewareFailOpen-CJgDCaUw.mjs +0 -33
- package/dist/packem_shared/relationReferencesUnknownField-CjbLScJ1.mjs +0 -54
- package/dist/packem_shared/relationReferencesUnknownTable-DrorpKYe.mjs +0 -33
- package/dist/packem_shared/rlsUncoveredTable-CxEfZ5eZ.mjs +0 -56
- package/dist/packem_shared/shapeTargetsGlobalTable-DHrf4Koi.mjs +0 -34
- package/dist/packem_shared/shapeUnknownTable-C8aDWFoe.mjs +0 -34
- package/dist/packem_shared/softDeleteIncludeDeletedFromArgs-BLqDKrkM.mjs +0 -38
- package/dist/packem_shared/sqlInjectionRisk-zwytYGLt.mjs +0 -26
- package/dist/packem_shared/storageGenerateUploadUrlNoContentTypePin-Da4L9Ge8.mjs +0 -26
- package/dist/packem_shared/storageKeyFromUserArgs-B86elJgS.mjs +0 -19
- package/dist/packem_shared/storagePresignedUrlForPrivateContent-yGmb8uqz.mjs +0 -47
- package/dist/packem_shared/storageUploadWithoutContentTypeAllowlist-BV-gF1lT.mjs +0 -27
- package/dist/packem_shared/storageUploadWithoutMaxSize-DpxO59wU.mjs +0 -27
- package/dist/packem_shared/tableWithoutInsert-CbbaYIP4.mjs +0 -34
- package/dist/packem_shared/unboundedStringArgument-DThg2-wt.mjs +0 -32
- package/dist/packem_shared/unindexedForeignKey-BgJbKyqK.mjs +0 -45
- package/dist/packem_shared/unindexedRelationTarget-D6eyj6Xx.mjs +0 -53
- package/dist/packem_shared/userCreatingMutationWithoutCaptcha-2DZtWIPb.mjs +0 -42
- package/dist/packem_shared/vectorsNamespaceFromUserInput-CQhr5bVn.mjs +0 -19
- package/dist/packem_shared/workflowDuplicateStepName-ioBxPBCy.mjs +0 -48
- package/dist/packem_shared/workflowUnknownTarget-Cdd7WhKQ.mjs +0 -34
- package/dist/packem_shared/workflowUnused-D0jHxdz9.mjs +0 -38
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as t}from"./finding-NrKO8idM.mjs";const n={categories:["SCHEMA"],description:"A `query`/`mutation` handler accesses Hyperdrive via `ctx.sql`. Hyperdrive hits an external database Lunora does not own: queries are non-deterministic (like `fetch`) and external writes are invisible to live queries. `ctx.sql` is available on `ActionCtx` only and must be confined to `action` handlers.",facing:"EXTERNAL",level:"WARN",name:"hyperdrive_outside_action",remediation:"Move the `ctx.sql` access into an `action(...)` (the only context where it is typed), where external I/O is allowed. If a query/mutation needs the data, have the action read it via `ctx.sql` and write a projection into a `defineSchema` DO/D1 table — that write is tracked by live queries, whereas the external DB is not.",run:i=>i.hyperdriveCalls===void 0?[]:i.hyperdriveCalls.map(e=>t(n,{cacheKey:`hyperdrive_outside_action:${e.file}:${e.line.toString()}:${e.callee}`,detail:`\`${e.callee}(…)\` in ${e.exportName} (${e.file}:${e.line.toString()}) runs inside a ${e.kind} handler — Hyperdrive's \`ctx.sql\` is non-deterministic and non-reactive, so it is available only in actions. Move the external SQL into an \`action\` and project the result into a Lunora table if a query/mutation needs it.`,metadata:{callee:e.callee,exportName:e.exportName,file:e.file,kind:e.kind,line:e.line}})),source:"static",title:"Hyperdrive `ctx.sql` used outside an action"};export{n as default};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as n}from"./finding-NrKO8idM.mjs";const d=25,i={categories:["PERFORMANCE"],description:"Recorded reads show an index the workload doesn't pay for: either a declared index no read ever used (dead overhead on every write) or a table read hot with no index at all (a repeated full scan that degrades as it grows).",facing:"INTERNAL",level:"INFO",name:"index_utilization",remediation:"Drop a dead index so writes stop maintaining it; add an index covering the hot full-scanned table's read predicate so the read stops scanning every row.",run:t=>{const a=[];for(const e of t.indexHits??[])e.reads>0||a.push(n(i,{cacheKey:`index_utilization:dead_index:${e.table}:${e.index}`,detail:`Index "${e.index}" on table "${e.table}" has recorded no reads since its counter was created — it's dead overhead: every write maintains it and nothing reads through it.`,metadata:{index:e.index,kind:"dead_index",reads:e.reads,table:e.table}}));for(const e of t.tableScans??[])e.scans<d||a.push(n(i,{cacheKey:`index_utilization:hot_scan:${e.table}`,detail:`Table "${e.table}" has been full-scanned ${e.scans.toString()} times (cumulative) with no index — a repeated full scan that degrades linearly as "${e.table}" grows. Add an index covering the read predicate.`,facing:"EXTERNAL",level:"WARN",metadata:{kind:"hot_scan",scans:e.scans,table:e.table}}));return a},source:"runtime",title:"Index utilization"};export{i as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as s}from"./finding-NrKO8idM.mjs";import{m as i}from"./procedure-protections-DRQotu9I.mjs";const r={categories:["SECURITY"],description:"A public procedure calls `ctx.db.insertManyUnsafe(...)`, which writes rows straight to storage and bypasses both the per-row validators and the schema's insert triggers (server-trusted columns, ownership stamping, RLS write checks). On a public entry point the rows are request-shaped, so a caller can write columns they shouldn't control and skip every trigger-enforced invariant.",facing:"EXTERNAL",level:"WARN",name:"insert_many_unsafe_user_data",remediation:"Use the validated `ctx.db.insert(...)` path in public procedures so validators and insert triggers run. If you need the unsafe bulk write, move it into an internal function (`internalMutation`/`internalAction`) that is fed only server-constructed rows, never request input.",run:t=>t.procedureProtections===void 0?[]:t.procedureProtections.filter(e=>i(e.usesInsertManyUnsafe)&&e.visibility==="public").map(e=>s(r,{cacheKey:`insert_many_unsafe_user_data:${e.file}:${e.exportName}`,detail:`\`${e.exportName}\` (${e.file}) is public and calls \`ctx.db.insertManyUnsafe(...)\`, bypassing validators and insert triggers on request-shaped rows. Switch to \`ctx.db.insert(...)\`, or move the bulk write into an internal function fed server-built rows.`,metadata:{exportName:e.exportName,file:e.file,kind:e.kind}})),source:"static",title:"Public procedure uses insertManyUnsafe (bypasses validators and triggers)"};export{r as default};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as a}from"./finding-NrKO8idM.mjs";const i={categories:["SECURITY"],description:"`createInboundEmailHandler({...})` has no `verify` hook, so it trusts every inbound message — the `from` address is spoofable and DKIM/SPF/DMARC verdicts go unchecked. If the handler dispatches into a Lunora function (admin bearer, RLS disabled), a forged sender can drive privileged writes (a confused-deputy escalation).",facing:"EXTERNAL",level:"ERROR",name:"mail_inbound_dispatch_without_verify",remediation:"Pass a `verify` hook to `createInboundEmailHandler({...})` that rejects the message unless its DKIM/SPF/DMARC authentication verdicts pass and the sender is expected — before any dispatch into a Lunora function. Never trust the `from` header alone.",run:t=>t.configCalls===void 0?[]:t.configCalls.filter(e=>e.callee==="createInboundEmailHandler"&&e.analyzable&&!e.presentKeys.includes("verify")).map(e=>a(i,{cacheKey:`mail_inbound_dispatch_without_verify:${e.file}:${e.line.toString()}`,detail:`\`createInboundEmailHandler({...})\` in ${e.file}:${e.line.toString()} has no \`verify\` hook — it trusts the spoofable \`from\` address and unchecked DKIM/SPF/DMARC verdicts. A forged sender can then reach whatever the handler dispatches to (a Lunora function runs with the admin bearer and RLS disabled). Add a \`verify\` gate that enforces the authentication verdicts before dispatch.`,metadata:{callee:e.callee,file:e.file,line:e.line}})),source:"static",title:"Inbound email handler without a verify hook"};export{i as default};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as l}from"./finding-NrKO8idM.mjs";const d={categories:["SECURITY"],description:"A public procedure reads a table whose columns are masked by `.use(mask(...))` elsewhere in the app, but this procedure's builder chain does not include `.use(mask(...))`. It returns those columns in the clear while a sibling procedure redacts them.",facing:"EXTERNAL",level:"WARN",name:"mask_uncovered_pii_column",remediation:"Add `.use(mask(policies))` to the procedure's builder chain — e.g. `c.use(mask(myMasks)).query(...)` — so its returned rows redact the same columns as the rest of the app. If this procedure is intentionally privileged (e.g. an admin export), declare it with `internalQuery` / `internalMutation` / `internalAction` instead of the public builder so the intent is explicit.",run:n=>{if(n.maskProcedures===void 0)return[];const i=new Map;for(const t of n.maskProcedures)for(const{column:o,table:e}of t.maskColumns){if(e===""||o==="")continue;const s=i.get(e)??new Set;s.add(o),i.set(e,s)}if(i.size===0)return[];const a=[];for(const t of n.maskProcedures){if(t.usesMask||t.visibility==="internal")continue;const o=new Set;for(const e of t.tablesRead)e!==""&&i.has(e)&&o.add(e);for(const e of o){const s=[...i.get(e)??new Set].toSorted((r,u)=>r.localeCompare(u)),c=s.map(r=>`\`${r}\``).join(", ");a.push(l(d,{cacheKey:`mask_uncovered_pii_column:${t.file}:${t.exportName}:${e}`,detail:`\`${t.exportName}\` in ${t.file} reads table \`${e}\` without \`.use(mask(...))\` — its column${s.length===1?"":"s"} ${c} ${s.length===1?"is":"are"} masked elsewhere in the app but returned in the clear here.`,metadata:{columns:s,exportName:t.exportName,file:t.file,table:e}}))}}return a},source:"static",title:"Maskable column returned without mask() middleware"};export{d as default};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as c}from"./finding-NrKO8idM.mjs";const d=t=>{const o=new Set;for(const a of t.maskProcedures??[])for(const{table:r}of a.maskColumns)r!==""&&o.add(r);for(const a of t.maskStrategies??[])a.table!==""&&o.add(a.table);return o},p=t=>{const o=new Map;for(const a of t.maskProcedures??[]){if(!a.usesMask)continue;const r=new Set(a.maskColumns.map(({table:n})=>n));o.set(`${a.file} ${a.exportName}`,r.size===0?"all":r)}return o},m=t=>{const o=new Map;for(const a of t.schema.tables)for(const r of a.relations)o.set(`${a.name} ${r.name}`,r.table);return o},u={categories:["SECURITY"],description:"A public read hydrates a masked table through a `with` relation, from a procedure whose own mask policy does not cover that table. Masking is per-procedure — the relation loader applies the READING procedure's policy to every hop — so a table masked only on its own reads comes back in the clear here.",facing:"EXTERNAL",level:"INFO",name:"masked_relation_leak_via_with",remediation:"Add the related table to this read's own `.use(mask({ … }))` policy — the relation loader applies it to every `with` hop, so one policy covers the parent and its children. Alternatively drop the relation or project only non-sensitive columns. A mask declared on the related table's own procedures does not carry over: masking is per-procedure.",run:t=>{if(t.relationLoads===void 0)return[];const o=d(t);if(o.size===0)return[];const a=m(t),r=p(t),n=[];for(const e of t.relationLoads){if(e.visibility!=="public"||e.parentTable==="")continue;const l=r.get(`${e.file} ${e.exportName}`);for(const i of e.relations){const s=a.get(`${e.parentTable} ${i}`);s===void 0||!o.has(s)||l==="all"||l?.has(s)===!0||n.push(c(u,{cacheKey:`masked_relation_leak_via_with:${e.file}:${e.line.toString()}:${e.parentTable}:${i}`,detail:`The public read in \`${e.exportName}\` (${e.file}:${e.line.toString()}) hydrates relation \`${i}\` (masked table \`${s}\`) via \`with\` on \`${e.parentTable}\`, and its own mask policy does not cover \`${s}\`. Masking is per-procedure, so \`${s}\`'s masked columns are returned in the clear here.`,metadata:{exportName:e.exportName,file:e.file,line:e.line,parentTable:e.parentTable,relation:i,relationTable:s}}))}}return n},source:"static",title:"Masked table surfaced unmasked through a with-relation on a public read"};export{u as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as r}from"./finding-NrKO8idM.mjs";const o={categories:["SCHEMA"],description:"A custom mutator's `server` impl writes with `ctx.db.replace(id, document)` — a whole-row overwrite. On a synced table this clobbers a concurrent edit to a different column; `ctx.db.patch(id, { field })` merges at the column level so independent field edits coexist.",facing:"EXTERNAL",level:"WARN",name:"mutator_full_row_replace",remediation:"Prefer `ctx.db.patch(id, { onlyTheChangedField })` so a concurrent edit to another column isn't lost. Keep `replace` only when the mutator genuinely owns the entire row (e.g. a full-form save).",run:t=>t.mutatorWrites===void 0?[]:t.mutatorWrites.map(e=>r(o,{cacheKey:`mutator_full_row_replace:${e.exportName}:${String(e.line)}`,detail:`Mutator \`${e.exportName}\` (${e.file}:${String(e.line)}) writes with \`ctx.db.replace(...)\`, overwriting the whole row. A concurrent edit to a different column is clobbered — use \`ctx.db.patch(id, { field })\` to merge at the column level.`,metadata:{exportName:e.exportName,file:e.file,line:e.line}})),source:"static",title:"Mutator overwrites the whole row (clobbers concurrent column edits)"};export{o as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as i}from"./finding-NrKO8idM.mjs";const r={categories:["SECURITY"],description:"A custom mutator declares no `owner` scope. Mutators are client-callable write endpoints, and `owner` is what ties each write to its caller's verified identity — without it the mutator authorizes nothing by itself.",facing:"EXTERNAL",level:"WARN",name:"mutator_without_owner_scope",remediation:'Add `owner: "<column>"` to the `defineMutator({ … })` declaration — it requires a verified identity, rejects a client-supplied owner that disagrees with it, and stamps the column with the verified value. If the write genuinely owns no row, or the `server` impl authorizes by hand, leave it and treat this as acknowledged.',run:t=>t.mutators===void 0?[]:t.mutators.filter(e=>e.owner===void 0).map(e=>i(r,{cacheKey:`mutator_without_owner_scope:${e.file}:${e.exportName}`,detail:`Mutator \`${e.exportName}\` (${e.file}:${String(e.line)}) declares no \`owner\`, so any client that can reach the endpoint can write through it and nothing ties the write to the caller's identity. Add \`owner: "<column>"\`, or authorize explicitly in the \`server\` impl.`,metadata:{exportName:e.exportName,file:e.file,line:e.line}})),source:"static",title:"Custom mutator has no owner scope"};export{r as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as u}from"./finding-NrKO8idM.mjs";const l=(t,n,a)=>t==="Date.now"||t==="new Date"?"Read the time through `ctx.now` instead: it is captured once when this run begins. A replay of the run captures it again, so a value that must survive a replay should come in as an argument.":t==="crypto.randomUUID"&&a==="mutation"?"Use `ctx.newId()` instead: a replay of the same call reproduces its ids. The ids are derived, not random, so a token or secret keeps `crypto.randomUUID()`.":a==="query"?"Compute it in an `action` and pass the value in as an argument. The query then reads a fixed input, so a re-run with the same arguments returns the same result.":`No action needed unless \`${n}\` is invoked from a workflow step or queue consumer that can itself replay.`,c={categories:["SCHEMA"],description:"A `query`/`mutation` handler calls a non-deterministic API (`Date.now`, `Math.random`, `crypto.randomUUID`, `crypto.getRandomValues`, or `fetch`). A `query` may be re-run by a live subscription, so non-determinism there can flicker between evaluations (WARN). An ordinary `mutation` handler does not replay on this runtime — it runs at most once per logical write — so this is informational there (INFO) unless the mutation is itself invoked from a workflow step or queue consumer that can replay. For `Date.now()`, read the time through `ctx.now` instead: it is the instant the handler began, captured once, so it is stable across re-evaluations and replays.",facing:"EXTERNAL",level:"WARN",name:"nondeterministic_query_mutation",remediation:"For `Date.now()`: use `ctx.now` in the query or mutation handler. It is the wall-clock instant (epoch ms) the handler began. A re-evaluation or a replay captures it again, so a value that must stay fixed belongs in an argument. For other non-deterministic calls in a `query`: move them into an `action(...)` (which runs once and may use ambient APIs), then pass the computed value into the mutation as an argument, or accept that the value may differ across re-evaluations. For an ordinary `mutation`: no action needed — the handler runs at most once per logical write on this runtime. If the mutation is dispatched from inside a workflow step or queue consumer, treat it like an action value instead, since the surrounding step/consumer can replay.",run:t=>{if(t.nondeterministicCalls===void 0)return[];const n=[],a=new Map;for(const e of t.nondeterministicCalls){const i=`${e.file}:${e.line.toString()}:${e.callee}`,r=(a.get(i)??0)+1;a.set(i,r);const o=r>1?`:${r.toString()}`:"",s={callee:e.callee,exportName:e.exportName,file:e.file,kind:e.kind,line:e.line};if(e.kind==="mutation"){n.push(u(c,{cacheKey:`nondeterministic_query_mutation:${i}${o}`,detail:`\`${e.callee}(…)\` in ${e.exportName} (${e.file}:${e.line.toString()}) runs inside a mutation handler. Ordinary mutations don't replay on this runtime (idempotency dedup returns a cached result rather than re-running the handler, and an OCC conflict throws to the caller instead of retrying internally), so this is informational. ${l(e.callee,e.exportName,"mutation")}`,facing:"INTERNAL",level:"INFO",metadata:s}));continue}n.push(u(c,{cacheKey:`nondeterministic_query_mutation:${i}${o}`,detail:`\`${e.callee}(…)\` in ${e.exportName} (${e.file}:${e.line.toString()}) runs inside a query handler — a live subscription may re-run this query, so the result can differ between evaluations. ${l(e.callee,e.exportName,"query")}`,metadata:s}))}return n},source:"static",title:"Non-deterministic call in query/mutation handler"};export{c as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as s}from"./finding-NrKO8idM.mjs";const n={delete:"deletes the row",get:"reads the row",patch:"patches the row"},l={categories:["SECURITY"],description:"A `.public()` `query`/`mutation` gates a `ctx.db.get`/`patch`/`delete` on a null-checked `ctx.db.normalizeId(table, id)` result with no ownership predicate and no RLS. `normalizeId` validates id *shape* only — it never reads the database — so a non-null result proves the id is well-formed, never that the caller owns the row (an IDOR).",facing:"EXTERNAL",level:"INFO",name:"normalize_id_used_as_authorization",remediation:'Don\'t treat a non-null `normalizeId` result as authorization — it only validates id shape. After loading the row, compare its ownership column (`userId`/`ownerId`/…) against the server-trusted identity (`ctx.auth.userId`), or protect the procedure with `.use(rls(...))` / put the schema in `.rls("required")` mode so the row policy runs on every access.',run:t=>{if(t.normalizeIdAuthorizations===void 0)return[];const i=t.schema.rlsMode==="required",r=new Set(t.schema.tables.filter(e=>e.isPublic).map(e=>e.name)),a=[];for(const e of t.normalizeIdAuthorizations){if(e.visibility!=="public"||e.usesRls||e.mentionsOwnership||i&&(e.table===""||!r.has(e.table)))continue;const o=e.table===""?"the row":`the \`${e.table}\` row`;a.push(s(l,{cacheKey:`normalize_id_used_as_authorization:${e.file}:${e.line.toString()}`,detail:`Public procedure \`${e.exportName}\` (${e.file}:${e.line.toString()}) gates access on \`ctx.db.normalizeId(...)\` and then ${n[e.sinkMethod]} — but \`normalizeId\` only validates id shape, it never reads the database or checks ownership. Any caller supplying a well-formed id of ${o} reaches it (IDOR). Compare the loaded row's ownership column against \`ctx.auth\`, or cover the procedure with \`.use(rls(...))\`.`,metadata:{exportName:e.exportName,file:e.file,line:e.line,sinkMethod:e.sinkMethod,table:e.table}}))}return a},source:"static",title:"normalizeId result used as authorization (validates id shape, not ownership)"};export{l as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as n}from"./finding-NrKO8idM.mjs";const i={categories:["SCHEMA"],description:"The app sends push notifications via `ctx.push` but `defineNotify(...)` configures neither a Web Push nor an FCM channel, so every send fails at runtime. Wire at least one push channel (`webPush` and/or `fcm`) and provide its secrets (`VAPID_*` / `FCM_*`).",facing:"INTERNAL",level:"WARN",name:"notify_missing_push_config",remediation:"In `lunora/notify.ts`, wire a push channel: `defineNotify({ webPush: (env) => webPushFromEnv(env) })` and set `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` in `.dev.vars` (or configure `fcm` with `FCM_PROJECT_ID`). Generate a VAPID keypair with `npx web-push generate-vapid-keys`.",run:s=>{const e=s.notifyConfig;return e===void 0||!e.usesPush||e.hasWebPush||e.hasFcm?[]:[n(i,{cacheKey:"notify_missing_push_config",detail:"`ctx.push` is used to send notifications, but `defineNotify(...)` wires neither `webPush` nor `fcm` — every push send will fail at runtime. Configure at least one push channel and its secrets.",metadata:{hasFcm:e.hasFcm,hasWebPush:e.hasWebPush,usesPush:e.usesPush}})]},source:"static",title:"Push used with no Web Push / FCM channel configured"};export{i as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as n}from"./finding-NrKO8idM.mjs";const i={categories:["SCHEMA"],description:"A `query`/`mutation` handler sends a notification via `ctx.notify`/`ctx.push`. A send is external I/O (a `fetch` to a push service / FCM) and cannot be undone: a `query` is re-run by every live subscription that reads it, so the send repeats per re-evaluation, and a `mutation` runs inside a transaction that can roll back, so the push goes out for a write that never landed. These facades are available on `ActionCtx` only and must be confined to `action` handlers.",facing:"EXTERNAL",level:"WARN",name:"notify_send_outside_action",remediation:"Move the `ctx.notify`/`ctx.push` send into an `action(...)`, where external I/O is allowed. If a query/mutation must trigger a notification, have it enqueue the send (`enqueuePushBroadcast` from `@lunora/notify`, onto a `ctx.queues.*` producer) or schedule an action — the queue/scheduler runs the send exactly once, off the transactional path.",run:t=>t.notifyCalls===void 0?[]:t.notifyCalls.map(e=>n(i,{cacheKey:`notify_send_outside_action:${e.file}:${e.line.toString()}:${e.callee}`,detail:e.kind==="query"?`\`${e.callee}(…)\` in ${e.exportName} (${e.file}:${e.line.toString()}) runs inside a query handler — a live subscription re-runs this query whenever a table it reads changes, so the send fires again on every re-evaluation. Move it into an \`action\`, or enqueue/schedule the send.`:`\`${e.callee}(…)\` in ${e.exportName} (${e.file}:${e.line.toString()}) runs inside a mutation handler — the mutation runs inside a transaction that can roll back (OCC conflict, RLS denial, validator, failed row mid-batch) but the send cannot, so the notification goes out for a write that never landed, and the caller's retry sends again. Move it into an \`action\`, or enqueue/schedule the send.`,metadata:{callee:e.callee,exportName:e.exportName,file:e.file,kind:e.kind,line:e.line}})),source:"static",title:"Notification send used outside an action"};export{i as default};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{dedupeCacheKeys as r}from"./dedupeCacheKeys-DtBOHffV.mjs";import{e as i}from"./finding-NrKO8idM.mjs";const o={categories:["SECURITY"],description:"A plaintext secret is committed to `wrangler.jsonc`'s `vars` block. `vars` are cleartext — baked into the deployed Worker and checked into source control — so the secret leaks via the repo and the bundle, and rotating it needs a config edit plus a redeploy.",facing:"INTERNAL",level:"ERROR",name:"plaintext_secret_in_wrangler_vars",remediation:"Move the value out of `vars`: bind it through a Secrets Store (`ctx.secrets.get(<NAME>)`) or set it with `wrangler secret put <NAME>` and read it from `env.<NAME>`. Delete the `vars` entry and rotate the exposed value — assume it is already compromised. Public/publishable keys (name them `*_PUBLIC` / `*_PUBLISHABLE`) are exempt.",run:t=>t.wranglerVariables===void 0?[]:r(t.wranglerVariables.map(e=>i(o,{cacheKey:`plaintext_secret_in_wrangler_vars:${e.file}:${e.key}`,detail:`The \`vars\` entry \`${e.key}\` in ${e.file} holds a plaintext ${e.kind.replaceAll("_"," ")} (${e.preview}). \`vars\` ship in cleartext to the deployed Worker and are committed to source control — move it to a Secrets Store binding or \`wrangler secret put\` and rotate the exposed value.`,metadata:{file:e.file,key:e.key,kind:e.kind,preview:e.preview}}))),source:"static",title:"Plaintext secret in wrangler vars"};export{o as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as s}from"./finding-NrKO8idM.mjs";const i={categories:["SECURITY"],description:"An RLS policy is bound to a `table` name that does not exist in the schema. The `rls()` middleware never matches it, so the table the policy was meant to protect is left ungated — reads return unrestricted rows and writes are allowed.",facing:"EXTERNAL",level:"WARN",name:"policy_references_unknown_table",remediation:"Fix the policy's `table` to a real table name (check for a typo or a table that was renamed/removed). If the table is gone, delete the dead policy.",run:a=>{if(a.rlsProcedures===void 0)return[];const r=new Set(a.schema.tables.map(t=>t.name)),n=new Set,o=[];for(const t of a.rlsProcedures)for(const e of t.rlsTables)e===""||r.has(e)||n.has(e)||(n.add(e),o.push(s(i,{cacheKey:`policy_references_unknown_table:${e}`,detail:`An RLS policy in \`${t.exportName}\` (${t.file}) is bound to table \`${e}\`, which is not declared in the schema. The policy never matches, so \`${e}\` — if it exists under another name — is left ungated.`,metadata:{exportName:t.exportName,file:t.file,table:e}})));return o},source:"static",title:"RLS policy bound to an unknown table"};export{i as default};
|
|
@@ -0,0 +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-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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as i}from"./finding-NrKO8idM.mjs";import{m as a}from"./procedure-protections-DRQotu9I.mjs";const r={categories:["SECURITY"],description:"A public procedure fans work out to a privileged dispatch surface (`ctx.scheduler.runAfter`/`runAt`, a `ctx.queues.<name>` send, a `ctx.topics.<name>.publish`, or `ctx.workflows.<name>.create`) with no rate-limit guard. Each dispatch runs later under the system identity and bills the account, so an anonymous caller can amplify cost and flood the async surface in a loop.",facing:"EXTERNAL",level:"WARN",name:"privileged_fanout_from_public_procedure",remediation:"Gate the public entry point with a rate limit (`.use(rateLimit(...))` or a `protectPublic({ rateLimit })` bundle), or make the fan-out procedure internal and trigger it only from an authenticated, guarded path. Never expose an unbounded scheduler/queue/topic/workflow dispatch to anonymous callers.",run:t=>t.procedureProtections===void 0?[]:t.procedureProtections.filter(e=>a(e.fanOut)&&e.visibility==="public"&&!e.usesRateLimit).map(e=>i(r,{cacheKey:`privileged_fanout_from_public_procedure:${e.file}:${e.exportName}`,detail:`\`${e.exportName}\` (${e.file}) is public and fans work out to a privileged dispatch surface (scheduler/queue/topic/workflow) with no rate limit — an anonymous caller can drive that billable, system-identity dispatch in a loop. Add a rate-limit guard, or make it internal and trigger it from an authenticated path.`,metadata:{exportName:e.exportName,file:e.file,kind:e.kind}})),source:"static",title:"Public procedure fans out to a privileged dispatch surface"};export{r as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const i=t=>t!==!1;export{i as m};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as i}from"./finding-NrKO8idM.mjs";const n={categories:["SCHEMA"],description:"A public `mutation`/`action` emits no structured event. When it fails you get a stack trace with no request context — no ids, no tenant, no outcome — so the failure is visible but not searchable.",facing:"INTERNAL",level:"INFO",name:"procedure_without_structured_event",remediation:'Emit one event on the primary path: `ctx.log.info("<verb>", { … })`, or wrap the handler in `ctx.span("<name>", …)` to attach timing too.',run:e=>e.procedureProtections===void 0?[]:e.procedureProtections.filter(t=>t.visibility==="public"&&t.kind!=="query"&&t.emitsEvent===!1).map(t=>i(n,{cacheKey:`procedure_without_structured_event:${t.file}:${t.exportName}`,detail:`Public ${t.kind} \`${t.exportName}\` (${t.file}) emits no structured event. Add a \`ctx.log\` line or a \`ctx.span\` so a failure carries its request context.`,metadata:{exportName:t.exportName,file:t.file,kind:t.kind}})),source:"static",title:"Public write emits no structured event"};export{n as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as t}from"./finding-NrKO8idM.mjs";const n={categories:["SECURITY"],description:"A public procedure declares a `v.any()` argument. `v.any()` accepts arbitrary untyped input from an untrusted client — defeating validation and opening injection / oversized-payload abuse.",facing:"EXTERNAL",level:"WARN",name:"public_arg_uses_any",remediation:"Replace `v.any()` with a precise validator — `v.object({...})`, `v.string()`, `v.union(...)`, etc. If the shape is genuinely dynamic, model the known variants with `v.union` rather than accepting anything.",run:e=>e.argValidators===void 0?[]:e.argValidators.flatMap(a=>a.anyArgs.map(i=>t(n,{cacheKey:`public_arg_uses_any:${a.file}:${a.exportName}:${i}`,detail:`Arg \`${i}\` of public procedure \`${a.exportName}\` (${a.file}:${a.line.toString()}) is \`v.any()\` — untrusted input with no validation. Give it a precise validator.`,metadata:{argument:i,exportName:a.exportName,file:a.file,line:a.line}}))),source:"static",title:"Public argument uses v.any()"};export{n as default};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as n}from"./finding-NrKO8idM.mjs";const r={categories:["SCHEMA"],description:"A queue consumer drops a message once it exhausts `maxRetries` (default 3) delivery attempts. Without a `deadLetterQueue`, that message is deleted permanently with no record — route exhausted messages to a DLQ so you can inspect, alert on, and replay them.",facing:"INTERNAL",level:"WARN",name:"queue_without_dlq",remediation:'Add `deadLetterQueue: "<name>-dlq"` to the `defineQueue({...})` options and declare a consumer for that queue so exhausted messages are inspectable (an unconsumed DLQ still expires at the retention window). `vis generate lunora-queue` with the best-practice setup scaffolds both. If the queue is fire-and-forget and message loss is acceptable, this advisory can be ignored.',run:t=>{if(t.queues===void 0)return[];const i=new Set(t.queues.map(e=>e.tuning.deadLetterQueue).filter(e=>e!==void 0&&e!=="")),s=[];for(const e of t.queues){const u=e.tuning.deadLetterQueue;if(u!==void 0&&u!==""||i.has(e.name))continue;const a=typeof e.tuning.maxRetries=="number"?e.tuning.maxRetries:3;s.push(n(r,{cacheKey:`queue_without_dlq:${e.exportName}`,detail:`Queue "${e.exportName}" declares no \`deadLetterQueue\`, so a message that exhausts its retries (\`maxRetries\` = ${String(a)}, ~${String(a+1)} delivery attempts) is dropped and permanently lost.`,metadata:{maxRetries:a,mode:e.mode,queue:e.exportName}}))}return s},source:"static",title:"Queue has no dead-letter queue"};export{r as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as i}from"./finding-NrKO8idM.mjs";const n={categories:["SCHEMA"],description:"A `query`/`mutation` handler accesses R2 SQL via `ctx.r2sql`. R2 SQL queries external Apache Iceberg tables over REST (no Workers binding): queries are non-deterministic (like `fetch`) and the reads are invisible to live queries. `ctx.r2sql` is available on `ActionCtx` only and must be confined to `action` handlers.",facing:"EXTERNAL",level:"WARN",name:"r2sql_outside_action",remediation:"Move the `ctx.r2sql` access into an `action(...)` (the only context where it is typed), where external I/O is allowed. If a query/mutation needs the data, have the action read it via `ctx.r2sql` and write a projection into a `defineSchema` DO/D1 table — that write is tracked by live queries, whereas R2 SQL is not.",run:t=>t.r2sqlCalls===void 0?[]:t.r2sqlCalls.map(e=>i(n,{cacheKey:`r2sql_outside_action:${e.file}:${e.line.toString()}:${e.callee}`,detail:`\`${e.callee}(…)\` in ${e.exportName} (${e.file}:${e.line.toString()}) runs inside a ${e.kind} handler — R2 SQL's \`ctx.r2sql\` is non-deterministic and non-reactive, so it is available only in actions. Move the external query into an \`action\` and project the result into a Lunora table if a query/mutation needs it.`,metadata:{callee:e.callee,exportName:e.exportName,file:e.file,kind:e.kind,line:e.line}})),source:"static",title:"R2 SQL `ctx.r2sql` used outside an action"};export{n as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as i}from"./finding-NrKO8idM.mjs";const r={categories:["SECURITY"],description:"`new RateLimiter({...})` has no `store`, so it falls back to a per-instance in-memory store. On Workers each request may hit a different isolate, so the counter never sums across them — the limit silently under-counts and is effectively a no-op under load, giving false protection against brute-force and cost abuse.",facing:"EXTERNAL",level:"WARN",name:"ratelimit_default_memory_store",remediation:"Pass a shared, durable `store` to `new RateLimiter({...})` (a Durable-Object- or KV-backed store) so every Worker isolate reads and writes the same bucket. The default in-memory store is per-isolate and does not enforce a global limit.",run:t=>t.configCalls===void 0?[]:t.configCalls.filter(e=>e.callee==="RateLimiter"&&e.analyzable&&!e.presentKeys.includes("store")).map(e=>i(r,{cacheKey:`ratelimit_default_memory_store:${e.file}:${e.line.toString()}`,detail:`\`new RateLimiter({...})\` in ${e.file}:${e.line.toString()} has no \`store\` — it uses the per-instance in-memory store, which never sums across Worker isolates, so the limit silently under-counts under load. Pass a Durable-Object- or KV-backed \`store\` so the limit is enforced globally.`,metadata:{callee:e.callee,file:e.file,line:e.line}})),source:"static",title:"RateLimiter using the default in-memory store"};export{r as default};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +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-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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as m}from"./finding-NrKO8idM.mjs";import{t as d}from"./helpers-DdZ_pyR3.mjs";const u=(o,r,a,n)=>{if(n.through!==void 0){const c=o.get(n.through.table);return c===void 0?[]:[n.through.from,n.through.to].filter(f=>!r(c).has(f)).map(f=>({column:f,owner:c.name,side:"field"}))}const e=o.get(n.table);if(!e)return[];const s=n.kind==="one"?a:e,t=n.kind==="one"?e:a,i=[];return r(s).has(n.field)||i.push({column:n.field,owner:s.name,side:"field"}),r(t).has(n.references)||i.push({column:n.references,owner:t.name,side:"references"}),i},l={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 r=new Map(o.schema.tables.map(e=>[e.name,e])),a=new Map,n=e=>{let s=a.get(e);return s||(s=d(e),a.set(e,s)),s};return o.schema.tables.flatMap(e=>e.relations.flatMap(s=>u(r,n,e,s).map(t=>m(l,{cacheKey:`relation_references_unknown_field:${e.name}:${s.name}:${t.side}`,detail:`Relation "${s.name}" on table "${e.name}" uses ${t.side} "${t.column}", which is not declared on table "${t.owner}".`,metadata:{column:t.column,owner:t.owner,relation:s.name,side:t.side,table:e.name}}))))},source:"static",title:"Relation references unknown field"};export{l as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as s}from"./finding-NrKO8idM.mjs";const l={categories:["SCHEMA"],description:"A relation targets a table that does not exist in the schema. The relation can never load — the target is a typo or a removed/renamed table.",facing:"INTERNAL",level:"ERROR",name:"relation_references_unknown_table",remediation:"Fix the target table name in the relation, or add the missing table to the schema.",run:n=>{const r=new Set(n.schema.tables.map(e=>e.name));return n.schema.tables.flatMap(e=>e.relations.flatMap(a=>[{name:a.table,role:""},...a.through===void 0?[]:[{name:a.through.table,role:":through"}]].filter(({name:t})=>!r.has(t)).map(({name:t,role:o})=>s(l,{cacheKey:`relation_references_unknown_table:${e.name}:${a.name}${o}`,detail:`Relation "${a.name}" on table "${e.name}" targets table "${t}", which does not exist in the schema.`,metadata:{relation:a.name,table:e.name,target:t}}))))},source:"static",title:"Relation references unknown table"};export{l as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as a}from"./finding-NrKO8idM.mjs";const l={categories:["SECURITY"],description:"A public procedure reads or writes a table that is covered by an RLS policy elsewhere in the app, but this procedure's builder chain does not include `.use(rls(...))`. The raw, unwrapped `ctx.db` silently bypasses every policy in the list.",facing:"EXTERNAL",level:"WARN",name:"rls_uncovered_table",remediation:"Add `.use(rls(policies))` to the procedure's builder chain — e.g. `c.use(rls(myPolicies)).query(...)` — so its `ctx.db` is wrapped by the same policy evaluator as the rest of the app. If this procedure is intentionally privileged (e.g. a background job), declare it with `internalQuery` / `internalMutation` / `internalAction` instead of the public builder so the intent is explicit.",run:r=>{if(r.rlsProcedures===void 0)return[];const s=new Set;for(const e of r.rlsProcedures)for(const i of e.rlsTables)i!==""&&s.add(i);if(s.size===0)return[];const o=[];for(const e of r.rlsProcedures){if(e.usesRls||e.visibility==="internal")continue;const i=new Set;for(const t of[...e.tablesRead,...e.tablesWritten])t!==""&&s.has(t)&&i.add(t);for(const t of i)o.push(a(l,{cacheKey:`rls_uncovered_table:${e.file}:${e.exportName}:${t}`,detail:`\`${e.exportName}\` in ${e.file} accesses table \`${t}\` without \`.use(rls(...))\` — the table is policy-gated elsewhere in the app but this procedure bypasses those policies entirely.`,metadata:{exportName:e.exportName,file:e.file,table:t}}))}return o},source:"static",title:"RLS-gated table accessed without rls() middleware"};export{l 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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as l}from"./finding-NrKO8idM.mjs";const r={categories:["PERFORMANCE"],description:"A `defineShape` replicates from a `.global()` table. Global tables live outside the shard DO's SQLite op-log, so the shape is served through the cross-shard tier — coordinator/poll-refreshed and latency-tiered, not poke-live like a sharded shape.",facing:"EXTERNAL",level:"WARN",name:"shape_targets_global_table",remediation:"Expected if you want the cross-shard tier — just don't assume poke-live freshness; global shapes refresh on a poll. For live updates, replicate from a sharded table instead (denormalize the columns you need into the shard).",run:a=>{if(a.shapes===void 0)return[];const t=new Set(a.schema.tables.filter(e=>e.shardKind==="global").map(e=>e.name));return a.shapes.filter(e=>e.table!==void 0&&t.has(e.table)).map(e=>l(r,{cacheKey:`shape_targets_global_table:${e.exportName}`,detail:`Shape \`${e.exportName}\` (${e.file}) replicates from the \`.global()\` table \`${e.table}\`. It is served through the cross-shard global tier — poll-refreshed and latency-tiered, not poke-live.`,metadata:{exportName:e.exportName,file:e.file,table:e.table}}))},source:"static",title:"Shape replicates from a global (cross-shard) table"};export{r as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as n}from"./finding-NrKO8idM.mjs";const o={categories:["SCHEMA"],description:"A `defineShape` is bound to a `table` name that does not exist in the schema. The shape can never resolve a rowset — its subscription seeds empty and errors at the first flush.",facing:"INTERNAL",level:"ERROR",name:"shape_unknown_table",remediation:"Fix the shape's `table` to a real table name (check for a typo or a table that was renamed/removed).",run:a=>{if(a.shapes===void 0)return[];const t=new Set(a.schema.tables.map(e=>e.name));return a.shapes.filter(e=>e.table!==void 0&&!t.has(e.table)).map(e=>n(o,{cacheKey:`shape_unknown_table:${e.exportName}`,detail:`Shape \`${e.exportName}\` (${e.file}) replicates from table \`${e.table}\`, which is not declared in the schema. The shape can never resolve a rowset.`,metadata:{exportName:e.exportName,file:e.file,table:e.table}}))},source:"static",title:"Shape bound to an unknown table"};export{o as default};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as s}from"./finding-NrKO8idM.mjs";const d={categories:["SECURITY"],description:"A `.public()` read passes `includeDeleted` (hardcoded `true` or derived from `args`) on a `.softDelete()` table, resurfacing soft-deleted rows to callers that should never see them.",facing:"EXTERNAL",level:"INFO",name:"soft_delete_include_deleted_from_args",remediation:"Drop `includeDeleted` from the public read so soft-deleted rows stay hidden, or move the trash/restore view behind an internal (or RLS/role-gated) procedure. Never wire `includeDeleted` from `args` on a public read — an attacker can set it to see rows the app treats as deleted.",run:t=>{if(t.softDeleteReads===void 0)return[];const l=new Set(t.schema.tables.filter(e=>e.softDelete!==void 0).map(e=>e.name));return l.size===0?[]:t.softDeleteReads.filter(e=>e.visibility==="public"&&e.table!==""&&l.has(e.table)&&(e.hardcodedTrue||e.fromArgs)).map(e=>{const a=`\`${e.exportName}\` (${e.file}:${e.line.toString()})`,r=e.fromArgs?`The public read in ${a} wires \`includeDeleted\` from request \`args\` on soft-delete table \`${e.table}\`, letting any caller resurface soft-deleted rows the app treats as gone.`:`The public read in ${a} hardcodes \`includeDeleted: true\` on soft-delete table \`${e.table}\`, returning soft-deleted rows to every caller.`;return s(d,{cacheKey:`soft_delete_include_deleted_from_args:${e.file}:${e.line.toString()}:${e.table}`,detail:r,metadata:{exportName:e.exportName,file:e.file,line:e.line,source:e.fromArgs?"args":"literal",table:e.table}})})},source:"static",title:"Soft-deleted rows resurfaced via includeDeleted on a public read"};export{d as default};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{isReachableSite as i}from"./callSiteCallers-DArHfQSU.mjs";import{e as s}from"./finding-NrKO8idM.mjs";const r={categories:["SCHEMA"],description:'No function inserts into this table via `ctx.db.insert("<table>", …)`. It may be read-only by design (seeded by a migration, replicated, or written through a path the advisor can\'t see) — or it may be dead schema.',facing:"INTERNAL",level:"INFO",name:"table_without_insert",remediation:'If the table should be writable, add a mutation that calls `ctx.db.insert("<table>", …)`. If it is read-only or seeded elsewhere, this advisory can be ignored.',run:t=>{if(t.inserts===void 0)return[];const a=new Set(t.inserts.filter(e=>e.table!==""&&i(e.scope)).map(e=>e.table));return t.schema.tables.filter(e=>!a.has(e.name)&&e.externallyManaged!==!0).map(e=>s(r,{cacheKey:`table_without_insert:${e.name}`,detail:`No function calls \`ctx.db.insert("${e.name}", …)\` — table "${e.name}" has no discovered insert path.`,metadata:{table:e.name}}))},source:"static",title:"Table has no insert path"};export{r as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as d}from"./finding-NrKO8idM.mjs";const h=(t,s,n)=>t.some(e=>e.unique===!0&&e.fields.length===2&&e.fields.includes(s)&&e.fields.includes(n)),l={categories:["SCHEMA"],description:"A many-to-many relation's join table has no unique index over its two keys, so the same link can be stored twice and every read through the relation returns the target twice.",facing:"INTERNAL",level:"ERROR",name:"through_relation_missing_unique_index",remediation:'Add a unique index over the two keys on the join table, e.g. `.index("byPostTag", ["postId", "tagId"], { unique: true })`.',run:t=>{const s=new Map(t.schema.tables.map(n=>[n.name,n]));return t.schema.tables.flatMap(n=>n.relations.flatMap(e=>{if(e.through===void 0)return[];const{from:i,table:o,to:a}=e.through,r=s.get(o);return r===void 0?[]:h(r.indexes.filter(u=>u.kind==="index"),i,a)?[]:[d(l,{cacheKey:`through_relation_missing_unique_index:${n.name}:${e.name}`,detail:`Relation "${e.name}" on table "${n.name}" goes through "${o}", which has no unique index over ("${i}", "${a}"). A repeated connect stores the same link twice.`,metadata:{relation:e.name,suggestedIndex:{fields:[i,a],name:`by_${i}_${a}`,unique:!0},table:o}})]}))},source:"static",title:"Through relation missing unique index"};export{l as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as a}from"./finding-NrKO8idM.mjs";const o=10,p={categories:["PERFORMANCE"],description:`A topic with more than ${String(o)} subscriptions makes every publish one queue send per subscription. A Worker runs at most six sends at once, so the publish adds several round trips to the calling handler, and a retried publish re-delivers to every subscription that already received it.`,facing:"INTERNAL",level:"INFO",name:"topic_too_many_subscriptions",remediation:"Fold subscriptions that do related work into one handler, or publish from an action instead of a mutation so the fan-out is off the write path. Keep every subscription handler idempotent: a retried publish re-delivers to all of them.",run:i=>{if(i.queues===void 0)return[];const r=Map.groupBy(i.queues.filter(e=>e.topic!==void 0),e=>e.topic),s=[];for(const[e,t]of r)t.length<=o||s.push(a(p,{cacheKey:`topic_too_many_subscriptions:${e}`,detail:`Topic "${e}" has ${String(t.length)} subscriptions, so each publish sends to ${String(t.length)} queues.`,metadata:{subscriptions:t.map(n=>n.exportName),topic:e}}));return s},source:"static",title:"Topic has many subscriptions"};export{p as default};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +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-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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as n}from"./finding-NrKO8idM.mjs";const i={categories:["SECURITY"],description:"A public `v.string()` argument has no maximum-length bound. An unbounded string lets a client submit arbitrarily large input — abusing storage and CPU on every request that processes it.",facing:"EXTERNAL",level:"INFO",name:"unbounded_string_arg",remediation:"Add an enforced max-length bound with `.max(n)` on the string validator (e.g. cap a name at 256, a body at a few KB). `.meta({ maxLength })` only documents a cap — the parser does not enforce it. Size the cap to the field's real-world maximum.",run:e=>e.argValidators===void 0?[]:e.argValidators.flatMap(a=>a.unboundedStringArgs.map(t=>n(i,{cacheKey:`unbounded_string_arg:${a.file}:${a.exportName}:${t}`,detail:`Arg \`${t}\` of public procedure \`${a.exportName}\` (${a.file}:${a.line.toString()}) is an unbounded \`v.string()\`. Add a max-length bound to cap payload size.`,metadata:{argument:t,exportName:a.exportName,file:a.file,line:a.line}}))),source:"static",title:"Public string argument has no length bound"};export{i as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as l}from"./finding-NrKO8idM.mjs";import{l as d,P as r,s as c}from"./fk-index-DXYS17cc.mjs";const u={categories:["PERFORMANCE"],description:"A foreign-key column declared by a `one` relation has no index leading with it. Reads that filter or join on the column full-scan the table, which gets linearly slower as rows accumulate.",facing:"EXTERNAL",level:"INFO",name:"unindexed_foreign_key",remediation:'Add a secondary index leading with the FK column, e.g. `.index("byAuthorId", ["authorId"])`.',run:o=>{const i=[];for(const a of o.schema.tables){const t=d(a);for(const n of a.relations){if(n.kind!=="one")continue;const e=n.field;if(e===r||t.has(e))continue;const s=c(e);i.push(l(u,{cacheKey:`unindexed_foreign_key:${a.name}:${e}`,detail:`Relation "${n.name}" on table "${a.name}" references "${n.table}" via column "${e}", which is not the leading column of any index. Reads filtering or joining on "${e}" full-scan "${a.name}".`,metadata:{fkColumn:e,references:{column:n.references,table:n.table},relation:n.name,suggestedIndex:{fields:[e],name:s},table:a.name}}))}}return i},source:"static",title:"Unindexed foreign key"};export{u as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as o}from"./finding-NrKO8idM.mjs";import{s as m,P as l,l as r}from"./fk-index-DXYS17cc.mjs";const d=e=>e.through===void 0?[{column:e.field,references:e.references,table:e.table}]:[{column:e.through.from,references:"_id",table:e.through.table},{column:e.through.to,references:"_id",table:e.through.table}],c=(e,i)=>i===l||e.relations.some(n=>n.kind==="one"&&n.field===i)||r(e).has(i),u={categories:["PERFORMANCE"],description:"A `many` relation's foreign-key column on the target table has no index leading with it. Relation predicates (`some`/`none`/`every`) and `with:` child reads filter the target on that column, full-scanning it as rows accumulate.",facing:"EXTERNAL",level:"INFO",name:"unindexed_relation_target",remediation:'Add a secondary index on the target table leading with the FK column, e.g. `.index("byAuthorId", ["authorId"])`.',run:e=>{const i=new Map(e.schema.tables.map(n=>[n.name,n]));return e.schema.tables.flatMap(n=>n.relations.filter(s=>s.kind==="many").flatMap(s=>d(s).flatMap(a=>{const t=i.get(a.table);return t===void 0||c(t,a.column)?[]:[o(u,{cacheKey:`unindexed_relation_target:${t.name}:${a.column}`,detail:`Relation "${s.name}" on table "${n.name}" is a to-many over "${t.name}" via its column "${a.column}", which is not the leading column of any index on "${t.name}". Relation predicates (\`some\`/\`none\`/\`every\`) and \`with:\` reads of "${s.name}" full-scan "${t.name}".`,metadata:{fkColumn:a.column,references:{column:a.references,table:n.name},relation:s.name,suggestedIndex:{fields:[a.column],name:m(a.column)},table:t.name}})]})))},source:"static",title:"Unindexed relation target"};export{u as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as i}from"./finding-NrKO8idM.mjs";const r={"empty-object":"`{}`",undefined:"`undefined`"},n={categories:["SECURITY"],description:"A branching `defineShape({ where })` / `definePolicy({ when })` predicate returns `{}` or `undefined` from one arm. A row predicate is a filter, not a boolean: `{}` matches EVERY row, so an arm meant to deny instead replicates or exposes the whole table — silently.",facing:"EXTERNAL",level:"ERROR",name:"unrestricted_where_branch",remediation:"Return `deny()` (from `@lunora/server`) — or `false`, which compiles to the same vacuously-false `{ OR: [] }` predicate — from the denial arm. Reach for `allowAll()` only when that arm genuinely means 'no further restriction'. For an owner check, prefer `.ownedBy(field)` on the table plus `defineShape({ owner: true })`, which derives the predicate from the verified identity and cannot be written backwards.",run:t=>t.unrestrictedWhereBranches===void 0?[]:t.unrestrictedWhereBranches.map(e=>i(n,{cacheKey:`unrestricted_where_branch:${e.file}:${e.line.toString()}:${e.key}`,detail:`\`${e.owner}\` \`${e.exportName}\` (${e.file}:${e.line.toString()}) returns ${r[e.form]} from one arm of its \`${e.key}\` predicate. A predicate is a filter, not a boolean — ${r[e.form]} imposes no restriction, so this arm matches every row instead of none. Return \`deny()\` (or \`false\`) if it was meant to deny; use \`allowAll()\` to state an intentionally unrestricted arm.`,metadata:{exportName:e.exportName,file:e.file,form:e.form,key:e.key,line:e.line,owner:e.owner}})),source:"static",title:"Predicate arm returns an unrestricted filter"};export{n as default};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{e as l}from"./finding-NrKO8idM.mjs";const d={categories:["SCHEMA"],description:"Two durable step call sites in this workflow share a name. Cloudflare caches a step under its name plus its occurrence number within the run, so which cached result each call site gets is decided by the order they ran — a conditional or a reordering between them re-points a later replay at the other step's stored output, silently and without an error. They are also indistinguishable in the step log.",facing:"INTERNAL",level:"ERROR",name:"workflow_duplicate_step_name",remediation:"Give every `step.do` / `step.sleep` / `step.sleepUntil` / `step.waitForEvent` CALL SITE in the workflow its own name. A single call site that repeats — a loop over items — is fine as written and is not flagged; interpolate the item id into the name only if you want the iterations distinguishable in the step log.",run:a=>{if(a.workflows===void 0)return[];const o=[];for(const t of a.workflows){const{steps:n}=t;if(n===void 0||n.length===0)continue;const s=new Map,r=new Set;for(const e of n){const i=s.get(e.name);if(i===void 0){s.set(e.name,e.line);continue}r.has(e.name)||(r.add(e.name),o.push(l(d,{cacheKey:`workflow_duplicate_step_name:${t.exportName}:${e.name}`,detail:`Workflow "${t.exportName}" reuses the durable step name "${e.name}" (first at line ${String(i)}, again at line ${String(e.line)}${t.handlerFile===void 0?"":` of ${t.handlerFile}`}). The second call returns the first's cached result instead of running.`,metadata:{firstLine:i,line:e.line,stepName:e.name,workflow:t.exportName}})))}}return o},source:"static",title:"Duplicate durable step name in workflow"};export{d as default};
|
|
@@ -0,0 +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-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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{isReachableSite as a}from"./callSiteCallers-DArHfQSU.mjs";import{e as i}from"./finding-NrKO8idM.mjs";const s={categories:["SCHEMA"],description:'No function starts this workflow via `ctx.workflows.get("<name>")`. It may be triggered through a path the advisor can\'t see (the Cloudflare API, a wrangler invocation, a cross-service binding) — or it may be dead code that still deploys as a billable WorkflowEntrypoint.',facing:"INTERNAL",level:"INFO",name:"workflow_unused",remediation:'If the workflow should be triggered in-app, start it from a mutation/action with `ctx.workflows.get("<name>").create({ params })`. If it is started externally or is no longer needed, this advisory can be ignored (or remove the `defineWorkflow` export).',run:o=>{if(o.workflows===void 0||o.workflowCalls===void 0)return[];const t=o.workflowCalls.filter(e=>a(e.scope));if(t.some(e=>e.workflow===""))return[];const r=new Set(t.map(e=>e.workflow));return o.workflows.filter(e=>!r.has(e.exportName)).map(e=>i(s,{cacheKey:`workflow_unused:${e.exportName}`,detail:`No function calls \`ctx.workflows.get("${e.exportName}")\` — workflow "${e.exportName}" is declared but never started in app code.`,metadata:{workflow:e.exportName}}))},source:"static",title:"Workflow is never started"};export{s as default};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/advisor",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.211",
|
|
4
4
|
"description": "Schema & query lints (splinter-style advisors) for Lunora, feeding the Studio Advisors view",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"advisor",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@lunora/errors": "1.0.0-alpha.2",
|
|
50
|
-
"@lunora/server": "1.0.0-alpha.
|
|
49
|
+
"@lunora/errors": ">=1.0.0-alpha.51 <2.0.0-0",
|
|
50
|
+
"@lunora/server": "1.0.0-alpha.195"
|
|
51
51
|
},
|
|
52
52
|
"engines": {
|
|
53
53
|
"node": "^22.15.0 || >=24.11.0"
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
|
|
3
|
-
const DATASET_NAME_PATTERN = /^[\w.-]+$/u;
|
|
4
|
-
const AE_METRIC_EVENTS = {
|
|
5
|
-
/** `lunora.index.hit` — one row per `(table, index)` use. `blob2`=table, `blob3`=index. */
|
|
6
|
-
indexHit: { event: "lunora.index.hit", index: "blob3", table: "blob2" },
|
|
7
|
-
/** `lunora.shard.request` — one row per shard dispatch. `blob2`=shardKey, `blob3`=group. */
|
|
8
|
-
shardRequest: { event: "lunora.shard.request", group: "blob3", shardKey: "blob2" },
|
|
9
|
-
/** `lunora.table.scan` — one row per full-scan. `blob2`=table. */
|
|
10
|
-
tableScan: { event: "lunora.table.scan", table: "blob2" }
|
|
11
|
-
};
|
|
12
|
-
const toCount = (value) => {
|
|
13
|
-
const numeric = typeof value === "number" ? value : Number(value);
|
|
14
|
-
return Number.isFinite(numeric) ? numeric : 0;
|
|
15
|
-
};
|
|
16
|
-
const toText = (value) => {
|
|
17
|
-
if (typeof value === "string") {
|
|
18
|
-
return value;
|
|
19
|
-
}
|
|
20
|
-
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
21
|
-
return String(value);
|
|
22
|
-
}
|
|
23
|
-
return "";
|
|
24
|
-
};
|
|
25
|
-
const assertDataset = (dataset) => {
|
|
26
|
-
if (!DATASET_NAME_PATTERN.test(dataset)) {
|
|
27
|
-
throw new LunoraError("INTERNAL", `@lunora/advisor: invalid Analytics Engine dataset name "${dataset}" — expected a bare table identifier.`);
|
|
28
|
-
}
|
|
29
|
-
};
|
|
30
|
-
const sqlString = (value) => `'${value.replaceAll("\\", "\\\\").replaceAll("'", "''")}'`;
|
|
31
|
-
const queryOrEmpty = async (source, sql) => {
|
|
32
|
-
try {
|
|
33
|
-
const result = await source.query(sql);
|
|
34
|
-
return result.rows;
|
|
35
|
-
} catch {
|
|
36
|
-
return [];
|
|
37
|
-
}
|
|
38
|
-
};
|
|
39
|
-
const loadShardTraffic = async (source, options) => {
|
|
40
|
-
const { event, group, shardKey } = AE_METRIC_EVENTS.shardRequest;
|
|
41
|
-
const groupFilter = options.group === void 0 ? "" : ` AND ${group} = ${sqlString(options.group)}`;
|
|
42
|
-
const sql = `SELECT ${shardKey} AS shardKey, ${group} AS shardGroup, sum(_sample_interval) AS requests FROM ${options.dataset} WHERE blob1 = ${sqlString(event)}${groupFilter} GROUP BY shardKey, shardGroup`;
|
|
43
|
-
const rows = await queryOrEmpty(source, sql);
|
|
44
|
-
return rows.map((row) => {
|
|
45
|
-
const shardGroup = toText(row.shardGroup);
|
|
46
|
-
return {
|
|
47
|
-
...shardGroup === "" ? {} : { group: shardGroup },
|
|
48
|
-
requests: toCount(row.requests),
|
|
49
|
-
shardKey: toText(row.shardKey)
|
|
50
|
-
};
|
|
51
|
-
});
|
|
52
|
-
};
|
|
53
|
-
const loadTableScans = async (source, options) => {
|
|
54
|
-
const { event, table } = AE_METRIC_EVENTS.tableScan;
|
|
55
|
-
const sql = `SELECT ${table} AS scanTable, sum(_sample_interval) AS scans FROM ${options.dataset} WHERE blob1 = ${sqlString(event)} GROUP BY scanTable`;
|
|
56
|
-
const rows = await queryOrEmpty(source, sql);
|
|
57
|
-
return rows.map((row) => {
|
|
58
|
-
return { scans: toCount(row.scans), table: toText(row.scanTable) };
|
|
59
|
-
});
|
|
60
|
-
};
|
|
61
|
-
const loadIndexHits = async (source, options) => {
|
|
62
|
-
const { event, index, table } = AE_METRIC_EVENTS.indexHit;
|
|
63
|
-
const sql = `SELECT ${table} AS hitTable, ${index} AS hitIndex, sum(_sample_interval) AS reads FROM ${options.dataset} WHERE blob1 = ${sqlString(event)} GROUP BY hitTable, hitIndex`;
|
|
64
|
-
const rows = await queryOrEmpty(source, sql);
|
|
65
|
-
const hits = rows.map((row) => {
|
|
66
|
-
return { index: toText(row.hitIndex), reads: toCount(row.reads), table: toText(row.hitTable) };
|
|
67
|
-
});
|
|
68
|
-
if (options.declaredIndexes === void 0) {
|
|
69
|
-
return hits;
|
|
70
|
-
}
|
|
71
|
-
const seen = new Set(hits.map((hit) => `${hit.table}\0${hit.index}`));
|
|
72
|
-
const zeros = options.declaredIndexes.filter((declared) => !seen.has(`${declared.table}\0${declared.index}`)).map((declared) => {
|
|
73
|
-
return { index: declared.index, reads: 0, table: declared.table };
|
|
74
|
-
});
|
|
75
|
-
return [...hits, ...zeros];
|
|
76
|
-
};
|
|
77
|
-
const loadAnalyticsRuntimeMetrics = async (source, options) => {
|
|
78
|
-
assertDataset(options.dataset);
|
|
79
|
-
const [shardTraffic, tableScans, indexHits] = await Promise.all([
|
|
80
|
-
loadShardTraffic(source, options),
|
|
81
|
-
loadTableScans(source, options),
|
|
82
|
-
loadIndexHits(source, options)
|
|
83
|
-
]);
|
|
84
|
-
return { indexHits, shardTraffic, tableScans };
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
export { AE_METRIC_EVENTS, loadAnalyticsRuntimeMetrics };
|