@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.
Files changed (49) hide show
  1. package/LICENSE.md +105 -0
  2. package/README.md +130 -9
  3. package/__assets__/package-og.svg +14 -0
  4. package/dist/index.d.mts +1680 -0
  5. package/dist/index.d.ts +1680 -0
  6. package/dist/index.mjs +89 -0
  7. package/dist/packem_shared/AE_METRIC_EVENTS-DexctYv6.mjs +85 -0
  8. package/dist/packem_shared/adminRouteWithoutGuard-UUGBkAjU.mjs +33 -0
  9. package/dist/packem_shared/authApiCallWithoutHeaders-BeJhCZaf.mjs +38 -0
  10. package/dist/packem_shared/circularFk-B2freHrP.mjs +84 -0
  11. package/dist/packem_shared/constraintValidator-Dr9Py3FD.mjs +186 -0
  12. package/dist/packem_shared/containerOversizedInstance-5U1VKPRM.mjs +36 -0
  13. package/dist/packem_shared/containerPublicInternet-CuNerJE5.mjs +30 -0
  14. package/dist/packem_shared/duplicateIndex-BOublMSt.mjs +57 -0
  15. package/dist/packem_shared/emptyIndex-BX8EuEY7.mjs +32 -0
  16. package/dist/packem_shared/externalSourceOnGlobal-Bg-NfCX9.mjs +30 -0
  17. package/dist/packem_shared/externalSourceUnscoped-5vT-Bup3.mjs +44 -0
  18. package/dist/packem_shared/filterWithoutIndex-BYVeJaSs.mjs +31 -0
  19. package/dist/packem_shared/finding-Dm_zvzS1.mjs +16 -0
  20. package/dist/packem_shared/fk-index-IUK1ukgs.mjs +7 -0
  21. package/dist/packem_shared/fromServerSchema-WVRvXPy8.mjs +56 -0
  22. package/dist/packem_shared/hardcodedSecret-W2pz1UZB.mjs +35 -0
  23. package/dist/packem_shared/helpers-DNCkMWZQ.mjs +4 -0
  24. package/dist/packem_shared/hotShard-Ir5D0B6J.mjs +48 -0
  25. package/dist/packem_shared/hyperdriveOutsideAction-BgZqX7Xg.mjs +30 -0
  26. package/dist/packem_shared/indexReferencesUnknownField-DH0_dbUY.mjs +36 -0
  27. package/dist/packem_shared/indexUtilization-B5DMQ3bI.mjs +45 -0
  28. package/dist/packem_shared/maskUncoveredPiiColumn-DjGIPG6M.mjs +61 -0
  29. package/dist/packem_shared/mutatorFullRowReplace-BJnNDaIV.mjs +26 -0
  30. package/dist/packem_shared/nondeterministicQueryMutation-GXES1fLp.mjs +35 -0
  31. package/dist/packem_shared/policyReferencesUnknownTable-DtaIEovd.mjs +38 -0
  32. package/dist/packem_shared/publicArgumentUsesAny-C71b2NCf.mjs +32 -0
  33. package/dist/packem_shared/publicMutationWithoutRatelimit-xBpJ6GWK.mjs +36 -0
  34. package/dist/packem_shared/r2sqlOutsideAction-CtqxvMuV.mjs +30 -0
  35. package/dist/packem_shared/relationReferencesUnknownField-YznyXt_7.mjs +54 -0
  36. package/dist/packem_shared/relationReferencesUnknownTable-DrorpKYe.mjs +33 -0
  37. package/dist/packem_shared/rlsUncoveredTable-CxEfZ5eZ.mjs +56 -0
  38. package/dist/packem_shared/shapeTargetsGlobalTable-DHrf4Koi.mjs +34 -0
  39. package/dist/packem_shared/shapeUnknownTable-C8aDWFoe.mjs +34 -0
  40. package/dist/packem_shared/sqlInjectionRisk-zwytYGLt.mjs +26 -0
  41. package/dist/packem_shared/tableWithoutInsert-CbbaYIP4.mjs +34 -0
  42. package/dist/packem_shared/unboundedStringArgument-DThg2-wt.mjs +32 -0
  43. package/dist/packem_shared/unindexedForeignKey-BgJbKyqK.mjs +45 -0
  44. package/dist/packem_shared/unindexedRelationTarget-D6eyj6Xx.mjs +53 -0
  45. package/dist/packem_shared/userCreatingMutationWithoutCaptcha-CH31YsUZ.mjs +42 -0
  46. package/dist/packem_shared/workflowDuplicateStepName-ioBxPBCy.mjs +48 -0
  47. package/dist/packem_shared/workflowUnknownTarget-Cdd7WhKQ.mjs +34 -0
  48. package/dist/packem_shared/workflowUnused-D0jHxdz9.mjs +38 -0
  49. package/package.json +40 -17
package/dist/index.mjs ADDED
@@ -0,0 +1,89 @@
1
+ import constraintValidator from './packem_shared/constraintValidator-Dr9Py3FD.mjs';
2
+ import hotShard from './packem_shared/hotShard-Ir5D0B6J.mjs';
3
+ import indexUtilization from './packem_shared/indexUtilization-B5DMQ3bI.mjs';
4
+ import adminRouteWithoutGuard from './packem_shared/adminRouteWithoutGuard-UUGBkAjU.mjs';
5
+ import authApiCallWithoutHeaders from './packem_shared/authApiCallWithoutHeaders-BeJhCZaf.mjs';
6
+ import circularFk from './packem_shared/circularFk-B2freHrP.mjs';
7
+ import containerOversizedInstance from './packem_shared/containerOversizedInstance-5U1VKPRM.mjs';
8
+ import containerPublicInternet from './packem_shared/containerPublicInternet-CuNerJE5.mjs';
9
+ import duplicateIndex from './packem_shared/duplicateIndex-BOublMSt.mjs';
10
+ import emptyIndex from './packem_shared/emptyIndex-BX8EuEY7.mjs';
11
+ import filterWithoutIndex from './packem_shared/filterWithoutIndex-BYVeJaSs.mjs';
12
+ import hardcodedSecret from './packem_shared/hardcodedSecret-W2pz1UZB.mjs';
13
+ import hyperdriveOutsideAction from './packem_shared/hyperdriveOutsideAction-BgZqX7Xg.mjs';
14
+ import indexReferencesUnknownField from './packem_shared/indexReferencesUnknownField-DH0_dbUY.mjs';
15
+ import maskUncoveredPiiColumn from './packem_shared/maskUncoveredPiiColumn-DjGIPG6M.mjs';
16
+ import mutatorFullRowReplace from './packem_shared/mutatorFullRowReplace-BJnNDaIV.mjs';
17
+ import nondeterministicQueryMutation from './packem_shared/nondeterministicQueryMutation-GXES1fLp.mjs';
18
+ import policyReferencesUnknownTable from './packem_shared/policyReferencesUnknownTable-DtaIEovd.mjs';
19
+ import publicArgumentUsesAny from './packem_shared/publicArgumentUsesAny-C71b2NCf.mjs';
20
+ import publicMutationWithoutRatelimit from './packem_shared/publicMutationWithoutRatelimit-xBpJ6GWK.mjs';
21
+ import r2sqlOutsideAction from './packem_shared/r2sqlOutsideAction-CtqxvMuV.mjs';
22
+ import relationReferencesUnknownField from './packem_shared/relationReferencesUnknownField-YznyXt_7.mjs';
23
+ import relationReferencesUnknownTable from './packem_shared/relationReferencesUnknownTable-DrorpKYe.mjs';
24
+ import rlsUncoveredTable from './packem_shared/rlsUncoveredTable-CxEfZ5eZ.mjs';
25
+ import shapeTargetsGlobalTable from './packem_shared/shapeTargetsGlobalTable-DHrf4Koi.mjs';
26
+ import shapeUnknownTable from './packem_shared/shapeUnknownTable-C8aDWFoe.mjs';
27
+ import sqlInjectionRisk from './packem_shared/sqlInjectionRisk-zwytYGLt.mjs';
28
+ import tableWithoutInsert from './packem_shared/tableWithoutInsert-CbbaYIP4.mjs';
29
+ import unboundedStringArgument from './packem_shared/unboundedStringArgument-DThg2-wt.mjs';
30
+ import unindexedForeignKey from './packem_shared/unindexedForeignKey-BgJbKyqK.mjs';
31
+ import unindexedRelationTarget from './packem_shared/unindexedRelationTarget-D6eyj6Xx.mjs';
32
+ import userCreatingMutationWithoutCaptcha from './packem_shared/userCreatingMutationWithoutCaptcha-CH31YsUZ.mjs';
33
+ import workflowDuplicateStepName from './packem_shared/workflowDuplicateStepName-ioBxPBCy.mjs';
34
+ import workflowUnknownTarget from './packem_shared/workflowUnknownTarget-Cdd7WhKQ.mjs';
35
+ import workflowUnused from './packem_shared/workflowUnused-D0jHxdz9.mjs';
36
+ export { AE_METRIC_EVENTS, loadAnalyticsRuntimeMetrics } from './packem_shared/AE_METRIC_EVENTS-DexctYv6.mjs';
37
+ export { default as externalSourceOnGlobal } from './packem_shared/externalSourceOnGlobal-Bg-NfCX9.mjs';
38
+ export { default as externalSourceUnscoped } from './packem_shared/externalSourceUnscoped-5vT-Bup3.mjs';
39
+ export { fromServerSchema } from './packem_shared/fromServerSchema-WVRvXPy8.mjs';
40
+
41
+ const STATIC_LINTS = [
42
+ indexReferencesUnknownField,
43
+ relationReferencesUnknownTable,
44
+ relationReferencesUnknownField,
45
+ workflowUnknownTarget,
46
+ workflowDuplicateStepName,
47
+ shapeUnknownTable,
48
+ emptyIndex,
49
+ circularFk,
50
+ unindexedForeignKey,
51
+ unindexedRelationTarget,
52
+ duplicateIndex,
53
+ tableWithoutInsert,
54
+ workflowUnused,
55
+ filterWithoutIndex,
56
+ shapeTargetsGlobalTable,
57
+ mutatorFullRowReplace,
58
+ nondeterministicQueryMutation,
59
+ hyperdriveOutsideAction,
60
+ r2sqlOutsideAction,
61
+ authApiCallWithoutHeaders,
62
+ policyReferencesUnknownTable,
63
+ rlsUncoveredTable,
64
+ maskUncoveredPiiColumn,
65
+ containerOversizedInstance,
66
+ containerPublicInternet,
67
+ publicMutationWithoutRatelimit,
68
+ userCreatingMutationWithoutCaptcha,
69
+ publicArgumentUsesAny,
70
+ unboundedStringArgument,
71
+ hardcodedSecret,
72
+ sqlInjectionRisk,
73
+ adminRouteWithoutGuard
74
+ ];
75
+ const RUNTIME_LINTS = [hotShard, indexUtilization, constraintValidator];
76
+ const ALL_LINTS = [...STATIC_LINTS, ...RUNTIME_LINTS];
77
+ const runAdvisor = (context, options = {}) => {
78
+ const lints = options.lints ?? ALL_LINTS;
79
+ const findings = [];
80
+ for (const lint of lints) {
81
+ if (options.source !== void 0 && lint.source !== options.source) {
82
+ continue;
83
+ }
84
+ findings.push(...lint.run(context));
85
+ }
86
+ return findings;
87
+ };
88
+
89
+ export { ALL_LINTS, RUNTIME_LINTS, STATIC_LINTS, adminRouteWithoutGuard, authApiCallWithoutHeaders, circularFk, constraintValidator, containerOversizedInstance, containerPublicInternet, duplicateIndex, emptyIndex, filterWithoutIndex, hardcodedSecret, hotShard, hyperdriveOutsideAction, indexReferencesUnknownField, indexUtilization, maskUncoveredPiiColumn, mutatorFullRowReplace, nondeterministicQueryMutation, policyReferencesUnknownTable, publicArgumentUsesAny, publicMutationWithoutRatelimit, r2sqlOutsideAction, relationReferencesUnknownField, relationReferencesUnknownTable, rlsUncoveredTable, runAdvisor, shapeTargetsGlobalTable, shapeUnknownTable, sqlInjectionRisk, tableWithoutInsert, unboundedStringArgument, unindexedForeignKey, unindexedRelationTarget, userCreatingMutationWithoutCaptcha, workflowDuplicateStepName, workflowUnknownTarget, workflowUnused };
@@ -0,0 +1,85 @@
1
+ const DATASET_NAME_PATTERN = /^[\w.-]+$/u;
2
+ const AE_METRIC_EVENTS = {
3
+ /** `lunora.index.hit` — one row per `(table, index)` use. `blob2`=table, `blob3`=index. */
4
+ indexHit: { event: "lunora.index.hit", index: "blob3", table: "blob2" },
5
+ /** `lunora.shard.request` — one row per shard dispatch. `blob2`=shardKey, `blob3`=group. */
6
+ shardRequest: { event: "lunora.shard.request", group: "blob3", shardKey: "blob2" },
7
+ /** `lunora.table.scan` — one row per full-scan. `blob2`=table. */
8
+ tableScan: { event: "lunora.table.scan", table: "blob2" }
9
+ };
10
+ const toCount = (value) => {
11
+ const numeric = typeof value === "number" ? value : Number(value);
12
+ return Number.isFinite(numeric) ? numeric : 0;
13
+ };
14
+ const toText = (value) => {
15
+ if (typeof value === "string") {
16
+ return value;
17
+ }
18
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
19
+ return String(value);
20
+ }
21
+ return "";
22
+ };
23
+ const assertDataset = (dataset) => {
24
+ if (!DATASET_NAME_PATTERN.test(dataset)) {
25
+ throw new Error(`@lunora/advisor: invalid Analytics Engine dataset name "${dataset}" — expected a bare table identifier.`);
26
+ }
27
+ };
28
+ const sqlString = (value) => `'${value.replaceAll("\\", "\\\\").replaceAll("'", "''")}'`;
29
+ const queryOrEmpty = async (source, sql) => {
30
+ try {
31
+ const result = await source.query(sql);
32
+ return result.rows;
33
+ } catch {
34
+ return [];
35
+ }
36
+ };
37
+ const loadShardTraffic = async (source, options) => {
38
+ const { event, group, shardKey } = AE_METRIC_EVENTS.shardRequest;
39
+ const groupFilter = options.group === void 0 ? "" : ` AND ${group} = ${sqlString(options.group)}`;
40
+ const sql = `SELECT ${shardKey} AS shardKey, ${group} AS shardGroup, sum(_sample_interval) AS requests FROM ${options.dataset} WHERE blob1 = ${sqlString(event)}${groupFilter} GROUP BY shardKey, shardGroup`;
41
+ const rows = await queryOrEmpty(source, sql);
42
+ return rows.map((row) => {
43
+ const shardGroup = toText(row.shardGroup);
44
+ return {
45
+ ...shardGroup === "" ? {} : { group: shardGroup },
46
+ requests: toCount(row.requests),
47
+ shardKey: toText(row.shardKey)
48
+ };
49
+ });
50
+ };
51
+ const loadTableScans = async (source, options) => {
52
+ const { event, table } = AE_METRIC_EVENTS.tableScan;
53
+ const sql = `SELECT ${table} AS scanTable, sum(_sample_interval) AS scans FROM ${options.dataset} WHERE blob1 = ${sqlString(event)} GROUP BY scanTable`;
54
+ const rows = await queryOrEmpty(source, sql);
55
+ return rows.map((row) => {
56
+ return { scans: toCount(row.scans), table: toText(row.scanTable) };
57
+ });
58
+ };
59
+ const loadIndexHits = async (source, options) => {
60
+ const { event, index, table } = AE_METRIC_EVENTS.indexHit;
61
+ const sql = `SELECT ${table} AS hitTable, ${index} AS hitIndex, sum(_sample_interval) AS reads FROM ${options.dataset} WHERE blob1 = ${sqlString(event)} GROUP BY hitTable, hitIndex`;
62
+ const rows = await queryOrEmpty(source, sql);
63
+ const hits = rows.map((row) => {
64
+ return { index: toText(row.hitIndex), reads: toCount(row.reads), table: toText(row.hitTable) };
65
+ });
66
+ if (options.declaredIndexes === void 0) {
67
+ return hits;
68
+ }
69
+ const seen = new Set(hits.map((hit) => `${hit.table}\0${hit.index}`));
70
+ const zeros = options.declaredIndexes.filter((declared) => !seen.has(`${declared.table}\0${declared.index}`)).map((declared) => {
71
+ return { index: declared.index, reads: 0, table: declared.table };
72
+ });
73
+ return [...hits, ...zeros];
74
+ };
75
+ const loadAnalyticsRuntimeMetrics = async (source, options) => {
76
+ assertDataset(options.dataset);
77
+ const [shardTraffic, tableScans, indexHits] = await Promise.all([
78
+ loadShardTraffic(source, options),
79
+ loadTableScans(source, options),
80
+ loadIndexHits(source, options)
81
+ ]);
82
+ return { indexHits, shardTraffic, tableScans };
83
+ };
84
+
85
+ export { AE_METRIC_EVENTS, loadAnalyticsRuntimeMetrics };
@@ -0,0 +1,33 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const adminRouteWithoutGuard = {
4
+ categories: ["SECURITY"],
5
+ description: "An `httpRoute` on an admin/privileged path has no visible auth/admin guard. REST routes aren't covered by RLS, so an unguarded `/admin/*` route is callable by anyone who reaches the URL.",
6
+ facing: "EXTERNAL",
7
+ level: "WARN",
8
+ name: "admin_route_without_guard",
9
+ remediation: "Assert an authenticated, authorized caller at the top of the handler — check `ctx.auth` / `getSession(...)`, or a `requireAdmin`-style guard — and reject (401/403) before doing privileged work. For machine callers, verify a secret with a timing-safe compare.",
10
+ run: (context) => {
11
+ if (context.adminRoutes === void 0) {
12
+ return [];
13
+ }
14
+ const findings = [];
15
+ for (const route of context.adminRoutes) {
16
+ if (route.usesGuard) {
17
+ continue;
18
+ }
19
+ findings.push(
20
+ emit(adminRouteWithoutGuard, {
21
+ cacheKey: `admin_route_without_guard:${route.file}:${route.method}:${route.path}`,
22
+ detail: `Route \`${route.method} ${route.path}\` (\`${route.exportName}\` in ${route.file}) is on an admin/privileged path but its handler shows no auth/admin guard. Assert an authorized caller before doing privileged work.`,
23
+ metadata: { exportName: route.exportName, file: route.file, method: route.method, path: route.path }
24
+ })
25
+ );
26
+ }
27
+ return findings;
28
+ },
29
+ source: "static",
30
+ title: "Admin route without an auth guard"
31
+ };
32
+
33
+ export { adminRouteWithoutGuard as default };
@@ -0,0 +1,38 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const authApiCallWithoutHeaders = {
4
+ categories: ["SECURITY"],
5
+ description: "A `ctx.authApi.<method>(...)` call omits `headers` — better-auth skips session authorization and runs the call with full server-to-server privileges, regardless of the caller's identity.",
6
+ facing: "EXTERNAL",
7
+ level: "WARN",
8
+ name: "auth_api_call_without_headers",
9
+ remediation: "Pass the inbound `headers` from the incoming request into every `ctx.authApi.*` call: `ctx.authApi.<method>({ body, headers: request.headers })`. See the `withAuthPlugins` middleware doc for details.",
10
+ run: (context) => {
11
+ if (context.authApiCalls === void 0) {
12
+ return [];
13
+ }
14
+ const findings = [];
15
+ const occurrenceCount = /* @__PURE__ */ new Map();
16
+ for (const call of context.authApiCalls) {
17
+ if (call.hasHeaders) {
18
+ continue;
19
+ }
20
+ const baseKey = `${call.file}:${call.line.toString()}:${call.method}`;
21
+ const occurrence = (occurrenceCount.get(baseKey) ?? 0) + 1;
22
+ occurrenceCount.set(baseKey, occurrence);
23
+ const occurrenceSuffix = occurrence > 1 ? `:${occurrence.toString()}` : "";
24
+ findings.push(
25
+ emit(authApiCallWithoutHeaders, {
26
+ cacheKey: `auth_api_call_without_headers:${baseKey}${occurrenceSuffix}`,
27
+ detail: `\`ctx.authApi.${call.method || "<method>"}(…)\` in ${call.exportName} (${call.file}:${call.line.toString()}) is called without \`headers\` — better-auth skips session authorization, so this runs with full privileges. Pass the inbound \`headers\`.`,
28
+ metadata: { exportName: call.exportName, file: call.file, line: call.line, method: call.method }
29
+ })
30
+ );
31
+ }
32
+ return findings;
33
+ },
34
+ source: "static",
35
+ title: "Privileged ctx.authApi call missing headers"
36
+ };
37
+
38
+ export { authApiCallWithoutHeaders as default };
@@ -0,0 +1,84 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const circularFk = {
4
+ categories: ["SCHEMA"],
5
+ 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.",
6
+ facing: "INTERNAL",
7
+ level: "WARN",
8
+ name: "circular_fk",
9
+ 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.",
10
+ run: (context) => {
11
+ const findings = [];
12
+ const edges = /* @__PURE__ */ new Map();
13
+ for (const table of context.schema.tables) {
14
+ for (const relation of table.relations) {
15
+ if (relation.kind !== "one") {
16
+ continue;
17
+ }
18
+ let targets = edges.get(table.name);
19
+ if (targets === void 0) {
20
+ targets = /* @__PURE__ */ new Set();
21
+ edges.set(table.name, targets);
22
+ }
23
+ targets.add(relation.table);
24
+ }
25
+ }
26
+ const onStack = /* @__PURE__ */ new Set();
27
+ const reported = /* @__PURE__ */ new Set();
28
+ const reportBackEdge = (neighbor, path) => {
29
+ const cycleStart = path.indexOf(neighbor);
30
+ if (cycleStart === -1) {
31
+ return;
32
+ }
33
+ const cycle = path.slice(cycleStart);
34
+ if (cycle.length < 2) {
35
+ return;
36
+ }
37
+ let minIndex = 0;
38
+ for (let index = 1; index < cycle.length; index += 1) {
39
+ if (cycle[index] < cycle[minIndex]) {
40
+ minIndex = index;
41
+ }
42
+ }
43
+ const canonical = [...cycle.slice(minIndex), ...cycle.slice(0, minIndex)];
44
+ const key = canonical.join("→");
45
+ if (reported.has(key)) {
46
+ return;
47
+ }
48
+ reported.add(key);
49
+ const displayPath = [...canonical, canonical[0]].join(" → ");
50
+ findings.push(
51
+ emit(circularFk, {
52
+ cacheKey: `circular_fk:${key}`,
53
+ detail: `Circular foreign-key dependency detected: ${displayPath}. This cycle can cause unexpected behavior during DELETE operations.`,
54
+ metadata: {
55
+ cycle: canonical,
56
+ path: displayPath,
57
+ tables: canonical
58
+ }
59
+ })
60
+ );
61
+ };
62
+ const dfs = (node, path) => {
63
+ onStack.add(node);
64
+ path.push(node);
65
+ for (const neighbor of edges.get(node) ?? []) {
66
+ if (onStack.has(neighbor)) {
67
+ reportBackEdge(neighbor, path);
68
+ } else {
69
+ dfs(neighbor, path);
70
+ }
71
+ }
72
+ path.pop();
73
+ onStack.delete(node);
74
+ };
75
+ for (const table of context.schema.tables) {
76
+ dfs(table.name, []);
77
+ }
78
+ return findings;
79
+ },
80
+ source: "static",
81
+ title: "Circular foreign-key dependency"
82
+ };
83
+
84
+ export { circularFk as default };
@@ -0,0 +1,186 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const MAX_EXAMPLE_IDS = 5;
4
+ const rowId = (row) => typeof row["_id"] === "string" ? row["_id"] : "?";
5
+ const truncatedSuffix = (capped, cap, phrase) => capped ? ` (sample capped at ${cap.toString()} rows — ${phrase})` : "";
6
+ const formatExamples = (ids, total) => {
7
+ const examples = ids.slice(0, MAX_EXAMPLE_IDS);
8
+ const more = total - examples.length;
9
+ const list = examples.map((id) => `"${id}"`).join(", ");
10
+ return more > 0 ? `${list} (+${more.toString()} more)` : list;
11
+ };
12
+ const checkFkRelation = (lint, sample, relation, targetSample) => {
13
+ if (relation.kind !== "one") {
14
+ return void 0;
15
+ }
16
+ const fkColumn = relation.field;
17
+ const danglingIds = [];
18
+ for (const row of sample.rows) {
19
+ const fkValue = row[fkColumn];
20
+ if (fkValue === null || fkValue === void 0) {
21
+ continue;
22
+ }
23
+ const fkString = String(fkValue);
24
+ if (!targetSample.existingIds.has(fkString)) {
25
+ danglingIds.push(rowId(row));
26
+ }
27
+ }
28
+ if (danglingIds.length === 0) {
29
+ return void 0;
30
+ }
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")}.`;
34
+ return emit(lint, {
35
+ cacheKey: `constraint_validator:fk:${sample.table}:${fkColumn}`,
36
+ detail,
37
+ metadata: {
38
+ cap,
39
+ column: fkColumn,
40
+ count: danglingIds.length,
41
+ examples: danglingIds.slice(0, MAX_EXAMPLE_IDS),
42
+ kind: "fk",
43
+ referencesTable: relation.table,
44
+ table: sample.table,
45
+ truncated: isTruncated
46
+ }
47
+ });
48
+ };
49
+ const checkNullField = (lint, sample, field, isRequired) => {
50
+ if (!isRequired) {
51
+ return void 0;
52
+ }
53
+ const nullIds = [];
54
+ for (const row of sample.rows) {
55
+ const value = row[field];
56
+ if (value === null || value === void 0) {
57
+ nullIds.push(rowId(row));
58
+ }
59
+ }
60
+ if (nullIds.length === 0) {
61
+ return void 0;
62
+ }
63
+ const detail = `Table "${sample.table}": ${nullIds.length.toString()} sampled row(s) have a null/missing value in column "${field}", which is declared as non-optional. Row ids: ${formatExamples(nullIds, nullIds.length)}${truncatedSuffix(sample.truncated, sample.cap, "more rows may exist beyond the window")}.`;
64
+ return emit(lint, {
65
+ cacheKey: `constraint_validator:null:${sample.table}:${field}`,
66
+ detail,
67
+ metadata: {
68
+ cap: sample.cap,
69
+ column: field,
70
+ count: nullIds.length,
71
+ examples: nullIds.slice(0, MAX_EXAMPLE_IDS),
72
+ kind: "null",
73
+ table: sample.table,
74
+ truncated: sample.truncated
75
+ }
76
+ });
77
+ };
78
+ const compositeKey = (row, fields) => {
79
+ const parts = [];
80
+ for (const field of fields) {
81
+ const value = row[field];
82
+ if (value === null || value === void 0) {
83
+ return void 0;
84
+ }
85
+ parts.push(JSON.stringify(value));
86
+ }
87
+ return parts.join("|");
88
+ };
89
+ const recordDuplicate = (id, existingId, duplicateIds) => {
90
+ if (!duplicateIds.includes(existingId)) {
91
+ duplicateIds.push(existingId);
92
+ }
93
+ duplicateIds.push(id);
94
+ };
95
+ const checkUniqueIndex = (lint, sample, index) => {
96
+ if (index.kind !== "index" || index.unique !== true) {
97
+ return void 0;
98
+ }
99
+ const seen = /* @__PURE__ */ new Map();
100
+ const duplicateIds = [];
101
+ for (const row of sample.rows) {
102
+ const id = rowId(row);
103
+ const key = compositeKey(row, index.fields);
104
+ if (key === void 0) {
105
+ continue;
106
+ }
107
+ const existingId = seen.get(key);
108
+ if (existingId === void 0) {
109
+ seen.set(key, id);
110
+ } else {
111
+ recordDuplicate(id, existingId, duplicateIds);
112
+ }
113
+ }
114
+ if (duplicateIds.length === 0) {
115
+ return void 0;
116
+ }
117
+ const detail = `Table "${sample.table}": ${duplicateIds.length.toString()} sampled row(s) share duplicate values on unique index "${index.name}" (${index.fields.join(", ")}). Row ids: ${formatExamples(duplicateIds, duplicateIds.length)}${truncatedSuffix(sample.truncated, sample.cap, "duplicates beyond the window may exist")}.`;
118
+ return emit(lint, {
119
+ cacheKey: `constraint_validator:unique:${sample.table}:${index.name}`,
120
+ detail,
121
+ metadata: {
122
+ cap: sample.cap,
123
+ count: duplicateIds.length,
124
+ examples: duplicateIds.slice(0, MAX_EXAMPLE_IDS),
125
+ fields: index.fields,
126
+ index: index.name,
127
+ kind: "unique",
128
+ table: sample.table,
129
+ truncated: sample.truncated
130
+ }
131
+ });
132
+ };
133
+ const checkTable = (lint, sample, sampleByTable, tableByName) => {
134
+ const schemaTable = tableByName.get(sample.table);
135
+ if (!schemaTable) {
136
+ return [];
137
+ }
138
+ const findings = [];
139
+ for (const relation of schemaTable.relations) {
140
+ const targetSample = sampleByTable.get(relation.table);
141
+ if (!targetSample) {
142
+ continue;
143
+ }
144
+ const f = checkFkRelation(lint, sample, relation, targetSample);
145
+ if (f) {
146
+ findings.push(f);
147
+ }
148
+ }
149
+ for (const field of schemaTable.fields) {
150
+ const isRequired = !schemaTable.optionalFields?.has(field);
151
+ const f = checkNullField(lint, sample, field, isRequired);
152
+ if (f) {
153
+ findings.push(f);
154
+ }
155
+ }
156
+ for (const index of schemaTable.indexes) {
157
+ const f = checkUniqueIndex(lint, sample, index);
158
+ if (f) {
159
+ findings.push(f);
160
+ }
161
+ }
162
+ return findings;
163
+ };
164
+ const constraintValidator = {
165
+ categories: ["SCHEMA"],
166
+ description: "Sampled rows violate one or more declared constraints: a foreign-key column references a non-existent row, a non-optional column contains null, or a unique-indexed column has duplicate values. These violations indicate data inserted before a constraint was enforced (e.g. a schema migration, a raw import, or a bug in a past data-migration transform).",
167
+ facing: "INTERNAL",
168
+ level: "WARN",
169
+ name: "constraint_validator",
170
+ remediation: "Inspect the listed rows and correct or remove the violating values. Run a data migration to backfill nulls, dedup unique violations, or re-link dangling FK references. For FK violations, confirm the target table rows exist before fixing the referencing rows.",
171
+ run: (context) => {
172
+ if (!context.tableSamples || context.tableSamples.length === 0) {
173
+ return [];
174
+ }
175
+ const sampleByTable = /* @__PURE__ */ new Map();
176
+ for (const sample of context.tableSamples) {
177
+ sampleByTable.set(sample.table, sample);
178
+ }
179
+ const tableByName = new Map(context.schema.tables.map((table) => [table.name, table]));
180
+ return context.tableSamples.flatMap((sample) => checkTable(constraintValidator, sample, sampleByTable, tableByName));
181
+ },
182
+ source: "runtime",
183
+ title: "Constraint violation"
184
+ };
185
+
186
+ export { constraintValidator as default };
@@ -0,0 +1,36 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const LARGE_NAMED = /* @__PURE__ */ new Set(["standard-3", "standard-4"]);
4
+ const VCPU_THRESHOLD = 2;
5
+ const MEMORY_MIB_THRESHOLD = 4096;
6
+ const isOversized = (instanceType) => typeof instanceType === "string" ? LARGE_NAMED.has(instanceType) : (instanceType.vcpu ?? 0) > VCPU_THRESHOLD || (instanceType.memoryMib ?? 0) > MEMORY_MIB_THRESHOLD;
7
+ const containerOversizedInstance = {
8
+ categories: ["PERFORMANCE"],
9
+ description: "A container is declared on a large instance type, whose provisioned memory and disk are billed for the lifetime of every running instance.",
10
+ facing: "INTERNAL",
11
+ level: "INFO",
12
+ name: "container_oversized_instance",
13
+ remediation: "Confirm the workload needs this size; a smaller instanceType (lite/basic/standard-1) costs less while idle-but-running.",
14
+ run: (context) => {
15
+ const findings = [];
16
+ for (const container of context.containers ?? []) {
17
+ const { instanceType } = container;
18
+ if (instanceType === void 0 || !isOversized(instanceType)) {
19
+ continue;
20
+ }
21
+ const size = typeof instanceType === "string" ? instanceType : JSON.stringify(instanceType);
22
+ findings.push(
23
+ emit(containerOversizedInstance, {
24
+ cacheKey: `container_oversized_instance:${container.exportName}`,
25
+ detail: `Container "${container.exportName}" uses a large instance type (${size}); its memory + disk are billed while any instance runs.`,
26
+ metadata: { container: container.exportName, instanceType }
27
+ })
28
+ );
29
+ }
30
+ return findings;
31
+ },
32
+ source: "static",
33
+ title: "Oversized container instance"
34
+ };
35
+
36
+ export { containerOversizedInstance as default };
@@ -0,0 +1,30 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const containerPublicInternet = {
4
+ categories: ["SECURITY"],
5
+ description: "A container leaves outbound internet access at the default (enabled), which is billed per GB of egress and widens the attack surface.",
6
+ facing: "INTERNAL",
7
+ level: "INFO",
8
+ name: "container_public_internet",
9
+ remediation: "Set `enableInternet: false` on the container if it doesn't call external services, or `true` to opt in deliberately.",
10
+ run: (context) => {
11
+ const findings = [];
12
+ for (const container of context.containers ?? []) {
13
+ if (container.enableInternet !== void 0) {
14
+ continue;
15
+ }
16
+ findings.push(
17
+ emit(containerPublicInternet, {
18
+ cacheKey: `container_public_internet:${container.exportName}`,
19
+ detail: `Container "${container.exportName}" doesn't set enableInternet, so outbound internet is on by default (egress is billed).`,
20
+ metadata: { container: container.exportName }
21
+ })
22
+ );
23
+ }
24
+ return findings;
25
+ },
26
+ source: "static",
27
+ title: "Container egress enabled by default"
28
+ };
29
+
30
+ export { containerPublicInternet as default };
@@ -0,0 +1,57 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const isLeadingPrefix = (a, b) => {
4
+ if (a.length > b.length) {
5
+ return false;
6
+ }
7
+ return a.every((field, position) => field === b[position]);
8
+ };
9
+ const duplicateIndex = {
10
+ categories: ["PERFORMANCE"],
11
+ description: "A secondary index is redundant because another index already covers every lookup it serves (its columns are a leading prefix of the other's). The redundant index costs storage and is maintained on every write for no read benefit.",
12
+ facing: "INTERNAL",
13
+ level: "INFO",
14
+ name: "duplicate_index",
15
+ remediation: "Drop the redundant index; the covering index already serves its lookups.",
16
+ run: (context) => {
17
+ const findings = [];
18
+ for (const table of context.schema.tables) {
19
+ const secondary = table.indexes.filter((index) => index.kind === "index");
20
+ for (const candidate of secondary) {
21
+ if (candidate.unique === true) {
22
+ continue;
23
+ }
24
+ const cover = secondary.find((other) => {
25
+ if (other === candidate) {
26
+ return false;
27
+ }
28
+ if (!isLeadingPrefix(candidate.fields, other.fields)) {
29
+ return false;
30
+ }
31
+ const sameLength = candidate.fields.length === other.fields.length;
32
+ return sameLength ? candidate.name > other.name : true;
33
+ });
34
+ if (!cover) {
35
+ continue;
36
+ }
37
+ findings.push(
38
+ emit(duplicateIndex, {
39
+ cacheKey: `duplicate_index:${table.name}:${candidate.name}`,
40
+ detail: `Index "${candidate.name}" on table "${table.name}" (${candidate.fields.join(", ")}) is redundant — index "${cover.name}" (${cover.fields.join(", ")}) already covers its lookups.`,
41
+ metadata: {
42
+ coveredBy: { fields: cover.fields, name: cover.name },
43
+ fields: candidate.fields,
44
+ index: candidate.name,
45
+ table: table.name
46
+ }
47
+ })
48
+ );
49
+ }
50
+ }
51
+ return findings;
52
+ },
53
+ source: "static",
54
+ title: "Duplicate / redundant index"
55
+ };
56
+
57
+ export { duplicateIndex as default };
@@ -0,0 +1,32 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const emptyIndex = {
4
+ categories: ["SCHEMA"],
5
+ description: "An index declares no columns, so it indexes nothing and can never narrow a read.",
6
+ facing: "INTERNAL",
7
+ level: "WARN",
8
+ name: "empty_index",
9
+ remediation: "Give the index its columns, or remove it.",
10
+ run: (context) => {
11
+ const findings = [];
12
+ for (const table of context.schema.tables) {
13
+ for (const index of table.indexes) {
14
+ if (index.kind !== "index" || index.fields.length > 0) {
15
+ continue;
16
+ }
17
+ findings.push(
18
+ emit(emptyIndex, {
19
+ cacheKey: `empty_index:${table.name}:${index.name}`,
20
+ detail: `Index "${index.name}" on table "${table.name}" declares no columns.`,
21
+ metadata: { index: index.name, table: table.name }
22
+ })
23
+ );
24
+ }
25
+ }
26
+ return findings;
27
+ },
28
+ source: "static",
29
+ title: "Empty index"
30
+ };
31
+
32
+ export { emptyIndex as default };