@lunora/advisor 0.0.0 → 1.0.0-alpha.10
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 +105 -0
- package/README.md +130 -9
- package/__assets__/package-og.svg +14 -0
- package/dist/index.d.mts +1680 -0
- package/dist/index.d.ts +1680 -0
- package/dist/index.mjs +89 -0
- package/dist/packem_shared/AE_METRIC_EVENTS-DexctYv6.mjs +85 -0
- package/dist/packem_shared/adminRouteWithoutGuard-UUGBkAjU.mjs +33 -0
- package/dist/packem_shared/authApiCallWithoutHeaders-BeJhCZaf.mjs +38 -0
- package/dist/packem_shared/circularFk-B2freHrP.mjs +84 -0
- package/dist/packem_shared/constraintValidator-Dr9Py3FD.mjs +186 -0
- package/dist/packem_shared/containerOversizedInstance-5U1VKPRM.mjs +36 -0
- package/dist/packem_shared/containerPublicInternet-CuNerJE5.mjs +30 -0
- package/dist/packem_shared/duplicateIndex-BOublMSt.mjs +57 -0
- package/dist/packem_shared/emptyIndex-BX8EuEY7.mjs +32 -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/filterWithoutIndex-BYVeJaSs.mjs +31 -0
- package/dist/packem_shared/finding-Dm_zvzS1.mjs +16 -0
- package/dist/packem_shared/fk-index-IUK1ukgs.mjs +7 -0
- package/dist/packem_shared/fromServerSchema-WVRvXPy8.mjs +56 -0
- package/dist/packem_shared/hardcodedSecret-W2pz1UZB.mjs +35 -0
- package/dist/packem_shared/helpers-DNCkMWZQ.mjs +4 -0
- package/dist/packem_shared/hotShard-Ir5D0B6J.mjs +48 -0
- package/dist/packem_shared/hyperdriveOutsideAction-BgZqX7Xg.mjs +30 -0
- package/dist/packem_shared/indexReferencesUnknownField-DH0_dbUY.mjs +36 -0
- package/dist/packem_shared/indexUtilization-B5DMQ3bI.mjs +45 -0
- package/dist/packem_shared/maskUncoveredPiiColumn-DjGIPG6M.mjs +61 -0
- package/dist/packem_shared/mutatorFullRowReplace-BJnNDaIV.mjs +26 -0
- package/dist/packem_shared/nondeterministicQueryMutation-GXES1fLp.mjs +35 -0
- package/dist/packem_shared/policyReferencesUnknownTable-DtaIEovd.mjs +38 -0
- package/dist/packem_shared/publicArgumentUsesAny-C71b2NCf.mjs +32 -0
- package/dist/packem_shared/publicMutationWithoutRatelimit-xBpJ6GWK.mjs +36 -0
- package/dist/packem_shared/r2sqlOutsideAction-CtqxvMuV.mjs +30 -0
- package/dist/packem_shared/relationReferencesUnknownField-YznyXt_7.mjs +54 -0
- package/dist/packem_shared/relationReferencesUnknownTable-DrorpKYe.mjs +33 -0
- package/dist/packem_shared/rlsUncoveredTable-CxEfZ5eZ.mjs +56 -0
- package/dist/packem_shared/shapeTargetsGlobalTable-DHrf4Koi.mjs +34 -0
- package/dist/packem_shared/shapeUnknownTable-C8aDWFoe.mjs +34 -0
- package/dist/packem_shared/sqlInjectionRisk-zwytYGLt.mjs +26 -0
- package/dist/packem_shared/tableWithoutInsert-CbbaYIP4.mjs +34 -0
- package/dist/packem_shared/unboundedStringArgument-DThg2-wt.mjs +32 -0
- package/dist/packem_shared/unindexedForeignKey-BgJbKyqK.mjs +45 -0
- package/dist/packem_shared/unindexedRelationTarget-D6eyj6Xx.mjs +53 -0
- package/dist/packem_shared/userCreatingMutationWithoutCaptcha-CH31YsUZ.mjs +42 -0
- package/dist/packem_shared/workflowDuplicateStepName-ioBxPBCy.mjs +48 -0
- package/dist/packem_shared/workflowUnknownTarget-Cdd7WhKQ.mjs +34 -0
- package/dist/packem_shared/workflowUnused-D0jHxdz9.mjs +38 -0
- package/package.json +40 -17
|
@@ -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,31 @@
|
|
|
1
|
+
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
+
|
|
3
|
+
const filterWithoutIndex = {
|
|
4
|
+
categories: ["PERFORMANCE"],
|
|
5
|
+
description: "A query calls `.filter()` without a `.withIndex()` / `.withSearchIndex()`, so it loads every row in the table and filters in memory — a full table scan that gets linearly slower as the table grows.",
|
|
6
|
+
facing: "EXTERNAL",
|
|
7
|
+
level: "WARN",
|
|
8
|
+
name: "filter_without_index",
|
|
9
|
+
remediation: 'Narrow the read with `.withIndex("name", (q) => q.eq(...))` first, then `.filter()` only for what the index cannot express.',
|
|
10
|
+
run: (context) => {
|
|
11
|
+
const findings = [];
|
|
12
|
+
for (const read of context.queries ?? []) {
|
|
13
|
+
if (!read.hasFilter || read.hasIndex || read.table === "") {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const location = read.line > 0 ? `${read.file}:${read.line.toString()}` : read.file;
|
|
17
|
+
findings.push(
|
|
18
|
+
emit(filterWithoutIndex, {
|
|
19
|
+
cacheKey: `filter_without_index:${read.file}:${read.line.toString()}:${read.table}`,
|
|
20
|
+
detail: `Query on "${read.table}" at ${location} calls .filter() without an index — it loads every row of "${read.table}" and filters in memory.`,
|
|
21
|
+
metadata: { file: read.file, line: read.line, table: read.table }
|
|
22
|
+
})
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return findings;
|
|
26
|
+
},
|
|
27
|
+
source: "static",
|
|
28
|
+
title: "Filter without index"
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export { filterWithoutIndex as default };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const emit = (lint, occurrence) => {
|
|
2
|
+
return {
|
|
3
|
+
cacheKey: occurrence.cacheKey,
|
|
4
|
+
categories: occurrence.categories ?? lint.categories,
|
|
5
|
+
description: lint.description,
|
|
6
|
+
detail: occurrence.detail,
|
|
7
|
+
facing: occurrence.facing ?? lint.facing,
|
|
8
|
+
level: occurrence.level ?? lint.level,
|
|
9
|
+
metadata: occurrence.metadata,
|
|
10
|
+
name: lint.name,
|
|
11
|
+
remediation: lint.remediation,
|
|
12
|
+
title: lint.title
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export { emit as e };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const PRIMARY_KEY = "_id";
|
|
2
|
+
const suggestIndexName = (field) => `by${field.charAt(0).toUpperCase()}${field.slice(1)}`;
|
|
3
|
+
const leadingIndexedColumns = (table) => new Set(
|
|
4
|
+
table.indexes.filter((index) => index.kind === "index").map((index) => index.fields[0]).filter((field) => field !== void 0)
|
|
5
|
+
);
|
|
6
|
+
|
|
7
|
+
export { PRIMARY_KEY as P, leadingIndexedColumns as l, suggestIndexName as s };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const fromServerSchema = (schema) => {
|
|
2
|
+
return {
|
|
3
|
+
tables: Object.entries(schema.tables).map(([name, table]) => {
|
|
4
|
+
const indexes = [
|
|
5
|
+
...table.indexes.map((index) => {
|
|
6
|
+
return { fields: index.fields, kind: "index", name: index.name, unique: index.unique };
|
|
7
|
+
}),
|
|
8
|
+
...table.searchIndexes.map((index) => {
|
|
9
|
+
return { fields: [index.field, ...index.filterFields ?? []], kind: "search", name: index.name };
|
|
10
|
+
}),
|
|
11
|
+
...table.rankIndexes.map((index) => {
|
|
12
|
+
return { fields: [...index.sortBy.map((key) => key.field), ...index.partitionBy ?? []], kind: "rank", name: index.name };
|
|
13
|
+
}),
|
|
14
|
+
...table.vectorIndexes.map((index) => {
|
|
15
|
+
return { fields: [index.field], kind: "vector", name: index.name };
|
|
16
|
+
})
|
|
17
|
+
];
|
|
18
|
+
const optionalFields = /* @__PURE__ */ new Set();
|
|
19
|
+
for (const [fieldName, validator] of Object.entries(table.shape)) {
|
|
20
|
+
if (validator.kind === "optional") {
|
|
21
|
+
optionalFields.add(fieldName);
|
|
22
|
+
} else {
|
|
23
|
+
const column = validator._meta?.column;
|
|
24
|
+
if (column?.notNull === false) {
|
|
25
|
+
optionalFields.add(fieldName);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
externallyManaged: table.isExternallyManaged ?? false,
|
|
31
|
+
externalSource: table.externalSource ? {
|
|
32
|
+
hasReconcile: table.externalSource.reconcileEveryMs !== void 0,
|
|
33
|
+
hasTenantBy: table.externalSource.tenantBy !== void 0,
|
|
34
|
+
mode: table.externalSource.mode
|
|
35
|
+
} : void 0,
|
|
36
|
+
fields: Object.keys(table.shape),
|
|
37
|
+
indexes,
|
|
38
|
+
name,
|
|
39
|
+
optionalFields,
|
|
40
|
+
shardKind: table.shardMode.kind,
|
|
41
|
+
relations: Object.entries(table.relationMap).map(([accessor, relation]) => {
|
|
42
|
+
return {
|
|
43
|
+
field: relation.field,
|
|
44
|
+
kind: relation.kind,
|
|
45
|
+
name: accessor,
|
|
46
|
+
onDelete: relation.onDelete,
|
|
47
|
+
references: relation.references,
|
|
48
|
+
table: relation.table
|
|
49
|
+
};
|
|
50
|
+
})
|
|
51
|
+
};
|
|
52
|
+
})
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export { fromServerSchema };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
+
|
|
3
|
+
const hardcodedSecret = {
|
|
4
|
+
categories: ["SECURITY"],
|
|
5
|
+
description: "A secret-shaped string literal (live API key, access key, private key, or high-entropy token) is hard-coded in the source. Committed secrets leak via clone/fork/history and force a redeploy to rotate.",
|
|
6
|
+
facing: "INTERNAL",
|
|
7
|
+
level: "ERROR",
|
|
8
|
+
name: "hardcoded_secret",
|
|
9
|
+
remediation: 'Move the secret out of source: set it locally in `.dev.vars` (`lunora env set <NAME> "<value>"`) and in production with `wrangler secret put <NAME>`, then read it at runtime from `env.<NAME>`. Rotate the exposed value — assume it is already compromised.',
|
|
10
|
+
run: (context) => {
|
|
11
|
+
if (context.secretLiterals === void 0) {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
const findings = [];
|
|
15
|
+
const occurrenceCount = /* @__PURE__ */ new Map();
|
|
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
|
+
findings.push(
|
|
22
|
+
emit(hardcodedSecret, {
|
|
23
|
+
cacheKey: `hardcoded_secret:${baseKey}${occurrenceSuffix}`,
|
|
24
|
+
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
|
+
metadata: { file: secret.file, kind: secret.kind, line: secret.line, preview: secret.preview }
|
|
26
|
+
})
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return findings;
|
|
30
|
+
},
|
|
31
|
+
source: "static",
|
|
32
|
+
title: "Hard-coded secret in source"
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export { hardcodedSecret as default };
|
|
@@ -0,0 +1,48 @@
|
|
|
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 totalRequests = active.reduce((sum, shard) => sum + shard.requests, 0);
|
|
15
|
+
if (active.length < 2 || totalRequests < MIN_TOTAL_REQUESTS) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
const findings = [];
|
|
19
|
+
for (const shard of active) {
|
|
20
|
+
const share = shard.requests / totalRequests;
|
|
21
|
+
if (share < HOT_SHARE_THRESHOLD) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const scope = shard.group !== void 0 && shard.group !== "" ? `"${shard.group}" ` : "";
|
|
25
|
+
const label = shard.shardKey === "" ? "the root shard" : `shard "${shard.shardKey}"`;
|
|
26
|
+
const percent = Math.round(share * 100);
|
|
27
|
+
findings.push(
|
|
28
|
+
emit(hotShard, {
|
|
29
|
+
cacheKey: `hot_shard:${shard.group ?? ""}:${shard.shardKey}`,
|
|
30
|
+
detail: `${scope}${label} handled ${shard.requests.toString()} of ${totalRequests.toString()} requests (${percent.toString()}%) across ${active.length.toString()} shards — a hot-key skew. Re-shard on a more evenly-distributed key.`,
|
|
31
|
+
metadata: {
|
|
32
|
+
group: shard.group,
|
|
33
|
+
requests: shard.requests,
|
|
34
|
+
shardCount: active.length,
|
|
35
|
+
shardKey: shard.shardKey,
|
|
36
|
+
share,
|
|
37
|
+
totalRequests
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return findings;
|
|
43
|
+
},
|
|
44
|
+
source: "runtime",
|
|
45
|
+
title: "Hot shard"
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export { hotShard as default };
|
|
@@ -0,0 +1,30 @@
|
|
|
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 };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
+
import { t as tableColumnSet } from './helpers-DNCkMWZQ.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 };
|
|
@@ -0,0 +1,45 @@
|
|
|
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 };
|
|
@@ -0,0 +1,61 @@
|
|
|
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 };
|
|
@@ -0,0 +1,26 @@
|
|
|
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 };
|
|
@@ -0,0 +1,35 @@
|
|
|
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 };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
+
|
|
3
|
+
const policyReferencesUnknownTable = {
|
|
4
|
+
categories: ["SECURITY"],
|
|
5
|
+
description: "An RLS policy is bound to a `table` name that does not exist in the schema. The `rls()` middleware never matches it, so the table the policy was meant to protect is left ungated — reads return unrestricted rows and writes are allowed.",
|
|
6
|
+
facing: "EXTERNAL",
|
|
7
|
+
level: "WARN",
|
|
8
|
+
name: "policy_references_unknown_table",
|
|
9
|
+
remediation: "Fix the policy's `table` to a real table name (check for a typo or a table that was renamed/removed). If the table is gone, delete the dead policy.",
|
|
10
|
+
run: (context) => {
|
|
11
|
+
if (context.rlsProcedures === void 0) {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
const knownTables = new Set(context.schema.tables.map((table) => table.name));
|
|
15
|
+
const reported = /* @__PURE__ */ new Set();
|
|
16
|
+
const findings = [];
|
|
17
|
+
for (const procedure of context.rlsProcedures) {
|
|
18
|
+
for (const table of procedure.rlsTables) {
|
|
19
|
+
if (table === "" || knownTables.has(table) || reported.has(table)) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
reported.add(table);
|
|
23
|
+
findings.push(
|
|
24
|
+
emit(policyReferencesUnknownTable, {
|
|
25
|
+
cacheKey: `policy_references_unknown_table:${table}`,
|
|
26
|
+
detail: `An RLS policy in \`${procedure.exportName}\` (${procedure.file}) is bound to table \`${table}\`, which is not declared in the schema. The policy never matches, so \`${table}\` — if it exists under another name — is left ungated.`,
|
|
27
|
+
metadata: { exportName: procedure.exportName, file: procedure.file, table }
|
|
28
|
+
})
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return findings;
|
|
33
|
+
},
|
|
34
|
+
source: "static",
|
|
35
|
+
title: "RLS policy bound to an unknown table"
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export { policyReferencesUnknownTable as default };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
+
|
|
3
|
+
const publicArgumentUsesAny = {
|
|
4
|
+
categories: ["SECURITY"],
|
|
5
|
+
description: "A public procedure declares a `v.any()` argument. `v.any()` accepts arbitrary untyped input from an untrusted client — defeating validation and opening injection / oversized-payload abuse.",
|
|
6
|
+
facing: "EXTERNAL",
|
|
7
|
+
level: "WARN",
|
|
8
|
+
name: "public_arg_uses_any",
|
|
9
|
+
remediation: "Replace `v.any()` with a precise validator — `v.object({...})`, `v.string()`, `v.union(...)`, etc. If the shape is genuinely dynamic, model the known variants with `v.union` rather than accepting anything.",
|
|
10
|
+
run: (context) => {
|
|
11
|
+
if (context.argValidators === void 0) {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
const findings = [];
|
|
15
|
+
for (const procedure of context.argValidators) {
|
|
16
|
+
for (const argument of procedure.anyArgs) {
|
|
17
|
+
findings.push(
|
|
18
|
+
emit(publicArgumentUsesAny, {
|
|
19
|
+
cacheKey: `public_arg_uses_any:${procedure.file}:${procedure.exportName}:${argument}`,
|
|
20
|
+
detail: `Arg \`${argument}\` of public procedure \`${procedure.exportName}\` (${procedure.file}:${procedure.line.toString()}) is \`v.any()\` — untrusted input with no validation. Give it a precise validator.`,
|
|
21
|
+
metadata: { argument, exportName: procedure.exportName, file: procedure.file, line: procedure.line }
|
|
22
|
+
})
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return findings;
|
|
27
|
+
},
|
|
28
|
+
source: "static",
|
|
29
|
+
title: "Public argument uses v.any()"
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export { publicArgumentUsesAny as default };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
+
|
|
3
|
+
const SENSITIVE_NAME_RE = /contact|forgot|login|magic|otp|register|reset|signin|signup|subscribe|verify/iu;
|
|
4
|
+
const publicMutationWithoutRatelimit = {
|
|
5
|
+
categories: ["SECURITY"],
|
|
6
|
+
description: "A public `mutation`/`action` has no `rateLimit` middleware. Publicly-callable writes are flood and brute-force targets — an attacker can exhaust writes, mail quota, or credits, or guess credentials on auth-shaped endpoints.",
|
|
7
|
+
facing: "EXTERNAL",
|
|
8
|
+
level: "WARN",
|
|
9
|
+
name: "public_mutation_without_ratelimit",
|
|
10
|
+
remediation: 'Attach a rate limit: `.use(rateLimit(limiter, "<bucket>"))` from `@lunora/ratelimit`, or wrap the recommended public-procedure guards with `.use(protectPublic({ rateLimit, captcha }))` from `@lunora/server`. Genuinely-open writes can be acknowledged by adding a permissive limiter.',
|
|
11
|
+
run: (context) => {
|
|
12
|
+
if (context.procedureProtections === void 0) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
const findings = [];
|
|
16
|
+
for (const procedure of context.procedureProtections) {
|
|
17
|
+
const isPublicWrite = procedure.visibility === "public" && (procedure.kind === "mutation" || procedure.kind === "action");
|
|
18
|
+
if (!isPublicWrite || procedure.usesRateLimit) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const sensitive = SENSITIVE_NAME_RE.test(procedure.exportName);
|
|
22
|
+
findings.push(
|
|
23
|
+
emit(publicMutationWithoutRatelimit, {
|
|
24
|
+
cacheKey: `public_mutation_without_ratelimit:${procedure.file}:${procedure.exportName}`,
|
|
25
|
+
detail: `Public ${procedure.kind} \`${procedure.exportName}\` (${procedure.file}) has no rate limit${sensitive ? " — its name suggests an auth/abuse-sensitive endpoint, so this is high-risk" : ""}. Add \`.use(rateLimit(...))\` or \`.use(protectPublic({ rateLimit }))\`.`,
|
|
26
|
+
metadata: { exportName: procedure.exportName, file: procedure.file, kind: procedure.kind, sensitive }
|
|
27
|
+
})
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return findings;
|
|
31
|
+
},
|
|
32
|
+
source: "static",
|
|
33
|
+
title: "Public write without a rate limit"
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export { publicMutationWithoutRatelimit as default };
|