@lunora/advisor 1.0.0-alpha.3 → 1.0.0-alpha.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE.md +6 -0
  2. package/README.md +1 -1
  3. package/__assets__/package-og.svg +1 -1
  4. package/dist/index.d.mts +2215 -10
  5. package/dist/index.d.ts +2215 -10
  6. package/dist/index.mjs +120 -13
  7. package/dist/packem_shared/{AE_METRIC_EVENTS-DexctYv6.mjs → AE_METRIC_EVENTS-BM14d0lm.mjs} +3 -1
  8. package/dist/packem_shared/actionFetchSsrf-wbmYgzmz.mjs +26 -0
  9. package/dist/packem_shared/aiRawRunEscapeHatch-C29jd32J.mjs +26 -0
  10. package/dist/packem_shared/aiToolSideEffectPromptInjection-BtD3alqB.mjs +33 -0
  11. package/dist/packem_shared/aiUnboundedGenerationPublic-CbAcSxLB.mjs +26 -0
  12. package/dist/packem_shared/allowUnauthenticatedShardAccessEnabled-BcASq-jP.mjs +30 -0
  13. package/dist/packem_shared/argument-derived-sink-C1xTTqAt.mjs +30 -0
  14. package/dist/packem_shared/authCsrfCheckDisabled-DCf9FSoD.mjs +26 -0
  15. package/dist/packem_shared/authEmailVerificationDisabled-Dk-Tc5oU.mjs +26 -0
  16. package/dist/packem_shared/authSecureCookiesDisabled-osJrHW9Y.mjs +26 -0
  17. package/dist/packem_shared/authSessionFreshageZero-CjufCc_Q.mjs +26 -0
  18. package/dist/packem_shared/authTrustedOriginsWildcard-ylv4PKzC.mjs +26 -0
  19. package/dist/packem_shared/browserAllowPrivateTargets-CBvQxLAM.mjs +26 -0
  20. package/dist/packem_shared/browserUserUrlWithoutAllowlist-CvhA6w59.mjs +26 -0
  21. package/dist/packem_shared/circularFk-CNcAVuYa.mjs +127 -0
  22. package/dist/packem_shared/{constraintValidator-Dr9Py3FD.mjs → constraintValidator-CxwtpJ6E.mjs} +24 -4
  23. package/dist/packem_shared/containerInstanceKeyFromUserInput-BUXj2J4w.mjs +19 -0
  24. package/dist/packem_shared/containerRuntimeEgressRelaxation-B0LlfmDA.mjs +26 -0
  25. package/dist/packem_shared/containerStartEnableInternetOverride-DDaHZQ1L.mjs +26 -0
  26. package/dist/packem_shared/dedupeCacheKeys-r5B7u_yq.mjs +13 -0
  27. package/dist/packem_shared/externalSourceIncrementalNoDeletePath-DvX9cRBD.mjs +45 -0
  28. package/dist/packem_shared/externalSourceOnGlobal-Bg-NfCX9.mjs +30 -0
  29. package/dist/packem_shared/externalSourceUnscoped-5vT-Bup3.mjs +44 -0
  30. package/dist/packem_shared/flagGatesSecurityWithUnsafeDefault-BhIs0shr.mjs +41 -0
  31. package/dist/packem_shared/{fromServerSchema-DinF1nph.mjs → fromServerSchema-BjAZdvJ6.mjs} +10 -0
  32. package/dist/packem_shared/{hardcodedSecret-W2pz1UZB.mjs → hardcodedSecret-Be-pKVdn.mjs} +3 -7
  33. package/dist/packem_shared/helpers-BySnKhVB.mjs +31 -0
  34. package/dist/packem_shared/hotShard-CkC7qpre.mjs +60 -0
  35. package/dist/packem_shared/httpActionMissingAuthGuard-CxipddNx.mjs +35 -0
  36. package/dist/packem_shared/httpActionResponseHeaderInjection-DOFS7pFT.mjs +39 -0
  37. package/dist/packem_shared/identityUndeclaredClaimTrusted-D8nXV2dd.mjs +32 -0
  38. package/dist/packem_shared/imagesUrlSourceFromUserInput-YcDQ0b_Y.mjs +19 -0
  39. package/dist/packem_shared/{indexReferencesUnknownField-DH0_dbUY.mjs → indexReferencesUnknownField-BSWNngxX.mjs} +1 -1
  40. package/dist/packem_shared/insertManyUnsafeUserData-Dn77XpmX.mjs +26 -0
  41. package/dist/packem_shared/kvUnscopedUserKeyIdor-YWwmfE8X.mjs +19 -0
  42. package/dist/packem_shared/mailInboundDispatchWithoutVerify-CHSRA8zz.mjs +28 -0
  43. package/dist/packem_shared/mailRecipientFromRequestInput-Cka2qu5J.mjs +26 -0
  44. package/dist/packem_shared/maskWeakHashStrategyOnPii-1c4q8Opf.mjs +35 -0
  45. package/dist/packem_shared/maskedRelationLeakViaWith-CPI4s0sl.mjs +76 -0
  46. package/dist/packem_shared/mutatorFullRowReplace-BJnNDaIV.mjs +26 -0
  47. package/dist/packem_shared/normalizeIdUsedAsAuthorization-BVPtCpzT.mjs +50 -0
  48. package/dist/packem_shared/outputProjectionMissingOnPublicRead-Bl5IMx0k.mjs +51 -0
  49. package/dist/packem_shared/ownerFieldFromArgsNotAuth-mOw3hE5z.mjs +26 -0
  50. package/dist/packem_shared/paymentCreateWithoutAuthorize-BYm4JLxo.mjs +26 -0
  51. package/dist/packem_shared/paymentWebhookWideTolerance-D9QQJMJq.mjs +35 -0
  52. package/dist/packem_shared/plaintextSecretInWranglerVariables-NKO4YkKf.mjs +35 -0
  53. package/dist/packem_shared/privilegedDispatchUnvalidatedPayload-5Forjckt.mjs +37 -0
  54. package/dist/packem_shared/privilegedFanoutFromPublicProcedure-D3dL01B8.mjs +26 -0
  55. package/dist/packem_shared/{publicMutationWithoutRatelimit-xBpJ6GWK.mjs → publicMutationWithoutRatelimit-DbIhgi7j.mjs} +2 -2
  56. package/dist/packem_shared/publicTableRlsOptoutConfusion-Bn71yoD4.mjs +38 -0
  57. package/dist/packem_shared/queueWithoutDlq-CSkGNb_0.mjs +41 -0
  58. package/dist/packem_shared/ratelimitDefaultMemoryStore-BISChG5C.mjs +26 -0
  59. package/dist/packem_shared/ratelimitKeySpoofableOrGlobal-DqlHYQQ3.mjs +26 -0
  60. package/dist/packem_shared/ratelimitMiddlewareFailOpen-CJgDCaUw.mjs +33 -0
  61. package/dist/packem_shared/{relationReferencesUnknownField-YznyXt_7.mjs → relationReferencesUnknownField-CjbLScJ1.mjs} +1 -1
  62. package/dist/packem_shared/shapeTargetsGlobalTable-DHrf4Koi.mjs +34 -0
  63. package/dist/packem_shared/shapeUnknownTable-C8aDWFoe.mjs +34 -0
  64. package/dist/packem_shared/softDeleteIncludeDeletedFromArgs-BLqDKrkM.mjs +38 -0
  65. package/dist/packem_shared/storageGenerateUploadUrlNoContentTypePin-Da4L9Ge8.mjs +26 -0
  66. package/dist/packem_shared/storageKeyFromUserArgs-B86elJgS.mjs +19 -0
  67. package/dist/packem_shared/storagePresignedUrlForPrivateContent-yGmb8uqz.mjs +47 -0
  68. package/dist/packem_shared/storageUploadWithoutContentTypeAllowlist-BV-gF1lT.mjs +27 -0
  69. package/dist/packem_shared/storageUploadWithoutMaxSize-DpxO59wU.mjs +27 -0
  70. package/dist/packem_shared/{userCreatingMutationWithoutCaptcha-CH31YsUZ.mjs → userCreatingMutationWithoutCaptcha-2DZtWIPb.mjs} +2 -2
  71. package/dist/packem_shared/vectorsNamespaceFromUserInput-CQhr5bVn.mjs +19 -0
  72. package/dist/packem_shared/workflowDuplicateStepName-ioBxPBCy.mjs +48 -0
  73. package/package.json +4 -3
  74. package/dist/packem_shared/circularFk-B2freHrP.mjs +0 -84
  75. package/dist/packem_shared/helpers-DNCkMWZQ.mjs +0 -4
  76. package/dist/packem_shared/hotShard-Ir5D0B6J.mjs +0 -48
@@ -0,0 +1,127 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const MAX_CYCLES = 100;
4
+ const circularFk = {
5
+ categories: ["SCHEMA"],
6
+ description: "A chain of foreign-key relations forms a cycle (e.g. A → B → C → A). Circular FK dependencies can cause unexpected behavior during DELETE operations — a CASCADE chain may loop indefinitely, and even a RESTRICT cycle prevents deletion of any row in the loop without temporarily disabling constraints.",
7
+ facing: "INTERNAL",
8
+ level: "WARN",
9
+ name: "circular_fk",
10
+ remediation: "Remove or break the cycle by dropping at least one FK relation from the loop. Consider replacing the circular dependency with a nullable FK and explicit application logic, or restructure the schema using a junction table.",
11
+ run: (context) => {
12
+ const findings = [];
13
+ const order = [...new Set(context.schema.tables.map((table) => table.name))].sort();
14
+ const indexOf = new Map(order.map((name, index) => [name, index]));
15
+ const adjacency = /* @__PURE__ */ new Map();
16
+ for (const table of context.schema.tables) {
17
+ for (const relation of table.relations) {
18
+ if (relation.kind !== "one" || !indexOf.has(relation.table)) {
19
+ continue;
20
+ }
21
+ let targets = adjacency.get(table.name);
22
+ if (targets === void 0) {
23
+ targets = /* @__PURE__ */ new Set();
24
+ adjacency.set(table.name, targets);
25
+ }
26
+ targets.add(relation.table);
27
+ }
28
+ }
29
+ const blocked = /* @__PURE__ */ new Set();
30
+ const blockMap = /* @__PURE__ */ new Map();
31
+ const stack = [];
32
+ const reported = /* @__PURE__ */ new Set();
33
+ let capped = false;
34
+ const unblock = (vertex) => {
35
+ blocked.delete(vertex);
36
+ const dependents = blockMap.get(vertex);
37
+ if (dependents === void 0) {
38
+ return;
39
+ }
40
+ for (const dependent of dependents) {
41
+ dependents.delete(dependent);
42
+ if (blocked.has(dependent)) {
43
+ unblock(dependent);
44
+ }
45
+ }
46
+ };
47
+ const emitCurrentCycle = () => {
48
+ const cycle = [...stack];
49
+ if (cycle.length < 2) {
50
+ return;
51
+ }
52
+ let minIndex = 0;
53
+ for (let index = 1; index < cycle.length; index += 1) {
54
+ if (cycle[index] < cycle[minIndex]) {
55
+ minIndex = index;
56
+ }
57
+ }
58
+ const canonical = [...cycle.slice(minIndex), ...cycle.slice(0, minIndex)];
59
+ const key = canonical.join("→");
60
+ if (reported.has(key)) {
61
+ return;
62
+ }
63
+ reported.add(key);
64
+ const displayPath = [...canonical, canonical[0]].join(" → ");
65
+ findings.push(
66
+ emit(circularFk, {
67
+ cacheKey: `circular_fk:${key}`,
68
+ detail: `Circular foreign-key dependency detected: ${displayPath}. This cycle can cause unexpected behavior during DELETE operations.`,
69
+ metadata: {
70
+ cycle: canonical,
71
+ path: displayPath,
72
+ tables: canonical
73
+ }
74
+ })
75
+ );
76
+ if (reported.size >= MAX_CYCLES) {
77
+ capped = true;
78
+ }
79
+ };
80
+ const circuit = (vertex, start, startIndex) => {
81
+ let foundCycle = false;
82
+ stack.push(vertex);
83
+ blocked.add(vertex);
84
+ for (const neighbor of adjacency.get(vertex) ?? []) {
85
+ if (capped) {
86
+ break;
87
+ }
88
+ if (indexOf.get(neighbor) < startIndex) {
89
+ continue;
90
+ }
91
+ if (neighbor === start) {
92
+ emitCurrentCycle();
93
+ foundCycle = true;
94
+ } else if (!blocked.has(neighbor) && circuit(neighbor, start, startIndex)) {
95
+ foundCycle = true;
96
+ }
97
+ }
98
+ if (foundCycle) {
99
+ unblock(vertex);
100
+ } else {
101
+ for (const neighbor of adjacency.get(vertex) ?? []) {
102
+ if (indexOf.get(neighbor) < startIndex) {
103
+ continue;
104
+ }
105
+ let dependents = blockMap.get(neighbor);
106
+ if (dependents === void 0) {
107
+ dependents = /* @__PURE__ */ new Set();
108
+ blockMap.set(neighbor, dependents);
109
+ }
110
+ dependents.add(vertex);
111
+ }
112
+ }
113
+ stack.pop();
114
+ return foundCycle;
115
+ };
116
+ for (let startIndex = 0; startIndex < order.length && !capped; startIndex += 1) {
117
+ blocked.clear();
118
+ blockMap.clear();
119
+ circuit(order[startIndex], order[startIndex], startIndex);
120
+ }
121
+ return findings;
122
+ },
123
+ source: "static",
124
+ title: "Circular foreign-key dependency"
125
+ };
126
+
127
+ export { circularFk as default };
@@ -9,10 +9,28 @@ const formatExamples = (ids, total) => {
9
9
  const list = examples.map((id) => `"${id}"`).join(", ");
10
10
  return more > 0 ? `${list} (+${more.toString()} more)` : list;
11
11
  };
12
+ const resolveReferenceValues = (referencedColumn, targetSample) => {
13
+ if (referencedColumn === "_id") {
14
+ return targetSample.existingIds;
15
+ }
16
+ const values = /* @__PURE__ */ new Set();
17
+ for (const row of targetSample.rows) {
18
+ const value = row[referencedColumn];
19
+ if (value !== null && value !== void 0) {
20
+ values.add(String(value));
21
+ }
22
+ }
23
+ return values;
24
+ };
12
25
  const checkFkRelation = (lint, sample, relation, targetSample) => {
13
26
  if (relation.kind !== "one") {
14
27
  return void 0;
15
28
  }
29
+ if (targetSample.truncated) {
30
+ return void 0;
31
+ }
32
+ const referencedColumn = relation.references;
33
+ const referenceValues = resolveReferenceValues(referencedColumn, targetSample);
16
34
  const fkColumn = relation.field;
17
35
  const danglingIds = [];
18
36
  for (const row of sample.rows) {
@@ -21,16 +39,17 @@ const checkFkRelation = (lint, sample, relation, targetSample) => {
21
39
  continue;
22
40
  }
23
41
  const fkString = String(fkValue);
24
- if (!targetSample.existingIds.has(fkString)) {
42
+ if (!referenceValues.has(fkString)) {
25
43
  danglingIds.push(rowId(row));
26
44
  }
27
45
  }
28
46
  if (danglingIds.length === 0) {
29
47
  return void 0;
30
48
  }
31
- const isTruncated = sample.truncated || targetSample.truncated;
32
- const cap = Math.min(sample.cap, targetSample.cap);
33
- const detail = `Table "${sample.table}": ${danglingIds.length.toString()} sampled row(s) have a dangling FK value in column "${fkColumn}" the referenced "${relation.table}" row does not exist. Row ids: ${formatExamples(danglingIds, danglingIds.length)}${truncatedSuffix(isTruncated, cap, "more rows may exist beyond the window")}.`;
49
+ const isTruncated = sample.truncated;
50
+ const { cap } = sample;
51
+ const referencedLabel = referencedColumn === "_id" ? `"${relation.table}"` : `"${relation.table}"."${referencedColumn}"`;
52
+ const detail = `Table "${sample.table}": ${danglingIds.length.toString()} sampled row(s) have a dangling FK value in column "${fkColumn}" — the referenced ${referencedLabel} row does not exist. Row ids: ${formatExamples(danglingIds, danglingIds.length)}${truncatedSuffix(isTruncated, cap, "more rows may exist beyond the window")}.`;
34
53
  return emit(lint, {
35
54
  cacheKey: `constraint_validator:fk:${sample.table}:${fkColumn}`,
36
55
  detail,
@@ -40,6 +59,7 @@ const checkFkRelation = (lint, sample, relation, targetSample) => {
40
59
  count: danglingIds.length,
41
60
  examples: danglingIds.slice(0, MAX_EXAMPLE_IDS),
42
61
  kind: "fk",
62
+ references: referencedColumn,
43
63
  referencesTable: relation.table,
44
64
  table: sample.table,
45
65
  truncated: isTruncated
@@ -0,0 +1,19 @@
1
+ import { m as makeArgumentDerivedSinkLint } from './argument-derived-sink-C1xTTqAt.mjs';
2
+
3
+ const containerInstanceKeyFromUserInput = makeArgumentDerivedSinkLint({
4
+ cacheKey: (access) => `container_instance_key_from_user_input:${access.file}:${access.line.toString()}`,
5
+ categories: ["SECURITY"],
6
+ description: "A `ctx.containers.<name>.get` call routes to a container instance using a key derived from the handler's `args` with no server-side scoping. Any caller can supply another tenant's key and reach that tenant's container instance — a cross-tenant insecure direct object reference (IDOR).",
7
+ detail: (access) => `\`ctx.containers.*.${access.method}\` in \`${access.exportName}\` (${access.file}:${access.line.toString()}) routes to a container instance using a key derived from \`args\` with no server-side scoping — any caller can reach another tenant's container (IDOR). Derive the key from a server-trusted identity (e.g. \`\${ctx.auth.userId}\`).`,
8
+ facing: "EXTERNAL",
9
+ getAccesses: (context) => context.containerKeyAccesses,
10
+ level: "WARN",
11
+ metadata: (access) => {
12
+ return { exportName: access.exportName, file: access.file, line: access.line, method: access.method };
13
+ },
14
+ name: "container_instance_key_from_user_input",
15
+ remediation: `Derive the container instance key from a server-trusted identity (e.g. \`\${ctx.auth.userId}\`) or a record the caller owns — never pass request input straight to \`ctx.containers.<name>.get\`.`,
16
+ title: "Possible cross-tenant IDOR from arg-derived unscoped container key"
17
+ });
18
+
19
+ export { containerInstanceKeyFromUserInput as default };
@@ -0,0 +1,26 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const containerRuntimeEgressRelaxation = {
4
+ categories: ["SECURITY"],
5
+ description: "A runtime `.egress.allow(...)` / `.egress.deny(...)` / `.egress.setAllowed(...)` call mutates the container's egress firewall at runtime, re-opening a network surface the static analysis assumes is locked to the `defineContainer` declaration.",
6
+ facing: "EXTERNAL",
7
+ level: "WARN",
8
+ name: "container_runtime_egress_relaxation",
9
+ remediation: "Prefer declaring the egress policy statically in `lunora/containers.ts` where it is auditable. When a runtime mutation is unavoidable, scope it behind a server-trusted check and keep the change narrow (a specific host, not a broad allow) rather than an unconditional relaxation.",
10
+ run: (context) => {
11
+ if (context.containerOverrides === void 0) {
12
+ return [];
13
+ }
14
+ return context.containerOverrides.filter((override) => override.kind === "egress_relaxation").map(
15
+ (override) => emit(containerRuntimeEgressRelaxation, {
16
+ cacheKey: `container_runtime_egress_relaxation:${override.file}:${override.line.toString()}`,
17
+ detail: `\`${override.exportName}\` calls \`.egress.${override.detail}(...)\` at runtime, mutating the container's egress firewall.`,
18
+ metadata: { exportName: override.exportName, file: override.file, line: override.line, method: override.detail }
19
+ })
20
+ );
21
+ },
22
+ source: "static",
23
+ title: "Runtime egress mutation relaxes the container firewall"
24
+ };
25
+
26
+ export { containerRuntimeEgressRelaxation as default };
@@ -0,0 +1,26 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const containerStartEnableInternetOverride = {
4
+ categories: ["SECURITY"],
5
+ description: "A runtime `.start({ enableInternet: true, … })` call overrides the static `defineContainer` declaration and re-opens public internet egress for that container instance, defeating the `container_public_internet` lint's assumption that the declaration is authoritative.",
6
+ facing: "EXTERNAL",
7
+ level: "WARN",
8
+ name: "container_start_enable_internet_override",
9
+ remediation: "Avoid overriding `enableInternet` at `.start()` time. Set it once on the `defineContainer` declaration in `lunora/containers.ts` so the static advisors can reason about it, and gate any runtime opt-in behind a server-trusted check rather than an unconditional literal.",
10
+ run: (context) => {
11
+ if (context.containerOverrides === void 0) {
12
+ return [];
13
+ }
14
+ return context.containerOverrides.filter((override) => override.kind === "enable_internet").map(
15
+ (override) => emit(containerStartEnableInternetOverride, {
16
+ cacheKey: `container_start_enable_internet_override:${override.file}:${override.line.toString()}`,
17
+ detail: `\`${override.exportName}\` calls \`.start({ enableInternet: true })\`, overriding the container's static egress posture.`,
18
+ metadata: { exportName: override.exportName, file: override.file, line: override.line }
19
+ })
20
+ );
21
+ },
22
+ source: "static",
23
+ title: "Runtime .start() override re-enables container internet access"
24
+ };
25
+
26
+ export { containerStartEnableInternetOverride as default };
@@ -0,0 +1,13 @@
1
+ const dedupeCacheKeys = (findings) => {
2
+ const seen = /* @__PURE__ */ new Map();
3
+ return findings.map((finding) => {
4
+ const occurrence = (seen.get(finding.cacheKey) ?? 0) + 1;
5
+ seen.set(finding.cacheKey, occurrence);
6
+ if (occurrence === 1) {
7
+ return finding;
8
+ }
9
+ return { ...finding, cacheKey: `${finding.cacheKey}:${occurrence.toString()}` };
10
+ });
11
+ };
12
+
13
+ export { dedupeCacheKeys };
@@ -0,0 +1,45 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const externalSourceIncrementalNoDeletePath = {
4
+ categories: ["SCHEMA"],
5
+ description: "A `.source({ mode: 'incremental' })` table declares no delete-visibility path (`reconcileEveryMs` or `softDeleteColumn`), so upstream deletes are never applied and the materialized table accumulates phantom rows.",
6
+ facing: "EXTERNAL",
7
+ level: "ERROR",
8
+ name: "external_source_incremental_no_delete_path",
9
+ remediation: "Add `reconcileEveryMs` (a periodic full-pull sweep that GCs vanished rows) or `softDeleteColumn` (an upstream tombstone column the incremental pull returns). Incremental ingest can't see a delete otherwise.",
10
+ run: (context) => {
11
+ const findings = [];
12
+ for (const table of context.schema.tables) {
13
+ const source = table.externalSource;
14
+ if (source === void 0) {
15
+ continue;
16
+ }
17
+ if (source.unanalyzable) {
18
+ findings.push(
19
+ emit(externalSourceIncrementalNoDeletePath, {
20
+ cacheKey: `external_source_incremental_no_delete_path:${table.name}`,
21
+ detail: `Table \`${table.name}\`'s \`.source(...)\` config is not a static object literal, so its \`mode\`/delete-visibility can't be verified. If it is \`mode: "incremental"\`, confirm it declares \`reconcileEveryMs\` or \`softDeleteColumn\` — an incremental source without one accumulates phantom rows on every upstream delete.`,
22
+ level: "WARN",
23
+ metadata: { table: table.name }
24
+ })
25
+ );
26
+ continue;
27
+ }
28
+ if (source.mode !== "incremental" || source.hasReconcile || source.hasSoftDelete) {
29
+ continue;
30
+ }
31
+ findings.push(
32
+ emit(externalSourceIncrementalNoDeletePath, {
33
+ cacheKey: `external_source_incremental_no_delete_path:${table.name}`,
34
+ detail: `Table \`${table.name}\` is \`.source({ mode: "incremental" })\` but declares neither \`reconcileEveryMs\` nor \`softDeleteColumn\` — an incremental pull never sees upstream deletes, so the materialized table would accumulate phantom rows. Add one of the two delete-visibility paths.`,
35
+ metadata: { table: table.name }
36
+ })
37
+ );
38
+ }
39
+ return findings;
40
+ },
41
+ source: "static",
42
+ title: "Incremental sourced table has no delete-visibility path"
43
+ };
44
+
45
+ export { externalSourceIncrementalNoDeletePath as default };
@@ -0,0 +1,30 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const externalSourceOnGlobal = {
4
+ categories: ["SCHEMA"],
5
+ description: "A table is both `.source(...)` and `.global()`. A global table lives in the external/global tier; a sourced table is materialized into a shard DO's SQLite — the two are mutually exclusive.",
6
+ facing: "INTERNAL",
7
+ level: "ERROR",
8
+ name: "external_source_on_global",
9
+ remediation: 'Drop either `.source(...)` or `.global()`. To ingest an external database into per-tenant DOs, use `.source(...)` with `.shardBy(...)`; to read it in place as a global table, use `.global({ backend: "hyperdrive" })`.',
10
+ run: (context) => {
11
+ const findings = [];
12
+ for (const table of context.schema.tables) {
13
+ if (table.externalSource === void 0 || table.shardKind !== "global") {
14
+ continue;
15
+ }
16
+ findings.push(
17
+ emit(externalSourceOnGlobal, {
18
+ cacheKey: `external_source_on_global:${table.name}`,
19
+ detail: `Table \`${table.name}\` is both \`.source(...)\` and \`.global()\` — contradictory. A sourced table materializes into a shard DO's SQLite; a global table lives in the external tier.`,
20
+ metadata: { table: table.name }
21
+ })
22
+ );
23
+ }
24
+ return findings;
25
+ },
26
+ source: "static",
27
+ title: "Sourced table cannot also be global"
28
+ };
29
+
30
+ export { externalSourceOnGlobal as default };
@@ -0,0 +1,44 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const externalSourceUnscoped = {
4
+ categories: ["SECURITY"],
5
+ description: "A `.source(...)` table that is also `.shardBy(...)` has no `tenantBy` mapper, so every tenant's Durable Object runs the same unscoped query and replicates the whole multitenant table — a cross-tenant data leak.",
6
+ facing: "EXTERNAL",
7
+ level: "ERROR",
8
+ name: "external_source_unscoped",
9
+ remediation: "Add `tenantBy: (shardKey) => [shardKey]` (matching the query's bound parameters) so each shard DO pulls only its own tenant's rows. The shard key MUST bind into the source `WHERE`.",
10
+ run: (context) => {
11
+ const findings = [];
12
+ for (const table of context.schema.tables) {
13
+ if (table.externalSource === void 0 || table.shardKind !== "shardBy") {
14
+ continue;
15
+ }
16
+ if (table.externalSource.unanalyzable) {
17
+ findings.push(
18
+ emit(externalSourceUnscoped, {
19
+ cacheKey: `external_source_unscoped:${table.name}`,
20
+ detail: `Table \`${table.name}\` is \`.source(...)\` + \`.shardBy(...)\` but its source config is not a static object literal, so \`tenantBy\` can't be verified. Confirm \`tenantBy\` binds the shard key into the query, or inline the config so the linter can check it — an unscoped query leaks every tenant's rows into each DO.`,
21
+ level: "WARN",
22
+ metadata: { table: table.name }
23
+ })
24
+ );
25
+ continue;
26
+ }
27
+ if (table.externalSource.hasTenantBy) {
28
+ continue;
29
+ }
30
+ findings.push(
31
+ emit(externalSourceUnscoped, {
32
+ cacheKey: `external_source_unscoped:${table.name}`,
33
+ detail: `Table \`${table.name}\` is \`.source(...)\` + \`.shardBy(...)\` but declares no \`tenantBy\` — every tenant DO would pull the whole table. Add a \`tenantBy\` that binds the shard key into the query.`,
34
+ metadata: { table: table.name }
35
+ })
36
+ );
37
+ }
38
+ return findings;
39
+ },
40
+ source: "static",
41
+ title: "Sourced + sharded table is not tenant-scoped"
42
+ };
43
+
44
+ export { externalSourceUnscoped as default };
@@ -0,0 +1,41 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const PROTECT_TOKENS = /* @__PURE__ */ new Set(["enforce", "gate", "lockdown", "rls"]);
4
+ const PERMIT_TOKENS = /* @__PURE__ */ new Set(["allow", "bypass", "permit"]);
5
+ const ACRONYM_WORD_BOUNDARY = /(?<=[A-Z])(?=[A-Z][a-z])/gu;
6
+ const CAMEL_HUMP_BOUNDARY = /(?<=[a-z0-9])(?=[A-Z])/gu;
7
+ const NON_ALPHANUMERIC_RUN = /[^a-z0-9]+/u;
8
+ const tokenize = (key) => key.replaceAll(ACRONYM_WORD_BOUNDARY, " ").replaceAll(CAMEL_HUMP_BOUNDARY, " ").toLowerCase().split(NON_ALPHANUMERIC_RUN).filter((token) => token.length > 0);
9
+ const hasUnsafeDefault = (row) => {
10
+ const tokens = tokenize(row.key);
11
+ const isProtect = tokens.some((token) => PROTECT_TOKENS.has(token));
12
+ const isPermit = tokens.some((token) => PERMIT_TOKENS.has(token));
13
+ if (isProtect === isPermit) {
14
+ return false;
15
+ }
16
+ return isProtect ? !row.defaultValue : row.defaultValue;
17
+ };
18
+ const flagGatesSecurityWithUnsafeDefault = {
19
+ categories: ["SECURITY"],
20
+ description: "A `ctx.flags.boolean(key, default)` read on a security-shaped key has a fail-open default that selects the permissive branch. OpenFeature returns the default when the provider errors, so an outage silently disables a protection (a `false` default on an `enforce`/`rls`/`gate`/`lockdown` key) or grants a permission (a `true` default on an `allow`/`permit`/`bypass` key).",
21
+ facing: "EXTERNAL",
22
+ level: "WARN",
23
+ name: "flag_gates_security_with_unsafe_default",
24
+ remediation: "Flip the default so a provider outage fails closed: default a protection flag (`enforce*`/`rls*`/`gate*`/`lockdown*`) to `true`, and a permission/bypass flag (`allow*`/`permit*`/`bypass*`) to `false`. The safe default is always the restrictive branch — never let an unreachable flag backend open access.",
25
+ run: (context) => {
26
+ if (context.flagSecurityDefaults === void 0) {
27
+ return [];
28
+ }
29
+ return context.flagSecurityDefaults.filter((row) => hasUnsafeDefault(row)).map(
30
+ (row) => emit(flagGatesSecurityWithUnsafeDefault, {
31
+ cacheKey: `flag_gates_security_with_unsafe_default:${row.file}:${row.line.toString()}`,
32
+ detail: `\`ctx.flags.boolean("${row.key}", ${String(row.defaultValue)})\` in \`${row.exportName}\` (${row.file}:${row.line.toString()}) fails open to the permissive branch — a provider outage returns \`${String(row.defaultValue)}\`, ${row.defaultValue ? "granting the guarded permission" : "disabling the guarded protection"}.`,
33
+ metadata: { defaultValue: row.defaultValue, exportName: row.exportName, file: row.file, key: row.key, line: row.line }
34
+ })
35
+ );
36
+ },
37
+ source: "static",
38
+ title: "Security flag fails open to the permissive branch"
39
+ };
40
+
41
+ export { flagGatesSecurityWithUnsafeDefault as default };
@@ -1,5 +1,6 @@
1
1
  const fromServerSchema = (schema) => {
2
2
  return {
3
+ rlsMode: schema.rlsMode,
3
4
  tables: Object.entries(schema.tables).map(([name, table]) => {
4
5
  const indexes = [
5
6
  ...table.indexes.map((index) => {
@@ -28,10 +29,19 @@ const fromServerSchema = (schema) => {
28
29
  }
29
30
  return {
30
31
  externallyManaged: table.isExternallyManaged ?? false,
32
+ externalSource: table.externalSource ? {
33
+ hasReconcile: table.externalSource.reconcileEveryMs !== void 0,
34
+ hasSoftDelete: table.externalSource.softDeleteColumn !== void 0,
35
+ hasTenantBy: table.externalSource.tenantBy !== void 0,
36
+ mode: table.externalSource.mode
37
+ } : void 0,
31
38
  fields: Object.keys(table.shape),
32
39
  indexes,
40
+ isPublic: table.isPublic ?? false,
33
41
  name,
34
42
  optionalFields,
43
+ shardKind: table.shardMode.kind,
44
+ softDelete: table.softDeleteMode,
35
45
  relations: Object.entries(table.relationMap).map(([accessor, relation]) => {
36
46
  return {
37
47
  field: relation.field,
@@ -1,3 +1,4 @@
1
+ import { dedupeCacheKeys } from './dedupeCacheKeys-r5B7u_yq.mjs';
1
2
  import { e as emit } from './finding-Dm_zvzS1.mjs';
2
3
 
3
4
  const hardcodedSecret = {
@@ -12,21 +13,16 @@ const hardcodedSecret = {
12
13
  return [];
13
14
  }
14
15
  const findings = [];
15
- const occurrenceCount = /* @__PURE__ */ new Map();
16
16
  for (const secret of context.secretLiterals) {
17
- const baseKey = `${secret.file}:${secret.line.toString()}:${secret.kind}`;
18
- const occurrence = (occurrenceCount.get(baseKey) ?? 0) + 1;
19
- occurrenceCount.set(baseKey, occurrence);
20
- const occurrenceSuffix = occurrence > 1 ? `:${occurrence.toString()}` : "";
21
17
  findings.push(
22
18
  emit(hardcodedSecret, {
23
- cacheKey: `hardcoded_secret:${baseKey}${occurrenceSuffix}`,
19
+ cacheKey: `hardcoded_secret:${secret.file}:${secret.line.toString()}:${secret.kind}`,
24
20
  detail: `A ${secret.kind.replaceAll("_", " ")} (${secret.preview}) is hard-coded at ${secret.file}:${secret.line.toString()}. Move it to \`.dev.vars\` / \`wrangler secret put\` and read it from \`env\`. Rotate the exposed value.`,
25
21
  metadata: { file: secret.file, kind: secret.kind, line: secret.line, preview: secret.preview }
26
22
  })
27
23
  );
28
24
  }
29
- return findings;
25
+ return dedupeCacheKeys(findings);
30
26
  },
31
27
  source: "static",
32
28
  title: "Hard-coded secret in source"
@@ -0,0 +1,31 @@
1
+ const OWNERSHIP_FIELD_NAMES = /* @__PURE__ */ new Set([
2
+ "accountId",
3
+ "authorId",
4
+ "createdBy",
5
+ "createdById",
6
+ "organizationId",
7
+ "orgId",
8
+ "ownerId",
9
+ "tenantId",
10
+ "updatedBy",
11
+ "userId",
12
+ "workspaceId"
13
+ ]);
14
+ const PII_FIELD_NAMES = /* @__PURE__ */ new Set([
15
+ "address",
16
+ "dateOfBirth",
17
+ "dob",
18
+ "email",
19
+ "firstName",
20
+ "lastName",
21
+ "phone",
22
+ "phoneNumber",
23
+ "socialSecurityNumber",
24
+ "ssn"
25
+ ]);
26
+ const SYSTEM_FIELDS = /* @__PURE__ */ new Set(["_creationTime", "_id"]);
27
+ const ownershipOrPiiColumns = (table) => table.fields.filter((field) => OWNERSHIP_FIELD_NAMES.has(field) || PII_FIELD_NAMES.has(field));
28
+ const tableColumnSet = (table) => /* @__PURE__ */ new Set([...SYSTEM_FIELDS, ...table.fields]);
29
+ const isPublicWrite = (procedure) => procedure.visibility === "public" && (procedure.kind === "mutation" || procedure.kind === "action");
30
+
31
+ export { PII_FIELD_NAMES as P, isPublicWrite as i, ownershipOrPiiColumns as o, tableColumnSet as t };
@@ -0,0 +1,60 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const HOT_SHARE_THRESHOLD = 0.5;
4
+ const MIN_TOTAL_REQUESTS = 50;
5
+ const hotShard = {
6
+ categories: ["PERFORMANCE"],
7
+ description: "One shard is taking a disproportionate share of a sharded function's traffic, so the busy shard becomes a single-DO bottleneck while its siblings sit idle — the hot-key skew sharding is meant to avoid.",
8
+ facing: "EXTERNAL",
9
+ level: "WARN",
10
+ name: "hot_shard",
11
+ remediation: "Re-shard on a higher-cardinality / more evenly-distributed key, or split the hot entity's state, so request volume spreads across shards instead of concentrating on one.",
12
+ run: (context) => {
13
+ const active = (context.shardTraffic ?? []).filter((shard) => shard.requests > 0);
14
+ const byGroup = /* @__PURE__ */ new Map();
15
+ for (const shard of active) {
16
+ const groupKey = shard.group ?? "";
17
+ const bucket = byGroup.get(groupKey);
18
+ if (bucket === void 0) {
19
+ byGroup.set(groupKey, [shard]);
20
+ } else {
21
+ bucket.push(shard);
22
+ }
23
+ }
24
+ const findings = [];
25
+ for (const [groupKey, groupShards] of byGroup) {
26
+ const totalRequests = groupShards.reduce((sum, shard) => sum + shard.requests, 0);
27
+ if (groupShards.length < 2 || totalRequests < MIN_TOTAL_REQUESTS) {
28
+ continue;
29
+ }
30
+ for (const shard of groupShards) {
31
+ const share = shard.requests / totalRequests;
32
+ if (share < HOT_SHARE_THRESHOLD) {
33
+ continue;
34
+ }
35
+ const scope = shard.group !== void 0 && shard.group !== "" ? `"${shard.group}" ` : "";
36
+ const label = shard.shardKey === "" ? "the root shard" : `shard "${shard.shardKey}"`;
37
+ const percent = Math.round(share * 100);
38
+ findings.push(
39
+ emit(hotShard, {
40
+ cacheKey: `hot_shard:${groupKey}:${shard.shardKey}`,
41
+ detail: `${scope}${label} handled ${shard.requests.toString()} of ${totalRequests.toString()} requests (${percent.toString()}%) across ${groupShards.length.toString()} shards — a hot-key skew. Re-shard on a more evenly-distributed key.`,
42
+ metadata: {
43
+ group: shard.group,
44
+ requests: shard.requests,
45
+ shardCount: groupShards.length,
46
+ shardKey: shard.shardKey,
47
+ share,
48
+ totalRequests
49
+ }
50
+ })
51
+ );
52
+ }
53
+ }
54
+ return findings;
55
+ },
56
+ source: "runtime",
57
+ title: "Hot shard"
58
+ };
59
+
60
+ export { hotShard as default };
@@ -0,0 +1,35 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const surfaceLabel = (kind, method) => kind === "httpRoute" && method ? `\`httpRoute\` ${method} handler` : "`httpAction` handler";
4
+ const httpActionMissingAuthGuard = {
5
+ categories: ["SECURITY"],
6
+ description: "An `httpAction`/`httpRoute` handler performs a side effect (`ctx.runMutation`/`ctx.runAction`/a `ctx.db` write) but never reads `ctx.auth` — an unauthenticated HTTP endpoint driving a state change, bypassing the identity/RLS checks that guard the rest of the app.",
7
+ facing: "EXTERNAL",
8
+ level: "WARN",
9
+ name: "http_action_missing_auth_guard",
10
+ remediation: "Read `ctx.auth` in the handler before the side effect — call `await ctx.auth.getIdentity()` (or check `ctx.auth.userId`) and reject unauthenticated/unauthorized requests, or forward through a `mutation`/`action` whose RLS policies enforce access. If the endpoint is intentionally public (e.g. a signed webhook), verify the provider signature before the write.",
11
+ run: (context) => {
12
+ if (context.httpActionGuards === void 0) {
13
+ return [];
14
+ }
15
+ return context.httpActionGuards.filter((row) => !row.readsAuth).map((row) => {
16
+ const surface = surfaceLabel(row.kind, row.method);
17
+ return emit(httpActionMissingAuthGuard, {
18
+ cacheKey: `http_action_missing_auth_guard:${row.file}:${row.line.toString()}`,
19
+ detail: `${surface} \`${row.exportName}\` (${row.file}:${row.line.toString()}) calls \`ctx.${row.sideEffect}\` but never reads \`ctx.auth\` — an anonymous caller can drive this write. Authenticate the request before the side effect.`,
20
+ metadata: {
21
+ exportName: row.exportName,
22
+ file: row.file,
23
+ kind: row.kind,
24
+ line: row.line,
25
+ sideEffect: row.sideEffect,
26
+ ...row.method ? { method: row.method } : {}
27
+ }
28
+ });
29
+ });
30
+ },
31
+ source: "static",
32
+ title: "Unauthenticated HTTP handler performs a side effect"
33
+ };
34
+
35
+ export { httpActionMissingAuthGuard as default };