@lunora/advisor 1.0.0-alpha.20 → 1.0.0-alpha.200
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 +3841 -2573
- package/dist/index.d.ts +3841 -2573
- package/dist/index.mjs +1 -181
- package/dist/packem_shared/MAP_VERSION-MQ5W2B6n.mjs +1 -0
- package/dist/packem_shared/actionFetchSsrf-B4ZaOBlW.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-B5980eT9.mjs +1 -0
- package/dist/packem_shared/aiRunWithoutLogging-CvHHtEN9.mjs +1 -0
- package/dist/packem_shared/aiToolSideEffectPromptInjection-_3MrX9Ul.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-D8U1hv8s.mjs +1 -0
- package/dist/packem_shared/authCsrfCheckDisabled-C6bxME5y.mjs +1 -0
- package/dist/packem_shared/authEmailVerificationDisabled-yROPDgn-.mjs +1 -0
- package/dist/packem_shared/authScimWithoutTransactions-w9qb3iBe.mjs +1 -0
- package/dist/packem_shared/authSecureCookiesDisabled-qrCktlch.mjs +1 -0
- package/dist/packem_shared/authSessionFreshageZero-BMisjIIS.mjs +1 -0
- package/dist/packem_shared/authTrustedOriginsWildcard-DFDpfN84.mjs +1 -0
- package/dist/packem_shared/browserAllowPrivateTargets-Cj5sizhv.mjs +1 -0
- package/dist/packem_shared/browserUserUrlWithoutAllowlist-CcK27rvt.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-DBdjUnQb.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-CioCA2zY.mjs +1 -0
- package/dist/packem_shared/containerStartEnableInternetOverride-C6dbznku.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/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/filterOnPrimaryKey-DbFriOZA.mjs +1 -0
- package/dist/packem_shared/filterWithoutIndex-Dc7Nn0y2.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-DB9M6hLM.mjs +1 -0
- package/dist/packem_shared/flagReadInSubscription-DabIhYGD.mjs +1 -0
- package/dist/packem_shared/fromServerSchema-fAymN8o2.mjs +1 -0
- package/dist/packem_shared/geoIndexFieldNotGeopoint-CDMNbAfp.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-BmppB5nL.mjs +1 -0
- package/dist/packem_shared/hotShard-BwGYZ3Tq.mjs +1 -0
- package/dist/packem_shared/httpActionMissingAuthGuard-BhvcmkwW.mjs +1 -0
- package/dist/packem_shared/httpActionResponseHeaderInjection-DKz2SlCa.mjs +1 -0
- package/dist/packem_shared/hyperdriveOutsideAction-CPDdGP2g.mjs +1 -0
- package/dist/packem_shared/identityUndeclaredClaimTrusted-CAUagXkJ.mjs +1 -0
- package/dist/packem_shared/imagesUrlSourceFromUserInput-C2CJTTCI.mjs +1 -0
- package/dist/packem_shared/indexReferencesUnknownField-Dm8SL4Tp.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-CXFKAyM2.mjs +1 -0
- package/dist/packem_shared/mailInboundDispatchWithoutVerify-CWwqXyPX.mjs +1 -0
- package/dist/packem_shared/mailRecipientFromRequestInput-CQZeOsP9.mjs +1 -0
- package/dist/packem_shared/maskUncoveredPiiColumn-axEka4nd.mjs +1 -0
- package/dist/packem_shared/maskWeakHashStrategyOnPii-njstapQY.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-sxmixc0l.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-DHivs_73.mjs +1 -0
- package/dist/packem_shared/ownerFieldFromArgsNotAuth-BFNc023N.mjs +1 -0
- package/dist/packem_shared/paymentCreateWithoutAuthorize-CICGmRFR.mjs +1 -0
- package/dist/packem_shared/paymentWebhookWideTolerance-CtZb2AVA.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-DhXa36mM.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-BeYapu1F.mjs +1 -0
- package/dist/packem_shared/publicTableRlsOptoutConfusion-BFmh1kH0.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-DlvJfSXs.mjs +1 -0
- package/dist/packem_shared/ratelimitMiddlewareFailOpen-DGtyt-pc.mjs +1 -0
- package/dist/packem_shared/relationReferencesUnknownField-CqGEJD57.mjs +1 -0
- package/dist/packem_shared/relationReferencesUnknownTable-CP4aWtAJ.mjs +1 -0
- package/dist/packem_shared/rlsUncoveredTable-CZ4ie4gX.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-CFtFMJz8.mjs +1 -0
- package/dist/packem_shared/softDeleteIncludeDeletedFromArgs-C7Ugg4zb.mjs +1 -0
- package/dist/packem_shared/sqlInjectionRisk-y-7c46LJ.mjs +1 -0
- package/dist/packem_shared/storageGenerateUploadUrlNoContentTypePin-Bx9oGOjM.mjs +1 -0
- package/dist/packem_shared/storageKeyFromUserArgs-BryKJJM-.mjs +1 -0
- package/dist/packem_shared/storagePresignedUrlForPrivateContent-lqE6LvNu.mjs +1 -0
- package/dist/packem_shared/storageUploadWithoutContentTypeAllowlist-DMr7w29r.mjs +1 -0
- package/dist/packem_shared/storageUploadWithoutMaxSize-rSNFNb2l.mjs +1 -0
- package/dist/packem_shared/tableWithoutInsert-ClJp_jDr.mjs +1 -0
- package/dist/packem_shared/topicTooManySubscriptions-CiZhFm9r.mjs +1 -0
- package/dist/packem_shared/ttlFieldNotTimestamp-Dxa-6RfM.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-CSqRJWYZ.mjs +1 -0
- package/dist/packem_shared/unrestrictedWhereBranch-CcIBmHik.mjs +1 -0
- package/dist/packem_shared/userCreatingMutationWithoutCaptcha-C8yaqIIj.mjs +1 -0
- package/dist/packem_shared/vectorsNamespaceFromUserInput-7I_Kwxvp.mjs +1 -0
- package/dist/packem_shared/workflowDuplicateStepName-B6eAJzwA.mjs +1 -0
- package/dist/packem_shared/workflowUnknownTarget-Cm1hfTTs.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
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const VIA_LABELS = {
|
|
4
|
-
"headers-append": "a `headers.append(...)` call",
|
|
5
|
-
"headers-ctor": "a `new Headers({...})` initializer",
|
|
6
|
-
"headers-set": "a `headers.set(...)` call",
|
|
7
|
-
"response-init": "a `Response` init `headers` object"
|
|
8
|
-
};
|
|
9
|
-
const httpActionResponseHeaderInjection = {
|
|
10
|
-
categories: ["SECURITY"],
|
|
11
|
-
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).",
|
|
12
|
-
facing: "EXTERNAL",
|
|
13
|
-
level: "WARN",
|
|
14
|
-
name: "http_action_response_header_injection",
|
|
15
|
-
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.",
|
|
16
|
-
run: (context) => {
|
|
17
|
-
if (context.httpHeaderWrites === void 0) {
|
|
18
|
-
return [];
|
|
19
|
-
}
|
|
20
|
-
return context.httpHeaderWrites.map((row) => {
|
|
21
|
-
const header = row.headerName === "" ? "a response header" : `the \`${row.headerName}\` response header`;
|
|
22
|
-
return emit(httpActionResponseHeaderInjection, {
|
|
23
|
-
cacheKey: `http_action_response_header_injection:${row.file}:${row.line.toString()}`,
|
|
24
|
-
detail: `\`${row.exportName}\` (${row.file}:${row.line.toString()}) writes ${header} from raw request input via ${VIA_LABELS[row.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.`,
|
|
25
|
-
metadata: {
|
|
26
|
-
exportName: row.exportName,
|
|
27
|
-
file: row.file,
|
|
28
|
-
headerName: row.headerName,
|
|
29
|
-
line: row.line,
|
|
30
|
-
via: row.via
|
|
31
|
-
}
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
},
|
|
35
|
-
source: "static",
|
|
36
|
-
title: "Response header written from unsanitized request input"
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
export { httpActionResponseHeaderInjection as default };
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const hyperdriveOutsideAction = {
|
|
4
|
-
categories: ["SCHEMA"],
|
|
5
|
-
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.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "hyperdrive_outside_action",
|
|
9
|
-
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.",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.hyperdriveCalls === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
const findings = [];
|
|
15
|
-
for (const call of context.hyperdriveCalls) {
|
|
16
|
-
findings.push(
|
|
17
|
-
emit(hyperdriveOutsideAction, {
|
|
18
|
-
cacheKey: `hyperdrive_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 — 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.`,
|
|
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: "Hyperdrive `ctx.sql` used outside an action"
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
export { hyperdriveOutsideAction as default };
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const identityUndeclaredClaimTrusted = {
|
|
4
|
-
categories: ["SECURITY"],
|
|
5
|
-
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.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "identity_undeclared_claim_trusted",
|
|
9
|
-
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.",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.identityClaimReads === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
return context.identityClaimReads.filter((row) => !row.declared).map((row) => {
|
|
15
|
-
const location = `\`${row.exportName}\` (${row.file}:${row.line.toString()})`;
|
|
16
|
-
return emit(identityUndeclaredClaimTrusted, {
|
|
17
|
-
cacheKey: `identity_undeclared_claim_trusted:${row.file}:${row.line.toString()}:${row.key}`,
|
|
18
|
-
detail: `\`${row.key}\` in ${location} is read off \`identity\` but is not declared in \`defineIdentity({ ... })\`. Undeclared claims are forwarded unvalidated, so this authorization decision trusts a forgeable value.`,
|
|
19
|
-
metadata: {
|
|
20
|
-
exportName: row.exportName,
|
|
21
|
-
file: row.file,
|
|
22
|
-
key: row.key,
|
|
23
|
-
line: row.line
|
|
24
|
-
}
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
},
|
|
28
|
-
source: "static",
|
|
29
|
-
title: "Authorization trusts an undeclared identity claim"
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
export { identityUndeclaredClaimTrusted as default };
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { m as makeArgumentDerivedSinkLint } from './argument-derived-sink-C1xTTqAt.mjs';
|
|
2
|
-
|
|
3
|
-
const imagesUrlSourceFromUserInput = makeArgumentDerivedSinkLint({
|
|
4
|
-
cacheKey: (access) => `images_url_source_from_user_input:${access.file}:${access.line.toString()}`,
|
|
5
|
-
categories: ["SECURITY"],
|
|
6
|
-
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.",
|
|
7
|
-
detail: (access) => `\`buildImageDeliveryUrl\` in \`${access.exportName}\` (${access.file}:${access.line.toString()}) 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.`,
|
|
8
|
-
facing: "EXTERNAL",
|
|
9
|
-
getAccesses: (context) => context.imageDeliveryUrlAccesses,
|
|
10
|
-
level: "WARN",
|
|
11
|
-
metadata: (access) => {
|
|
12
|
-
return { exportName: access.exportName, file: access.file, line: access.line };
|
|
13
|
-
},
|
|
14
|
-
name: "images_url_source_from_user_input",
|
|
15
|
-
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.",
|
|
16
|
-
title: "Image delivery URL built from unscoped user input"
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
export { imagesUrlSourceFromUserInput as default };
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
import { t as tableColumnSet } from './helpers-BySnKhVB.mjs';
|
|
3
|
-
|
|
4
|
-
const indexReferencesUnknownField = {
|
|
5
|
-
categories: ["SCHEMA"],
|
|
6
|
-
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.",
|
|
7
|
-
facing: "INTERNAL",
|
|
8
|
-
level: "ERROR",
|
|
9
|
-
name: "index_references_unknown_field",
|
|
10
|
-
remediation: "Fix the column name in the index declaration, or add the column to the table.",
|
|
11
|
-
run: (context) => {
|
|
12
|
-
const findings = [];
|
|
13
|
-
for (const table of context.schema.tables) {
|
|
14
|
-
const columns = tableColumnSet(table);
|
|
15
|
-
for (const index of table.indexes) {
|
|
16
|
-
for (const field of index.fields) {
|
|
17
|
-
if (columns.has(field)) {
|
|
18
|
-
continue;
|
|
19
|
-
}
|
|
20
|
-
findings.push(
|
|
21
|
-
emit(indexReferencesUnknownField, {
|
|
22
|
-
cacheKey: `index_references_unknown_field:${table.name}:${index.name}:${field}`,
|
|
23
|
-
detail: `Index "${index.name}" on table "${table.name}" references column "${field}", which is not declared on the table.`,
|
|
24
|
-
metadata: { field, index: index.name, indexKind: index.kind, table: table.name }
|
|
25
|
-
})
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return findings;
|
|
31
|
-
},
|
|
32
|
-
source: "static",
|
|
33
|
-
title: "Index references unknown field"
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
export { indexReferencesUnknownField as default };
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const HOT_SCAN_THRESHOLD = 25;
|
|
4
|
-
const indexUtilization = {
|
|
5
|
-
categories: ["PERFORMANCE"],
|
|
6
|
-
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).",
|
|
7
|
-
facing: "INTERNAL",
|
|
8
|
-
level: "INFO",
|
|
9
|
-
name: "index_utilization",
|
|
10
|
-
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.",
|
|
11
|
-
run: (context) => {
|
|
12
|
-
const findings = [];
|
|
13
|
-
for (const hit of context.indexHits ?? []) {
|
|
14
|
-
if (hit.reads > 0) {
|
|
15
|
-
continue;
|
|
16
|
-
}
|
|
17
|
-
findings.push(
|
|
18
|
-
emit(indexUtilization, {
|
|
19
|
-
cacheKey: `index_utilization:dead_index:${hit.table}:${hit.index}`,
|
|
20
|
-
detail: `Index "${hit.index}" on table "${hit.table}" has recorded no reads since its counter was created — it's dead overhead: every write maintains it and nothing reads through it.`,
|
|
21
|
-
metadata: { index: hit.index, kind: "dead_index", reads: hit.reads, table: hit.table }
|
|
22
|
-
})
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
for (const scan of context.tableScans ?? []) {
|
|
26
|
-
if (scan.scans < HOT_SCAN_THRESHOLD) {
|
|
27
|
-
continue;
|
|
28
|
-
}
|
|
29
|
-
findings.push(
|
|
30
|
-
emit(indexUtilization, {
|
|
31
|
-
cacheKey: `index_utilization:hot_scan:${scan.table}`,
|
|
32
|
-
detail: `Table "${scan.table}" has been full-scanned ${scan.scans.toString()} times (cumulative) with no index — a repeated full scan that degrades linearly as "${scan.table}" grows. Add an index covering the read predicate.`,
|
|
33
|
-
facing: "EXTERNAL",
|
|
34
|
-
level: "WARN",
|
|
35
|
-
metadata: { kind: "hot_scan", scans: scan.scans, table: scan.table }
|
|
36
|
-
})
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
return findings;
|
|
40
|
-
},
|
|
41
|
-
source: "runtime",
|
|
42
|
-
title: "Index utilization"
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export { indexUtilization as default };
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const insertManyUnsafeUserData = {
|
|
4
|
-
categories: ["SECURITY"],
|
|
5
|
-
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.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "insert_many_unsafe_user_data",
|
|
9
|
-
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.",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.procedureProtections === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
return context.procedureProtections.filter((procedure) => procedure.usesInsertManyUnsafe && procedure.visibility === "public").map(
|
|
15
|
-
(procedure) => emit(insertManyUnsafeUserData, {
|
|
16
|
-
cacheKey: `insert_many_unsafe_user_data:${procedure.file}:${procedure.exportName}`,
|
|
17
|
-
detail: `\`${procedure.exportName}\` (${procedure.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.`,
|
|
18
|
-
metadata: { exportName: procedure.exportName, file: procedure.file, kind: procedure.kind }
|
|
19
|
-
})
|
|
20
|
-
);
|
|
21
|
-
},
|
|
22
|
-
source: "static",
|
|
23
|
-
title: "Public procedure uses insertManyUnsafe (bypasses validators and triggers)"
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export { insertManyUnsafeUserData as default };
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { m as makeArgumentDerivedSinkLint } from './argument-derived-sink-C1xTTqAt.mjs';
|
|
2
|
-
|
|
3
|
-
const kvUnscopedUserKeyIdor = makeArgumentDerivedSinkLint({
|
|
4
|
-
cacheKey: (access) => `kv_unscoped_user_key_idor:${access.file}:${access.line.toString()}`,
|
|
5
|
-
categories: ["SECURITY"],
|
|
6
|
-
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).",
|
|
7
|
-
detail: (access) => `\`ctx.kv.${access.method}\` in \`${access.exportName}\` (${access.file}:${access.line.toString()}) 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}:…\`).`,
|
|
8
|
-
facing: "EXTERNAL",
|
|
9
|
-
getAccesses: (context) => context.kvKeyAccesses,
|
|
10
|
-
level: "ERROR",
|
|
11
|
-
metadata: (access) => {
|
|
12
|
-
return { exportName: access.exportName, file: access.file, line: access.line, method: access.method };
|
|
13
|
-
},
|
|
14
|
-
name: "kv_unscoped_user_key_idor",
|
|
15
|
-
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\`.`,
|
|
16
|
-
title: "Possible IDOR from arg-derived unscoped KV key"
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
export { kvUnscopedUserKeyIdor as default };
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const mailInboundDispatchWithoutVerify = {
|
|
4
|
-
categories: ["SECURITY"],
|
|
5
|
-
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).",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "ERROR",
|
|
8
|
-
// eslint-disable-next-line no-secrets/no-secrets -- the lint's rule id, not a credential
|
|
9
|
-
name: "mail_inbound_dispatch_without_verify",
|
|
10
|
-
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.",
|
|
11
|
-
run: (context) => {
|
|
12
|
-
if (context.configCalls === void 0) {
|
|
13
|
-
return [];
|
|
14
|
-
}
|
|
15
|
-
return context.configCalls.filter((call) => call.callee === "createInboundEmailHandler" && call.analyzable && !call.presentKeys.includes("verify")).map(
|
|
16
|
-
(call) => emit(mailInboundDispatchWithoutVerify, {
|
|
17
|
-
// eslint-disable-next-line no-secrets/no-secrets -- the lint's rule id, not a credential
|
|
18
|
-
cacheKey: `mail_inbound_dispatch_without_verify:${call.file}:${call.line.toString()}`,
|
|
19
|
-
detail: `\`createInboundEmailHandler({...})\` in ${call.file}:${call.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.`,
|
|
20
|
-
metadata: { callee: call.callee, file: call.file, line: call.line }
|
|
21
|
-
})
|
|
22
|
-
);
|
|
23
|
-
},
|
|
24
|
-
source: "static",
|
|
25
|
-
title: "Inbound email handler without a verify hook"
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
export { mailInboundDispatchWithoutVerify as default };
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const mailRecipientFromRequestInput = {
|
|
4
|
-
categories: ["SECURITY"],
|
|
5
|
-
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.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "mail_recipient_from_request_input",
|
|
9
|
-
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.",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.mailRecipientAccesses === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
return context.mailRecipientAccesses.map(
|
|
15
|
-
(access) => emit(mailRecipientFromRequestInput, {
|
|
16
|
-
cacheKey: `mail_recipient_from_request_input:${access.file}:${access.line.toString()}`,
|
|
17
|
-
detail: `\`ctx.mail.${access.method}\` in \`${access.exportName}\` (${access.file}:${access.line.toString()}) 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\`).`,
|
|
18
|
-
metadata: { exportName: access.exportName, file: access.file, line: access.line, method: access.method }
|
|
19
|
-
})
|
|
20
|
-
);
|
|
21
|
-
},
|
|
22
|
-
source: "static",
|
|
23
|
-
title: "Mail recipient derived from request input"
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export { mailRecipientFromRequestInput as default };
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const maskUncoveredPiiColumn = {
|
|
4
|
-
categories: ["SECURITY"],
|
|
5
|
-
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.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "mask_uncovered_pii_column",
|
|
9
|
-
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.",
|
|
10
|
-
// eslint-disable-next-line sonarjs/cognitive-complexity -- the procedure/table/column cross-reference reads clearest as one linear pass; splitting it would obscure the flow.
|
|
11
|
-
run: (context) => {
|
|
12
|
-
if (context.maskProcedures === void 0) {
|
|
13
|
-
return [];
|
|
14
|
-
}
|
|
15
|
-
const maskedColumnsByTable = /* @__PURE__ */ new Map();
|
|
16
|
-
for (const procedure of context.maskProcedures) {
|
|
17
|
-
for (const { column, table } of procedure.maskColumns) {
|
|
18
|
-
if (table === "" || column === "") {
|
|
19
|
-
continue;
|
|
20
|
-
}
|
|
21
|
-
const columns = maskedColumnsByTable.get(table) ?? /* @__PURE__ */ new Set();
|
|
22
|
-
columns.add(column);
|
|
23
|
-
maskedColumnsByTable.set(table, columns);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
if (maskedColumnsByTable.size === 0) {
|
|
27
|
-
return [];
|
|
28
|
-
}
|
|
29
|
-
const findings = [];
|
|
30
|
-
for (const procedure of context.maskProcedures) {
|
|
31
|
-
if (procedure.usesMask) {
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
if (procedure.visibility === "internal") {
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
const touched = /* @__PURE__ */ new Set();
|
|
38
|
-
for (const table of procedure.tablesRead) {
|
|
39
|
-
if (table !== "" && maskedColumnsByTable.has(table)) {
|
|
40
|
-
touched.add(table);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
for (const table of touched) {
|
|
44
|
-
const columns = [...maskedColumnsByTable.get(table) ?? /* @__PURE__ */ new Set()].toSorted((a, b) => a.localeCompare(b));
|
|
45
|
-
const columnList = columns.map((column) => `\`${column}\``).join(", ");
|
|
46
|
-
findings.push(
|
|
47
|
-
emit(maskUncoveredPiiColumn, {
|
|
48
|
-
cacheKey: `mask_uncovered_pii_column:${procedure.file}:${procedure.exportName}:${table}`,
|
|
49
|
-
detail: `\`${procedure.exportName}\` in ${procedure.file} reads table \`${table}\` without \`.use(mask(...))\` — its column${columns.length === 1 ? "" : "s"} ${columnList} ${columns.length === 1 ? "is" : "are"} masked elsewhere in the app but returned in the clear here.`,
|
|
50
|
-
metadata: { columns, exportName: procedure.exportName, file: procedure.file, table }
|
|
51
|
-
})
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return findings;
|
|
56
|
-
},
|
|
57
|
-
source: "static",
|
|
58
|
-
title: "Maskable column returned without mask() middleware"
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
export { maskUncoveredPiiColumn as default };
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const PII_COLUMN_RE = /address|birthdate|creditcard|dateofbirth|dob|driverslicense|email|firstname|fullname|lastname|nationalid|passport|phone|socialsecurity|ssn|taxid/u;
|
|
4
|
-
const isPiiColumn = (column) => PII_COLUMN_RE.test(column.replaceAll(/[^a-z0-9]/giu, "").toLowerCase());
|
|
5
|
-
const maskWeakHashStrategyOnPii = {
|
|
6
|
-
categories: ["SECURITY"],
|
|
7
|
-
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.',
|
|
8
|
-
facing: "EXTERNAL",
|
|
9
|
-
level: "WARN",
|
|
10
|
-
name: "mask_weak_hash_strategy_on_pii",
|
|
11
|
-
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.',
|
|
12
|
-
run: (context) => {
|
|
13
|
-
if (context.maskStrategies === void 0) {
|
|
14
|
-
return [];
|
|
15
|
-
}
|
|
16
|
-
const findings = [];
|
|
17
|
-
for (const strategy of context.maskStrategies) {
|
|
18
|
-
if (strategy.strategy !== "hash" || !isPiiColumn(strategy.column)) {
|
|
19
|
-
continue;
|
|
20
|
-
}
|
|
21
|
-
findings.push(
|
|
22
|
-
emit(maskWeakHashStrategyOnPii, {
|
|
23
|
-
cacheKey: `mask_weak_hash_strategy_on_pii:${strategy.file}:${strategy.line.toString()}`,
|
|
24
|
-
detail: `\`${strategy.exportName}\` in ${strategy.file} masks \`${strategy.table === "" ? strategy.column : `${strategy.table}.${strategy.column}`}\` with \`"hash"\` — its name suggests PII, and \`"hash"\` is brute-force-recoverable and leaks cross-row equality. Use \`"redact"\` instead.`,
|
|
25
|
-
metadata: { column: strategy.column, exportName: strategy.exportName, file: strategy.file, table: strategy.table }
|
|
26
|
-
})
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
return findings;
|
|
30
|
-
},
|
|
31
|
-
source: "static",
|
|
32
|
-
title: 'Weak "hash" mask strategy on a PII column'
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
export { maskWeakHashStrategyOnPii as default };
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const maskedTablesOf = (context) => {
|
|
4
|
-
const tables = /* @__PURE__ */ new Set();
|
|
5
|
-
for (const procedure of context.maskProcedures ?? []) {
|
|
6
|
-
for (const { table } of procedure.maskColumns) {
|
|
7
|
-
if (table !== "") {
|
|
8
|
-
tables.add(table);
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
for (const strategy of context.maskStrategies ?? []) {
|
|
13
|
-
if (strategy.table !== "") {
|
|
14
|
-
tables.add(strategy.table);
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
return tables;
|
|
18
|
-
};
|
|
19
|
-
const relationTargetsOf = (context) => {
|
|
20
|
-
const targets = /* @__PURE__ */ new Map();
|
|
21
|
-
for (const table of context.schema.tables) {
|
|
22
|
-
for (const relation of table.relations) {
|
|
23
|
-
targets.set(`${table.name} ${relation.name}`, relation.table);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return targets;
|
|
27
|
-
};
|
|
28
|
-
const maskedRelationLeakViaWith = {
|
|
29
|
-
categories: ["SECURITY"],
|
|
30
|
-
description: "A public read hydrates a masked table through a `with` relation. Column masking does not descend into `with`-hydrated relations, so the related table's masked columns are returned in the clear.",
|
|
31
|
-
facing: "EXTERNAL",
|
|
32
|
-
level: "INFO",
|
|
33
|
-
name: "masked_relation_leak_via_with",
|
|
34
|
-
remediation: "Don't hydrate a masked table through `with` on an unprotected read: drop the relation, project only non-sensitive columns, or route the parent read through a procedure that re-applies masking to the joined table. Masking is per-procedure and top-level only — it never reaches `with`-loaded relations.",
|
|
35
|
-
run: (context) => {
|
|
36
|
-
if (context.relationLoads === void 0) {
|
|
37
|
-
return [];
|
|
38
|
-
}
|
|
39
|
-
const maskedTables = maskedTablesOf(context);
|
|
40
|
-
if (maskedTables.size === 0) {
|
|
41
|
-
return [];
|
|
42
|
-
}
|
|
43
|
-
const relationTargets = relationTargetsOf(context);
|
|
44
|
-
const findings = [];
|
|
45
|
-
for (const row of context.relationLoads) {
|
|
46
|
-
if (row.visibility !== "public" || row.parentTable === "") {
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
for (const relation of row.relations) {
|
|
50
|
-
const target = relationTargets.get(`${row.parentTable} ${relation}`);
|
|
51
|
-
if (target === void 0 || !maskedTables.has(target)) {
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
findings.push(
|
|
55
|
-
emit(maskedRelationLeakViaWith, {
|
|
56
|
-
cacheKey: `masked_relation_leak_via_with:${row.file}:${row.line.toString()}:${row.parentTable}:${relation}`,
|
|
57
|
-
detail: `The public read in \`${row.exportName}\` (${row.file}:${row.line.toString()}) hydrates relation \`${relation}\` (masked table \`${target}\`) via \`with\` on \`${row.parentTable}\`. Masking is top-level only and does not descend into \`with\`, so \`${target}\`'s masked columns are returned in the clear.`,
|
|
58
|
-
metadata: {
|
|
59
|
-
exportName: row.exportName,
|
|
60
|
-
file: row.file,
|
|
61
|
-
line: row.line,
|
|
62
|
-
parentTable: row.parentTable,
|
|
63
|
-
relation,
|
|
64
|
-
relationTable: target
|
|
65
|
-
}
|
|
66
|
-
})
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return findings;
|
|
71
|
-
},
|
|
72
|
-
source: "static",
|
|
73
|
-
title: "Masked table surfaced unmasked through a with-relation on a public read"
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
export { maskedRelationLeakViaWith as default };
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const mutatorFullRowReplace = {
|
|
4
|
-
categories: ["SCHEMA"],
|
|
5
|
-
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.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "mutator_full_row_replace",
|
|
9
|
-
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).",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.mutatorWrites === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
return context.mutatorWrites.map(
|
|
15
|
-
(write) => emit(mutatorFullRowReplace, {
|
|
16
|
-
cacheKey: `mutator_full_row_replace:${write.exportName}:${String(write.line)}`,
|
|
17
|
-
detail: `Mutator \`${write.exportName}\` (${write.file}:${String(write.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.`,
|
|
18
|
-
metadata: { exportName: write.exportName, file: write.file, line: write.line }
|
|
19
|
-
})
|
|
20
|
-
);
|
|
21
|
-
},
|
|
22
|
-
source: "static",
|
|
23
|
-
title: "Mutator overwrites the whole row (clobbers concurrent column edits)"
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export { mutatorFullRowReplace as default };
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const nondeterministicQueryMutation = {
|
|
4
|
-
categories: ["SCHEMA"],
|
|
5
|
-
description: "A `query`/`mutation` handler calls a non-deterministic API (`Date.now`, `Math.random`, `crypto.randomUUID`, `crypto.getRandomValues`, or `fetch`). These handlers may be re-run on OCC retry / subscription re-evaluation, so they must be deterministic — time, randomness, and network belong in an `action`.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "nondeterministic_query_mutation",
|
|
9
|
-
remediation: "Move the non-deterministic call into an `action(...)` (which runs once and may use ambient APIs), then pass the computed value into the mutation as an argument — e.g. compute `Date.now()` in the action and call `ctx.runMutation(api.…, { now })`. Take generated ids/timestamps as args instead of producing them in the handler.",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.nondeterministicCalls === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
const findings = [];
|
|
15
|
-
const occurrenceCount = /* @__PURE__ */ new Map();
|
|
16
|
-
for (const call of context.nondeterministicCalls) {
|
|
17
|
-
const baseKey = `${call.file}:${call.line.toString()}:${call.callee}`;
|
|
18
|
-
const occurrence = (occurrenceCount.get(baseKey) ?? 0) + 1;
|
|
19
|
-
occurrenceCount.set(baseKey, occurrence);
|
|
20
|
-
const occurrenceSuffix = occurrence > 1 ? `:${occurrence.toString()}` : "";
|
|
21
|
-
findings.push(
|
|
22
|
-
emit(nondeterministicQueryMutation, {
|
|
23
|
-
cacheKey: `nondeterministic_query_mutation:${baseKey}${occurrenceSuffix}`,
|
|
24
|
-
detail: `\`${call.callee}(…)\` in ${call.exportName} (${call.file}:${call.line.toString()}) runs inside a ${call.kind} handler — query/mutation handlers must be deterministic. Compute it in an \`action\` and pass the value into the mutation as an argument.`,
|
|
25
|
-
metadata: { callee: call.callee, exportName: call.exportName, file: call.file, kind: call.kind, line: call.line }
|
|
26
|
-
})
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
return findings;
|
|
30
|
-
},
|
|
31
|
-
source: "static",
|
|
32
|
-
title: "Non-deterministic call in query/mutation handler"
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
export { nondeterministicQueryMutation as default };
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const SINK_LABELS = {
|
|
4
|
-
delete: "deletes the row",
|
|
5
|
-
get: "reads the row",
|
|
6
|
-
patch: "patches the row"
|
|
7
|
-
};
|
|
8
|
-
const normalizeIdUsedAsAuthorization = {
|
|
9
|
-
categories: ["SECURITY"],
|
|
10
|
-
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).",
|
|
11
|
-
facing: "EXTERNAL",
|
|
12
|
-
level: "INFO",
|
|
13
|
-
name: "normalize_id_used_as_authorization",
|
|
14
|
-
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.',
|
|
15
|
-
run: (context) => {
|
|
16
|
-
if (context.normalizeIdAuthorizations === void 0) {
|
|
17
|
-
return [];
|
|
18
|
-
}
|
|
19
|
-
const rlsRequired = context.schema.rlsMode === "required";
|
|
20
|
-
const rlsOptOutTables = new Set(context.schema.tables.filter((table) => table.isPublic).map((table) => table.name));
|
|
21
|
-
const findings = [];
|
|
22
|
-
for (const row of context.normalizeIdAuthorizations) {
|
|
23
|
-
if (row.visibility !== "public" || row.usesRls || row.mentionsOwnership) {
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
if (rlsRequired && (row.table === "" || !rlsOptOutTables.has(row.table))) {
|
|
27
|
-
continue;
|
|
28
|
-
}
|
|
29
|
-
const target = row.table === "" ? "the row" : `the \`${row.table}\` row`;
|
|
30
|
-
findings.push(
|
|
31
|
-
emit(normalizeIdUsedAsAuthorization, {
|
|
32
|
-
cacheKey: `normalize_id_used_as_authorization:${row.file}:${row.line.toString()}`,
|
|
33
|
-
detail: `Public procedure \`${row.exportName}\` (${row.file}:${row.line.toString()}) gates access on \`ctx.db.normalizeId(...)\` and then ${SINK_LABELS[row.sinkMethod]} — but \`normalizeId\` only validates id shape, it never reads the database or checks ownership. Any caller supplying a well-formed id of ${target} reaches it (IDOR). Compare the loaded row's ownership column against \`ctx.auth\`, or cover the procedure with \`.use(rls(...))\`.`,
|
|
34
|
-
metadata: {
|
|
35
|
-
exportName: row.exportName,
|
|
36
|
-
file: row.file,
|
|
37
|
-
line: row.line,
|
|
38
|
-
sinkMethod: row.sinkMethod,
|
|
39
|
-
table: row.table
|
|
40
|
-
}
|
|
41
|
-
})
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
return findings;
|
|
45
|
-
},
|
|
46
|
-
source: "static",
|
|
47
|
-
title: "normalizeId result used as authorization (validates id shape, not ownership)"
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
export { normalizeIdUsedAsAuthorization as default };
|