@lunora/advisor 1.0.0-alpha.3 → 1.0.0-alpha.31
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/LICENSE.md +6 -0
- package/README.md +1 -1
- package/__assets__/package-og.svg +1 -1
- package/dist/index.d.mts +3145 -940
- package/dist/index.d.ts +3145 -940
- package/dist/index.mjs +120 -13
- package/dist/packem_shared/{AE_METRIC_EVENTS-DexctYv6.mjs → AE_METRIC_EVENTS-BM14d0lm.mjs} +3 -1
- package/dist/packem_shared/actionFetchSsrf-wbmYgzmz.mjs +26 -0
- package/dist/packem_shared/aiRawRunEscapeHatch-C29jd32J.mjs +26 -0
- package/dist/packem_shared/aiToolSideEffectPromptInjection-BtD3alqB.mjs +33 -0
- package/dist/packem_shared/aiUnboundedGenerationPublic-CbAcSxLB.mjs +26 -0
- package/dist/packem_shared/allowUnauthenticatedShardAccessEnabled-BcASq-jP.mjs +30 -0
- package/dist/packem_shared/argument-derived-sink-C1xTTqAt.mjs +30 -0
- package/dist/packem_shared/authCsrfCheckDisabled-DCf9FSoD.mjs +26 -0
- package/dist/packem_shared/authEmailVerificationDisabled-Dk-Tc5oU.mjs +26 -0
- package/dist/packem_shared/authSecureCookiesDisabled-osJrHW9Y.mjs +26 -0
- package/dist/packem_shared/authSessionFreshageZero-CjufCc_Q.mjs +26 -0
- package/dist/packem_shared/authTrustedOriginsWildcard-ylv4PKzC.mjs +26 -0
- package/dist/packem_shared/browserAllowPrivateTargets-CBvQxLAM.mjs +26 -0
- package/dist/packem_shared/browserUserUrlWithoutAllowlist-CvhA6w59.mjs +26 -0
- package/dist/packem_shared/circularFk-CNcAVuYa.mjs +127 -0
- package/dist/packem_shared/{constraintValidator-Dr9Py3FD.mjs → constraintValidator-CxwtpJ6E.mjs} +24 -4
- package/dist/packem_shared/containerInstanceKeyFromUserInput-BUXj2J4w.mjs +19 -0
- package/dist/packem_shared/containerRuntimeEgressRelaxation-B0LlfmDA.mjs +26 -0
- package/dist/packem_shared/containerStartEnableInternetOverride-DDaHZQ1L.mjs +26 -0
- package/dist/packem_shared/dedupeCacheKeys-r5B7u_yq.mjs +13 -0
- package/dist/packem_shared/externalSourceIncrementalNoDeletePath-DvX9cRBD.mjs +45 -0
- package/dist/packem_shared/externalSourceOnGlobal-Bg-NfCX9.mjs +30 -0
- package/dist/packem_shared/externalSourceUnscoped-5vT-Bup3.mjs +44 -0
- package/dist/packem_shared/flagGatesSecurityWithUnsafeDefault-BhIs0shr.mjs +41 -0
- package/dist/packem_shared/{fromServerSchema-DinF1nph.mjs → fromServerSchema-BjAZdvJ6.mjs} +10 -0
- package/dist/packem_shared/{hardcodedSecret-W2pz1UZB.mjs → hardcodedSecret-Be-pKVdn.mjs} +3 -7
- package/dist/packem_shared/helpers-BySnKhVB.mjs +31 -0
- package/dist/packem_shared/hotShard-CkC7qpre.mjs +60 -0
- package/dist/packem_shared/httpActionMissingAuthGuard-CxipddNx.mjs +35 -0
- package/dist/packem_shared/httpActionResponseHeaderInjection-DOFS7pFT.mjs +39 -0
- package/dist/packem_shared/identityUndeclaredClaimTrusted-D8nXV2dd.mjs +32 -0
- package/dist/packem_shared/imagesUrlSourceFromUserInput-YcDQ0b_Y.mjs +19 -0
- package/dist/packem_shared/{indexReferencesUnknownField-DH0_dbUY.mjs → indexReferencesUnknownField-BSWNngxX.mjs} +1 -1
- package/dist/packem_shared/insertManyUnsafeUserData-Dn77XpmX.mjs +26 -0
- package/dist/packem_shared/kvUnscopedUserKeyIdor-YWwmfE8X.mjs +19 -0
- package/dist/packem_shared/mailInboundDispatchWithoutVerify-CHSRA8zz.mjs +28 -0
- package/dist/packem_shared/mailRecipientFromRequestInput-Cka2qu5J.mjs +26 -0
- package/dist/packem_shared/maskWeakHashStrategyOnPii-1c4q8Opf.mjs +35 -0
- package/dist/packem_shared/maskedRelationLeakViaWith-CPI4s0sl.mjs +76 -0
- package/dist/packem_shared/mutatorFullRowReplace-BJnNDaIV.mjs +26 -0
- package/dist/packem_shared/normalizeIdUsedAsAuthorization-BVPtCpzT.mjs +50 -0
- package/dist/packem_shared/outputProjectionMissingOnPublicRead-Bl5IMx0k.mjs +51 -0
- package/dist/packem_shared/ownerFieldFromArgsNotAuth-mOw3hE5z.mjs +26 -0
- package/dist/packem_shared/paymentCreateWithoutAuthorize-BYm4JLxo.mjs +26 -0
- package/dist/packem_shared/paymentWebhookWideTolerance-D9QQJMJq.mjs +35 -0
- package/dist/packem_shared/plaintextSecretInWranglerVariables-NKO4YkKf.mjs +35 -0
- package/dist/packem_shared/privilegedDispatchUnvalidatedPayload-5Forjckt.mjs +37 -0
- package/dist/packem_shared/privilegedFanoutFromPublicProcedure-D3dL01B8.mjs +26 -0
- package/dist/packem_shared/{publicMutationWithoutRatelimit-xBpJ6GWK.mjs → publicMutationWithoutRatelimit-DbIhgi7j.mjs} +2 -2
- package/dist/packem_shared/publicTableRlsOptoutConfusion-Bn71yoD4.mjs +38 -0
- package/dist/packem_shared/queueWithoutDlq-CSkGNb_0.mjs +41 -0
- package/dist/packem_shared/ratelimitDefaultMemoryStore-BISChG5C.mjs +26 -0
- package/dist/packem_shared/ratelimitKeySpoofableOrGlobal-DqlHYQQ3.mjs +26 -0
- package/dist/packem_shared/ratelimitMiddlewareFailOpen-CJgDCaUw.mjs +33 -0
- package/dist/packem_shared/{relationReferencesUnknownField-YznyXt_7.mjs → relationReferencesUnknownField-CjbLScJ1.mjs} +1 -1
- package/dist/packem_shared/shapeTargetsGlobalTable-DHrf4Koi.mjs +34 -0
- package/dist/packem_shared/shapeUnknownTable-C8aDWFoe.mjs +34 -0
- package/dist/packem_shared/softDeleteIncludeDeletedFromArgs-BLqDKrkM.mjs +38 -0
- package/dist/packem_shared/storageGenerateUploadUrlNoContentTypePin-Da4L9Ge8.mjs +26 -0
- package/dist/packem_shared/storageKeyFromUserArgs-B86elJgS.mjs +19 -0
- package/dist/packem_shared/storagePresignedUrlForPrivateContent-yGmb8uqz.mjs +47 -0
- package/dist/packem_shared/storageUploadWithoutContentTypeAllowlist-BV-gF1lT.mjs +27 -0
- package/dist/packem_shared/storageUploadWithoutMaxSize-DpxO59wU.mjs +27 -0
- package/dist/packem_shared/{userCreatingMutationWithoutCaptcha-CH31YsUZ.mjs → userCreatingMutationWithoutCaptcha-2DZtWIPb.mjs} +2 -2
- package/dist/packem_shared/vectorsNamespaceFromUserInput-CQhr5bVn.mjs +19 -0
- package/dist/packem_shared/workflowDuplicateStepName-ioBxPBCy.mjs +48 -0
- package/package.json +4 -3
- package/dist/packem_shared/circularFk-B2freHrP.mjs +0 -84
- package/dist/packem_shared/helpers-DNCkMWZQ.mjs +0 -4
- package/dist/packem_shared/hotShard-Ir5D0B6J.mjs +0 -48
|
@@ -0,0 +1,26 @@
|
|
|
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,4 +1,5 @@
|
|
|
1
1
|
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
+
import { i as isPublicWrite } from './helpers-BySnKhVB.mjs';
|
|
2
3
|
|
|
3
4
|
const SENSITIVE_NAME_RE = /contact|forgot|login|magic|otp|register|reset|signin|signup|subscribe|verify/iu;
|
|
4
5
|
const publicMutationWithoutRatelimit = {
|
|
@@ -14,8 +15,7 @@ const publicMutationWithoutRatelimit = {
|
|
|
14
15
|
}
|
|
15
16
|
const findings = [];
|
|
16
17
|
for (const procedure of context.procedureProtections) {
|
|
17
|
-
|
|
18
|
-
if (!isPublicWrite || procedure.usesRateLimit) {
|
|
18
|
+
if (!isPublicWrite(procedure) || procedure.usesRateLimit) {
|
|
19
19
|
continue;
|
|
20
20
|
}
|
|
21
21
|
const sensitive = SENSITIVE_NAME_RE.test(procedure.exportName);
|
|
@@ -0,0 +1,38 @@
|
|
|
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 };
|
|
@@ -0,0 +1,41 @@
|
|
|
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 };
|
|
@@ -0,0 +1,26 @@
|
|
|
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 };
|
|
@@ -0,0 +1,26 @@
|
|
|
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 };
|
|
@@ -0,0 +1,33 @@
|
|
|
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,5 +1,5 @@
|
|
|
1
1
|
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
import { t as tableColumnSet } from './helpers-
|
|
2
|
+
import { t as tableColumnSet } from './helpers-BySnKhVB.mjs';
|
|
3
3
|
|
|
4
4
|
const missingColumns = (byName, columnsOf, holder, relation) => {
|
|
5
5
|
const target = byName.get(relation.table);
|
|
@@ -0,0 +1,34 @@
|
|
|
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 };
|
|
@@ -0,0 +1,34 @@
|
|
|
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 };
|
|
@@ -0,0 +1,38 @@
|
|
|
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 };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
+
|
|
3
|
+
const storageGenerateUploadUrlNoContentTypePin = {
|
|
4
|
+
categories: ["SECURITY"],
|
|
5
|
+
description: "A `ctx.storage.generateUploadUrl(key, …)` call has no `contentType` pin, so the signed PUT URL it mints authorizes any content-type/size the client chooses — `upload()`'s `allowedContentTypes`/`maxSize` guards never run for a client-side PUT against this URL.",
|
|
6
|
+
facing: "EXTERNAL",
|
|
7
|
+
level: "WARN",
|
|
8
|
+
name: "storage_generate_upload_url_no_content_type_pin",
|
|
9
|
+
remediation: "Pass `contentType` to `ctx.storage.generateUploadUrl(key, { contentType })` so the signature only authorizes a PUT with that exact content-type. If the client's upload size/type also needs bounding, prefer `upload()`/`store()` (with `allowedContentTypes`/`maxSize`) over a client-side signed PUT.",
|
|
10
|
+
run: (context) => {
|
|
11
|
+
if (context.storageUploads === void 0) {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
return context.storageUploads.filter((row) => row.method === "generateUploadUrl" && row.analyzable && !row.presentKeys.includes("contentType")).map(
|
|
15
|
+
(row) => emit(storageGenerateUploadUrlNoContentTypePin, {
|
|
16
|
+
cacheKey: `storage_generate_upload_url_no_content_type_pin:${row.file}:${row.line.toString()}`,
|
|
17
|
+
detail: `\`ctx.storage.generateUploadUrl\` in \`${row.exportName}\` (${row.file}:${row.line.toString()}) has no \`contentType\` pin — the signed PUT it mints accepts any type/size, minted client-side and bypassing \`upload()\`'s guards.`,
|
|
18
|
+
metadata: { exportName: row.exportName, file: row.file, line: row.line }
|
|
19
|
+
})
|
|
20
|
+
);
|
|
21
|
+
},
|
|
22
|
+
source: "static",
|
|
23
|
+
title: "generateUploadUrl signed PUT with no content-type pin"
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export { storageGenerateUploadUrlNoContentTypePin as default };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { m as makeArgumentDerivedSinkLint } from './argument-derived-sink-C1xTTqAt.mjs';
|
|
2
|
+
|
|
3
|
+
const storageKeyFromUserArgs = makeArgumentDerivedSinkLint({
|
|
4
|
+
cacheKey: (access) => `storage_key_from_user_args:${access.file}:${access.line.toString()}`,
|
|
5
|
+
categories: ["SECURITY"],
|
|
6
|
+
description: "A `ctx.storage.*` call uses an R2 object key taken directly from the handler's `args` with no server-side scoping. The bucket methods key by the caller-supplied string, so any caller can read, overwrite, or delete another user's object — object-level IDOR.",
|
|
7
|
+
detail: (access) => `\`ctx.storage.*.${access.method}\` in \`${access.exportName}\` (${access.file}:${access.line.toString()}) uses an object key derived from \`args\` with no server-side scoping — any caller can read/overwrite/delete another user's object (IDOR). Prefix the key with a server-trusted identity (e.g. \`\${ctx.auth.userId}/…\`) or resolve the object through an owned record.`,
|
|
8
|
+
facing: "EXTERNAL",
|
|
9
|
+
getAccesses: (context) => context.storageKeyAccesses,
|
|
10
|
+
level: "ERROR",
|
|
11
|
+
metadata: (access) => {
|
|
12
|
+
return { exportName: access.exportName, file: access.file, line: access.line, method: access.method };
|
|
13
|
+
},
|
|
14
|
+
name: "storage_key_from_user_args",
|
|
15
|
+
remediation: `Prefix the object key with a server-trusted identity (e.g. \`\${ctx.auth.userId}/…\`) or resolve the object through a record the caller is known to own. Never pass request input straight through as an R2 object key.`,
|
|
16
|
+
title: "R2 object key taken directly from user args (IDOR)"
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export { storageKeyFromUserArgs as default };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
+
|
|
3
|
+
const SIGNER_METHODS = /* @__PURE__ */ new Set(["getPresignedUrl", "getSignedUrl"]);
|
|
4
|
+
const NEAR_MAX_EXPIRES_THRESHOLD_SECONDS = 6 * 24 * 60 * 60;
|
|
5
|
+
const isNativePresignedCall = (row) => row.method === "getPresignedUrl";
|
|
6
|
+
const isNearExpiryCeiling = (row) => SIGNER_METHODS.has(row.method) && row.expiresInSeconds !== void 0 && row.expiresInSeconds >= NEAR_MAX_EXPIRES_THRESHOLD_SECONDS;
|
|
7
|
+
const storagePresignedUrlForPrivateContent = {
|
|
8
|
+
categories: ["SECURITY"],
|
|
9
|
+
description: "`ctx.storage.getPresignedUrl(...)` mints a native S3 SigV4 URL that resolves directly against R2, bypassing the Worker's auth/RLS/rate-limit entirely — the wrong choice for private or policy-gated content. Either signer minting an `expiresInSeconds` near the shared 7-day ceiling also hands out a long-lived bearer credential.",
|
|
10
|
+
facing: "EXTERNAL",
|
|
11
|
+
level: "WARN",
|
|
12
|
+
name: "storage_presigned_url_for_private_content",
|
|
13
|
+
remediation: "For private/per-user/policy-gated content, use `getSignedUrl` (resolves back through the Worker, so your auth/RLS/rate-limit gates still run) instead of `getPresignedUrl`. Reserve `getPresignedUrl` for genuinely public or bulk content. On either signer, keep `expiresInSeconds` as short as the use case allows — well under the 7-day ceiling — rather than minting a near-maximum-lifetime URL.",
|
|
14
|
+
run: (context) => {
|
|
15
|
+
if (context.storageUploads === void 0) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
return context.storageUploads.filter((row) => isNativePresignedCall(row) || isNearExpiryCeiling(row)).map((row) => {
|
|
19
|
+
const native = isNativePresignedCall(row);
|
|
20
|
+
const nearCeiling = isNearExpiryCeiling(row);
|
|
21
|
+
const location = `\`${row.exportName}\` (${row.file}:${row.line.toString()})`;
|
|
22
|
+
let detail;
|
|
23
|
+
if (native && nearCeiling) {
|
|
24
|
+
detail = `\`ctx.storage.getPresignedUrl\` in ${location} mints a native S3 SigV4 URL — bypasses the Worker's auth/RLS/rate-limit entirely — with an \`expiresInSeconds\` of ${(row.expiresInSeconds ?? 0).toString()}s, near the 7-day signing ceiling.`;
|
|
25
|
+
} else if (native) {
|
|
26
|
+
detail = `\`ctx.storage.getPresignedUrl\` in ${location} mints a native S3 SigV4 URL that resolves directly against R2, bypassing the Worker's auth/RLS/rate-limit entirely. Use \`getSignedUrl\` for app-gated access to private content.`;
|
|
27
|
+
} else {
|
|
28
|
+
detail = `\`ctx.storage.${row.method}\` in ${location} requests an \`expiresInSeconds\` of ${(row.expiresInSeconds ?? 0).toString()}s, near the 7-day signing ceiling — a leaked link stays valid for almost a week.`;
|
|
29
|
+
}
|
|
30
|
+
return emit(storagePresignedUrlForPrivateContent, {
|
|
31
|
+
cacheKey: `storage_presigned_url_for_private_content:${row.file}:${row.line.toString()}`,
|
|
32
|
+
detail,
|
|
33
|
+
metadata: {
|
|
34
|
+
expiresInSeconds: row.expiresInSeconds,
|
|
35
|
+
exportName: row.exportName,
|
|
36
|
+
file: row.file,
|
|
37
|
+
line: row.line,
|
|
38
|
+
method: row.method
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
source: "static",
|
|
44
|
+
title: "Native presigned URL or near-max-TTL signed URL for private content"
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export { storagePresignedUrlForPrivateContent as default };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
+
|
|
3
|
+
const UPLOAD_METHODS = /* @__PURE__ */ new Set(["store", "upload"]);
|
|
4
|
+
const storageUploadWithoutContentTypeAllowlist = {
|
|
5
|
+
categories: ["SECURITY"],
|
|
6
|
+
description: "A `ctx.storage.upload`/`store` call has no `allowedContentTypes` allowlist, so an uploader can store any content-type — including `text/html` or `image/svg+xml` — which, served back from `publicBaseUrl`, is a stored-XSS vector against your own origin.",
|
|
7
|
+
facing: "EXTERNAL",
|
|
8
|
+
level: "WARN",
|
|
9
|
+
name: "storage_upload_without_content_type_allowlist",
|
|
10
|
+
remediation: 'Pass `allowedContentTypes: ["image/png", "image/jpeg", …]` to `ctx.storage.upload`/`store` so an uploaded object\'s `contentType` must match an approved list. Reject anything HTML/SVG/script-executable unless the app genuinely needs to serve it.',
|
|
11
|
+
run: (context) => {
|
|
12
|
+
if (context.storageUploads === void 0) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
return context.storageUploads.filter((row) => UPLOAD_METHODS.has(row.method) && row.analyzable && !row.presentKeys.includes("allowedContentTypes")).map(
|
|
16
|
+
(row) => emit(storageUploadWithoutContentTypeAllowlist, {
|
|
17
|
+
cacheKey: `storage_upload_without_content_type_allowlist:${row.file}:${row.line.toString()}`,
|
|
18
|
+
detail: `\`ctx.storage.${row.method}\` in \`${row.exportName}\` (${row.file}:${row.line.toString()}) has no \`allowedContentTypes\` allowlist — any content-type is accepted, including \`text/html\`/\`image/svg+xml\`, a stored-XSS risk if the object is served from \`publicBaseUrl\`.`,
|
|
19
|
+
metadata: { exportName: row.exportName, file: row.file, line: row.line, method: row.method }
|
|
20
|
+
})
|
|
21
|
+
);
|
|
22
|
+
},
|
|
23
|
+
source: "static",
|
|
24
|
+
title: "Storage upload with no content-type allowlist"
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export { storageUploadWithoutContentTypeAllowlist as default };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
+
|
|
3
|
+
const UPLOAD_METHODS = /* @__PURE__ */ new Set(["store", "upload"]);
|
|
4
|
+
const storageUploadWithoutMaxSize = {
|
|
5
|
+
categories: ["SECURITY"],
|
|
6
|
+
description: "A `ctx.storage.upload`/`store` call has no `maxSize` cap, so a caller can push an unbounded body straight into R2 through the Worker — unbounded storage/billing exhaustion, and for a streamed body, unbounded worker time.",
|
|
7
|
+
facing: "EXTERNAL",
|
|
8
|
+
level: "WARN",
|
|
9
|
+
name: "storage_upload_without_max_size",
|
|
10
|
+
remediation: "Pass a `maxSize` (bytes) to `ctx.storage.upload`/`store` sized to the largest legitimate object the app accepts. `ArrayBuffer`/`Blob` bodies are rejected up front when oversized; a `ReadableStream` body is aborted mid-transfer once the limit is exceeded.",
|
|
11
|
+
run: (context) => {
|
|
12
|
+
if (context.storageUploads === void 0) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
return context.storageUploads.filter((row) => UPLOAD_METHODS.has(row.method) && row.analyzable && !row.presentKeys.includes("maxSize")).map(
|
|
16
|
+
(row) => emit(storageUploadWithoutMaxSize, {
|
|
17
|
+
cacheKey: `storage_upload_without_max_size:${row.file}:${row.line.toString()}`,
|
|
18
|
+
detail: `\`ctx.storage.${row.method}\` in \`${row.exportName}\` (${row.file}:${row.line.toString()}) has no \`maxSize\` cap — a caller can push an unbounded body into R2, exhausting storage/billing.`,
|
|
19
|
+
metadata: { exportName: row.exportName, file: row.file, line: row.line, method: row.method }
|
|
20
|
+
})
|
|
21
|
+
);
|
|
22
|
+
},
|
|
23
|
+
source: "static",
|
|
24
|
+
title: "Storage upload with no size cap"
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export { storageUploadWithoutMaxSize as default };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
+
import { i as isPublicWrite } from './helpers-BySnKhVB.mjs';
|
|
2
3
|
|
|
3
4
|
const userCreatingMutationWithoutCaptcha = {
|
|
4
5
|
categories: ["SECURITY"],
|
|
@@ -13,9 +14,8 @@ const userCreatingMutationWithoutCaptcha = {
|
|
|
13
14
|
}
|
|
14
15
|
const findings = [];
|
|
15
16
|
for (const procedure of context.procedureProtections) {
|
|
16
|
-
const isPublicWrite = procedure.visibility === "public" && (procedure.kind === "mutation" || procedure.kind === "action");
|
|
17
17
|
const sensitive = procedure.writesUserTable || procedure.callsMail;
|
|
18
|
-
if (!isPublicWrite || !sensitive || procedure.usesCaptcha) {
|
|
18
|
+
if (!isPublicWrite(procedure) || !sensitive || procedure.usesCaptcha) {
|
|
19
19
|
continue;
|
|
20
20
|
}
|
|
21
21
|
const reason = procedure.writesUserTable ? "writes a user/session table" : "sends mail";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { m as makeArgumentDerivedSinkLint } from './argument-derived-sink-C1xTTqAt.mjs';
|
|
2
|
+
|
|
3
|
+
const vectorsNamespaceFromUserInput = makeArgumentDerivedSinkLint({
|
|
4
|
+
cacheKey: (access) => `vectors_namespace_from_user_input:${access.file}:${access.line.toString()}`,
|
|
5
|
+
categories: ["SECURITY"],
|
|
6
|
+
description: "A `ctx.vectors.query`/`upsert`/`upsertMany` call uses a `namespace` derived from the handler's `args` with no server-side scoping. A Vectorize namespace partitions one index into isolated sub-collections, so an unscoped namespace lets any caller read or poison another tenant's vectors.",
|
|
7
|
+
detail: (access) => `\`ctx.vectors.${access.method}\` in \`${access.exportName}\` (${access.file}:${access.line.toString()}) uses a Vectorize namespace derived from \`args\` with no server-side scoping — any caller can read or poison another tenant's vectors. Derive the namespace from a server-trusted identity (e.g. \`\${ctx.auth.orgId}\`), never from \`args\`.`,
|
|
8
|
+
facing: "EXTERNAL",
|
|
9
|
+
getAccesses: (context) => context.vectorNamespaceAccesses,
|
|
10
|
+
level: "WARN",
|
|
11
|
+
metadata: (access) => {
|
|
12
|
+
return { exportName: access.exportName, file: access.file, line: access.line, method: access.method };
|
|
13
|
+
},
|
|
14
|
+
name: "vectors_namespace_from_user_input",
|
|
15
|
+
remediation: `Derive the \`namespace\` from a server-trusted identity (e.g. \`\${ctx.auth.orgId}\`), never from \`args\`.`,
|
|
16
|
+
title: "Vectorize namespace derived from unscoped user input"
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export { vectorsNamespaceFromUserInput as default };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
+
|
|
3
|
+
const workflowDuplicateStepName = {
|
|
4
|
+
categories: ["SCHEMA"],
|
|
5
|
+
description: "Two durable steps in this workflow share a name. Cloudflare memoizes a step by its name, so on replay the second call returns the first step's cached result instead of running its body — silently skipping the work without an error.",
|
|
6
|
+
facing: "INTERNAL",
|
|
7
|
+
level: "ERROR",
|
|
8
|
+
// eslint-disable-next-line no-secrets/no-secrets -- the lint's rule id, not a credential
|
|
9
|
+
name: "workflow_duplicate_step_name",
|
|
10
|
+
remediation: "Give every `step.do` / `step.sleep` / `step.sleepUntil` / `step.waitForEvent` call in the workflow a unique name. If a step legitimately repeats (e.g. a loop), make the name distinct per iteration by interpolating the item id into the step name.",
|
|
11
|
+
run: (context) => {
|
|
12
|
+
if (context.workflows === void 0) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
const findings = [];
|
|
16
|
+
for (const workflow of context.workflows) {
|
|
17
|
+
const { steps } = workflow;
|
|
18
|
+
if (steps === void 0 || steps.length === 0) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const firstLineByName = /* @__PURE__ */ new Map();
|
|
22
|
+
const reported = /* @__PURE__ */ new Set();
|
|
23
|
+
for (const step of steps) {
|
|
24
|
+
const firstLine = firstLineByName.get(step.name);
|
|
25
|
+
if (firstLine === void 0) {
|
|
26
|
+
firstLineByName.set(step.name, step.line);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (reported.has(step.name)) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
reported.add(step.name);
|
|
33
|
+
findings.push(
|
|
34
|
+
emit(workflowDuplicateStepName, {
|
|
35
|
+
cacheKey: `workflow_duplicate_step_name:${workflow.exportName}:${step.name}`,
|
|
36
|
+
detail: `Workflow "${workflow.exportName}" reuses the durable step name "${step.name}" (first at line ${String(firstLine)}, again at line ${String(step.line)}). The second call returns the first's cached result instead of running.`,
|
|
37
|
+
metadata: { firstLine, line: step.line, stepName: step.name, workflow: workflow.exportName }
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return findings;
|
|
43
|
+
},
|
|
44
|
+
source: "static",
|
|
45
|
+
title: "Duplicate durable step name in workflow"
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export { workflowDuplicateStepName as default };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/advisor",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.31",
|
|
4
4
|
"description": "Schema & query lints (splinter-style advisors) for Lunora, feeding the Studio Advisors view",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"advisor",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"directory": "packages/advisor"
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
|
-
"dist",
|
|
28
|
+
"./dist",
|
|
29
29
|
"README.md",
|
|
30
30
|
"LICENSE.md",
|
|
31
31
|
"__assets__"
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@lunora/
|
|
49
|
+
"@lunora/errors": "1.0.0-alpha.6",
|
|
50
|
+
"@lunora/server": "1.0.0-alpha.28"
|
|
50
51
|
},
|
|
51
52
|
"engines": {
|
|
52
53
|
"node": "^22.15.0 || >=24.11.0"
|