@lunora/advisor 1.0.0-alpha.34 → 1.0.0-alpha.35
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/dist/index.d.mts +61 -1
- package/dist/index.d.ts +61 -1
- package/dist/index.mjs +1 -200
- package/dist/packem_shared/AE_METRIC_EVENTS-LP_0D7Uw.mjs +1 -0
- package/dist/packem_shared/actionFetchSsrf-BbWxy1Yf.mjs +1 -0
- package/dist/packem_shared/adminRouteWithoutGuard-DSo4t9j4.mjs +1 -0
- package/dist/packem_shared/aiRawRunEscapeHatch-DfhjYm43.mjs +1 -0
- package/dist/packem_shared/aiToolSideEffectPromptInjection-DnWQOoKi.mjs +1 -0
- package/dist/packem_shared/aiUnboundedGenerationPublic-D3HZrh5_.mjs +1 -0
- package/dist/packem_shared/allowUnauthenticatedShardAccessEnabled-D4aMNBb0.mjs +1 -0
- package/dist/packem_shared/argument-derived-sink-BpO3NPPM.mjs +1 -0
- package/dist/packem_shared/authApiCallWithoutHeaders-BItMMdZP.mjs +1 -0
- package/dist/packem_shared/authCsrfCheckDisabled-De9ammtV.mjs +1 -0
- package/dist/packem_shared/authEmailVerificationDisabled-BFKdJcx4.mjs +1 -0
- package/dist/packem_shared/authSecureCookiesDisabled-B4WIzvMm.mjs +1 -0
- package/dist/packem_shared/authSessionFreshageZero-Bpcaf_Pe.mjs +1 -0
- package/dist/packem_shared/authTrustedOriginsWildcard-DjU9NXEm.mjs +1 -0
- package/dist/packem_shared/browserAllowPrivateTargets-Dxyeh6lg.mjs +1 -0
- package/dist/packem_shared/browserUserUrlWithoutAllowlist-BHBS6b0N.mjs +1 -0
- package/dist/packem_shared/circularFk-BMuJKmuZ.mjs +1 -0
- package/dist/packem_shared/constraintValidator-N339mfyx.mjs +1 -0
- package/dist/packem_shared/containerInstanceKeyFromUserInput-C8S4T3ty.mjs +1 -0
- package/dist/packem_shared/containerOversizedInstance-B9wW_sU5.mjs +1 -0
- package/dist/packem_shared/containerPublicInternet-Bx2J7tha.mjs +1 -0
- package/dist/packem_shared/containerRuntimeEgressRelaxation-BQ2ZD1aV.mjs +1 -0
- package/dist/packem_shared/containerStartEnableInternetOverride-B62WUura.mjs +1 -0
- package/dist/packem_shared/dedupeCacheKeys-7JgxaQWV.mjs +1 -0
- package/dist/packem_shared/duplicateIndex-DFT7qLQD.mjs +1 -0
- package/dist/packem_shared/emptyIndex-BiXEfyfN.mjs +1 -0
- package/dist/packem_shared/exportSinkMisconfigured-DvpAaa6j.mjs +1 -0
- package/dist/packem_shared/externalSourceIncrementalNoDeletePath-DnoL64YY.mjs +1 -0
- package/dist/packem_shared/externalSourceOnGlobal-DNPLoNUg.mjs +1 -0
- package/dist/packem_shared/externalSourceUnscoped-DqBp5jYr.mjs +1 -0
- package/dist/packem_shared/filterWithoutIndex-ht2GZQWV.mjs +1 -0
- package/dist/packem_shared/finding-BAVgqnCF.mjs +1 -0
- package/dist/packem_shared/fk-index-Bmk19RvQ.mjs +1 -0
- package/dist/packem_shared/flagGatesSecurityWithUnsafeDefault-DPGFEHTt.mjs +1 -0
- package/dist/packem_shared/fromServerSchema-CJ-AQ2kw.mjs +1 -0
- package/dist/packem_shared/geoIndexFieldNotGeopoint-DAGLZtaH.mjs +1 -0
- package/dist/packem_shared/geoIndexUnused-BgDE6_AR.mjs +1 -0
- package/dist/packem_shared/hardcodedSecret-rIRc3aNk.mjs +1 -0
- package/dist/packem_shared/helpers-DwlvEUCh.mjs +1 -0
- package/dist/packem_shared/hotShard-DVs_U17R.mjs +1 -0
- package/dist/packem_shared/httpActionMissingAuthGuard-BZDdeW_D.mjs +1 -0
- package/dist/packem_shared/httpActionResponseHeaderInjection-D063rB7U.mjs +1 -0
- package/dist/packem_shared/hyperdriveOutsideAction-CRmOQz67.mjs +1 -0
- package/dist/packem_shared/identityUndeclaredClaimTrusted-BYRYGQ74.mjs +1 -0
- package/dist/packem_shared/imagesUrlSourceFromUserInput-Cy5pc_dA.mjs +1 -0
- package/dist/packem_shared/indexReferencesUnknownField-r3QHlaz6.mjs +1 -0
- package/dist/packem_shared/indexUtilization-QfbIH_w4.mjs +1 -0
- package/dist/packem_shared/insertManyUnsafeUserData-DrH7PdRs.mjs +1 -0
- package/dist/packem_shared/kvUnscopedUserKeyIdor-B4lGxRxh.mjs +1 -0
- package/dist/packem_shared/mailInboundDispatchWithoutVerify-BivYJ5F9.mjs +1 -0
- package/dist/packem_shared/mailRecipientFromRequestInput-BR_oSh65.mjs +1 -0
- package/dist/packem_shared/maskUncoveredPiiColumn-DqPfZM2O.mjs +1 -0
- package/dist/packem_shared/maskWeakHashStrategyOnPii-CEiiGgT7.mjs +1 -0
- package/dist/packem_shared/maskedRelationLeakViaWith-CxYk0MDW.mjs +1 -0
- package/dist/packem_shared/mutatorFullRowReplace-CEgecSGm.mjs +1 -0
- package/dist/packem_shared/nondeterministicQueryMutation-BaMfbAk_.mjs +1 -0
- package/dist/packem_shared/normalizeIdUsedAsAuthorization-FjISSXJm.mjs +1 -0
- package/dist/packem_shared/notifyMissingPushConfig-C_GbVw3S.mjs +1 -0
- package/dist/packem_shared/notifySendOutsideAction-B0jZuUP3.mjs +1 -0
- package/dist/packem_shared/outputProjectionMissingOnPublicRead-9Pm9766s.mjs +1 -0
- package/dist/packem_shared/ownerFieldFromArgsNotAuth-BoR9vvst.mjs +1 -0
- package/dist/packem_shared/paymentCreateWithoutAuthorize-CurtqN8K.mjs +1 -0
- package/dist/packem_shared/paymentWebhookWideTolerance-I7ingfR8.mjs +1 -0
- package/dist/packem_shared/plaintextSecretInWranglerVariables-DXjKIm8H.mjs +1 -0
- package/dist/packem_shared/policyReferencesUnknownTable-D3WelAQa.mjs +1 -0
- package/dist/packem_shared/privilegedDispatchUnvalidatedPayload-74GuGye1.mjs +1 -0
- package/dist/packem_shared/privilegedFanoutFromPublicProcedure-C2j2GSUN.mjs +1 -0
- package/dist/packem_shared/publicArgumentUsesAny-BJb4cTqL.mjs +1 -0
- package/dist/packem_shared/publicMutationWithoutRatelimit-D9phluHW.mjs +1 -0
- package/dist/packem_shared/publicTableRlsOptoutConfusion-CaWtPGAn.mjs +1 -0
- package/dist/packem_shared/queueWithoutDlq-BLY3iy9Q.mjs +1 -0
- package/dist/packem_shared/r2sqlOutsideAction-CKK_I1Ms.mjs +1 -0
- package/dist/packem_shared/ratelimitDefaultMemoryStore-mOdI3Jmb.mjs +1 -0
- package/dist/packem_shared/ratelimitKeySpoofableOrGlobal-DaGId_Fh.mjs +1 -0
- package/dist/packem_shared/ratelimitMiddlewareFailOpen-CTWVosEc.mjs +1 -0
- package/dist/packem_shared/relationReferencesUnknownField-clqyB69Y.mjs +1 -0
- package/dist/packem_shared/relationReferencesUnknownTable-CLdrhkmS.mjs +1 -0
- package/dist/packem_shared/rlsUncoveredTable-DYHgnoos.mjs +1 -0
- package/dist/packem_shared/shapeTargetsGlobalTable-Blz-aSkg.mjs +1 -0
- package/dist/packem_shared/shapeUnknownTable-DFxtyFio.mjs +1 -0
- package/dist/packem_shared/signupMutationWithoutDisposableGating-3ir808Vz.mjs +1 -0
- package/dist/packem_shared/softDeleteIncludeDeletedFromArgs-DgxpQHRA.mjs +1 -0
- package/dist/packem_shared/sqlInjectionRisk-D2NaT-5c.mjs +1 -0
- package/dist/packem_shared/storageGenerateUploadUrlNoContentTypePin-C9cjl6Jh.mjs +1 -0
- package/dist/packem_shared/storageKeyFromUserArgs-BeS5cp56.mjs +1 -0
- package/dist/packem_shared/storagePresignedUrlForPrivateContent-j7VhwIdu.mjs +1 -0
- package/dist/packem_shared/storageUploadWithoutContentTypeAllowlist-oxKLecuf.mjs +1 -0
- package/dist/packem_shared/storageUploadWithoutMaxSize-DUDxPutk.mjs +1 -0
- package/dist/packem_shared/tableWithoutInsert-D3AhaNzR.mjs +1 -0
- package/dist/packem_shared/ttlFieldNotTimestamp-BFiRkSZD.mjs +1 -0
- package/dist/packem_shared/unboundedStringArgument-AkkIcr-S.mjs +1 -0
- package/dist/packem_shared/unindexedForeignKey-DK4eWXkb.mjs +1 -0
- package/dist/packem_shared/unindexedRelationTarget-B5fFq1E8.mjs +1 -0
- package/dist/packem_shared/unrestrictedWhereBranch-YKiydie4.mjs +1 -0
- package/dist/packem_shared/userCreatingMutationWithoutCaptcha-iDjRkUFO.mjs +1 -0
- package/dist/packem_shared/vectorsNamespaceFromUserInput-Cyq5B2kF.mjs +1 -0
- package/dist/packem_shared/workflowDuplicateStepName-cXF6Rpvg.mjs +1 -0
- package/dist/packem_shared/workflowUnknownTarget-JF8rITLP.mjs +1 -0
- package/dist/packem_shared/workflowUnused-Dx_FQwGE.mjs +1 -0
- package/package.json +2 -2
- 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-CNcAVuYa.mjs +0 -127
- package/dist/packem_shared/constraintValidator-CxwtpJ6E.mjs +0 -206
- 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/dedupeCacheKeys-r5B7u_yq.mjs +0 -13
- package/dist/packem_shared/duplicateIndex-BOublMSt.mjs +0 -57
- package/dist/packem_shared/emptyIndex-BX8EuEY7.mjs +0 -32
- package/dist/packem_shared/exportSinkMisconfigured-897sVMUa.mjs +0 -46
- package/dist/packem_shared/externalSourceIncrementalNoDeletePath-DvX9cRBD.mjs +0 -45
- 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-CVnbSf8j.mjs +0 -70
- package/dist/packem_shared/geoIndexFieldNotGeopoint-CcaInLhY.mjs +0 -40
- package/dist/packem_shared/geoIndexUnused-D7I0UyND.mjs +0 -40
- package/dist/packem_shared/hardcodedSecret-Be-pKVdn.mjs +0 -31
- package/dist/packem_shared/helpers-BySnKhVB.mjs +0 -31
- package/dist/packem_shared/hotShard-CkC7qpre.mjs +0 -60
- 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/notifyMissingPushConfig-Ca9EzRzy.mjs +0 -27
- package/dist/packem_shared/notifySendOutsideAction-BUZSIZa4.mjs +0 -30
- 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/signupMutationWithoutDisposableGating-Dnbol2CS.mjs +0 -39
- 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/ttlFieldNotTimestamp-Dpaj63Z6.mjs +0 -36
- 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
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const privilegedDispatchUnvalidatedPayload = {
|
|
4
|
-
categories: ["SECURITY"],
|
|
5
|
-
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.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "ERROR",
|
|
8
|
-
name: "privileged_dispatch_unvalidated_payload",
|
|
9
|
-
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.",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.privilegedDispatches === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
const rlsProcedures = context.rlsProcedures ?? [];
|
|
15
|
-
const targetUsesRls = (targetFile, targetExport) => rlsProcedures.some((procedure) => procedure.usesRls && procedure.file === targetFile && procedure.exportName === targetExport);
|
|
16
|
-
return context.privilegedDispatches.filter((dispatch) => targetUsesRls(dispatch.targetFile, dispatch.targetExport)).map((dispatch) => {
|
|
17
|
-
const handlerKind = dispatch.dispatchKind === "queue" ? "queue" : "workflow";
|
|
18
|
-
const payloadSource = dispatch.dispatchKind === "queue" ? "queue message body" : "workflow params";
|
|
19
|
-
return emit(privilegedDispatchUnvalidatedPayload, {
|
|
20
|
-
cacheKey: `privileged_dispatch_unvalidated_payload:${dispatch.file}:${dispatch.line.toString()}`,
|
|
21
|
-
detail: `The ${handlerKind} handler \`${dispatch.handlerExport}\` (${dispatch.file}:${dispatch.line.toString()}) forwards its untrusted ${payloadSource} straight into \`${dispatch.targetFile}.${dispatch.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.`,
|
|
22
|
-
metadata: {
|
|
23
|
-
dispatchKind: dispatch.dispatchKind,
|
|
24
|
-
file: dispatch.file,
|
|
25
|
-
handlerExport: dispatch.handlerExport,
|
|
26
|
-
line: dispatch.line,
|
|
27
|
-
targetExport: dispatch.targetExport,
|
|
28
|
-
targetFile: dispatch.targetFile
|
|
29
|
-
}
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
},
|
|
33
|
-
source: "static",
|
|
34
|
-
title: "Privileged handler forwards untrusted payload into an RLS-gated function"
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export { privilegedDispatchUnvalidatedPayload as default };
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const privilegedFanoutFromPublicProcedure = {
|
|
4
|
-
categories: ["SECURITY"],
|
|
5
|
-
description: "A public procedure fans work out to a privileged dispatch surface (`ctx.scheduler.runAfter`/`runAt`, a `ctx.queues.<name>` send, 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.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "privileged_fanout_from_public_procedure",
|
|
9
|
-
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/workflow dispatch to anonymous callers.",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.procedureProtections === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
return context.procedureProtections.filter((procedure) => procedure.fanOut && procedure.visibility === "public" && !procedure.usesRateLimit).map(
|
|
15
|
-
(procedure) => emit(privilegedFanoutFromPublicProcedure, {
|
|
16
|
-
cacheKey: `privileged_fanout_from_public_procedure:${procedure.file}:${procedure.exportName}`,
|
|
17
|
-
detail: `\`${procedure.exportName}\` (${procedure.file}) is public and fans work out to a privileged dispatch surface (scheduler/queue/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.`,
|
|
18
|
-
metadata: { exportName: procedure.exportName, file: procedure.file, kind: procedure.kind }
|
|
19
|
-
})
|
|
20
|
-
);
|
|
21
|
-
},
|
|
22
|
-
source: "static",
|
|
23
|
-
title: "Public procedure fans out to a privileged dispatch surface"
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export { privilegedFanoutFromPublicProcedure as default };
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const publicArgumentUsesAny = {
|
|
4
|
-
categories: ["SECURITY"],
|
|
5
|
-
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.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "public_arg_uses_any",
|
|
9
|
-
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.",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.argValidators === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
const findings = [];
|
|
15
|
-
for (const procedure of context.argValidators) {
|
|
16
|
-
for (const argument of procedure.anyArgs) {
|
|
17
|
-
findings.push(
|
|
18
|
-
emit(publicArgumentUsesAny, {
|
|
19
|
-
cacheKey: `public_arg_uses_any:${procedure.file}:${procedure.exportName}:${argument}`,
|
|
20
|
-
detail: `Arg \`${argument}\` of public procedure \`${procedure.exportName}\` (${procedure.file}:${procedure.line.toString()}) is \`v.any()\` — untrusted input with no validation. Give it a precise validator.`,
|
|
21
|
-
metadata: { argument, exportName: procedure.exportName, file: procedure.file, line: procedure.line }
|
|
22
|
-
})
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return findings;
|
|
27
|
-
},
|
|
28
|
-
source: "static",
|
|
29
|
-
title: "Public argument uses v.any()"
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
export { publicArgumentUsesAny as default };
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
import { i as isPublicWrite } from './helpers-BySnKhVB.mjs';
|
|
3
|
-
|
|
4
|
-
const SENSITIVE_NAME_RE = /contact|forgot|login|magic|otp|register|reset|signin|signup|subscribe|verify/iu;
|
|
5
|
-
const publicMutationWithoutRatelimit = {
|
|
6
|
-
categories: ["SECURITY"],
|
|
7
|
-
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.",
|
|
8
|
-
facing: "EXTERNAL",
|
|
9
|
-
level: "WARN",
|
|
10
|
-
name: "public_mutation_without_ratelimit",
|
|
11
|
-
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.',
|
|
12
|
-
run: (context) => {
|
|
13
|
-
if (context.procedureProtections === void 0) {
|
|
14
|
-
return [];
|
|
15
|
-
}
|
|
16
|
-
const findings = [];
|
|
17
|
-
for (const procedure of context.procedureProtections) {
|
|
18
|
-
if (!isPublicWrite(procedure) || procedure.usesRateLimit) {
|
|
19
|
-
continue;
|
|
20
|
-
}
|
|
21
|
-
const sensitive = SENSITIVE_NAME_RE.test(procedure.exportName);
|
|
22
|
-
findings.push(
|
|
23
|
-
emit(publicMutationWithoutRatelimit, {
|
|
24
|
-
cacheKey: `public_mutation_without_ratelimit:${procedure.file}:${procedure.exportName}`,
|
|
25
|
-
detail: `Public ${procedure.kind} \`${procedure.exportName}\` (${procedure.file}) has no rate limit${sensitive ? " — its name suggests an auth/abuse-sensitive endpoint, so this is high-risk" : ""}. Add \`.use(rateLimit(...))\` or \`.use(protectPublic({ rateLimit }))\`.`,
|
|
26
|
-
metadata: { exportName: procedure.exportName, file: procedure.file, kind: procedure.kind, sensitive }
|
|
27
|
-
})
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
return findings;
|
|
31
|
-
},
|
|
32
|
-
source: "static",
|
|
33
|
-
title: "Public write without a rate limit"
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
export { publicMutationWithoutRatelimit as default };
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
import { o as ownershipOrPiiColumns } from './helpers-BySnKhVB.mjs';
|
|
3
|
-
|
|
4
|
-
const publicTableRlsOptoutConfusion = {
|
|
5
|
-
categories: ["SECURITY"],
|
|
6
|
-
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".',
|
|
7
|
-
facing: "EXTERNAL",
|
|
8
|
-
level: "WARN",
|
|
9
|
-
name: "public_table_rls_optout_confusion",
|
|
10
|
-
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.',
|
|
11
|
-
run: (context) => {
|
|
12
|
-
if (context.schema.rlsMode !== "required") {
|
|
13
|
-
return [];
|
|
14
|
-
}
|
|
15
|
-
const findings = [];
|
|
16
|
-
for (const table of context.schema.tables) {
|
|
17
|
-
if (!table.isPublic) {
|
|
18
|
-
continue;
|
|
19
|
-
}
|
|
20
|
-
const flaggedColumns = ownershipOrPiiColumns(table);
|
|
21
|
-
if (flaggedColumns.length === 0) {
|
|
22
|
-
continue;
|
|
23
|
-
}
|
|
24
|
-
findings.push(
|
|
25
|
-
emit(publicTableRlsOptoutConfusion, {
|
|
26
|
-
cacheKey: `public_table_rls_optout_confusion:${table.name}`,
|
|
27
|
-
detail: `Table "${table.name}" is \`.public()\` (opted OUT of the schema's \`.rls("required")\` enforcement) but carries ownership/PII-shaped column(s): ${flaggedColumns.join(", ")}. \`.public()\` means "exempt from RLS", not "safe to expose" — confirm this table really needs the opt-out.`,
|
|
28
|
-
metadata: { columns: flaggedColumns, table: table.name }
|
|
29
|
-
})
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
return findings;
|
|
33
|
-
},
|
|
34
|
-
source: "static",
|
|
35
|
-
title: "Public table opts out of RLS but carries sensitive columns"
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
export { publicTableRlsOptoutConfusion as default };
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const queueWithoutDlq = {
|
|
4
|
-
categories: ["SCHEMA"],
|
|
5
|
-
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.",
|
|
6
|
-
facing: "INTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "queue_without_dlq",
|
|
9
|
-
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.',
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.queues === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
const dlqTargets = new Set(
|
|
15
|
-
context.queues.map((queue) => queue.tuning.deadLetterQueue).filter((name) => name !== void 0 && name !== "")
|
|
16
|
-
);
|
|
17
|
-
const findings = [];
|
|
18
|
-
for (const queue of context.queues) {
|
|
19
|
-
const dlq = queue.tuning.deadLetterQueue;
|
|
20
|
-
if (dlq !== void 0 && dlq !== "") {
|
|
21
|
-
continue;
|
|
22
|
-
}
|
|
23
|
-
if (dlqTargets.has(queue.name)) {
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
const maxRetries = typeof queue.tuning.maxRetries === "number" ? queue.tuning.maxRetries : 3;
|
|
27
|
-
findings.push(
|
|
28
|
-
emit(queueWithoutDlq, {
|
|
29
|
-
cacheKey: `queue_without_dlq:${queue.exportName}`,
|
|
30
|
-
detail: `Queue "${queue.exportName}" declares no \`deadLetterQueue\`, so a message that exhausts its retries (\`maxRetries\` = ${String(maxRetries)}, ~${String(maxRetries + 1)} delivery attempts) is dropped and permanently lost.`,
|
|
31
|
-
metadata: { maxRetries, mode: queue.mode, queue: queue.exportName }
|
|
32
|
-
})
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
return findings;
|
|
36
|
-
},
|
|
37
|
-
source: "static",
|
|
38
|
-
title: "Queue has no dead-letter queue"
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
export { queueWithoutDlq as default };
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const r2sqlOutsideAction = {
|
|
4
|
-
categories: ["SCHEMA"],
|
|
5
|
-
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.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "r2sql_outside_action",
|
|
9
|
-
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.",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.r2sqlCalls === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
const findings = [];
|
|
15
|
-
for (const call of context.r2sqlCalls) {
|
|
16
|
-
findings.push(
|
|
17
|
-
emit(r2sqlOutsideAction, {
|
|
18
|
-
cacheKey: `r2sql_outside_action:${call.file}:${call.line.toString()}:${call.callee}`,
|
|
19
|
-
detail: `\`${call.callee}(…)\` in ${call.exportName} (${call.file}:${call.line.toString()}) runs inside a ${call.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.`,
|
|
20
|
-
metadata: { callee: call.callee, exportName: call.exportName, file: call.file, kind: call.kind, line: call.line }
|
|
21
|
-
})
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
return findings;
|
|
25
|
-
},
|
|
26
|
-
source: "static",
|
|
27
|
-
title: "R2 SQL `ctx.r2sql` used outside an action"
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
export { r2sqlOutsideAction as default };
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const ratelimitDefaultMemoryStore = {
|
|
4
|
-
categories: ["SECURITY"],
|
|
5
|
-
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.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "ratelimit_default_memory_store",
|
|
9
|
-
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.",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.configCalls === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
return context.configCalls.filter((call) => call.callee === "RateLimiter" && call.analyzable && !call.presentKeys.includes("store")).map(
|
|
15
|
-
(call) => emit(ratelimitDefaultMemoryStore, {
|
|
16
|
-
cacheKey: `ratelimit_default_memory_store:${call.file}:${call.line.toString()}`,
|
|
17
|
-
detail: `\`new RateLimiter({...})\` in ${call.file}:${call.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.`,
|
|
18
|
-
metadata: { callee: call.callee, file: call.file, line: call.line }
|
|
19
|
-
})
|
|
20
|
-
);
|
|
21
|
-
},
|
|
22
|
-
source: "static",
|
|
23
|
-
title: "RateLimiter using the default in-memory store"
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export { ratelimitDefaultMemoryStore as default };
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const ratelimitKeySpoofableOrGlobal = {
|
|
4
|
-
categories: ["SECURITY"],
|
|
5
|
-
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.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "ratelimit_key_spoofable_or_global",
|
|
9
|
-
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.',
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.ratelimitKeySelectors === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
return context.ratelimitKeySelectors.map(
|
|
15
|
-
(selector) => emit(ratelimitKeySpoofableOrGlobal, {
|
|
16
|
-
cacheKey: `ratelimit_key_spoofable_or_global:${selector.file}:${selector.line.toString()}`,
|
|
17
|
-
detail: `\`${selector.callee}(…, "${selector.limitName}", { key })\` in \`${selector.exportName}\` (${selector.file}:${selector.line.toString()}) 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.`,
|
|
18
|
-
metadata: { callee: selector.callee, exportName: selector.exportName, file: selector.file, limitName: selector.limitName, line: selector.line }
|
|
19
|
-
})
|
|
20
|
-
);
|
|
21
|
-
},
|
|
22
|
-
source: "static",
|
|
23
|
-
title: "Rate-limit key derived from spoofable user input"
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export { ratelimitKeySpoofableOrGlobal as default };
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const SENSITIVE_TOKENS = ["signin", "signup", "login", "register", "reset", "password", "otp", "verify", "payment", "checkout"];
|
|
4
|
-
const normalize = (value) => value.toLowerCase().replaceAll(/[^a-z0-9]+/gu, "");
|
|
5
|
-
const matchesSensitiveToken = (value) => {
|
|
6
|
-
const normalized = normalize(value);
|
|
7
|
-
return SENSITIVE_TOKENS.some((token) => normalized.includes(token));
|
|
8
|
-
};
|
|
9
|
-
const guardsSensitiveFlow = (row) => matchesSensitiveToken(row.exportName) || matchesSensitiveToken(row.limitName);
|
|
10
|
-
const ratelimitMiddlewareFailOpen = {
|
|
11
|
-
categories: ["SECURITY"],
|
|
12
|
-
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.",
|
|
13
|
-
facing: "EXTERNAL",
|
|
14
|
-
level: "WARN",
|
|
15
|
-
name: "ratelimit_middleware_fail_open",
|
|
16
|
-
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.",
|
|
17
|
-
run: (context) => {
|
|
18
|
-
if (context.failOpenGuards === void 0) {
|
|
19
|
-
return [];
|
|
20
|
-
}
|
|
21
|
-
return context.failOpenGuards.filter((row) => row.failOpen && guardsSensitiveFlow(row)).map(
|
|
22
|
-
(row) => emit(ratelimitMiddlewareFailOpen, {
|
|
23
|
-
cacheKey: `ratelimit_middleware_fail_open:${row.file}:${row.line.toString()}`,
|
|
24
|
-
detail: `\`${row.callee}(...)\` in \`${row.exportName}\` (${row.file}:${row.line.toString()}) sets \`failOpen: true\` while guarding a sensitive flow — a limiter/siteverify outage then admits every request instead of rejecting it.`,
|
|
25
|
-
metadata: { callee: row.callee, exportName: row.exportName, file: row.file, limitName: row.limitName, line: row.line }
|
|
26
|
-
})
|
|
27
|
-
);
|
|
28
|
-
},
|
|
29
|
-
source: "static",
|
|
30
|
-
title: "Fail-open rate-limit / CAPTCHA guard on a sensitive procedure"
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export { ratelimitMiddlewareFailOpen as default };
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
import { t as tableColumnSet } from './helpers-BySnKhVB.mjs';
|
|
3
|
-
|
|
4
|
-
const missingColumns = (byName, columnsOf, holder, relation) => {
|
|
5
|
-
const target = byName.get(relation.table);
|
|
6
|
-
if (!target) {
|
|
7
|
-
return [];
|
|
8
|
-
}
|
|
9
|
-
const fkTable = relation.kind === "one" ? holder : target;
|
|
10
|
-
const referencedTable = relation.kind === "one" ? target : holder;
|
|
11
|
-
const problems = [];
|
|
12
|
-
if (!columnsOf(fkTable).has(relation.field)) {
|
|
13
|
-
problems.push({ column: relation.field, owner: fkTable.name, side: "field" });
|
|
14
|
-
}
|
|
15
|
-
if (!columnsOf(referencedTable).has(relation.references)) {
|
|
16
|
-
problems.push({ column: relation.references, owner: referencedTable.name, side: "references" });
|
|
17
|
-
}
|
|
18
|
-
return problems;
|
|
19
|
-
};
|
|
20
|
-
const relationReferencesUnknownField = {
|
|
21
|
-
categories: ["SCHEMA"],
|
|
22
|
-
description: "A relation references a foreign-key or referenced column that is not declared on its table, so the join can never resolve.",
|
|
23
|
-
facing: "INTERNAL",
|
|
24
|
-
level: "ERROR",
|
|
25
|
-
name: "relation_references_unknown_field",
|
|
26
|
-
remediation: "Fix the `field` / `references` column name in the relation, or add the missing column.",
|
|
27
|
-
run: (context) => {
|
|
28
|
-
const byName = new Map(context.schema.tables.map((table) => [table.name, table]));
|
|
29
|
-
const columnsCache = /* @__PURE__ */ new Map();
|
|
30
|
-
const columnsOf = (table) => {
|
|
31
|
-
let columns = columnsCache.get(table);
|
|
32
|
-
if (!columns) {
|
|
33
|
-
columns = tableColumnSet(table);
|
|
34
|
-
columnsCache.set(table, columns);
|
|
35
|
-
}
|
|
36
|
-
return columns;
|
|
37
|
-
};
|
|
38
|
-
return context.schema.tables.flatMap(
|
|
39
|
-
(table) => table.relations.flatMap(
|
|
40
|
-
(relation) => missingColumns(byName, columnsOf, table, relation).map(
|
|
41
|
-
(problem) => emit(relationReferencesUnknownField, {
|
|
42
|
-
cacheKey: `relation_references_unknown_field:${table.name}:${relation.name}:${problem.side}`,
|
|
43
|
-
detail: `Relation "${relation.name}" on table "${table.name}" uses ${problem.side} "${problem.column}", which is not declared on table "${problem.owner}".`,
|
|
44
|
-
metadata: { column: problem.column, owner: problem.owner, relation: relation.name, side: problem.side, table: table.name }
|
|
45
|
-
})
|
|
46
|
-
)
|
|
47
|
-
)
|
|
48
|
-
);
|
|
49
|
-
},
|
|
50
|
-
source: "static",
|
|
51
|
-
title: "Relation references unknown field"
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
export { relationReferencesUnknownField as default };
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const relationReferencesUnknownTable = {
|
|
4
|
-
categories: ["SCHEMA"],
|
|
5
|
-
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.",
|
|
6
|
-
facing: "INTERNAL",
|
|
7
|
-
level: "ERROR",
|
|
8
|
-
name: "relation_references_unknown_table",
|
|
9
|
-
remediation: "Fix the target table name in the relation, or add the missing table to the schema.",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
const findings = [];
|
|
12
|
-
const tableNames = new Set(context.schema.tables.map((table) => table.name));
|
|
13
|
-
for (const table of context.schema.tables) {
|
|
14
|
-
for (const relation of table.relations) {
|
|
15
|
-
if (tableNames.has(relation.table)) {
|
|
16
|
-
continue;
|
|
17
|
-
}
|
|
18
|
-
findings.push(
|
|
19
|
-
emit(relationReferencesUnknownTable, {
|
|
20
|
-
cacheKey: `relation_references_unknown_table:${table.name}:${relation.name}`,
|
|
21
|
-
detail: `Relation "${relation.name}" on table "${table.name}" targets table "${relation.table}", which does not exist in the schema.`,
|
|
22
|
-
metadata: { relation: relation.name, table: table.name, target: relation.table }
|
|
23
|
-
})
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
return findings;
|
|
28
|
-
},
|
|
29
|
-
source: "static",
|
|
30
|
-
title: "Relation references unknown table"
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export { relationReferencesUnknownTable as default };
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const rlsUncoveredTable = {
|
|
4
|
-
categories: ["SECURITY"],
|
|
5
|
-
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.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "rls_uncovered_table",
|
|
9
|
-
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.",
|
|
10
|
-
// eslint-disable-next-line sonarjs/cognitive-complexity -- the policy/table cross-reference is clearest as one linear pass; splitting it would obscure the flow.
|
|
11
|
-
run: (context) => {
|
|
12
|
-
if (context.rlsProcedures === void 0) {
|
|
13
|
-
return [];
|
|
14
|
-
}
|
|
15
|
-
const policyCoveredTables = /* @__PURE__ */ new Set();
|
|
16
|
-
for (const procedure of context.rlsProcedures) {
|
|
17
|
-
for (const table of procedure.rlsTables) {
|
|
18
|
-
if (table !== "") {
|
|
19
|
-
policyCoveredTables.add(table);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
if (policyCoveredTables.size === 0) {
|
|
24
|
-
return [];
|
|
25
|
-
}
|
|
26
|
-
const findings = [];
|
|
27
|
-
for (const procedure of context.rlsProcedures) {
|
|
28
|
-
if (procedure.usesRls) {
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
if (procedure.visibility === "internal") {
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
const touched = /* @__PURE__ */ new Set();
|
|
35
|
-
for (const table of [...procedure.tablesRead, ...procedure.tablesWritten]) {
|
|
36
|
-
if (table !== "" && policyCoveredTables.has(table)) {
|
|
37
|
-
touched.add(table);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
for (const table of touched) {
|
|
41
|
-
findings.push(
|
|
42
|
-
emit(rlsUncoveredTable, {
|
|
43
|
-
cacheKey: `rls_uncovered_table:${procedure.file}:${procedure.exportName}:${table}`,
|
|
44
|
-
detail: `\`${procedure.exportName}\` in ${procedure.file} accesses table \`${table}\` without \`.use(rls(...))\` — the table is policy-gated elsewhere in the app but this procedure bypasses those policies entirely.`,
|
|
45
|
-
metadata: { exportName: procedure.exportName, file: procedure.file, table }
|
|
46
|
-
})
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return findings;
|
|
51
|
-
},
|
|
52
|
-
source: "static",
|
|
53
|
-
title: "RLS-gated table accessed without rls() middleware"
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
export { rlsUncoveredTable as default };
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const shapeTargetsGlobalTable = {
|
|
4
|
-
categories: ["PERFORMANCE"],
|
|
5
|
-
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.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "shape_targets_global_table",
|
|
9
|
-
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).",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.shapes === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
const globalTables = new Set(context.schema.tables.filter((table) => table.shardKind === "global").map((table) => table.name));
|
|
15
|
-
const findings = [];
|
|
16
|
-
for (const shape of context.shapes) {
|
|
17
|
-
if (shape.table === void 0 || !globalTables.has(shape.table)) {
|
|
18
|
-
continue;
|
|
19
|
-
}
|
|
20
|
-
findings.push(
|
|
21
|
-
emit(shapeTargetsGlobalTable, {
|
|
22
|
-
cacheKey: `shape_targets_global_table:${shape.exportName}`,
|
|
23
|
-
detail: `Shape \`${shape.exportName}\` (${shape.file}) replicates from the \`.global()\` table \`${shape.table}\`. It is served through the cross-shard global tier — poll-refreshed and latency-tiered, not poke-live.`,
|
|
24
|
-
metadata: { exportName: shape.exportName, file: shape.file, table: shape.table }
|
|
25
|
-
})
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
return findings;
|
|
29
|
-
},
|
|
30
|
-
source: "static",
|
|
31
|
-
title: "Shape replicates from a global (cross-shard) table"
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export { shapeTargetsGlobalTable as default };
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const shapeUnknownTable = {
|
|
4
|
-
categories: ["SCHEMA"],
|
|
5
|
-
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.",
|
|
6
|
-
facing: "INTERNAL",
|
|
7
|
-
level: "ERROR",
|
|
8
|
-
name: "shape_unknown_table",
|
|
9
|
-
remediation: "Fix the shape's `table` to a real table name (check for a typo or a table that was renamed/removed).",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.shapes === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
const knownTables = new Set(context.schema.tables.map((table) => table.name));
|
|
15
|
-
const findings = [];
|
|
16
|
-
for (const shape of context.shapes) {
|
|
17
|
-
if (shape.table === void 0 || knownTables.has(shape.table)) {
|
|
18
|
-
continue;
|
|
19
|
-
}
|
|
20
|
-
findings.push(
|
|
21
|
-
emit(shapeUnknownTable, {
|
|
22
|
-
cacheKey: `shape_unknown_table:${shape.exportName}`,
|
|
23
|
-
detail: `Shape \`${shape.exportName}\` (${shape.file}) replicates from table \`${shape.table}\`, which is not declared in the schema. The shape can never resolve a rowset.`,
|
|
24
|
-
metadata: { exportName: shape.exportName, file: shape.file, table: shape.table }
|
|
25
|
-
})
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
return findings;
|
|
29
|
-
},
|
|
30
|
-
source: "static",
|
|
31
|
-
title: "Shape bound to an unknown table"
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export { shapeUnknownTable as default };
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
import { i as isPublicWrite } from './helpers-BySnKhVB.mjs';
|
|
3
|
-
|
|
4
|
-
const signupMutationWithoutDisposableGating = {
|
|
5
|
-
categories: ["SECURITY"],
|
|
6
|
-
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.",
|
|
7
|
-
facing: "EXTERNAL",
|
|
8
|
-
level: "WARN",
|
|
9
|
-
name: "signup_mutation_without_disposable_gating",
|
|
10
|
-
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(...)`.",
|
|
11
|
-
run: (context) => {
|
|
12
|
-
if (context.procedureProtections === void 0) {
|
|
13
|
-
return [];
|
|
14
|
-
}
|
|
15
|
-
const findings = [];
|
|
16
|
-
for (const procedure of context.procedureProtections) {
|
|
17
|
-
if (!isPublicWrite(procedure) || !procedure.writesUserTable || procedure.usesEmailGate) {
|
|
18
|
-
continue;
|
|
19
|
-
}
|
|
20
|
-
findings.push(
|
|
21
|
-
emit(signupMutationWithoutDisposableGating, {
|
|
22
|
-
cacheKey: `signup_mutation_without_disposable_gating:${procedure.file}:${procedure.exportName}`,
|
|
23
|
-
detail: `Public ${procedure.kind} \`${procedure.exportName}\` (${procedure.file}) writes a user/session/account table but has no disposable-email gate. Add \`.use(emailGateMiddleware(...))\` from \`@lunora/auth/email-guard\`.`,
|
|
24
|
-
metadata: {
|
|
25
|
-
exportName: procedure.exportName,
|
|
26
|
-
file: procedure.file,
|
|
27
|
-
kind: procedure.kind,
|
|
28
|
-
writesUserTable: procedure.writesUserTable
|
|
29
|
-
}
|
|
30
|
-
})
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
return findings;
|
|
34
|
-
},
|
|
35
|
-
source: "static",
|
|
36
|
-
title: "Account-creating write without a disposable-email gate"
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
export { signupMutationWithoutDisposableGating as default };
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const softDeleteIncludeDeletedFromArgs = {
|
|
4
|
-
categories: ["SECURITY"],
|
|
5
|
-
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.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "INFO",
|
|
8
|
-
name: "soft_delete_include_deleted_from_args",
|
|
9
|
-
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.",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.softDeleteReads === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
const softDeleteTables = new Set(context.schema.tables.filter((table) => table.softDelete !== void 0).map((table) => table.name));
|
|
15
|
-
if (softDeleteTables.size === 0) {
|
|
16
|
-
return [];
|
|
17
|
-
}
|
|
18
|
-
return context.softDeleteReads.filter((row) => row.visibility === "public" && row.table !== "" && softDeleteTables.has(row.table) && (row.hardcodedTrue || row.fromArgs)).map((row) => {
|
|
19
|
-
const location = `\`${row.exportName}\` (${row.file}:${row.line.toString()})`;
|
|
20
|
-
const detail = row.fromArgs ? `The public read in ${location} wires \`includeDeleted\` from request \`args\` on soft-delete table \`${row.table}\`, letting any caller resurface soft-deleted rows the app treats as gone.` : `The public read in ${location} hardcodes \`includeDeleted: true\` on soft-delete table \`${row.table}\`, returning soft-deleted rows to every caller.`;
|
|
21
|
-
return emit(softDeleteIncludeDeletedFromArgs, {
|
|
22
|
-
cacheKey: `soft_delete_include_deleted_from_args:${row.file}:${row.line.toString()}:${row.table}`,
|
|
23
|
-
detail,
|
|
24
|
-
metadata: {
|
|
25
|
-
exportName: row.exportName,
|
|
26
|
-
file: row.file,
|
|
27
|
-
line: row.line,
|
|
28
|
-
source: row.fromArgs ? "args" : "literal",
|
|
29
|
-
table: row.table
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
});
|
|
33
|
-
},
|
|
34
|
-
source: "static",
|
|
35
|
-
title: "Soft-deleted rows resurfaced via includeDeleted on a public read"
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
export { softDeleteIncludeDeletedFromArgs as default };
|