@cosmicdrift/kumiko-guards 0.355.0 → 0.357.0
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/package.json +2 -2
- package/src/_lib/bun-lock-packages.ts +5 -5
- package/src/_lib/guard-kit.ts +180 -131
- package/src/_lib/qn.ts +9 -9
- package/src/_lib/roots.ts +4 -3
- package/src/_lib/security-baseline.ts +193 -116
- package/src/_lib/sql-inventory.ts +34 -32
- package/src/changes.json +6 -0
- package/src/check-as-casts.ts +196 -180
- package/src/check-complexity.ts +68 -69
- package/src/check-predicates.ts +93 -67
- package/src/check-secret-literals.ts +49 -38
- package/src/check-single-runtime-instance.ts +1 -1
- package/src/cli.ts +30 -17
- package/src/guard-access-denied-test.ts +49 -23
- package/src/guard-app-feature-structure.ts +92 -73
- package/src/guard-broker-subscribe.ts +17 -17
- package/src/guard-comment-lang.ts +126 -54
- package/src/guard-direct-entity-writes.ts +58 -45
- package/src/guard-escape-hatch-declared.ts +26 -23
- package/src/guard-html-escape.ts +112 -86
- package/src/guard-i18n-keys.ts +39 -36
- package/src/guard-i18n-locale-mount.ts +47 -40
- package/src/guard-i18n-ui-strings.ts +108 -92
- package/src/guard-lib-test-coverage.ts +37 -37
- package/src/guard-loadall-events.ts +17 -17
- package/src/guard-no-framed-extension-sections.ts +4 -4
- package/src/guard-no-temporal-polyfill-import.ts +19 -1
- package/src/guard-pii-annotations.ts +16 -16
- package/src/guard-primitives-discipline.ts +61 -65
- package/src/guard-screen-conventions.ts +2 -0
- package/src/guard-table-ddl.ts +56 -62
- package/src/guard-tenant-escalation.ts +42 -34
- package/src/guard-test-stack-drift.ts +6 -6
- package/src/guard-test-timeouts.ts +5 -4
- package/src/guard-text-field-stance.ts +22 -22
- package/src/guard-thin-wrappers.ts +42 -45
- package/src/guard-write-handler-qns.ts +29 -29
- package/src/runtime-isolation-classify.ts +4 -3
|
@@ -93,7 +93,7 @@ type DirectWriteException = {
|
|
|
93
93
|
readonly file: RegExp;
|
|
94
94
|
readonly functionName: string;
|
|
95
95
|
readonly table: string;
|
|
96
|
-
readonly
|
|
96
|
+
readonly justification: string;
|
|
97
97
|
};
|
|
98
98
|
|
|
99
99
|
const DIRECT_WRITE_EXCEPTIONS: readonly DirectWriteException[] = [
|
|
@@ -101,7 +101,7 @@ const DIRECT_WRITE_EXCEPTIONS: readonly DirectWriteException[] = [
|
|
|
101
101
|
file: /(^|\/)packages\/bundled-features\/src\/template-resolver\/seeding\.ts$/,
|
|
102
102
|
functionName: "resolveExistingForEventStoreSeed",
|
|
103
103
|
table: "templateResourcesTable",
|
|
104
|
-
|
|
104
|
+
justification: "Orphan projection row without events, no event-store verb possible",
|
|
105
105
|
},
|
|
106
106
|
];
|
|
107
107
|
|
|
@@ -183,6 +183,53 @@ export function collectEsTables(files: readonly SourceFile[]): Set<TableId> {
|
|
|
183
183
|
return tables;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
function unwrappedIdentifierDeclId(node: Node | undefined): TableId | undefined {
|
|
187
|
+
const identifier = node && unwrapTableExpression(node).asKind(SyntaxKind.Identifier);
|
|
188
|
+
return identifier ? declIdOf(identifier) : undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The `{ table }` override of `r.entity(name, def, { table })`.
|
|
192
|
+
function entityTableOverrideId(opts: Node | undefined): TableId | undefined {
|
|
193
|
+
if (opts?.getKind() !== SyntaxKind.ObjectLiteralExpression) return undefined;
|
|
194
|
+
const tableProp = opts.asKindOrThrow(SyntaxKind.ObjectLiteralExpression).getProperty("table");
|
|
195
|
+
if (tableProp?.getKind() !== SyntaxKind.PropertyAssignment) return undefined;
|
|
196
|
+
return unwrappedIdentifierDeclId(
|
|
197
|
+
tableProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer(),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
type EntityRegistration = {
|
|
202
|
+
readonly entityDid: TableId | undefined;
|
|
203
|
+
readonly overrideTableDid: TableId | undefined;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
function entityRegistrationOf(call: CallExpression): EntityRegistration | undefined {
|
|
207
|
+
const expr = call.getExpression();
|
|
208
|
+
if (expr.getKind() !== SyntaxKind.PropertyAccessExpression) return undefined;
|
|
209
|
+
if (expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName() !== "entity") {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
const args = call.getArguments();
|
|
213
|
+
if (!args[0] || args[0].getKind() !== SyntaxKind.StringLiteral) return undefined;
|
|
214
|
+
return {
|
|
215
|
+
entityDid: unwrappedIdentifierDeclId(args[1]),
|
|
216
|
+
overrideTableDid: entityTableOverrideId(args[2]),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// `const xTable = buildEntityTable(<name>, <entityDef>)` with a rebuildable entityDef.
|
|
221
|
+
function rebuildableEntityTableDeclId(
|
|
222
|
+
call: CallExpression,
|
|
223
|
+
rebuildableEntities: ReadonlySet<TableId>,
|
|
224
|
+
): TableId | undefined {
|
|
225
|
+
if (call.getExpression().getText() !== "buildEntityTable") return undefined;
|
|
226
|
+
const entityDid = unwrappedIdentifierDeclId(call.getArguments()[1]);
|
|
227
|
+
if (!entityDid || !rebuildableEntities.has(entityDid)) return undefined;
|
|
228
|
+
const nameNode = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration)?.getNameNode();
|
|
229
|
+
if (nameNode?.getKind() !== SyntaxKind.Identifier) return undefined;
|
|
230
|
+
return declIdOf(nameNode.asKindOrThrow(SyntaxKind.Identifier));
|
|
231
|
+
}
|
|
232
|
+
|
|
186
233
|
// Tables registered via `r.entity(name, entityDef)` as a rebuildable implicit
|
|
187
234
|
// projection, linked entity→table via the shared entity-def symbol (see header), not by name string.
|
|
188
235
|
export function collectEntityProjectionTables(files: readonly SourceFile[]): Set<TableId> {
|
|
@@ -192,33 +239,10 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
|
|
|
192
239
|
// Pass 1: entity-def decls that r.entity makes rebuildable (+ {table} override).
|
|
193
240
|
for (const sf of files) {
|
|
194
241
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
if (!args[0] || args[0].getKind() !== SyntaxKind.StringLiteral) continue;
|
|
200
|
-
|
|
201
|
-
const entityIdentifier =
|
|
202
|
-
args[1] && unwrapTableExpression(args[1]).asKind(SyntaxKind.Identifier);
|
|
203
|
-
if (entityIdentifier) {
|
|
204
|
-
const did = declIdOf(entityIdentifier);
|
|
205
|
-
if (did) rebuildableEntities.add(did);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const opts = args[2];
|
|
209
|
-
if (opts?.getKind() === SyntaxKind.ObjectLiteralExpression) {
|
|
210
|
-
const tableProp = opts
|
|
211
|
-
.asKindOrThrow(SyntaxKind.ObjectLiteralExpression)
|
|
212
|
-
.getProperty("table");
|
|
213
|
-
if (tableProp?.getKind() === SyntaxKind.PropertyAssignment) {
|
|
214
|
-
const init = tableProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
|
|
215
|
-
const tableIdentifier = init && unwrapTableExpression(init).asKind(SyntaxKind.Identifier);
|
|
216
|
-
if (tableIdentifier) {
|
|
217
|
-
const did = declIdOf(tableIdentifier);
|
|
218
|
-
if (did) tables.add(did);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
242
|
+
const registration = entityRegistrationOf(call);
|
|
243
|
+
if (!registration) continue;
|
|
244
|
+
if (registration.entityDid) rebuildableEntities.add(registration.entityDid);
|
|
245
|
+
if (registration.overrideTableDid) tables.add(registration.overrideTableDid);
|
|
222
246
|
}
|
|
223
247
|
}
|
|
224
248
|
|
|
@@ -226,18 +250,7 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
|
|
|
226
250
|
// is in the rebuildable set → xTable is a rebuildable entity table.
|
|
227
251
|
for (const sf of files) {
|
|
228
252
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
229
|
-
|
|
230
|
-
const entityArg = call.getArguments()[1];
|
|
231
|
-
const entityIdentifier =
|
|
232
|
-
entityArg && unwrapTableExpression(entityArg).asKind(SyntaxKind.Identifier);
|
|
233
|
-
if (!entityIdentifier) continue;
|
|
234
|
-
const entityDid = declIdOf(entityIdentifier);
|
|
235
|
-
if (!entityDid || !rebuildableEntities.has(entityDid)) continue;
|
|
236
|
-
|
|
237
|
-
const varDecl = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
|
|
238
|
-
const nameNode = varDecl?.getNameNode();
|
|
239
|
-
if (nameNode?.getKind() !== SyntaxKind.Identifier) continue;
|
|
240
|
-
const did = declIdOf(nameNode.asKindOrThrow(SyntaxKind.Identifier));
|
|
253
|
+
const did = rebuildableEntityTableDeclId(call, rebuildableEntities);
|
|
241
254
|
if (did) tables.add(did);
|
|
242
255
|
}
|
|
243
256
|
}
|
|
@@ -259,7 +272,7 @@ type Violation = {
|
|
|
259
272
|
* projection apply (e.g. a db.transaction sub-tx
|
|
260
273
|
* in production code).
|
|
261
274
|
*/
|
|
262
|
-
|
|
275
|
+
kind: "non-tx-receiver" | "tx-outside-apply";
|
|
263
276
|
};
|
|
264
277
|
|
|
265
278
|
/**
|
|
@@ -427,7 +440,7 @@ export function scanDirectWrites(
|
|
|
427
440
|
op: w.op,
|
|
428
441
|
table: w.tableArg.getText(),
|
|
429
442
|
snippet: call.getText().slice(0, 120),
|
|
430
|
-
|
|
443
|
+
kind: "tx-outside-apply",
|
|
431
444
|
});
|
|
432
445
|
continue;
|
|
433
446
|
}
|
|
@@ -438,7 +451,7 @@ export function scanDirectWrites(
|
|
|
438
451
|
op: w.op,
|
|
439
452
|
table: w.tableArg.getText(),
|
|
440
453
|
snippet: call.getText().slice(0, 120),
|
|
441
|
-
|
|
454
|
+
kind: "non-tx-receiver",
|
|
442
455
|
});
|
|
443
456
|
}
|
|
444
457
|
return out;
|
|
@@ -483,7 +496,7 @@ export const guard: AstGuard = {
|
|
|
483
496
|
violations.push({
|
|
484
497
|
file: path.relative(ROOT, file),
|
|
485
498
|
line: hit.line,
|
|
486
|
-
message: `[${hit.
|
|
499
|
+
message: `[${hit.kind}] ${hit.receiver}.${hit.op}(${hit.table}) — ${hit.snippet}`,
|
|
487
500
|
});
|
|
488
501
|
}
|
|
489
502
|
}
|
|
@@ -73,11 +73,13 @@
|
|
|
73
73
|
*/
|
|
74
74
|
import * as path from "node:path";
|
|
75
75
|
import {
|
|
76
|
+
type BindingElement,
|
|
76
77
|
type Node,
|
|
77
78
|
type ObjectLiteralExpression,
|
|
78
79
|
type PropertyAssignment,
|
|
79
80
|
type SourceFile,
|
|
80
81
|
SyntaxKind,
|
|
82
|
+
type VariableDeclaration,
|
|
81
83
|
VariableDeclarationKind,
|
|
82
84
|
} from "ts-morph";
|
|
83
85
|
import { isFrameworkImportOf } from "./_lib/framework-import";
|
|
@@ -158,32 +160,33 @@ function isDirectTenantDbAccess(node: Node): boolean {
|
|
|
158
160
|
return false;
|
|
159
161
|
}
|
|
160
162
|
|
|
163
|
+
function isTenantDbVariable(decl: VariableDeclaration): boolean {
|
|
164
|
+
if (decl.getTypeNode()?.getText() === "TenantDb") return true;
|
|
165
|
+
const init = decl.getInitializer();
|
|
166
|
+
return init !== undefined && isDirectTenantDbAccess(init);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isCtxDbBindingElement(decl: BindingElement): boolean {
|
|
170
|
+
const propName = decl.getPropertyNameNode()?.getText() ?? decl.getName();
|
|
171
|
+
if (!DB_RECEIVER_NAMES.has(propName)) return false;
|
|
172
|
+
// Only `const { db } = ctx` — parameter destructuring (`({ db }) =>`)
|
|
173
|
+
// stays a deliberate false-negative, not worth the ambiguity.
|
|
174
|
+
const owner = decl.getParent()?.getParent();
|
|
175
|
+
const init = owner?.isKind(SyntaxKind.VariableDeclaration) ? owner.getInitializer() : undefined;
|
|
176
|
+
return init?.isKind(SyntaxKind.Identifier) === true && CTX_IDENTIFIER_NAMES.has(init.getText());
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isTenantDbDeclaration(decl: Node): boolean {
|
|
180
|
+
if (decl.isKind(SyntaxKind.VariableDeclaration)) return isTenantDbVariable(decl);
|
|
181
|
+
if (decl.isKind(SyntaxKind.BindingElement)) return isCtxDbBindingElement(decl);
|
|
182
|
+
if (decl.isKind(SyntaxKind.Parameter)) return decl.getTypeNode()?.getText() === "TenantDb";
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
161
186
|
function isTenantDbExpression(node: Node): boolean {
|
|
162
187
|
if (isDirectTenantDbAccess(node)) return true;
|
|
163
188
|
if (!node.isKind(SyntaxKind.Identifier)) return false;
|
|
164
|
-
|
|
165
|
-
for (const decl of decls) {
|
|
166
|
-
if (decl.isKind(SyntaxKind.VariableDeclaration)) {
|
|
167
|
-
if (decl.getTypeNode()?.getText() === "TenantDb") return true;
|
|
168
|
-
const init = decl.getInitializer();
|
|
169
|
-
if (init && isDirectTenantDbAccess(init)) return true;
|
|
170
|
-
} else if (decl.isKind(SyntaxKind.BindingElement)) {
|
|
171
|
-
const propName = decl.getPropertyNameNode()?.getText() ?? decl.getName();
|
|
172
|
-
if (!DB_RECEIVER_NAMES.has(propName)) continue;
|
|
173
|
-
// Only `const { db } = ctx` — parameter destructuring (`({ db }) =>`)
|
|
174
|
-
// stays a deliberate false-negative, not worth the ambiguity.
|
|
175
|
-
const owner = decl.getParent()?.getParent();
|
|
176
|
-
const init = owner?.isKind(SyntaxKind.VariableDeclaration)
|
|
177
|
-
? owner.getInitializer()
|
|
178
|
-
: undefined;
|
|
179
|
-
if (init?.isKind(SyntaxKind.Identifier) && CTX_IDENTIFIER_NAMES.has(init.getText())) {
|
|
180
|
-
return true;
|
|
181
|
-
}
|
|
182
|
-
} else if (decl.isKind(SyntaxKind.Parameter)) {
|
|
183
|
-
if (decl.getTypeNode()?.getText() === "TenantDb") return true;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
return false;
|
|
189
|
+
return (node.getSymbol()?.getDeclarations() ?? []).some(isTenantDbDeclaration);
|
|
187
190
|
}
|
|
188
191
|
|
|
189
192
|
function findRawFindings(
|
package/src/guard-html-escape.ts
CHANGED
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
import * as path from "node:path";
|
|
34
34
|
import {
|
|
35
35
|
type ArrowFunction,
|
|
36
|
+
type BinaryExpression,
|
|
37
|
+
type CallExpression,
|
|
36
38
|
type FunctionDeclaration,
|
|
37
39
|
type FunctionExpression,
|
|
38
40
|
type Identifier,
|
|
@@ -215,29 +217,120 @@ function isSafeLocalCall(callee: Node, sf: SourceFile, depth: number): boolean {
|
|
|
215
217
|
});
|
|
216
218
|
}
|
|
217
219
|
|
|
218
|
-
function
|
|
219
|
-
|
|
220
|
-
if (expr.isKind(SyntaxKind.ParenthesizedExpression)) {
|
|
221
|
-
return isSafeExpression(expr.getExpression(), sf, depth + 1);
|
|
222
|
-
}
|
|
223
|
-
if (
|
|
220
|
+
function isLiteralSafeKind(expr: Node): boolean {
|
|
221
|
+
return (
|
|
224
222
|
expr.isKind(SyntaxKind.StringLiteral) ||
|
|
225
223
|
expr.isKind(SyntaxKind.NumericLiteral) ||
|
|
226
224
|
expr.isKind(SyntaxKind.TrueKeyword) ||
|
|
227
|
-
expr.isKind(SyntaxKind.FalseKeyword)
|
|
225
|
+
expr.isKind(SyntaxKind.FalseKeyword) ||
|
|
226
|
+
expr.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// A template with an HTML tag is scanned as its own candidate (incl. its
|
|
231
|
+
// html-ok comments). One without is never scanned, so its interpolations
|
|
232
|
+
// must be safe here.
|
|
233
|
+
function isSafeTemplateExpression(
|
|
234
|
+
expr: TemplateExpression,
|
|
235
|
+
sf: SourceFile,
|
|
236
|
+
depth: number,
|
|
237
|
+
): boolean {
|
|
238
|
+
if (HTML_TAG.test(templateStaticText(expr))) return true;
|
|
239
|
+
return expr
|
|
240
|
+
.getTemplateSpans()
|
|
241
|
+
.every((span) => isSafeExpression(span.getExpression(), sf, depth + 1));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function isSafeBinaryExpression(expr: BinaryExpression, sf: SourceFile, depth: number): boolean {
|
|
245
|
+
const op = expr.getOperatorToken().getKind();
|
|
246
|
+
if (
|
|
247
|
+
op === SyntaxKind.QuestionQuestionToken ||
|
|
248
|
+
op === SyntaxKind.BarBarToken ||
|
|
249
|
+
op === SyntaxKind.AmpersandAmpersandToken
|
|
250
|
+
) {
|
|
251
|
+
return (
|
|
252
|
+
isSafeExpression(expr.getLeft(), sf, depth + 1) &&
|
|
253
|
+
isSafeExpression(expr.getRight(), sf, depth + 1)
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
// String concatenation is safe when both operands are.
|
|
257
|
+
if (
|
|
258
|
+
op === SyntaxKind.PlusToken &&
|
|
259
|
+
isSafeExpression(expr.getLeft(), sf, depth + 1) &&
|
|
260
|
+
isSafeExpression(expr.getRight(), sf, depth + 1)
|
|
228
261
|
) {
|
|
229
262
|
return true;
|
|
230
263
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
264
|
+
// Arithmetic (`${width / 2}`) is safe as long as the result type is number.
|
|
265
|
+
return isCompileTimeKnownType(expr.getType());
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function calleeName(callee: Node): string {
|
|
269
|
+
return callee.isKind(SyntaxKind.PropertyAccessExpression)
|
|
270
|
+
? callee.getNameNode().getText()
|
|
271
|
+
: callee.getText();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isSafeMapCallbackResult(
|
|
275
|
+
mapCallback: ArrowFunction | FunctionExpression,
|
|
276
|
+
sf: SourceFile,
|
|
277
|
+
depth: number,
|
|
278
|
+
): boolean {
|
|
279
|
+
const body = mapCallback.getBody();
|
|
280
|
+
if (Node.isBlock(body)) {
|
|
281
|
+
const stmts = body.getStatements();
|
|
282
|
+
const only = stmts.length === 1 ? stmts[0] : undefined;
|
|
283
|
+
const ret = only && Node.isReturnStatement(only) ? only.getExpression() : undefined;
|
|
284
|
+
return ret !== undefined && isSafeExpression(ret, sf, depth + 1);
|
|
285
|
+
}
|
|
286
|
+
return isSafeExpression(body, sf, depth + 1);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// `.join(...)` is only safe when the joined array itself consists of
|
|
290
|
+
// safe fragments (e.g. arr.map(x => escapeHtml(x)).join("")) — a raw
|
|
291
|
+
// `stringArray.join("")` from foreign data (parameter/import) was
|
|
292
|
+
// previously treated as unconditionally safe, a real bypass.
|
|
293
|
+
function isSafeJoinOfMappedFragments(callee: Node, sf: SourceFile, depth: number): boolean {
|
|
294
|
+
if (!callee.isKind(SyntaxKind.PropertyAccessExpression)) return false;
|
|
295
|
+
const receiver = callee.getExpression();
|
|
296
|
+
if (!receiver.isKind(SyntaxKind.CallExpression)) return false;
|
|
297
|
+
if (calleeName(receiver.getExpression()) !== "map") return false;
|
|
298
|
+
const mapCallback = receiver.getArguments()[0];
|
|
299
|
+
if (
|
|
300
|
+
!mapCallback ||
|
|
301
|
+
!(
|
|
302
|
+
mapCallback.isKind(SyntaxKind.ArrowFunction) ||
|
|
303
|
+
mapCallback.isKind(SyntaxKind.FunctionExpression)
|
|
304
|
+
)
|
|
305
|
+
) {
|
|
306
|
+
return false;
|
|
240
307
|
}
|
|
308
|
+
return isSafeMapCallbackResult(mapCallback, sf, depth);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isSafeCallExpression(expr: CallExpression, sf: SourceFile, depth: number): boolean {
|
|
312
|
+
const callee = expr.getExpression();
|
|
313
|
+
const name = calleeName(callee);
|
|
314
|
+
if (SAFE_CALL_NAMES.has(name)) return true;
|
|
315
|
+
if (name === "join" && isSafeJoinOfMappedFragments(callee, sf, depth)) return true;
|
|
316
|
+
if (endsWithHtmlConvention(callee)) return true;
|
|
317
|
+
if (isSafeLocalCall(callee, sf, depth)) return true;
|
|
318
|
+
return isCompileTimeKnownType(expr.getType());
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function isSafeIdentifierExpression(ident: Identifier, sf: SourceFile, depth: number): boolean {
|
|
322
|
+
if (UPPER_SNAKE.test(ident.getText())) return true;
|
|
323
|
+
if (isSafeLocalIdentifier(ident, sf, depth)) return true;
|
|
324
|
+
return isCompileTimeKnownType(ident.getType());
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isSafeExpression(expr: Node, sf: SourceFile, depth: number): boolean {
|
|
328
|
+
if (depth > MAX_RESOLVE_DEPTH) return false;
|
|
329
|
+
if (expr.isKind(SyntaxKind.ParenthesizedExpression)) {
|
|
330
|
+
return isSafeExpression(expr.getExpression(), sf, depth + 1);
|
|
331
|
+
}
|
|
332
|
+
if (isLiteralSafeKind(expr)) return true;
|
|
333
|
+
if (expr.isKind(SyntaxKind.TemplateExpression)) return isSafeTemplateExpression(expr, sf, depth);
|
|
241
334
|
if (expr.isKind(SyntaxKind.TaggedTemplateExpression)) return isSafeTemplateTag(expr.getTag());
|
|
242
335
|
if (expr.isKind(SyntaxKind.ConditionalExpression)) {
|
|
243
336
|
return (
|
|
@@ -245,77 +338,10 @@ function isSafeExpression(expr: Node, sf: SourceFile, depth: number): boolean {
|
|
|
245
338
|
isSafeExpression(expr.getWhenFalse(), sf, depth + 1)
|
|
246
339
|
);
|
|
247
340
|
}
|
|
248
|
-
if (expr.isKind(SyntaxKind.BinaryExpression))
|
|
249
|
-
const op = expr.getOperatorToken().getKind();
|
|
250
|
-
if (
|
|
251
|
-
op === SyntaxKind.QuestionQuestionToken ||
|
|
252
|
-
op === SyntaxKind.BarBarToken ||
|
|
253
|
-
op === SyntaxKind.AmpersandAmpersandToken
|
|
254
|
-
) {
|
|
255
|
-
return (
|
|
256
|
-
isSafeExpression(expr.getLeft(), sf, depth + 1) &&
|
|
257
|
-
isSafeExpression(expr.getRight(), sf, depth + 1)
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
// String concatenation is safe when both operands are.
|
|
261
|
-
if (
|
|
262
|
-
op === SyntaxKind.PlusToken &&
|
|
263
|
-
isSafeExpression(expr.getLeft(), sf, depth + 1) &&
|
|
264
|
-
isSafeExpression(expr.getRight(), sf, depth + 1)
|
|
265
|
-
) {
|
|
266
|
-
return true;
|
|
267
|
-
}
|
|
268
|
-
// Arithmetic (`${width / 2}`) is safe as long as the result type is number.
|
|
269
|
-
return isCompileTimeKnownType(expr.getType());
|
|
270
|
-
}
|
|
341
|
+
if (expr.isKind(SyntaxKind.BinaryExpression)) return isSafeBinaryExpression(expr, sf, depth);
|
|
271
342
|
if (endsWithHtmlConvention(expr)) return true;
|
|
272
|
-
if (expr.isKind(SyntaxKind.CallExpression))
|
|
273
|
-
|
|
274
|
-
const name = callee.isKind(SyntaxKind.PropertyAccessExpression)
|
|
275
|
-
? callee.getNameNode().getText()
|
|
276
|
-
: callee.getText();
|
|
277
|
-
if (SAFE_CALL_NAMES.has(name)) return true;
|
|
278
|
-
// `.join(...)` is only safe when the joined array itself consists of
|
|
279
|
-
// safe fragments (e.g. arr.map(x => escapeHtml(x)).join("")) — a raw
|
|
280
|
-
// `stringArray.join("")` from foreign data (parameter/import) was
|
|
281
|
-
// previously treated as unconditionally safe, a real bypass.
|
|
282
|
-
if (name === "join" && callee.isKind(SyntaxKind.PropertyAccessExpression)) {
|
|
283
|
-
const receiver = callee.getExpression();
|
|
284
|
-
if (receiver.isKind(SyntaxKind.CallExpression)) {
|
|
285
|
-
const receiverCallee = receiver.getExpression();
|
|
286
|
-
const receiverName = receiverCallee.isKind(SyntaxKind.PropertyAccessExpression)
|
|
287
|
-
? receiverCallee.getNameNode().getText()
|
|
288
|
-
: receiverCallee.getText();
|
|
289
|
-
if (receiverName === "map") {
|
|
290
|
-
const mapCallback = receiver.getArguments()[0];
|
|
291
|
-
if (
|
|
292
|
-
mapCallback &&
|
|
293
|
-
(mapCallback.isKind(SyntaxKind.ArrowFunction) ||
|
|
294
|
-
mapCallback.isKind(SyntaxKind.FunctionExpression))
|
|
295
|
-
) {
|
|
296
|
-
const body = mapCallback.getBody();
|
|
297
|
-
if (Node.isBlock(body)) {
|
|
298
|
-
const stmts = body.getStatements();
|
|
299
|
-
const only = stmts.length === 1 ? stmts[0] : undefined;
|
|
300
|
-
const ret = only && Node.isReturnStatement(only) ? only.getExpression() : undefined;
|
|
301
|
-
if (ret && isSafeExpression(ret, sf, depth + 1)) return true;
|
|
302
|
-
} else if (isSafeExpression(body, sf, depth + 1)) {
|
|
303
|
-
return true;
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
if (endsWithHtmlConvention(callee)) return true;
|
|
310
|
-
if (isSafeLocalCall(callee, sf, depth)) return true;
|
|
311
|
-
return isCompileTimeKnownType(expr.getType());
|
|
312
|
-
}
|
|
313
|
-
if (expr.isKind(SyntaxKind.Identifier)) {
|
|
314
|
-
const ident = expr as Identifier;
|
|
315
|
-
if (UPPER_SNAKE.test(ident.getText())) return true;
|
|
316
|
-
if (isSafeLocalIdentifier(ident, sf, depth)) return true;
|
|
317
|
-
return isCompileTimeKnownType(expr.getType());
|
|
318
|
-
}
|
|
343
|
+
if (expr.isKind(SyntaxKind.CallExpression)) return isSafeCallExpression(expr, sf, depth);
|
|
344
|
+
if (expr.isKind(SyntaxKind.Identifier)) return isSafeIdentifierExpression(expr, sf, depth);
|
|
319
345
|
if (
|
|
320
346
|
expr.isKind(SyntaxKind.PropertyAccessExpression) ||
|
|
321
347
|
expr.isKind(SyntaxKind.ElementAccessExpression)
|
package/src/guard-i18n-keys.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Guard: i18n
|
|
3
|
+
* Guard: i18n keys must be defined before they are used.
|
|
4
4
|
*
|
|
5
|
-
* Scan: t()
|
|
6
|
-
*
|
|
5
|
+
* Scan: t() calls in app tsx, definitions from r.translations and i18n bundles.
|
|
6
|
+
* Declarative screen/nav keys: validateBoot (runtime).
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
9
|
* bun guards/guard-i18n-keys.ts
|
|
@@ -52,8 +52,8 @@ function isI18nBundleFile(filePath: string): boolean {
|
|
|
52
52
|
// Flat single-file bundle (src/i18n.ts), same status as src/i18n/index.ts.
|
|
53
53
|
/\/i18n\.ts$/.test(filePath) ||
|
|
54
54
|
/\/features\/[^/]+\/i18n\./.test(filePath) ||
|
|
55
|
-
//
|
|
56
|
-
// (
|
|
55
|
+
// Covers any depth below bundled-features/src/<feature>/
|
|
56
|
+
// (e.g. also .../schema/i18n.ts), not just exactly one level.
|
|
57
57
|
/bundled-features\/src\/.*\/i18n\.ts$/.test(filePath)
|
|
58
58
|
);
|
|
59
59
|
}
|
|
@@ -240,6 +240,39 @@ function collectBundleDefinedKeys(sf: SourceFile): DefinedKey[] {
|
|
|
240
240
|
return defined;
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
// Collects the entries of one `keys:` initializer of an inline `.translations({...})` call.
|
|
244
|
+
function collectInlineKeysInitializer(
|
|
245
|
+
defined: DefinedKey[],
|
|
246
|
+
initializer: Node | undefined,
|
|
247
|
+
featureName: string,
|
|
248
|
+
file: string,
|
|
249
|
+
): void {
|
|
250
|
+
if (initializer?.isKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
251
|
+
for (const entry of extractKeysFromTranslationsObject(initializer)) {
|
|
252
|
+
pushDefined(defined, `${featureName}:${entry.key}`, entry.locales, file, entry.line);
|
|
253
|
+
if (entry.key.includes(":")) {
|
|
254
|
+
pushDefined(defined, entry.key, entry.locales, file, entry.line);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} else if (initializer?.isKind(SyntaxKind.Identifier)) {
|
|
258
|
+
// getDefinitionNodes() follows "go to definition" through an
|
|
259
|
+
// import alias to the real declaration (possibly in another
|
|
260
|
+
// file) instead of stopping at the ImportSpecifier — plain
|
|
261
|
+
// getSymbol().getDeclarations() only resolved a same-file const.
|
|
262
|
+
const decl = initializer
|
|
263
|
+
.getDefinitionNodes()
|
|
264
|
+
.find((n) => n.isKind(SyntaxKind.VariableDeclaration));
|
|
265
|
+
if (decl?.isKind(SyntaxKind.VariableDeclaration)) {
|
|
266
|
+
const bundleInit = decl.getInitializer();
|
|
267
|
+
if (bundleInit?.isKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
268
|
+
for (const entry of extractKeysFromTranslationsObject(bundleInit)) {
|
|
269
|
+
addDefinedEntries(defined, [entry], file, featureName);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
243
276
|
function collectInlineTranslationsDefinedKeys(sf: SourceFile): DefinedKey[] {
|
|
244
277
|
const defined: DefinedKey[] = [];
|
|
245
278
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
@@ -255,37 +288,7 @@ function collectInlineTranslationsDefinedKeys(sf: SourceFile): DefinedKey[] {
|
|
|
255
288
|
for (const prop of first.getProperties()) {
|
|
256
289
|
if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
257
290
|
if (prop.getNameNode().getText() !== "keys") continue;
|
|
258
|
-
|
|
259
|
-
if (initializer?.isKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
260
|
-
for (const entry of extractKeysFromTranslationsObject(initializer)) {
|
|
261
|
-
pushDefined(
|
|
262
|
-
defined,
|
|
263
|
-
`${featureName}:${entry.key}`,
|
|
264
|
-
entry.locales,
|
|
265
|
-
relFile(sf),
|
|
266
|
-
entry.line,
|
|
267
|
-
);
|
|
268
|
-
if (entry.key.includes(":")) {
|
|
269
|
-
pushDefined(defined, entry.key, entry.locales, relFile(sf), entry.line);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
} else if (initializer?.isKind(SyntaxKind.Identifier)) {
|
|
273
|
-
// getDefinitionNodes() follows "go to definition" through an
|
|
274
|
-
// import alias to the real declaration (possibly in another
|
|
275
|
-
// file) instead of stopping at the ImportSpecifier — plain
|
|
276
|
-
// getSymbol().getDeclarations() only resolved a same-file const.
|
|
277
|
-
const decl = initializer
|
|
278
|
-
.getDefinitionNodes()
|
|
279
|
-
.find((n) => n.isKind(SyntaxKind.VariableDeclaration));
|
|
280
|
-
if (decl?.isKind(SyntaxKind.VariableDeclaration)) {
|
|
281
|
-
const bundleInit = decl.getInitializer();
|
|
282
|
-
if (bundleInit?.isKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
283
|
-
for (const entry of extractKeysFromTranslationsObject(bundleInit)) {
|
|
284
|
-
addDefinedEntries(defined, [entry], relFile(sf), featureName);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
291
|
+
collectInlineKeysInitializer(defined, prop.getInitializer(), featureName, relFile(sf));
|
|
289
292
|
}
|
|
290
293
|
}
|
|
291
294
|
return defined;
|