@apifuse/provider-sdk 2.2.0-beta.40 → 2.2.0-beta.41
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/CHANGELOG.md +4 -0
- package/bin/apifuse-check.ts +61 -0
- package/bin/apifuse-migrate-shape.ts +84 -0
- package/bin/apifuse-submit-check.ts +1760 -222
- package/dist/cli/commands.d.ts +1 -1
- package/dist/cli/commands.js +8 -0
- package/dist/cli/create.js +6 -0
- package/dist/cli/migrate-provider-shape.d.ts +52 -0
- package/dist/cli/migrate-provider-shape.js +515 -0
- package/dist/cli/templates/provider/provider.json.tpl +6 -0
- package/dist/contract.js +1 -0
- package/dist/define.js +22 -1
- package/dist/error-observability.d.ts +7 -0
- package/dist/error-observability.js +61 -0
- package/dist/errors.d.ts +15 -0
- package/dist/fixture-sanitization.js +13 -3
- package/dist/index.d.ts +1 -1
- package/dist/provider.d.ts +1 -1
- package/dist/runtime/executor.js +11 -1
- package/dist/server/error-observability.d.ts +1 -0
- package/dist/server/error-observability.js +1 -0
- package/dist/server/index.d.ts +2 -1
- package/dist/server/self-test.js +3 -0
- package/dist/server/serve-implementation.d.ts +12 -0
- package/dist/server/serve-implementation.js +135 -66
- package/dist/types.d.ts +18 -10
- package/package.json +3 -3
- package/src/cli/commands.ts +10 -0
- package/src/cli/create.ts +6 -0
- package/src/cli/migrate-provider-shape.ts +701 -0
- package/src/cli/templates/provider/provider.json.tpl +6 -0
- package/src/contract.ts +1 -0
- package/src/define.ts +33 -1
- package/src/error-observability.ts +64 -0
- package/src/errors.ts +16 -0
- package/src/fixture-sanitization.ts +19 -3
- package/src/index.ts +1 -0
- package/src/provider.ts +1 -0
- package/src/runtime/executor.ts +13 -1
- package/src/server/error-observability.ts +1 -0
- package/src/server/index.ts +2 -0
- package/src/server/self-test.ts +5 -0
- package/src/server/serve-implementation.ts +172 -84
- package/src/types.ts +38 -27
|
@@ -4,10 +4,11 @@ import { type ChildProcess, spawn } from "node:child_process";
|
|
|
4
4
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
5
5
|
import { writeFile } from "node:fs/promises";
|
|
6
6
|
import { createServer } from "node:net";
|
|
7
|
-
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
8
8
|
import { pathToFileURL } from "node:url";
|
|
9
9
|
|
|
10
10
|
import * as acorn from "acorn";
|
|
11
|
+
import ts from "typescript";
|
|
11
12
|
import { z } from "zod";
|
|
12
13
|
|
|
13
14
|
import packageJson from "../package.json";
|
|
@@ -112,11 +113,14 @@ export type SmokeResult = {
|
|
|
112
113
|
type SourceFinding = {
|
|
113
114
|
file: string;
|
|
114
115
|
line: number;
|
|
116
|
+
detail?: string;
|
|
115
117
|
};
|
|
116
118
|
|
|
117
119
|
const SDK_NATIVE_CATEGORY = "sdk-native";
|
|
118
120
|
const VENDOR_SHIM_PROVIDER_ID_PREFIX = "apifuse-provider-";
|
|
119
121
|
const MAX_SOURCE_FINDING_EVIDENCE = 5;
|
|
122
|
+
const MAX_LOAD_ERROR_EVIDENCE = 5;
|
|
123
|
+
const MAX_LOAD_ERROR_MESSAGE_LENGTH = 500;
|
|
120
124
|
|
|
121
125
|
const CATEGORY_MAX_POINTS = {
|
|
122
126
|
definition: 15,
|
|
@@ -271,7 +275,20 @@ export async function buildSubmitCheckReport(
|
|
|
271
275
|
): Promise<SubmitCheckReport> {
|
|
272
276
|
const checks: SubmitCheck[] = [];
|
|
273
277
|
const baseChecks = await safeRunChecks(providerRoot);
|
|
274
|
-
const
|
|
278
|
+
const loadResult = await safeLoadProvider(providerRoot);
|
|
279
|
+
const provider = loadResult.ok ? loadResult.provider : undefined;
|
|
280
|
+
let indexParseError: string | undefined;
|
|
281
|
+
if (!provider) {
|
|
282
|
+
const indexPath = resolve(providerRoot, "index.ts");
|
|
283
|
+
if (existsSync(indexPath)) {
|
|
284
|
+
const relPath = toRelativeProviderPath(providerRoot, indexPath);
|
|
285
|
+
try {
|
|
286
|
+
maskCommentsAndStrings(readFileSync(indexPath, "utf8"), relPath);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
indexParseError = error instanceof Error ? error.message : String(error);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
275
292
|
|
|
276
293
|
// Prompt-asset freshness is reported by its own dedicated zero-point
|
|
277
294
|
// blocker below; filter the base-check duplicate so it is not double
|
|
@@ -282,17 +299,20 @@ export async function buildSubmitCheckReport(
|
|
|
282
299
|
),
|
|
283
300
|
);
|
|
284
301
|
checks.push(scorePromptAssetFreshness(providerRoot));
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
302
|
+
if (!indexParseError) {
|
|
303
|
+
checks.push(scoreProviderIdSlug(providerRoot, provider));
|
|
304
|
+
checks.push(scoreNoVendorShim(providerRoot));
|
|
305
|
+
checks.push(scoreNoVendorImport(providerRoot));
|
|
306
|
+
checks.push(scoreDescribeKey(providerRoot));
|
|
307
|
+
checks.push(scoreNoRawFetch(providerRoot));
|
|
308
|
+
checks.push(scoreNoDynamicCode(providerRoot));
|
|
309
|
+
checks.push(scoreNoRedundantRuntimeGuards(providerRoot));
|
|
310
|
+
checks.push(scoreManagedBrowserRuntime(providerRoot));
|
|
311
|
+
checks.push(scoreAsAssertionCount(providerRoot));
|
|
312
|
+
checks.push(scoreUnsafeInputPassthrough(providerRoot));
|
|
313
|
+
checks.push(scoreUnjustifiedLooseSchema(providerRoot));
|
|
314
|
+
checks.push(scoreFlatOperationComposition(providerRoot));
|
|
315
|
+
}
|
|
296
316
|
|
|
297
317
|
if (provider) {
|
|
298
318
|
const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
|
|
@@ -311,15 +331,38 @@ export async function buildSubmitCheckReport(
|
|
|
311
331
|
checks.push(scoreRepositoryDx(providerRoot));
|
|
312
332
|
checks.push(scoreSecrets(providerRoot, provider));
|
|
313
333
|
} else {
|
|
334
|
+
const loadEvidence = indexParseError
|
|
335
|
+
? undefined
|
|
336
|
+
: loadResult.ok
|
|
337
|
+
? undefined
|
|
338
|
+
: formatLoadErrorEvidence(loadResult.error, providerRoot);
|
|
339
|
+
const loadRemediation = indexParseError
|
|
340
|
+
? "Fix the syntax error reported by the provider-load-parse blocker before apifuse check can load the provider."
|
|
341
|
+
: !loadResult.ok
|
|
342
|
+
? "Fix the import/initialization failure shown in evidence so apifuse check can load the provider."
|
|
343
|
+
: "Fix index.ts so it default-exports defineProvider(...).";
|
|
314
344
|
checks.push(
|
|
315
345
|
blocker(
|
|
316
346
|
"provider-load",
|
|
317
347
|
"definition",
|
|
318
348
|
"Provider could not be loaded.",
|
|
319
|
-
|
|
349
|
+
loadRemediation,
|
|
320
350
|
CATEGORY_MAX_POINTS.definition,
|
|
351
|
+
loadEvidence,
|
|
321
352
|
),
|
|
322
353
|
);
|
|
354
|
+
if (indexParseError) {
|
|
355
|
+
checks.push(
|
|
356
|
+
blocker(
|
|
357
|
+
"provider-load-parse",
|
|
358
|
+
"definition",
|
|
359
|
+
"Provider index.ts could not be parsed safely.",
|
|
360
|
+
"Fix the syntax error in index.ts before submitting.",
|
|
361
|
+
0,
|
|
362
|
+
[indexParseError],
|
|
363
|
+
),
|
|
364
|
+
);
|
|
365
|
+
}
|
|
323
366
|
}
|
|
324
367
|
|
|
325
368
|
const total = clamp(Math.round(checks.reduce((sum, check) => sum + check.points, 0)), 0, 100);
|
|
@@ -447,7 +490,7 @@ function scoreDescribeKey(providerRoot: string): SubmitCheck {
|
|
|
447
490
|
}
|
|
448
491
|
|
|
449
492
|
function scoreNoRawFetch(providerRoot: string): SubmitCheck {
|
|
450
|
-
const findings =
|
|
493
|
+
const findings = findGlobalSinkCalls(providerRoot, FETCH_SINKS);
|
|
451
494
|
if (findings.length > 0) {
|
|
452
495
|
const evidence = formatSourceFindings(findings);
|
|
453
496
|
return blocker(
|
|
@@ -463,6 +506,435 @@ function scoreNoRawFetch(providerRoot: string): SubmitCheck {
|
|
|
463
506
|
return pass("no-raw-fetch", SDK_NATIVE_CATEGORY, "Provider source avoids raw fetch().", 0);
|
|
464
507
|
}
|
|
465
508
|
|
|
509
|
+
const DYNAMIC_CODE_SINKS: ReadonlySet<string> = new Set(["eval", "Function"]);
|
|
510
|
+
const FETCH_SINKS: ReadonlySet<string> = new Set(["fetch"]);
|
|
511
|
+
const DYNAMIC_CODE_GLOBAL_OBJECTS: ReadonlySet<string> = new Set([
|
|
512
|
+
"globalThis",
|
|
513
|
+
"window",
|
|
514
|
+
"self",
|
|
515
|
+
"global",
|
|
516
|
+
]);
|
|
517
|
+
|
|
518
|
+
function unwrapLocalExpression(expression: ts.Expression): ts.Expression {
|
|
519
|
+
let current = expression;
|
|
520
|
+
while (
|
|
521
|
+
ts.isParenthesizedExpression(current) ||
|
|
522
|
+
ts.isAsExpression(current) ||
|
|
523
|
+
ts.isSatisfiesExpression(current) ||
|
|
524
|
+
ts.isNonNullExpression(current)
|
|
525
|
+
) {
|
|
526
|
+
current = current.expression;
|
|
527
|
+
}
|
|
528
|
+
if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.CommaToken) {
|
|
529
|
+
return unwrapLocalExpression(current.right);
|
|
530
|
+
}
|
|
531
|
+
return current;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
type LocalBinding = {
|
|
535
|
+
declaration: ts.Identifier;
|
|
536
|
+
initializer?: ts.Expression;
|
|
537
|
+
destructuredProperty?: string;
|
|
538
|
+
mutable: boolean;
|
|
539
|
+
reassigned: boolean;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
type LocalBindings = {
|
|
543
|
+
scopes: Map<ts.Node, Map<string, LocalBinding[]>>;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
function isLexicalScope(node: ts.Node): boolean {
|
|
547
|
+
return (
|
|
548
|
+
ts.isSourceFile(node) ||
|
|
549
|
+
ts.isFunctionLike(node) ||
|
|
550
|
+
ts.isBlock(node) ||
|
|
551
|
+
ts.isModuleBlock(node) ||
|
|
552
|
+
ts.isCaseBlock(node) ||
|
|
553
|
+
ts.isCatchClause(node) ||
|
|
554
|
+
ts.isForStatement(node) ||
|
|
555
|
+
ts.isForInStatement(node) ||
|
|
556
|
+
ts.isForOfStatement(node)
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function enclosingScope(node: ts.Node, functionScoped: boolean): ts.Node {
|
|
561
|
+
let current: ts.Node | undefined = node.parent;
|
|
562
|
+
while (current !== undefined) {
|
|
563
|
+
if (
|
|
564
|
+
ts.isSourceFile(current) ||
|
|
565
|
+
(functionScoped ? ts.isFunctionLike(current) : isLexicalScope(current))
|
|
566
|
+
) {
|
|
567
|
+
return current;
|
|
568
|
+
}
|
|
569
|
+
current = current.parent;
|
|
570
|
+
}
|
|
571
|
+
return node.getSourceFile();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function collectLocalBindings(sourceFile: ts.SourceFile): LocalBindings {
|
|
575
|
+
const bindings: LocalBindings = { scopes: new Map() };
|
|
576
|
+
const addBinding = (
|
|
577
|
+
scope: ts.Node,
|
|
578
|
+
name: ts.Identifier,
|
|
579
|
+
binding: Omit<LocalBinding, "declaration" | "reassigned">,
|
|
580
|
+
): void => {
|
|
581
|
+
let scopeBindings = bindings.scopes.get(scope);
|
|
582
|
+
if (scopeBindings === undefined) {
|
|
583
|
+
scopeBindings = new Map();
|
|
584
|
+
bindings.scopes.set(scope, scopeBindings);
|
|
585
|
+
}
|
|
586
|
+
const existing = scopeBindings.get(name.text);
|
|
587
|
+
const localBinding: LocalBinding = { ...binding, declaration: name, reassigned: false };
|
|
588
|
+
if (existing === undefined) {
|
|
589
|
+
scopeBindings.set(name.text, [localBinding]);
|
|
590
|
+
} else {
|
|
591
|
+
existing.push(localBinding);
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
const addBindingName = (
|
|
595
|
+
bindingName: ts.BindingName,
|
|
596
|
+
scope: ts.Node,
|
|
597
|
+
binding: Omit<LocalBinding, "declaration" | "reassigned">,
|
|
598
|
+
): void => {
|
|
599
|
+
if (ts.isIdentifier(bindingName)) {
|
|
600
|
+
addBinding(scope, bindingName, binding);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
for (const element of bindingName.elements) {
|
|
604
|
+
if (ts.isOmittedExpression(element)) {
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (ts.isObjectBindingPattern(bindingName) && !element.dotDotDotToken) {
|
|
608
|
+
const property = element.propertyName ?? element.name;
|
|
609
|
+
const propertyName = propertyNameText(property);
|
|
610
|
+
if (propertyName !== undefined && ts.isIdentifier(element.name)) {
|
|
611
|
+
addBinding(scope, element.name, {
|
|
612
|
+
initializer: binding.initializer,
|
|
613
|
+
destructuredProperty: propertyName,
|
|
614
|
+
mutable: binding.mutable,
|
|
615
|
+
});
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
addBindingName(element.name, scope, { mutable: binding.mutable });
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
const visit = (node: ts.Node): void => {
|
|
623
|
+
if (ts.isVariableDeclaration(node)) {
|
|
624
|
+
const declarationList = ts.isVariableDeclarationList(node.parent) ? node.parent : undefined;
|
|
625
|
+
const isConstBinding =
|
|
626
|
+
declarationList !== undefined && (declarationList.flags & ts.NodeFlags.Const) !== 0;
|
|
627
|
+
const isBlockScoped =
|
|
628
|
+
declarationList !== undefined && (declarationList.flags & ts.NodeFlags.BlockScoped) !== 0;
|
|
629
|
+
addBindingName(node.name, enclosingScope(node, !isBlockScoped), {
|
|
630
|
+
initializer: node.initializer,
|
|
631
|
+
mutable: !isConstBinding,
|
|
632
|
+
});
|
|
633
|
+
} else if (ts.isFunctionDeclaration(node) && node.name !== undefined) {
|
|
634
|
+
addBinding(enclosingScope(node, false), node.name, { mutable: false });
|
|
635
|
+
} else if (ts.isFunctionExpression(node) && node.name !== undefined) {
|
|
636
|
+
addBinding(node, node.name, { mutable: false });
|
|
637
|
+
} else if (ts.isClassDeclaration(node) && node.name !== undefined) {
|
|
638
|
+
addBinding(enclosingScope(node, false), node.name, { mutable: false });
|
|
639
|
+
} else if (ts.isClassExpression(node) && node.name !== undefined) {
|
|
640
|
+
addBinding(node, node.name, { mutable: false });
|
|
641
|
+
} else if (ts.isParameter(node)) {
|
|
642
|
+
addBindingName(node.name, enclosingScope(node, true), { mutable: true });
|
|
643
|
+
} else if (ts.isImportClause(node) && node.name !== undefined) {
|
|
644
|
+
addBinding(sourceFile, node.name, { mutable: false });
|
|
645
|
+
} else if (ts.isNamespaceImport(node) || ts.isImportSpecifier(node)) {
|
|
646
|
+
addBinding(sourceFile, node.name, { mutable: false });
|
|
647
|
+
}
|
|
648
|
+
ts.forEachChild(node, visit);
|
|
649
|
+
};
|
|
650
|
+
visit(sourceFile);
|
|
651
|
+
|
|
652
|
+
const markReassigned = (identifier: ts.Identifier): void => {
|
|
653
|
+
for (const binding of lookupLocalBindings(identifier, bindings) ?? []) {
|
|
654
|
+
binding.reassigned = true;
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
const markAssignmentTarget = (node: ts.Node): void => {
|
|
658
|
+
const target = ts.isExpression(node) ? unwrapLocalExpression(node) : node;
|
|
659
|
+
if (ts.isIdentifier(target)) {
|
|
660
|
+
markReassigned(target);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (ts.isObjectLiteralExpression(target)) {
|
|
664
|
+
for (const element of target.properties) {
|
|
665
|
+
if (ts.isSpreadAssignment(element)) {
|
|
666
|
+
markAssignmentTarget(element.expression);
|
|
667
|
+
} else if (ts.isPropertyAssignment(element)) {
|
|
668
|
+
markAssignmentTarget(element.initializer);
|
|
669
|
+
} else if (ts.isShorthandPropertyAssignment(element)) {
|
|
670
|
+
markAssignmentTarget(element.name);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
} else if (ts.isArrayLiteralExpression(target)) {
|
|
674
|
+
for (const element of target.elements) {
|
|
675
|
+
if (!ts.isOmittedExpression(element)) {
|
|
676
|
+
markAssignmentTarget(ts.isSpreadElement(element) ? element.expression : element);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
const visitAssignments = (node: ts.Node): void => {
|
|
682
|
+
if (
|
|
683
|
+
ts.isBinaryExpression(node) &&
|
|
684
|
+
node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
685
|
+
node.operatorToken.kind <= ts.SyntaxKind.LastAssignment
|
|
686
|
+
) {
|
|
687
|
+
markAssignmentTarget(node.left);
|
|
688
|
+
} else if (
|
|
689
|
+
(ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
|
|
690
|
+
(node.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
691
|
+
node.operator === ts.SyntaxKind.MinusMinusToken)
|
|
692
|
+
) {
|
|
693
|
+
markAssignmentTarget(node.operand);
|
|
694
|
+
} else if (
|
|
695
|
+
(ts.isForInStatement(node) || ts.isForOfStatement(node)) &&
|
|
696
|
+
!ts.isVariableDeclarationList(node.initializer)
|
|
697
|
+
) {
|
|
698
|
+
markAssignmentTarget(node.initializer);
|
|
699
|
+
}
|
|
700
|
+
ts.forEachChild(node, visitAssignments);
|
|
701
|
+
};
|
|
702
|
+
visitAssignments(sourceFile);
|
|
703
|
+
return bindings;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function lookupLocalBindings(
|
|
707
|
+
reference: ts.Identifier,
|
|
708
|
+
bindings: LocalBindings,
|
|
709
|
+
): readonly LocalBinding[] | undefined {
|
|
710
|
+
let current: ts.Node | undefined = reference;
|
|
711
|
+
while (current !== undefined) {
|
|
712
|
+
const localBindings = bindings.scopes.get(current)?.get(reference.text);
|
|
713
|
+
if (localBindings !== undefined && localBindings.length > 0) {
|
|
714
|
+
return localBindings;
|
|
715
|
+
}
|
|
716
|
+
current = current.parent;
|
|
717
|
+
}
|
|
718
|
+
return undefined;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function propertyNameText(name: ts.PropertyName | ts.BindingName): string | undefined {
|
|
722
|
+
if (
|
|
723
|
+
ts.isIdentifier(name) ||
|
|
724
|
+
ts.isStringLiteral(name) ||
|
|
725
|
+
ts.isNumericLiteral(name) ||
|
|
726
|
+
ts.isNoSubstitutionTemplateLiteral(name)
|
|
727
|
+
) {
|
|
728
|
+
return name.text;
|
|
729
|
+
}
|
|
730
|
+
if (ts.isComputedPropertyName(name)) {
|
|
731
|
+
const expression = unwrapLocalExpression(name.expression);
|
|
732
|
+
if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
|
|
733
|
+
return expression.text;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return undefined;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function isUnshadowedGlobalObject(reference: ts.Expression, bindings: LocalBindings): boolean {
|
|
740
|
+
const receiver = unwrapLocalExpression(reference);
|
|
741
|
+
return (
|
|
742
|
+
ts.isIdentifier(receiver) &&
|
|
743
|
+
DYNAMIC_CODE_GLOBAL_OBJECTS.has(receiver.text) &&
|
|
744
|
+
lookupLocalBindings(receiver, bindings) === undefined
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function isGlobalMemberSink(
|
|
749
|
+
expression: ts.Expression,
|
|
750
|
+
sinkNames: ReadonlySet<string>,
|
|
751
|
+
bindings: LocalBindings,
|
|
752
|
+
): boolean {
|
|
753
|
+
const callee = unwrapLocalExpression(expression);
|
|
754
|
+
let sinkName: string | undefined;
|
|
755
|
+
let receiver: ts.Expression | undefined;
|
|
756
|
+
if (ts.isPropertyAccessExpression(callee)) {
|
|
757
|
+
sinkName = callee.name.text;
|
|
758
|
+
receiver = callee.expression;
|
|
759
|
+
} else if (ts.isElementAccessExpression(callee)) {
|
|
760
|
+
const argument = callee.argumentExpression;
|
|
761
|
+
if (
|
|
762
|
+
argument !== undefined &&
|
|
763
|
+
(ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
|
|
764
|
+
) {
|
|
765
|
+
sinkName = argument.text;
|
|
766
|
+
receiver = callee.expression;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if (sinkName === undefined || receiver === undefined || !sinkNames.has(sinkName)) {
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
return isUnshadowedGlobalObject(receiver, bindings);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function isGlobalSinkReference(
|
|
776
|
+
expression: ts.Expression,
|
|
777
|
+
sinkNames: ReadonlySet<string>,
|
|
778
|
+
bindings: LocalBindings,
|
|
779
|
+
resolving: ReadonlySet<LocalBinding>,
|
|
780
|
+
): boolean {
|
|
781
|
+
const reference = unwrapLocalExpression(expression);
|
|
782
|
+
if (isGlobalMemberSink(reference, sinkNames, bindings)) {
|
|
783
|
+
return true;
|
|
784
|
+
}
|
|
785
|
+
if (!ts.isIdentifier(reference)) {
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
return isGlobalSinkIdentifier(reference, sinkNames, bindings, resolving);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function isGlobalSinkIdentifier(
|
|
792
|
+
reference: ts.Identifier,
|
|
793
|
+
sinkNames: ReadonlySet<string>,
|
|
794
|
+
bindings: LocalBindings,
|
|
795
|
+
resolving: ReadonlySet<LocalBinding>,
|
|
796
|
+
): boolean {
|
|
797
|
+
const localBindings = lookupLocalBindings(reference, bindings);
|
|
798
|
+
if (localBindings === undefined || localBindings.length === 0) {
|
|
799
|
+
return sinkNames.has(reference.text);
|
|
800
|
+
}
|
|
801
|
+
return localBindings.every((binding) => {
|
|
802
|
+
if (resolving.has(binding) || binding.initializer === undefined) {
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
// A mutable alias is safe to resolve only when no assignment targeting
|
|
806
|
+
// this lexical binding exists in its scope (including nested closures).
|
|
807
|
+
// Reassigned let/var aliases remain unresolved to avoid false positives.
|
|
808
|
+
if (binding.mutable && binding.reassigned) {
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
const nextResolving = new Set(resolving);
|
|
812
|
+
nextResolving.add(binding);
|
|
813
|
+
if (binding.destructuredProperty !== undefined) {
|
|
814
|
+
return (
|
|
815
|
+
sinkNames.has(binding.destructuredProperty) &&
|
|
816
|
+
isUnshadowedGlobalObject(binding.initializer, bindings)
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
return isGlobalSinkReference(binding.initializer, sinkNames, bindings, nextResolving);
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function isGlobalSinkCallee(
|
|
824
|
+
expression: ts.Expression,
|
|
825
|
+
sinkNames: ReadonlySet<string>,
|
|
826
|
+
bindings: LocalBindings,
|
|
827
|
+
): boolean {
|
|
828
|
+
const callee = unwrapLocalExpression(expression);
|
|
829
|
+
if (isGlobalMemberSink(callee, sinkNames, bindings)) {
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
if (!ts.isIdentifier(callee)) {
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
return isGlobalSinkIdentifier(callee, sinkNames, bindings, new Set());
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function resolveConstStringIdentifier(
|
|
839
|
+
reference: ts.Identifier,
|
|
840
|
+
bindings: LocalBindings,
|
|
841
|
+
resolving: ReadonlySet<LocalBinding> = new Set(),
|
|
842
|
+
): string | undefined {
|
|
843
|
+
const localBindings = lookupLocalBindings(reference, bindings);
|
|
844
|
+
if (localBindings === undefined || localBindings.length === 0) {
|
|
845
|
+
return undefined;
|
|
846
|
+
}
|
|
847
|
+
let resolved: string | undefined;
|
|
848
|
+
for (const binding of localBindings) {
|
|
849
|
+
if (
|
|
850
|
+
resolving.has(binding) ||
|
|
851
|
+
binding.mutable ||
|
|
852
|
+
binding.initializer === undefined ||
|
|
853
|
+
binding.destructuredProperty !== undefined
|
|
854
|
+
) {
|
|
855
|
+
return undefined;
|
|
856
|
+
}
|
|
857
|
+
const nextResolving = new Set(resolving);
|
|
858
|
+
nextResolving.add(binding);
|
|
859
|
+
const initializer = unwrapLocalExpression(binding.initializer);
|
|
860
|
+
let value: string | undefined;
|
|
861
|
+
if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) {
|
|
862
|
+
value = initializer.text;
|
|
863
|
+
} else if (ts.isIdentifier(initializer)) {
|
|
864
|
+
value = resolveConstStringIdentifier(initializer, bindings, nextResolving);
|
|
865
|
+
}
|
|
866
|
+
if (value === undefined || (resolved !== undefined && resolved !== value)) {
|
|
867
|
+
return undefined;
|
|
868
|
+
}
|
|
869
|
+
resolved = value;
|
|
870
|
+
}
|
|
871
|
+
return resolved;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function findGlobalSinkCalls(
|
|
875
|
+
providerRoot: string,
|
|
876
|
+
sinkNames: ReadonlySet<string>,
|
|
877
|
+
includeConstructors = false,
|
|
878
|
+
): SourceFinding[] {
|
|
879
|
+
const findings: SourceFinding[] = [];
|
|
880
|
+
const seen = new Set<string>();
|
|
881
|
+
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
882
|
+
const source = readFileSync(filePath, "utf8");
|
|
883
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
884
|
+
const sourceFile = ts.createSourceFile(
|
|
885
|
+
relPath,
|
|
886
|
+
source,
|
|
887
|
+
ts.ScriptTarget.Latest,
|
|
888
|
+
true,
|
|
889
|
+
ts.ScriptKind.TS,
|
|
890
|
+
);
|
|
891
|
+
const bindings = collectLocalBindings(sourceFile);
|
|
892
|
+
const visit = (node: ts.Node): void => {
|
|
893
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (
|
|
897
|
+
(ts.isCallExpression(node) || (includeConstructors && ts.isNewExpression(node))) &&
|
|
898
|
+
isGlobalSinkCallee(node.expression, sinkNames, bindings)
|
|
899
|
+
) {
|
|
900
|
+
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
901
|
+
const key = `${relPath}:${line}`;
|
|
902
|
+
if (!seen.has(key)) {
|
|
903
|
+
seen.add(key);
|
|
904
|
+
findings.push({ file: relPath, line });
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
ts.forEachChild(node, visit);
|
|
908
|
+
};
|
|
909
|
+
visit(sourceFile);
|
|
910
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
911
|
+
break;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return findings;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function scoreNoDynamicCode(providerRoot: string): SubmitCheck {
|
|
918
|
+
const findings = findGlobalSinkCalls(providerRoot, DYNAMIC_CODE_SINKS, true);
|
|
919
|
+
if (findings.length > 0) {
|
|
920
|
+
return blocker(
|
|
921
|
+
"no-dynamic-code",
|
|
922
|
+
SDK_NATIVE_CATEGORY,
|
|
923
|
+
"Dynamic code evaluation is not permitted in provider source.",
|
|
924
|
+
"Remove eval() and Function constructor calls. Provider HTTP access must go through ctx.http.",
|
|
925
|
+
0,
|
|
926
|
+
formatSourceFindings(findings),
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
return pass(
|
|
931
|
+
"no-dynamic-code",
|
|
932
|
+
SDK_NATIVE_CATEGORY,
|
|
933
|
+
"Provider source avoids dynamic code evaluation.",
|
|
934
|
+
0,
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
|
|
466
938
|
const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
|
|
467
939
|
/\bctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\?\./,
|
|
468
940
|
];
|
|
@@ -518,7 +990,11 @@ function countAsAssertions(providerRoot: string): {
|
|
|
518
990
|
|
|
519
991
|
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
520
992
|
const content = readFileSync(filePath, "utf8");
|
|
521
|
-
const lines =
|
|
993
|
+
const lines = maskCommentsAndStrings(
|
|
994
|
+
content,
|
|
995
|
+
toRelativeProviderPath(providerRoot, filePath),
|
|
996
|
+
{ blankPropertyKeys: true },
|
|
997
|
+
).split(/\r?\n/);
|
|
522
998
|
for (let index = 0; index < lines.length; index += 1) {
|
|
523
999
|
const line = lines[index];
|
|
524
1000
|
if (
|
|
@@ -709,18 +1185,44 @@ function offsetToLine(source: string, offset: number): number {
|
|
|
709
1185
|
// between them, so `.passthrough ()` / `.passthrough\n()` are still detected.
|
|
710
1186
|
const PASSTHROUGH_CALL = /\.passthrough\s*\(\s*\)/;
|
|
711
1187
|
|
|
1188
|
+
type ExpressionSlice = {
|
|
1189
|
+
raw: string;
|
|
1190
|
+
masked: string;
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1193
|
+
function trimExpressionSlice(expr: ExpressionSlice): ExpressionSlice {
|
|
1194
|
+
const start = expr.raw.length - expr.raw.trimStart().length;
|
|
1195
|
+
const end = expr.raw.trimEnd().length;
|
|
1196
|
+
return {
|
|
1197
|
+
raw: expr.raw.slice(start, end),
|
|
1198
|
+
masked: expr.masked.slice(start, end),
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function balancedExpressionSlice(
|
|
1203
|
+
source: string,
|
|
1204
|
+
valueStart: number,
|
|
1205
|
+
fileName?: string,
|
|
1206
|
+
): ExpressionSlice {
|
|
1207
|
+
const raw = balancedValueExpression(source, valueStart, fileName);
|
|
1208
|
+
return trimExpressionSlice({
|
|
1209
|
+
raw,
|
|
1210
|
+
masked: maskCommentsAndStrings(source, fileName).slice(valueStart, valueStart + raw.length),
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
|
|
712
1214
|
// Strips redundant wrapping parentheses from an expression so that a value like
|
|
713
1215
|
// `(makeOperations())` or `((x))` classifies the same as `makeOperations()`.
|
|
714
1216
|
// Only unwraps when the leading `(` matches the trailing `)` at depth 0 (i.e.
|
|
715
1217
|
// the whole expression is parenthesized), preserving call expressions such as
|
|
716
1218
|
// `makeOperations()` whose first `(` is not a wrapper.
|
|
717
|
-
function unwrapParens(expr:
|
|
718
|
-
let value = expr
|
|
719
|
-
while (value.startsWith("(")) {
|
|
1219
|
+
function unwrapParens(expr: ExpressionSlice): ExpressionSlice {
|
|
1220
|
+
let value = trimExpressionSlice(expr);
|
|
1221
|
+
while (value.raw.startsWith("(")) {
|
|
720
1222
|
let depth = 0;
|
|
721
1223
|
let matchIndex = -1;
|
|
722
|
-
for (let i = 0; i < value.length; i += 1) {
|
|
723
|
-
const ch = value[i];
|
|
1224
|
+
for (let i = 0; i < value.raw.length; i += 1) {
|
|
1225
|
+
const ch = value.masked[i];
|
|
724
1226
|
if (ch === "(") {
|
|
725
1227
|
depth += 1;
|
|
726
1228
|
} else if (ch === ")") {
|
|
@@ -733,8 +1235,11 @@ function unwrapParens(expr: string): string {
|
|
|
733
1235
|
}
|
|
734
1236
|
// Only a true wrapper spans the entire expression (closing paren is the
|
|
735
1237
|
// last char). Otherwise the leading `(` belongs to a sub-expression.
|
|
736
|
-
if (matchIndex === value.length - 1) {
|
|
737
|
-
value =
|
|
1238
|
+
if (matchIndex === value.raw.length - 1) {
|
|
1239
|
+
value = trimExpressionSlice({
|
|
1240
|
+
raw: value.raw.slice(1, -1),
|
|
1241
|
+
masked: value.masked.slice(1, -1),
|
|
1242
|
+
});
|
|
738
1243
|
} else {
|
|
739
1244
|
break;
|
|
740
1245
|
}
|
|
@@ -746,8 +1251,8 @@ function unwrapParens(expr: string): string {
|
|
|
746
1251
|
// across (){}[] and stopping at the first top-level `,`/`;` or unmatched
|
|
747
1252
|
// closing bracket. This lets a property value be read across newlines, so a
|
|
748
1253
|
// multi-line `input: z.object({...})\n.passthrough()` is captured whole.
|
|
749
|
-
function balancedValueExpression(source: string, valueStart: number): string {
|
|
750
|
-
const masked = maskCommentsAndStrings(source);
|
|
1254
|
+
function balancedValueExpression(source: string, valueStart: number, fileName?: string): string {
|
|
1255
|
+
const masked = maskCommentsAndStrings(source, fileName);
|
|
751
1256
|
let depth = 0;
|
|
752
1257
|
let index = valueStart;
|
|
753
1258
|
for (; index < source.length; index += 1) {
|
|
@@ -771,14 +1276,14 @@ function balancedValueExpression(source: string, valueStart: number): string {
|
|
|
771
1276
|
// nested deeper than the outer object (inside handler bodies, nested objects,
|
|
772
1277
|
// or arrays) are ignored, so only a factory composition of the object itself
|
|
773
1278
|
// is detected. Input is expected to start at the outer `{`.
|
|
774
|
-
function hasTopLevelFactorySpread(expr:
|
|
775
|
-
const open = expr.indexOf("{");
|
|
1279
|
+
function hasTopLevelFactorySpread(expr: ExpressionSlice): boolean {
|
|
1280
|
+
const open = expr.masked.indexOf("{");
|
|
776
1281
|
if (open === -1) {
|
|
777
1282
|
return false;
|
|
778
1283
|
}
|
|
779
1284
|
let depth = 0;
|
|
780
|
-
for (let i = open; i < expr.length; i += 1) {
|
|
781
|
-
const ch = expr[i];
|
|
1285
|
+
for (let i = open; i < expr.raw.length; i += 1) {
|
|
1286
|
+
const ch = expr.masked[i];
|
|
782
1287
|
if (ch === "{" || ch === "(" || ch === "[") {
|
|
783
1288
|
depth += 1;
|
|
784
1289
|
} else if (ch === "}" || ch === ")" || ch === "]") {
|
|
@@ -786,11 +1291,11 @@ function hasTopLevelFactorySpread(expr: string): boolean {
|
|
|
786
1291
|
if (depth === 0) {
|
|
787
1292
|
break;
|
|
788
1293
|
}
|
|
789
|
-
} else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
|
|
1294
|
+
} else if (ch === "." && depth === 1 && expr.masked.startsWith("...", i)) {
|
|
790
1295
|
// A spread at the object's own level. Check whether the spread
|
|
791
1296
|
// argument is a call expression (factory) rather than a plain
|
|
792
1297
|
// identifier/member spread of an already-built object.
|
|
793
|
-
const rest = expr.slice(i + 3);
|
|
1298
|
+
const rest = expr.raw.slice(i + 3);
|
|
794
1299
|
if (/^\s*[A-Za-z_$][\w$.]*\s*\(/.test(rest)) {
|
|
795
1300
|
return true;
|
|
796
1301
|
}
|
|
@@ -805,15 +1310,15 @@ function hasTopLevelFactorySpread(expr: string): boolean {
|
|
|
805
1310
|
// hasTopLevelFactorySpread, so it is excluded here. These identifiers must be
|
|
806
1311
|
// resolved to their declarations: `const hidden = makeOperations()` spread as
|
|
807
1312
|
// `{ ...hidden }` is still a factory-composed map and must block.
|
|
808
|
-
function topLevelSpreadIdentifiers(expr:
|
|
809
|
-
const open = expr.indexOf("{");
|
|
1313
|
+
function topLevelSpreadIdentifiers(expr: ExpressionSlice): string[] {
|
|
1314
|
+
const open = expr.masked.indexOf("{");
|
|
810
1315
|
if (open === -1) {
|
|
811
1316
|
return [];
|
|
812
1317
|
}
|
|
813
1318
|
const names: string[] = [];
|
|
814
1319
|
let depth = 0;
|
|
815
|
-
for (let i = open; i < expr.length; i += 1) {
|
|
816
|
-
const ch = expr[i];
|
|
1320
|
+
for (let i = open; i < expr.raw.length; i += 1) {
|
|
1321
|
+
const ch = expr.masked[i];
|
|
817
1322
|
if (ch === "{" || ch === "(" || ch === "[") {
|
|
818
1323
|
depth += 1;
|
|
819
1324
|
} else if (ch === "}" || ch === ")" || ch === "]") {
|
|
@@ -821,8 +1326,8 @@ function topLevelSpreadIdentifiers(expr: string): string[] {
|
|
|
821
1326
|
if (depth === 0) {
|
|
822
1327
|
break;
|
|
823
1328
|
}
|
|
824
|
-
} else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
|
|
825
|
-
const rest = expr.slice(i + 3);
|
|
1329
|
+
} else if (ch === "." && depth === 1 && expr.masked.startsWith("...", i)) {
|
|
1330
|
+
const rest = expr.raw.slice(i + 3);
|
|
826
1331
|
// Bare identifier spread (no call parens) -> needs declaration
|
|
827
1332
|
// resolution. `...obj.prop` member spreads are treated as already
|
|
828
1333
|
// built and ignored (the leading identifier is captured).
|
|
@@ -855,8 +1360,8 @@ function topLevelSpreadIdentifiers(expr: string): string[] {
|
|
|
855
1360
|
// `makeOperations()` — stays classified as factory composition.
|
|
856
1361
|
const TRANSPARENT_RESHAPE_HEAD = /^Object\s*\.\s*fromEntries\s*\(/;
|
|
857
1362
|
const OBJECT_ENTRIES_HEAD = /^Object\s*\.\s*entries\s*\(/;
|
|
858
|
-
function isTransparentObjectReshape(expr:
|
|
859
|
-
const head = TRANSPARENT_RESHAPE_HEAD.exec(expr);
|
|
1363
|
+
function isTransparentObjectReshape(expr: ExpressionSlice): boolean {
|
|
1364
|
+
const head = TRANSPARENT_RESHAPE_HEAD.exec(expr.masked);
|
|
860
1365
|
if (!head) {
|
|
861
1366
|
return false;
|
|
862
1367
|
}
|
|
@@ -864,7 +1369,7 @@ function isTransparentObjectReshape(expr: string): boolean {
|
|
|
864
1369
|
// transparent only when that argument's root callee is `Object.entries(`
|
|
865
1370
|
// (optionally chained: `Object.entries(obj).filter(...)`), so the source
|
|
866
1371
|
// object is enumerable from source rather than produced by an opaque call.
|
|
867
|
-
const firstArg = expr.slice(head[0].length).trimStart();
|
|
1372
|
+
const firstArg = expr.masked.slice(head[0].length).trimStart();
|
|
868
1373
|
return OBJECT_ENTRIES_HEAD.test(firstArg);
|
|
869
1374
|
}
|
|
870
1375
|
|
|
@@ -998,7 +1503,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
998
1503
|
continue;
|
|
999
1504
|
}
|
|
1000
1505
|
const valueStart = match.index + match[0].length;
|
|
1001
|
-
const value = balancedValueExpression(source, valueStart);
|
|
1506
|
+
const value = balancedValueExpression(source, valueStart, relPath);
|
|
1002
1507
|
if (PASSTHROUGH_CALL.test(value)) {
|
|
1003
1508
|
const site: ConstSite = {
|
|
1004
1509
|
file: relPath,
|
|
@@ -1027,8 +1532,8 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
1027
1532
|
// that is itself a passthrough expression, or that references a passthrough
|
|
1028
1533
|
// const by name (resolved against the provider-wide map), is a violation.
|
|
1029
1534
|
for (const filePath of files) {
|
|
1030
|
-
const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
|
|
1031
1535
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
1536
|
+
const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
|
|
1032
1537
|
|
|
1033
1538
|
const inputProp = /\binput\s*:\s*/g;
|
|
1034
1539
|
for (let match = inputProp.exec(source); match !== null; match = inputProp.exec(source)) {
|
|
@@ -1039,7 +1544,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
1039
1544
|
continue;
|
|
1040
1545
|
}
|
|
1041
1546
|
const valueStart = match.index + match[0].length;
|
|
1042
|
-
const value = balancedValueExpression(source, valueStart);
|
|
1547
|
+
const value = balancedValueExpression(source, valueStart, relPath);
|
|
1043
1548
|
if (PASSTHROUGH_CALL.test(value)) {
|
|
1044
1549
|
push({ file: relPath, line: offsetToLine(source, valueStart) });
|
|
1045
1550
|
continue;
|
|
@@ -1150,13 +1655,19 @@ function spreadIdentifierResolvesToFactory(
|
|
|
1150
1655
|
if (!existsSync(filePath)) {
|
|
1151
1656
|
continue;
|
|
1152
1657
|
}
|
|
1658
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
1153
1659
|
const fileSource = filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
|
|
1660
|
+
const maskedFileSource = maskCommentsAndStrings(fileSource, relPath, {
|
|
1661
|
+
blankPropertyKeys: true,
|
|
1662
|
+
});
|
|
1154
1663
|
const re = new RegExp(declRe.source, "g");
|
|
1155
|
-
for (let m = re.exec(
|
|
1664
|
+
for (let m = re.exec(maskedFileSource); m !== null; m = re.exec(maskedFileSource)) {
|
|
1156
1665
|
sawDeclaration = true;
|
|
1157
|
-
const expr = unwrapParens(
|
|
1666
|
+
const expr = unwrapParens(
|
|
1667
|
+
balancedExpressionSlice(fileSource, m.index + m[0].length, relPath),
|
|
1668
|
+
);
|
|
1158
1669
|
const isFactory =
|
|
1159
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
|
|
1670
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr.masked) || hasTopLevelFactorySpread(expr)) &&
|
|
1160
1671
|
!isTransparentObjectReshape(expr);
|
|
1161
1672
|
if (isFactory) {
|
|
1162
1673
|
return true;
|
|
@@ -1165,12 +1676,232 @@ function spreadIdentifierResolvesToFactory(
|
|
|
1165
1676
|
}
|
|
1166
1677
|
// No local declaration anywhere but imported into index.ts => constructed
|
|
1167
1678
|
// out of view; treat as factory (conservative, false-negative-safe).
|
|
1168
|
-
if (
|
|
1679
|
+
if (
|
|
1680
|
+
!sawDeclaration &&
|
|
1681
|
+
fileImportsBinding(
|
|
1682
|
+
maskCommentsAndStrings(indexSource, "index.ts", { blankPropertyKeys: true }),
|
|
1683
|
+
name,
|
|
1684
|
+
)
|
|
1685
|
+
) {
|
|
1169
1686
|
return true;
|
|
1170
1687
|
}
|
|
1171
1688
|
return false;
|
|
1172
1689
|
}
|
|
1173
1690
|
|
|
1691
|
+
function isDefineProviderCall(node: ts.CallExpression): boolean {
|
|
1692
|
+
return ts.isIdentifier(node.expression) && node.expression.text === "defineProvider";
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
function resolveDefineProviderImplementationCall(
|
|
1696
|
+
candidate: ts.CallExpression,
|
|
1697
|
+
): ts.CallExpression | undefined {
|
|
1698
|
+
let root = candidate;
|
|
1699
|
+
while (ts.isCallExpression(root.expression)) {
|
|
1700
|
+
root = root.expression;
|
|
1701
|
+
}
|
|
1702
|
+
return isDefineProviderCall(root) ? candidate : undefined;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
function findProviderImplementationCall(sourceFile: ts.SourceFile): ts.CallExpression | undefined {
|
|
1706
|
+
const defaultExports = sourceFile.statements.filter(
|
|
1707
|
+
(statement): statement is ts.ExportAssignment =>
|
|
1708
|
+
ts.isExportAssignment(statement) && !statement.isExportEquals,
|
|
1709
|
+
);
|
|
1710
|
+
|
|
1711
|
+
// Phase-separated providers export a builder invocation directly. Preserve
|
|
1712
|
+
// the previous locator's preference for that call over every defineProvider
|
|
1713
|
+
// declaration that may also appear in the file.
|
|
1714
|
+
for (const assignment of defaultExports) {
|
|
1715
|
+
const expression = unwrapImplementationExpression(assignment.expression);
|
|
1716
|
+
if (!ts.isCallExpression(expression)) {
|
|
1717
|
+
continue;
|
|
1718
|
+
}
|
|
1719
|
+
if (resolveDefineProviderImplementationCall(expression) !== undefined) {
|
|
1720
|
+
continue;
|
|
1721
|
+
}
|
|
1722
|
+
if (ts.isIdentifier(expression.expression)) {
|
|
1723
|
+
return expression;
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
// For `defineProvider(metadata)(implementation)`, select the outer call whose
|
|
1728
|
+
// argument is the implementation. A plain `defineProvider(implementation)`
|
|
1729
|
+
// selects the single call itself.
|
|
1730
|
+
for (const assignment of defaultExports) {
|
|
1731
|
+
const defaultExpression = unwrapImplementationExpression(assignment.expression);
|
|
1732
|
+
if (!ts.isCallExpression(defaultExpression)) {
|
|
1733
|
+
continue;
|
|
1734
|
+
}
|
|
1735
|
+
const implementationCall = resolveDefineProviderImplementationCall(defaultExpression);
|
|
1736
|
+
if (implementationCall !== undefined) {
|
|
1737
|
+
return implementationCall;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// Resolve `export default provider` to a top-level variable initialized by
|
|
1742
|
+
// defineProvider, including its curried implementation call when present.
|
|
1743
|
+
for (const assignment of defaultExports) {
|
|
1744
|
+
if (!ts.isIdentifier(assignment.expression)) {
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
const exportedName = assignment.expression.text;
|
|
1748
|
+
for (const statement of sourceFile.statements) {
|
|
1749
|
+
if (!ts.isVariableStatement(statement)) {
|
|
1750
|
+
continue;
|
|
1751
|
+
}
|
|
1752
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1753
|
+
if (
|
|
1754
|
+
!ts.isIdentifier(declaration.name) ||
|
|
1755
|
+
declaration.name.text !== exportedName ||
|
|
1756
|
+
declaration.initializer === undefined
|
|
1757
|
+
) {
|
|
1758
|
+
continue;
|
|
1759
|
+
}
|
|
1760
|
+
if (!ts.isCallExpression(declaration.initializer)) {
|
|
1761
|
+
continue;
|
|
1762
|
+
}
|
|
1763
|
+
const implementationCall = resolveDefineProviderImplementationCall(declaration.initializer);
|
|
1764
|
+
if (implementationCall !== undefined) {
|
|
1765
|
+
return implementationCall;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// Structural fixtures without a recognizable default export retain the
|
|
1772
|
+
// previous fallback to the first defineProvider call in source order.
|
|
1773
|
+
let firstCall: ts.CallExpression | undefined;
|
|
1774
|
+
const visit = (node: ts.Node): void => {
|
|
1775
|
+
if (firstCall !== undefined) {
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
if (ts.isCallExpression(node)) {
|
|
1779
|
+
const implementationCall = resolveDefineProviderImplementationCall(node);
|
|
1780
|
+
if (implementationCall !== undefined) {
|
|
1781
|
+
firstCall = implementationCall;
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
ts.forEachChild(node, visit);
|
|
1786
|
+
};
|
|
1787
|
+
visit(sourceFile);
|
|
1788
|
+
return firstCall;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
function unwrapImplementationExpression(expression: ts.Expression): ts.Expression {
|
|
1792
|
+
let current = expression;
|
|
1793
|
+
while (
|
|
1794
|
+
ts.isSatisfiesExpression(current) ||
|
|
1795
|
+
ts.isAsExpression(current) ||
|
|
1796
|
+
ts.isParenthesizedExpression(current) ||
|
|
1797
|
+
ts.isNonNullExpression(current)
|
|
1798
|
+
) {
|
|
1799
|
+
current = current.expression;
|
|
1800
|
+
}
|
|
1801
|
+
return current;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
function isOperationsProperty(
|
|
1805
|
+
property: ts.ObjectLiteralElementLike,
|
|
1806
|
+
): property is ts.PropertyAssignment | ts.ShorthandPropertyAssignment {
|
|
1807
|
+
if (ts.isShorthandPropertyAssignment(property)) {
|
|
1808
|
+
return property.name.text === "operations";
|
|
1809
|
+
}
|
|
1810
|
+
if (!ts.isPropertyAssignment(property)) {
|
|
1811
|
+
return false;
|
|
1812
|
+
}
|
|
1813
|
+
return (
|
|
1814
|
+
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
1815
|
+
property.name.text === "operations"
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
function isOperationsAccessorOrMethod(
|
|
1820
|
+
property: ts.ObjectLiteralElementLike,
|
|
1821
|
+
): property is ts.GetAccessorDeclaration | ts.SetAccessorDeclaration | ts.MethodDeclaration {
|
|
1822
|
+
if (
|
|
1823
|
+
!ts.isGetAccessorDeclaration(property) &&
|
|
1824
|
+
!ts.isSetAccessorDeclaration(property) &&
|
|
1825
|
+
!ts.isMethodDeclaration(property)
|
|
1826
|
+
) {
|
|
1827
|
+
return false;
|
|
1828
|
+
}
|
|
1829
|
+
return (
|
|
1830
|
+
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
1831
|
+
property.name.text === "operations"
|
|
1832
|
+
);
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
function isSourceEnumerableStaticObjectLiteral(expression: ts.Expression): boolean {
|
|
1836
|
+
const unwrapped = unwrapImplementationExpression(expression);
|
|
1837
|
+
if (!ts.isObjectLiteralExpression(unwrapped)) {
|
|
1838
|
+
return false;
|
|
1839
|
+
}
|
|
1840
|
+
return unwrapped.properties.every((property) => {
|
|
1841
|
+
if (ts.isSpreadAssignment(property)) {
|
|
1842
|
+
return isSourceEnumerableStaticObjectLiteral(property.expression);
|
|
1843
|
+
}
|
|
1844
|
+
if (property.name === undefined || ts.isComputedPropertyName(property.name)) {
|
|
1845
|
+
return property.name === undefined;
|
|
1846
|
+
}
|
|
1847
|
+
return property.name.text !== "operations" || isOperationsProperty(property);
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
function findComputedImplementationProperty(
|
|
1852
|
+
objectLiteral: ts.ObjectLiteralExpression,
|
|
1853
|
+
): ts.ObjectLiteralElementLike | undefined {
|
|
1854
|
+
for (const property of objectLiteral.properties) {
|
|
1855
|
+
if (property.name !== undefined && ts.isComputedPropertyName(property.name)) {
|
|
1856
|
+
return property;
|
|
1857
|
+
}
|
|
1858
|
+
if (ts.isSpreadAssignment(property)) {
|
|
1859
|
+
const spreadExpression = unwrapImplementationExpression(property.expression);
|
|
1860
|
+
if (ts.isObjectLiteralExpression(spreadExpression)) {
|
|
1861
|
+
const nested = findComputedImplementationProperty(spreadExpression);
|
|
1862
|
+
if (nested !== undefined) {
|
|
1863
|
+
return nested;
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
return undefined;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function findEffectiveOperationsProperty(
|
|
1872
|
+
objectLiteral: ts.ObjectLiteralExpression,
|
|
1873
|
+
):
|
|
1874
|
+
| ts.PropertyAssignment
|
|
1875
|
+
| ts.ShorthandPropertyAssignment
|
|
1876
|
+
| ts.GetAccessorDeclaration
|
|
1877
|
+
| ts.SetAccessorDeclaration
|
|
1878
|
+
| ts.MethodDeclaration
|
|
1879
|
+
| undefined {
|
|
1880
|
+
let effective:
|
|
1881
|
+
| ts.PropertyAssignment
|
|
1882
|
+
| ts.ShorthandPropertyAssignment
|
|
1883
|
+
| ts.GetAccessorDeclaration
|
|
1884
|
+
| ts.SetAccessorDeclaration
|
|
1885
|
+
| ts.MethodDeclaration
|
|
1886
|
+
| undefined;
|
|
1887
|
+
for (const property of objectLiteral.properties) {
|
|
1888
|
+
if (isOperationsProperty(property) || isOperationsAccessorOrMethod(property)) {
|
|
1889
|
+
effective = property;
|
|
1890
|
+
continue;
|
|
1891
|
+
}
|
|
1892
|
+
if (ts.isSpreadAssignment(property)) {
|
|
1893
|
+
const spreadExpression = unwrapImplementationExpression(property.expression);
|
|
1894
|
+
if (ts.isObjectLiteralExpression(spreadExpression)) {
|
|
1895
|
+
const nested = findEffectiveOperationsProperty(spreadExpression);
|
|
1896
|
+
if (nested !== undefined) {
|
|
1897
|
+
effective = nested;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
return effective;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1174
1905
|
function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
1175
1906
|
const indexPath = resolve(providerRoot, "index.ts");
|
|
1176
1907
|
const ruleId = "flat-operation-composition";
|
|
@@ -1184,10 +1915,23 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1184
1915
|
}
|
|
1185
1916
|
|
|
1186
1917
|
const source = readFileSync(indexPath, "utf8");
|
|
1187
|
-
|
|
1188
|
-
//
|
|
1189
|
-
//
|
|
1190
|
-
|
|
1918
|
+
const indexRelPath = toRelativeProviderPath(providerRoot, indexPath);
|
|
1919
|
+
// The report preflight already rejects unparseable index.ts sources before
|
|
1920
|
+
// this rule runs, so this local AST parse only replaces property location.
|
|
1921
|
+
const sourceFile = ts.createSourceFile(
|
|
1922
|
+
indexRelPath,
|
|
1923
|
+
source,
|
|
1924
|
+
ts.ScriptTarget.Latest,
|
|
1925
|
+
true,
|
|
1926
|
+
ts.ScriptKind.TS,
|
|
1927
|
+
);
|
|
1928
|
+
const implementationCall = findProviderImplementationCall(sourceFile);
|
|
1929
|
+
const rawImplementationArg = implementationCall?.arguments[0];
|
|
1930
|
+
const implementationArg =
|
|
1931
|
+
rawImplementationArg === undefined
|
|
1932
|
+
? undefined
|
|
1933
|
+
: unwrapImplementationExpression(rawImplementationArg);
|
|
1934
|
+
if (implementationArg === undefined || !ts.isObjectLiteralExpression(implementationArg)) {
|
|
1191
1935
|
return pass(
|
|
1192
1936
|
ruleId,
|
|
1193
1937
|
SDK_NATIVE_CATEGORY,
|
|
@@ -1196,82 +1940,78 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1196
1940
|
);
|
|
1197
1941
|
}
|
|
1198
1942
|
|
|
1199
|
-
//
|
|
1200
|
-
//
|
|
1201
|
-
//
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
const builderDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*\(/.exec(source);
|
|
1210
|
-
if (builderDefault?.[1] !== undefined && builderDefault[1] !== "defineProvider") {
|
|
1211
|
-
defineParenIndex = builderDefault.index + builderDefault[0].length - 1;
|
|
1212
|
-
}
|
|
1213
|
-
const inlineDefault = /\bexport\s+default\s+defineProvider\s*\(/.exec(source);
|
|
1214
|
-
if (defineParenIndex === -1 && inlineDefault) {
|
|
1215
|
-
const declarationParen = inlineDefault.index + inlineDefault[0].length - 1;
|
|
1216
|
-
const declarationStart = declarationParen + 1;
|
|
1217
|
-
const declaration = balancedValueExpression(source, declarationStart);
|
|
1218
|
-
let cursor = declarationStart + declaration.length;
|
|
1219
|
-
while (/\s/.test(source[cursor] ?? "")) cursor++;
|
|
1220
|
-
if (source[cursor] === ")") cursor++;
|
|
1221
|
-
while (/\s/.test(source[cursor] ?? "")) cursor++;
|
|
1222
|
-
defineParenIndex = source[cursor] === "(" ? cursor : declarationParen;
|
|
1223
|
-
} else if (defineParenIndex === -1) {
|
|
1224
|
-
const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(source);
|
|
1225
|
-
const exportedName = namedDefault?.[1];
|
|
1226
|
-
if (exportedName !== undefined) {
|
|
1227
|
-
const namedDecl = new RegExp(
|
|
1228
|
-
`(?:^|\\n)[ \t]*(?:export\\s+)?(?:const|let|var)\\s+${exportedName}\\s*(?::[^=\\n]+)?\\s*=\\s*defineProvider\\s*\\(`,
|
|
1229
|
-
).exec(source);
|
|
1230
|
-
if (namedDecl) {
|
|
1231
|
-
defineParenIndex = namedDecl.index + namedDecl[0].length - 1;
|
|
1232
|
-
}
|
|
1233
|
-
}
|
|
1234
|
-
if (defineParenIndex === -1) {
|
|
1235
|
-
const firstCall = /\bdefineProvider\s*\(/.exec(source);
|
|
1236
|
-
if (firstCall) {
|
|
1237
|
-
defineParenIndex = firstCall.index + firstCall[0].length - 1;
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1943
|
+
// Resolve the top-level operations property from the AST, then feed its raw
|
|
1944
|
+
// initializer offset into the existing expression classifier and alias
|
|
1945
|
+
// resolver unchanged.
|
|
1946
|
+
const opsProp = findEffectiveOperationsProperty(implementationArg);
|
|
1947
|
+
let opsValue: ExpressionSlice | undefined;
|
|
1948
|
+
let opsLine = 1;
|
|
1949
|
+
if (opsProp !== undefined && ts.isPropertyAssignment(opsProp)) {
|
|
1950
|
+
const valueStart = opsProp.initializer.getStart(sourceFile);
|
|
1951
|
+
opsValue = unwrapParens(balancedExpressionSlice(source, valueStart, indexRelPath));
|
|
1952
|
+
opsLine = offsetToLine(source, valueStart);
|
|
1240
1953
|
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1954
|
+
|
|
1955
|
+
// Property shorthand: `buildProvider({ operations })` — resolve the
|
|
1956
|
+
// local `operations` const initializer.
|
|
1957
|
+
let aliasName: string | undefined;
|
|
1958
|
+
if (opsProp !== undefined && ts.isShorthandPropertyAssignment(opsProp)) {
|
|
1959
|
+
aliasName = "operations";
|
|
1960
|
+
} else if (opsValue !== undefined && /^[A-Za-z_$][\w$]*$/.test(opsValue.masked)) {
|
|
1961
|
+
// `operations: ops` — a bare identifier alias to resolve.
|
|
1962
|
+
aliasName = opsValue.raw;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
// A computed property might resolve to `operations`, but its composition is
|
|
1966
|
+
// not source-enumerable here. Fail closed, like an unresolved imported alias.
|
|
1967
|
+
const computed = findComputedImplementationProperty(implementationArg);
|
|
1968
|
+
if (computed !== undefined) {
|
|
1969
|
+
const computedLine = offsetToLine(source, computed.getStart(sourceFile));
|
|
1970
|
+
return blocker(
|
|
1243
1971
|
ruleId,
|
|
1244
1972
|
SDK_NATIVE_CATEGORY,
|
|
1245
|
-
"
|
|
1973
|
+
"The defineProvider operations property uses a computed property name that cannot be statically resolved.",
|
|
1974
|
+
"Declare operations with the literal property name `operations` and a static object literal value so the provider-registry AST gate can enumerate it.",
|
|
1246
1975
|
0,
|
|
1976
|
+
[`${indexRelPath}:${computedLine} (computed property name)`],
|
|
1247
1977
|
);
|
|
1248
1978
|
}
|
|
1249
|
-
const argStart = defineParenIndex + 1;
|
|
1250
|
-
const argText = balancedValueExpression(source, argStart);
|
|
1251
1979
|
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
opsLine = offsetToLine(source, valueStart);
|
|
1980
|
+
if (opsProp !== undefined && isOperationsAccessorOrMethod(opsProp)) {
|
|
1981
|
+
const memberLine = offsetToLine(source, opsProp.getStart(sourceFile));
|
|
1982
|
+
return blocker(
|
|
1983
|
+
ruleId,
|
|
1984
|
+
SDK_NATIVE_CATEGORY,
|
|
1985
|
+
"The defineProvider operations member is an accessor/method whose value is not statically enumerable.",
|
|
1986
|
+
"Declare operations as a property assignment with a static object literal value so the provider-registry AST gate can enumerate it.",
|
|
1987
|
+
0,
|
|
1988
|
+
[`${indexRelPath}:${memberLine} (operations accessor/method)`],
|
|
1989
|
+
);
|
|
1263
1990
|
}
|
|
1264
1991
|
|
|
1265
|
-
//
|
|
1266
|
-
//
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1992
|
+
// A spread can override a preceding literal `operations` property. Only an
|
|
1993
|
+
// inline static object literal (including recursively inline static spreads)
|
|
1994
|
+
// exposes all of its property names to this source-only gate. Identifiers,
|
|
1995
|
+
// calls, and other expressions are opaque and therefore fail closed.
|
|
1996
|
+
const opaqueSpread = implementationArg.properties.find(
|
|
1997
|
+
(property): property is ts.SpreadAssignment =>
|
|
1998
|
+
ts.isSpreadAssignment(property) &&
|
|
1999
|
+
!isSourceEnumerableStaticObjectLiteral(property.expression),
|
|
2000
|
+
);
|
|
2001
|
+
if (opaqueSpread !== undefined) {
|
|
2002
|
+
const spreadLine = offsetToLine(source, opaqueSpread.getStart(sourceFile));
|
|
2003
|
+
return escapeHatchResult(
|
|
2004
|
+
providerRoot,
|
|
2005
|
+
ruleId,
|
|
2006
|
+
[{ file: indexRelPath, line: spreadLine }],
|
|
2007
|
+
{
|
|
2008
|
+
blockerMessage:
|
|
2009
|
+
"The defineProvider implementation uses a non-enumerable spread that could override operations.",
|
|
2010
|
+
remediation:
|
|
2011
|
+
"Declare operations directly with a static object literal. Implementation spreads must be inline static object literals so the provider-registry AST gate can enumerate every property name.",
|
|
2012
|
+
passMessage: "defineProvider declares operations as a static object literal.",
|
|
2013
|
+
},
|
|
2014
|
+
);
|
|
1275
2015
|
}
|
|
1276
2016
|
|
|
1277
2017
|
// Determine the effective initializer expression to classify. The alias may
|
|
@@ -1305,7 +2045,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1305
2045
|
// do not resolve the exact import target path; "any same-named factory
|
|
1306
2046
|
// blocks" is the conservative, false-negative-avoiding choice for a gate.)
|
|
1307
2047
|
type Candidate = {
|
|
1308
|
-
expr:
|
|
2048
|
+
expr: ExpressionSlice;
|
|
1309
2049
|
line: number;
|
|
1310
2050
|
file: string;
|
|
1311
2051
|
isFactory: boolean;
|
|
@@ -1315,15 +2055,22 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1315
2055
|
if (!existsSync(filePath)) {
|
|
1316
2056
|
continue;
|
|
1317
2057
|
}
|
|
1318
|
-
const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
|
|
1319
2058
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
2059
|
+
const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
|
|
2060
|
+
const maskedFileSource = maskCommentsAndStrings(fileSource, relPath, {
|
|
2061
|
+
blankPropertyKeys: true,
|
|
2062
|
+
});
|
|
1320
2063
|
|
|
1321
2064
|
const declRe = new RegExp(aliasDecl.source, "g");
|
|
1322
|
-
for (
|
|
2065
|
+
for (
|
|
2066
|
+
let m = declRe.exec(maskedFileSource);
|
|
2067
|
+
m !== null;
|
|
2068
|
+
m = declRe.exec(maskedFileSource)
|
|
2069
|
+
) {
|
|
1323
2070
|
const valueStart = m.index + m[0].length;
|
|
1324
|
-
const expr = unwrapParens(
|
|
2071
|
+
const expr = unwrapParens(balancedExpressionSlice(fileSource, valueStart, relPath));
|
|
1325
2072
|
const isFactory =
|
|
1326
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
|
|
2073
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr.masked) || hasTopLevelFactorySpread(expr)) &&
|
|
1327
2074
|
!isTransparentObjectReshape(expr);
|
|
1328
2075
|
candidates.push({
|
|
1329
2076
|
expr,
|
|
@@ -1333,9 +2080,14 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1333
2080
|
});
|
|
1334
2081
|
}
|
|
1335
2082
|
const destructRe = new RegExp(destructured.source, "g");
|
|
1336
|
-
for (
|
|
2083
|
+
for (
|
|
2084
|
+
let m = destructRe.exec(maskedFileSource);
|
|
2085
|
+
m !== null;
|
|
2086
|
+
m = destructRe.exec(maskedFileSource)
|
|
2087
|
+
) {
|
|
2088
|
+
const raw = `${m[1]}(`;
|
|
1337
2089
|
candidates.push({
|
|
1338
|
-
expr:
|
|
2090
|
+
expr: { raw, masked: raw },
|
|
1339
2091
|
line: offsetToLine(fileSource, m.index),
|
|
1340
2092
|
file: relPath,
|
|
1341
2093
|
isFactory: true,
|
|
@@ -1361,11 +2113,15 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1361
2113
|
// the unresolved import as a factory-composed (non-static) shape rather
|
|
1362
2114
|
// than silently passing.
|
|
1363
2115
|
if (!resolved) {
|
|
1364
|
-
const
|
|
1365
|
-
|
|
1366
|
-
);
|
|
2116
|
+
const maskedIndexSource = maskCommentsAndStrings(source, indexRelPath, {
|
|
2117
|
+
blankPropertyKeys: true,
|
|
2118
|
+
});
|
|
2119
|
+
const importMatch = new RegExp(
|
|
2120
|
+
`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`,
|
|
2121
|
+
).exec(maskedIndexSource);
|
|
1367
2122
|
if (importMatch) {
|
|
1368
|
-
|
|
2123
|
+
const raw = `${aliasName}(`;
|
|
2124
|
+
effective = { raw, masked: raw };
|
|
1369
2125
|
effectiveLine = offsetToLine(source, importMatch.index);
|
|
1370
2126
|
effectiveFile = "index.ts";
|
|
1371
2127
|
}
|
|
@@ -1389,14 +2145,16 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1389
2145
|
spreadIdentifierResolvesToFactory(providerRoot, indexPath, source, name),
|
|
1390
2146
|
);
|
|
1391
2147
|
const isStaticLiteral =
|
|
1392
|
-
effective?.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
|
|
2148
|
+
effective?.masked.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
|
|
1393
2149
|
// A call expression `ident(...)` (factory) or a factory-spread literal is
|
|
1394
2150
|
// the rejected, non-static shape — UNLESS it is the stdlib
|
|
1395
2151
|
// `Object.fromEntries(Object.entries(<source-visible obj>)...)` reshape,
|
|
1396
2152
|
// whose op set is still enumerable from source (verified golden pattern).
|
|
1397
2153
|
const isFactoryCall =
|
|
1398
2154
|
effective !== undefined &&
|
|
1399
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) ||
|
|
2155
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(effective.masked) ||
|
|
2156
|
+
hasFactorySpread ||
|
|
2157
|
+
hasFactorySpreadIdentifier) &&
|
|
1400
2158
|
!isTransparentObjectReshape(effective);
|
|
1401
2159
|
|
|
1402
2160
|
if (isFactoryCall && !isStaticLiteral) {
|
|
@@ -1422,7 +2180,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1422
2180
|
}
|
|
1423
2181
|
|
|
1424
2182
|
function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
|
|
1425
|
-
const credentialReferences =
|
|
2183
|
+
const credentialReferences = findCredentialReferences(providerRoot);
|
|
1426
2184
|
const authMode = provider.auth?.mode ?? "none";
|
|
1427
2185
|
const credentialKeys = provider.credential?.keys ?? [];
|
|
1428
2186
|
const storesProviderCredential = authMode !== "none" || credentialKeys.length > 0;
|
|
@@ -1440,16 +2198,117 @@ function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition
|
|
|
1440
2198
|
"Persist provider session state through the SDK credential context instead of process-local state. See providers/catchtable for the reference pattern.",
|
|
1441
2199
|
};
|
|
1442
2200
|
}
|
|
1443
|
-
|
|
1444
|
-
return pass(
|
|
1445
|
-
"credential-usage",
|
|
1446
|
-
SDK_NATIVE_CATEGORY,
|
|
1447
|
-
authMode === "none" && credentialKeys.length === 0
|
|
1448
|
-
? "Provider does not declare reusable credentials."
|
|
1449
|
-
: "Credential-backed provider references ctx.credential.",
|
|
1450
|
-
0,
|
|
1451
|
-
credentialReferences.length > 0 ? formatSourceFindings(credentialReferences) : undefined,
|
|
1452
|
-
);
|
|
2201
|
+
|
|
2202
|
+
return pass(
|
|
2203
|
+
"credential-usage",
|
|
2204
|
+
SDK_NATIVE_CATEGORY,
|
|
2205
|
+
authMode === "none" && credentialKeys.length === 0
|
|
2206
|
+
? "Provider does not declare reusable credentials."
|
|
2207
|
+
: "Credential-backed provider references ctx.credential.",
|
|
2208
|
+
0,
|
|
2209
|
+
credentialReferences.length > 0 ? formatSourceFindings(credentialReferences) : undefined,
|
|
2210
|
+
);
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
function isDirectCredentialReference(expression: ts.Expression): boolean {
|
|
2214
|
+
const reference = unwrapLocalExpression(expression);
|
|
2215
|
+
let receiver: ts.Expression | undefined;
|
|
2216
|
+
let memberName: string | undefined;
|
|
2217
|
+
if (ts.isPropertyAccessExpression(reference)) {
|
|
2218
|
+
receiver = reference.expression;
|
|
2219
|
+
memberName = reference.name.text;
|
|
2220
|
+
} else if (ts.isElementAccessExpression(reference)) {
|
|
2221
|
+
receiver = reference.expression;
|
|
2222
|
+
const argument = reference.argumentExpression;
|
|
2223
|
+
if (
|
|
2224
|
+
argument !== undefined &&
|
|
2225
|
+
(ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
|
|
2226
|
+
) {
|
|
2227
|
+
memberName = argument.text;
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
const unwrappedReceiver = receiver === undefined ? undefined : unwrapLocalExpression(receiver);
|
|
2231
|
+
return (
|
|
2232
|
+
memberName === "credential" &&
|
|
2233
|
+
unwrappedReceiver !== undefined &&
|
|
2234
|
+
ts.isIdentifier(unwrappedReceiver) &&
|
|
2235
|
+
unwrappedReceiver.text === "ctx"
|
|
2236
|
+
);
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
function isCredentialReference(
|
|
2240
|
+
expression: ts.Expression,
|
|
2241
|
+
bindings: LocalBindings,
|
|
2242
|
+
resolving: ReadonlySet<LocalBinding> = new Set(),
|
|
2243
|
+
): boolean {
|
|
2244
|
+
const reference = unwrapLocalExpression(expression);
|
|
2245
|
+
if (isDirectCredentialReference(reference)) {
|
|
2246
|
+
return true;
|
|
2247
|
+
}
|
|
2248
|
+
if (!ts.isIdentifier(reference)) {
|
|
2249
|
+
return false;
|
|
2250
|
+
}
|
|
2251
|
+
const localBindings = lookupLocalBindings(reference, bindings);
|
|
2252
|
+
if (localBindings === undefined || localBindings.length === 0) {
|
|
2253
|
+
return false;
|
|
2254
|
+
}
|
|
2255
|
+
return localBindings.every((binding) => {
|
|
2256
|
+
if (resolving.has(binding) || binding.mutable || binding.initializer === undefined) {
|
|
2257
|
+
return false;
|
|
2258
|
+
}
|
|
2259
|
+
const nextResolving = new Set(resolving);
|
|
2260
|
+
nextResolving.add(binding);
|
|
2261
|
+
if (binding.destructuredProperty !== undefined) {
|
|
2262
|
+
const source = unwrapLocalExpression(binding.initializer);
|
|
2263
|
+
return (
|
|
2264
|
+
binding.destructuredProperty === "credential" &&
|
|
2265
|
+
ts.isIdentifier(source) &&
|
|
2266
|
+
source.text === "ctx"
|
|
2267
|
+
);
|
|
2268
|
+
}
|
|
2269
|
+
return isCredentialReference(binding.initializer, bindings, nextResolving);
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
function findCredentialReferences(providerRoot: string): SourceFinding[] {
|
|
2274
|
+
const findings: SourceFinding[] = [];
|
|
2275
|
+
const seen = new Set<string>();
|
|
2276
|
+
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
2277
|
+
const source = readFileSync(filePath, "utf8");
|
|
2278
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
2279
|
+
const sourceFile = ts.createSourceFile(
|
|
2280
|
+
relPath,
|
|
2281
|
+
source,
|
|
2282
|
+
ts.ScriptTarget.Latest,
|
|
2283
|
+
true,
|
|
2284
|
+
ts.ScriptKind.TS,
|
|
2285
|
+
);
|
|
2286
|
+
const bindings = collectLocalBindings(sourceFile);
|
|
2287
|
+
const visit = (node: ts.Node): void => {
|
|
2288
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
if (
|
|
2292
|
+
ts.isExpression(node) &&
|
|
2293
|
+
(isDirectCredentialReference(node) ||
|
|
2294
|
+
((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) &&
|
|
2295
|
+
isCredentialReference(node.expression, bindings)))
|
|
2296
|
+
) {
|
|
2297
|
+
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
2298
|
+
const key = `${relPath}:${line}`;
|
|
2299
|
+
if (!seen.has(key)) {
|
|
2300
|
+
seen.add(key);
|
|
2301
|
+
findings.push({ file: relPath, line });
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
ts.forEachChild(node, visit);
|
|
2305
|
+
};
|
|
2306
|
+
visit(sourceFile);
|
|
2307
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
2308
|
+
break;
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
return findings;
|
|
1453
2312
|
}
|
|
1454
2313
|
|
|
1455
2314
|
// ---------------------------------------------------------------------------
|
|
@@ -1608,23 +2467,34 @@ function scoreSdkOwnedSecretPresence(
|
|
|
1608
2467
|
function findSourceLineMatches(
|
|
1609
2468
|
providerRoot: string,
|
|
1610
2469
|
pattern: RegExp | ((line: string) => boolean),
|
|
2470
|
+
useMaskedSource = false,
|
|
1611
2471
|
): SourceFinding[] {
|
|
1612
|
-
return findSourceFindings(
|
|
2472
|
+
return findSourceFindings(
|
|
2473
|
+
providerRoot,
|
|
2474
|
+
(line) => matchesLinePattern(line, pattern),
|
|
2475
|
+
useMaskedSource,
|
|
2476
|
+
);
|
|
1613
2477
|
}
|
|
1614
2478
|
|
|
1615
2479
|
function findSourceFindings(
|
|
1616
2480
|
providerRoot: string,
|
|
1617
2481
|
matchesLine: (line: string, remainingLines: readonly string[]) => boolean,
|
|
2482
|
+
useMaskedSource = false,
|
|
1618
2483
|
): SourceFinding[] {
|
|
1619
2484
|
const findings: SourceFinding[] = [];
|
|
1620
2485
|
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
1621
2486
|
const content = readFileSync(filePath, "utf8");
|
|
1622
|
-
const
|
|
2487
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
2488
|
+
const lines = (
|
|
2489
|
+
useMaskedSource
|
|
2490
|
+
? maskCommentsAndStrings(content, relPath, { blankPropertyKeys: true })
|
|
2491
|
+
: content
|
|
2492
|
+
).split(/\r?\n/);
|
|
1623
2493
|
for (let index = 0; index < lines.length; index += 1) {
|
|
1624
2494
|
const line = lines[index];
|
|
1625
2495
|
if (line !== undefined && matchesLine(line, lines.slice(index + 1))) {
|
|
1626
2496
|
findings.push({
|
|
1627
|
-
file:
|
|
2497
|
+
file: relPath,
|
|
1628
2498
|
line: index + 1,
|
|
1629
2499
|
});
|
|
1630
2500
|
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
@@ -1740,7 +2610,10 @@ function toRelativeProviderPath(providerRoot: string, filePath: string): string
|
|
|
1740
2610
|
}
|
|
1741
2611
|
|
|
1742
2612
|
function formatSourceFindings(findings: readonly SourceFinding[]): string[] {
|
|
1743
|
-
return findings.map(
|
|
2613
|
+
return findings.map(
|
|
2614
|
+
(finding) =>
|
|
2615
|
+
`${finding.file}:${finding.line}${finding.detail === undefined ? "" : `: ${finding.detail}`}`,
|
|
2616
|
+
);
|
|
1744
2617
|
}
|
|
1745
2618
|
|
|
1746
2619
|
function scorePromptAssetFreshness(providerRoot: string): SubmitCheck {
|
|
@@ -1836,7 +2709,7 @@ async function safeRunChecks(providerRoot: string): Promise<CheckResult[]> {
|
|
|
1836
2709
|
{
|
|
1837
2710
|
message: "Base provider checks can run",
|
|
1838
2711
|
passed: false,
|
|
1839
|
-
details: [
|
|
2712
|
+
details: [formatThrownValue(error)],
|
|
1840
2713
|
},
|
|
1841
2714
|
];
|
|
1842
2715
|
}
|
|
@@ -2392,19 +3265,37 @@ function findVendorKeyLeakFindings(providerRoot: string): SourceFinding[] {
|
|
|
2392
3265
|
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
2393
3266
|
const source = readFileSync(filePath, "utf8");
|
|
2394
3267
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
3268
|
+
maskCommentsAndStrings(source, relPath);
|
|
3269
|
+
const sourceFile = ts.createSourceFile(
|
|
3270
|
+
relPath,
|
|
3271
|
+
source,
|
|
3272
|
+
ts.ScriptTarget.Latest,
|
|
3273
|
+
true,
|
|
3274
|
+
ts.ScriptKind.TS,
|
|
3275
|
+
);
|
|
3276
|
+
const bindings = collectLocalBindings(sourceFile);
|
|
2395
3277
|
const upstreamRanges = findUpstreamMarkedConstRanges(source);
|
|
2396
3278
|
for (const zObject of findZObjectLiterals(source)) {
|
|
2397
3279
|
if (rangeContainsOffset(upstreamRanges, zObject.callStart)) {
|
|
2398
3280
|
continue;
|
|
2399
3281
|
}
|
|
2400
|
-
if (!zObjectAppearsPublicOutput(
|
|
3282
|
+
if (!zObjectAppearsPublicOutput(sourceFile, zObject, relPath)) {
|
|
2401
3283
|
continue;
|
|
2402
3284
|
}
|
|
2403
|
-
for (const keyFinding of vendorKeyFindingsForObject(
|
|
3285
|
+
for (const keyFinding of vendorKeyFindingsForObject(
|
|
3286
|
+
source,
|
|
3287
|
+
sourceFile,
|
|
3288
|
+
zObject,
|
|
3289
|
+
bindings,
|
|
3290
|
+
)) {
|
|
2404
3291
|
const key = `${relPath}:${keyFinding.line}:${keyFinding.key}`;
|
|
2405
3292
|
if (!seen.has(key)) {
|
|
2406
3293
|
seen.add(key);
|
|
2407
|
-
findings.push({
|
|
3294
|
+
findings.push({
|
|
3295
|
+
file: relPath,
|
|
3296
|
+
line: keyFinding.line,
|
|
3297
|
+
detail: keyFinding.key,
|
|
3298
|
+
});
|
|
2408
3299
|
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
2409
3300
|
return findings;
|
|
2410
3301
|
}
|
|
@@ -2440,20 +3331,260 @@ function findZObjectLiterals(source: string): ZObjectLiteral[] {
|
|
|
2440
3331
|
return literals;
|
|
2441
3332
|
}
|
|
2442
3333
|
|
|
2443
|
-
function zObjectAppearsPublicOutput(
|
|
2444
|
-
|
|
2445
|
-
|
|
3334
|
+
function zObjectAppearsPublicOutput(
|
|
3335
|
+
sourceFile: ts.SourceFile,
|
|
3336
|
+
zObject: ZObjectLiteral,
|
|
3337
|
+
relPath: string,
|
|
3338
|
+
): boolean {
|
|
3339
|
+
let zObjectCall: ts.CallExpression | undefined;
|
|
3340
|
+
const findCall = (node: ts.Node): void => {
|
|
3341
|
+
if (
|
|
3342
|
+
zObjectCall === undefined &&
|
|
3343
|
+
ts.isCallExpression(node) &&
|
|
3344
|
+
node.getStart(sourceFile) === zObject.callStart
|
|
3345
|
+
) {
|
|
3346
|
+
zObjectCall = node;
|
|
3347
|
+
return;
|
|
3348
|
+
}
|
|
3349
|
+
ts.forEachChild(node, findCall);
|
|
3350
|
+
};
|
|
3351
|
+
findCall(sourceFile);
|
|
3352
|
+
if (zObjectCall === undefined) {
|
|
3353
|
+
return true;
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
let expression: ts.Expression = zObjectCall;
|
|
3357
|
+
while (
|
|
3358
|
+
ts.isParenthesizedExpression(expression.parent) ||
|
|
3359
|
+
ts.isAsExpression(expression.parent) ||
|
|
3360
|
+
ts.isSatisfiesExpression(expression.parent) ||
|
|
3361
|
+
ts.isNonNullExpression(expression.parent)
|
|
3362
|
+
) {
|
|
3363
|
+
expression = expression.parent;
|
|
3364
|
+
}
|
|
3365
|
+
if (
|
|
3366
|
+
ts.isPropertyAssignment(expression.parent) &&
|
|
3367
|
+
isPublicOutputPropertyName(expression.parent.name)
|
|
3368
|
+
) {
|
|
3369
|
+
return true;
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
const declaration = findEnclosingVariableDeclaration(expression);
|
|
3373
|
+
if (
|
|
3374
|
+
declaration === undefined ||
|
|
3375
|
+
!ts.isIdentifier(declaration.name) ||
|
|
3376
|
+
declaration.initializer === undefined ||
|
|
3377
|
+
!ts.isVariableDeclarationList(declaration.parent) ||
|
|
3378
|
+
(declaration.parent.flags & ts.NodeFlags.Const) === 0
|
|
3379
|
+
) {
|
|
3380
|
+
return true;
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
const reachability = localSchemaBindingReachability(
|
|
3384
|
+
sourceFile,
|
|
3385
|
+
declaration.name.text,
|
|
3386
|
+
declaration,
|
|
3387
|
+
new Set(),
|
|
3388
|
+
);
|
|
3389
|
+
if (reachability === "public") {
|
|
3390
|
+
return true;
|
|
3391
|
+
}
|
|
3392
|
+
if (reachability === "unknown") {
|
|
3393
|
+
return true;
|
|
3394
|
+
}
|
|
3395
|
+
// Sibling modules are scanned independently and therefore remain
|
|
3396
|
+
// fail-closed. An exported binding in index.ts is likewise consumable by
|
|
3397
|
+
// another module, so local inertness does not prove it is private.
|
|
3398
|
+
if (relPath !== "index.ts") {
|
|
3399
|
+
return true;
|
|
3400
|
+
}
|
|
3401
|
+
return isExportedVariableBinding(sourceFile, declaration);
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
type SchemaReachability = "public" | "internal" | "unknown";
|
|
3405
|
+
|
|
3406
|
+
const PARSE_LIKE_SCHEMA_METHODS: ReadonlySet<string> = new Set([
|
|
3407
|
+
"parse",
|
|
3408
|
+
"safeParse",
|
|
3409
|
+
"parseAsync",
|
|
3410
|
+
"safeParseAsync",
|
|
3411
|
+
]);
|
|
3412
|
+
|
|
3413
|
+
function isExportedVariableBinding(
|
|
3414
|
+
sourceFile: ts.SourceFile,
|
|
3415
|
+
declaration: ts.VariableDeclaration,
|
|
3416
|
+
): boolean {
|
|
3417
|
+
const declarationStatement = declaration.parent.parent;
|
|
3418
|
+
if (
|
|
3419
|
+
ts.isVariableStatement(declarationStatement) &&
|
|
3420
|
+
declarationStatement.modifiers?.some(
|
|
3421
|
+
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
|
|
3422
|
+
) === true
|
|
3423
|
+
) {
|
|
2446
3424
|
return true;
|
|
2447
3425
|
}
|
|
2448
|
-
|
|
2449
|
-
|
|
3426
|
+
if (!ts.isIdentifier(declaration.name)) {
|
|
3427
|
+
return false;
|
|
3428
|
+
}
|
|
3429
|
+
const name = declaration.name.text;
|
|
3430
|
+
let exported = false;
|
|
3431
|
+
const visit = (node: ts.Node): void => {
|
|
3432
|
+
if (exported) {
|
|
3433
|
+
return;
|
|
3434
|
+
}
|
|
3435
|
+
if (ts.isExportSpecifier(node)) {
|
|
3436
|
+
const localName = node.propertyName ?? node.name;
|
|
3437
|
+
if (ts.isIdentifier(localName) && localName.text === name) {
|
|
3438
|
+
exported = true;
|
|
3439
|
+
return;
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
if (ts.isExportAssignment(node)) {
|
|
3443
|
+
const expression = unwrapLocalExpression(node.expression);
|
|
3444
|
+
if (ts.isIdentifier(expression) && expression.text === name) {
|
|
3445
|
+
exported = true;
|
|
3446
|
+
return;
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
ts.forEachChild(node, visit);
|
|
3450
|
+
};
|
|
3451
|
+
for (const statement of sourceFile.statements) {
|
|
3452
|
+
visit(statement);
|
|
3453
|
+
if (exported) {
|
|
3454
|
+
break;
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
return exported;
|
|
3458
|
+
}
|
|
3459
|
+
|
|
3460
|
+
function isPublicOutputPropertyName(name: ts.PropertyName): boolean {
|
|
3461
|
+
const text = propertyNameText(name);
|
|
3462
|
+
return text === "output" || text === "response";
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
function findEnclosingVariableDeclaration(node: ts.Node): ts.VariableDeclaration | undefined {
|
|
3466
|
+
let current: ts.Node | undefined = node;
|
|
3467
|
+
while (current !== undefined && !ts.isSourceFile(current)) {
|
|
3468
|
+
if (ts.isVariableDeclaration(current)) {
|
|
3469
|
+
return current;
|
|
3470
|
+
}
|
|
3471
|
+
current = current.parent;
|
|
3472
|
+
}
|
|
3473
|
+
return undefined;
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
function localSchemaBindingReachability(
|
|
3477
|
+
sourceFile: ts.SourceFile,
|
|
3478
|
+
name: string,
|
|
3479
|
+
declaration: ts.VariableDeclaration,
|
|
3480
|
+
resolving: ReadonlySet<string>,
|
|
3481
|
+
): SchemaReachability {
|
|
3482
|
+
if (resolving.has(name)) {
|
|
3483
|
+
return "unknown";
|
|
3484
|
+
}
|
|
3485
|
+
const nextResolving = new Set(resolving);
|
|
3486
|
+
nextResolving.add(name);
|
|
3487
|
+
let result: SchemaReachability = "internal";
|
|
3488
|
+
const visit = (node: ts.Node): void => {
|
|
3489
|
+
if (result === "public") {
|
|
3490
|
+
return;
|
|
3491
|
+
}
|
|
3492
|
+
if (ts.isIdentifier(node) && node.text === name && node !== declaration.name) {
|
|
3493
|
+
const parent = node.parent;
|
|
3494
|
+
if (isParseLikeSchemaReceiver(node)) {
|
|
3495
|
+
return;
|
|
3496
|
+
}
|
|
3497
|
+
if (ts.isExportSpecifier(parent)) {
|
|
3498
|
+
const localName = parent.propertyName ?? parent.name;
|
|
3499
|
+
if (localName === node) {
|
|
3500
|
+
result = "public";
|
|
3501
|
+
}
|
|
3502
|
+
return;
|
|
3503
|
+
}
|
|
3504
|
+
if (
|
|
3505
|
+
ts.isPropertyAssignment(parent) &&
|
|
3506
|
+
parent.initializer === node &&
|
|
3507
|
+
isPublicOutputPropertyName(parent.name)
|
|
3508
|
+
) {
|
|
3509
|
+
result = "public";
|
|
3510
|
+
return;
|
|
3511
|
+
}
|
|
3512
|
+
if (ts.isShorthandPropertyAssignment(parent)) {
|
|
3513
|
+
result = isPublicOutputPropertyName(parent.name) ? "public" : "unknown";
|
|
3514
|
+
return;
|
|
3515
|
+
}
|
|
3516
|
+
if (
|
|
3517
|
+
ts.isVariableDeclaration(parent) &&
|
|
3518
|
+
parent.initializer === node &&
|
|
3519
|
+
ts.isIdentifier(parent.name) &&
|
|
3520
|
+
ts.isVariableDeclarationList(parent.parent) &&
|
|
3521
|
+
(parent.parent.flags & ts.NodeFlags.Const) !== 0
|
|
3522
|
+
) {
|
|
3523
|
+
const aliasReachability = localSchemaBindingReachability(
|
|
3524
|
+
sourceFile,
|
|
3525
|
+
parent.name.text,
|
|
3526
|
+
parent,
|
|
3527
|
+
nextResolving,
|
|
3528
|
+
);
|
|
3529
|
+
if (aliasReachability === "public" || result === "internal") {
|
|
3530
|
+
result = aliasReachability;
|
|
3531
|
+
}
|
|
3532
|
+
return;
|
|
3533
|
+
}
|
|
3534
|
+
if (ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) {
|
|
3535
|
+
return;
|
|
3536
|
+
}
|
|
3537
|
+
if (
|
|
3538
|
+
(ts.isPropertyAccessExpression(parent) && parent.name === node) ||
|
|
3539
|
+
(ts.isPropertyAssignment(parent) && parent.name === node) ||
|
|
3540
|
+
ts.isBindingElement(parent) ||
|
|
3541
|
+
ts.isImportSpecifier(parent)
|
|
3542
|
+
) {
|
|
3543
|
+
return;
|
|
3544
|
+
}
|
|
3545
|
+
result = "unknown";
|
|
3546
|
+
}
|
|
3547
|
+
ts.forEachChild(node, visit);
|
|
3548
|
+
};
|
|
3549
|
+
visit(sourceFile);
|
|
3550
|
+
return result;
|
|
3551
|
+
}
|
|
3552
|
+
|
|
3553
|
+
function isParseLikeSchemaReceiver(identifier: ts.Identifier): boolean {
|
|
3554
|
+
const access = identifier.parent;
|
|
3555
|
+
let methodName: string | undefined;
|
|
3556
|
+
if (ts.isPropertyAccessExpression(access) && access.expression === identifier) {
|
|
3557
|
+
methodName = access.name.text;
|
|
3558
|
+
} else if (ts.isElementAccessExpression(access) && access.expression === identifier) {
|
|
3559
|
+
const argument = access.argumentExpression;
|
|
3560
|
+
if (
|
|
3561
|
+
argument !== undefined &&
|
|
3562
|
+
(ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
|
|
3563
|
+
) {
|
|
3564
|
+
methodName = argument.text;
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
return (
|
|
3568
|
+
methodName !== undefined &&
|
|
3569
|
+
PARSE_LIKE_SCHEMA_METHODS.has(methodName) &&
|
|
3570
|
+
ts.isCallExpression(access.parent) &&
|
|
3571
|
+
access.parent.expression === access
|
|
3572
|
+
);
|
|
2450
3573
|
}
|
|
2451
3574
|
|
|
2452
3575
|
function vendorKeyFindingsForObject(
|
|
2453
3576
|
source: string,
|
|
3577
|
+
sourceFile: ts.SourceFile,
|
|
2454
3578
|
zObject: ZObjectLiteral,
|
|
3579
|
+
bindings: LocalBindings,
|
|
2455
3580
|
): Array<{ key: string; line: number }> {
|
|
2456
|
-
const keys = collectTopLevelObjectKeys(
|
|
3581
|
+
const keys = collectTopLevelObjectKeys(
|
|
3582
|
+
source,
|
|
3583
|
+
sourceFile,
|
|
3584
|
+
zObject.objectStart,
|
|
3585
|
+
zObject.objectEnd,
|
|
3586
|
+
bindings,
|
|
3587
|
+
);
|
|
2457
3588
|
const digitFamilies = new Map<string, Set<string>>();
|
|
2458
3589
|
for (const key of keys) {
|
|
2459
3590
|
const member = numberedFamilyMember(key.name);
|
|
@@ -2509,8 +3640,10 @@ function isAllowedPublicOutputKeyName(name: string): boolean {
|
|
|
2509
3640
|
|
|
2510
3641
|
function collectTopLevelObjectKeys(
|
|
2511
3642
|
source: string,
|
|
3643
|
+
sourceFile: ts.SourceFile,
|
|
2512
3644
|
objectStart: number,
|
|
2513
3645
|
objectEnd: number,
|
|
3646
|
+
bindings: LocalBindings,
|
|
2514
3647
|
): Array<{ name: string; offset: number }> {
|
|
2515
3648
|
const keys: Array<{ name: string; offset: number }> = [];
|
|
2516
3649
|
const masked = maskCommentsAndStrings(source);
|
|
@@ -2532,17 +3665,16 @@ function collectTopLevelObjectKeys(
|
|
|
2532
3665
|
index = endQuote + 1;
|
|
2533
3666
|
} else if (masked[index] === "[") {
|
|
2534
3667
|
const computedEnd = findMatchingBracket(masked, index);
|
|
2535
|
-
|
|
2536
|
-
if (computedEnd === -1 || literalStart === -1) {
|
|
3668
|
+
if (computedEnd === -1) {
|
|
2537
3669
|
break;
|
|
2538
3670
|
}
|
|
2539
|
-
const
|
|
2540
|
-
if (
|
|
2541
|
-
const
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
if (
|
|
2545
|
-
key =
|
|
3671
|
+
const expression = computedPropertyExpressionAtOffset(sourceFile, index);
|
|
3672
|
+
if (expression !== undefined) {
|
|
3673
|
+
const unwrapped = unwrapLocalExpression(expression);
|
|
3674
|
+
if (ts.isStringLiteral(unwrapped) || ts.isNoSubstitutionTemplateLiteral(unwrapped)) {
|
|
3675
|
+
key = unwrapped.text;
|
|
3676
|
+
} else if (ts.isIdentifier(unwrapped)) {
|
|
3677
|
+
key = resolveConstStringIdentifier(unwrapped, bindings);
|
|
2546
3678
|
}
|
|
2547
3679
|
}
|
|
2548
3680
|
index = computedEnd + 1;
|
|
@@ -2569,6 +3701,25 @@ function collectTopLevelObjectKeys(
|
|
|
2569
3701
|
return keys;
|
|
2570
3702
|
}
|
|
2571
3703
|
|
|
3704
|
+
function computedPropertyExpressionAtOffset(
|
|
3705
|
+
sourceFile: ts.SourceFile,
|
|
3706
|
+
offset: number,
|
|
3707
|
+
): ts.Expression | undefined {
|
|
3708
|
+
let expression: ts.Expression | undefined;
|
|
3709
|
+
const visit = (node: ts.Node): void => {
|
|
3710
|
+
if (expression !== undefined || offset < node.getFullStart() || offset >= node.getEnd()) {
|
|
3711
|
+
return;
|
|
3712
|
+
}
|
|
3713
|
+
if (ts.isComputedPropertyName(node) && node.getStart(sourceFile) === offset) {
|
|
3714
|
+
expression = node.expression;
|
|
3715
|
+
return;
|
|
3716
|
+
}
|
|
3717
|
+
ts.forEachChild(node, visit);
|
|
3718
|
+
};
|
|
3719
|
+
visit(sourceFile);
|
|
3720
|
+
return expression;
|
|
3721
|
+
}
|
|
3722
|
+
|
|
2572
3723
|
function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[] {
|
|
2573
3724
|
const findings: SourceFinding[] = [];
|
|
2574
3725
|
const seen = new Set<string>();
|
|
@@ -2576,6 +3727,15 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
|
|
|
2576
3727
|
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
2577
3728
|
const source = readFileSync(filePath, "utf8");
|
|
2578
3729
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
3730
|
+
maskCommentsAndStrings(source, relPath);
|
|
3731
|
+
const sourceFile = ts.createSourceFile(
|
|
3732
|
+
relPath,
|
|
3733
|
+
source,
|
|
3734
|
+
ts.ScriptTarget.Latest,
|
|
3735
|
+
true,
|
|
3736
|
+
ts.ScriptKind.TS,
|
|
3737
|
+
);
|
|
3738
|
+
const bindings = collectLocalBindings(sourceFile);
|
|
2579
3739
|
const zObjectRanges = findZObjectLiterals(source).map((zObject) => ({
|
|
2580
3740
|
start: zObject.callStart,
|
|
2581
3741
|
end: zObject.objectEnd,
|
|
@@ -2588,7 +3748,7 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
|
|
|
2588
3748
|
].filter((range) => rangeContainedInRanges(fixtureRanges, range));
|
|
2589
3749
|
|
|
2590
3750
|
for (const range of fixtureResponseRanges) {
|
|
2591
|
-
for (const literal of findStringLiteralsInRange(source, range)) {
|
|
3751
|
+
for (const literal of findStringLiteralsInRange(source, range, relPath)) {
|
|
2592
3752
|
if (
|
|
2593
3753
|
rangeContainsOffset(zObjectRanges, literal.offset) ||
|
|
2594
3754
|
rangeContainsOffset(upstreamRanges, literal.offset) ||
|
|
@@ -2609,12 +3769,86 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
|
|
|
2609
3769
|
}
|
|
2610
3770
|
}
|
|
2611
3771
|
}
|
|
3772
|
+
|
|
3773
|
+
const visitAliases = (node: ts.Node): void => {
|
|
3774
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
3775
|
+
return;
|
|
3776
|
+
}
|
|
3777
|
+
const offset = node.getStart(sourceFile);
|
|
3778
|
+
if (
|
|
3779
|
+
ts.isIdentifier(node) &&
|
|
3780
|
+
isIdentifierValueUse(node) &&
|
|
3781
|
+
offset >= range.start &&
|
|
3782
|
+
offset <= range.end &&
|
|
3783
|
+
!rangeContainsOffset(zObjectRanges, offset) &&
|
|
3784
|
+
!rangeContainsOffset(upstreamRanges, offset)
|
|
3785
|
+
) {
|
|
3786
|
+
const value = resolveConstStringIdentifier(node, bindings);
|
|
3787
|
+
if (
|
|
3788
|
+
value !== undefined &&
|
|
3789
|
+
isVendorTimestampCandidate(value, propertyNameForValueExpression(node))
|
|
3790
|
+
) {
|
|
3791
|
+
const line = offsetToLine(source, offset);
|
|
3792
|
+
const key = `${relPath}:${line}:${value}`;
|
|
3793
|
+
if (!seen.has(key)) {
|
|
3794
|
+
seen.add(key);
|
|
3795
|
+
findings.push({ file: relPath, line });
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
}
|
|
3799
|
+
ts.forEachChild(node, visitAliases);
|
|
3800
|
+
};
|
|
3801
|
+
visitAliases(sourceFile);
|
|
3802
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
3803
|
+
return findings;
|
|
3804
|
+
}
|
|
2612
3805
|
}
|
|
2613
3806
|
}
|
|
2614
3807
|
|
|
2615
3808
|
return findings;
|
|
2616
3809
|
}
|
|
2617
3810
|
|
|
3811
|
+
function isIdentifierValueUse(identifier: ts.Identifier): boolean {
|
|
3812
|
+
const parent = identifier.parent;
|
|
3813
|
+
return !(
|
|
3814
|
+
(ts.isPropertyAssignment(parent) && parent.name === identifier) ||
|
|
3815
|
+
(ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
|
|
3816
|
+
ts.isBindingElement(parent) ||
|
|
3817
|
+
ts.isVariableDeclaration(parent) ||
|
|
3818
|
+
ts.isParameter(parent) ||
|
|
3819
|
+
ts.isImportClause(parent) ||
|
|
3820
|
+
ts.isImportSpecifier(parent) ||
|
|
3821
|
+
ts.isNamespaceImport(parent) ||
|
|
3822
|
+
ts.isFunctionDeclaration(parent) ||
|
|
3823
|
+
ts.isFunctionExpression(parent) ||
|
|
3824
|
+
ts.isClassDeclaration(parent) ||
|
|
3825
|
+
ts.isClassExpression(parent) ||
|
|
3826
|
+
ts.isPropertyDeclaration(parent) ||
|
|
3827
|
+
ts.isPropertySignature(parent) ||
|
|
3828
|
+
ts.isMethodDeclaration(parent) ||
|
|
3829
|
+
ts.isMethodSignature(parent)
|
|
3830
|
+
);
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3833
|
+
function propertyNameForValueExpression(expression: ts.Expression): string | undefined {
|
|
3834
|
+
let current: ts.Expression = expression;
|
|
3835
|
+
while (
|
|
3836
|
+
ts.isParenthesizedExpression(current.parent) ||
|
|
3837
|
+
ts.isAsExpression(current.parent) ||
|
|
3838
|
+
ts.isSatisfiesExpression(current.parent) ||
|
|
3839
|
+
ts.isNonNullExpression(current.parent)
|
|
3840
|
+
) {
|
|
3841
|
+
current = current.parent;
|
|
3842
|
+
}
|
|
3843
|
+
if (ts.isPropertyAssignment(current.parent) && current.parent.initializer === current) {
|
|
3844
|
+
return propertyNameText(current.parent.name);
|
|
3845
|
+
}
|
|
3846
|
+
if (ts.isShorthandPropertyAssignment(current.parent)) {
|
|
3847
|
+
return current.parent.name.text;
|
|
3848
|
+
}
|
|
3849
|
+
return undefined;
|
|
3850
|
+
}
|
|
3851
|
+
|
|
2618
3852
|
function findPropertyObjectRanges(source: string, propertyName: string): ObjectRange[] {
|
|
2619
3853
|
const ranges: ObjectRange[] = [];
|
|
2620
3854
|
const masked = maskCommentsAndStrings(source);
|
|
@@ -2658,23 +3892,16 @@ function findNamedConstValueRanges(source: string): NamedObjectRange[] {
|
|
|
2658
3892
|
return ranges;
|
|
2659
3893
|
}
|
|
2660
3894
|
|
|
2661
|
-
function findConstValueRangeContaining(
|
|
2662
|
-
source: string,
|
|
2663
|
-
offset: number,
|
|
2664
|
-
): NamedObjectRange | undefined {
|
|
2665
|
-
return findNamedConstValueRanges(source).find(
|
|
2666
|
-
(range) => offset >= range.start && offset <= range.end,
|
|
2667
|
-
);
|
|
2668
|
-
}
|
|
2669
|
-
|
|
2670
3895
|
function findStringLiteralsInRange(
|
|
2671
3896
|
source: string,
|
|
2672
3897
|
range: ObjectRange,
|
|
3898
|
+
fileName: string,
|
|
2673
3899
|
): Array<{ value: string; offset: number }> {
|
|
2674
3900
|
const literals: Array<{ value: string; offset: number }> = [];
|
|
3901
|
+
const masked = maskCommentsAndStrings(source, fileName);
|
|
2675
3902
|
let index = range.start;
|
|
2676
3903
|
while (index <= range.end) {
|
|
2677
|
-
const quote =
|
|
3904
|
+
const quote = masked[index];
|
|
2678
3905
|
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
|
2679
3906
|
index += 1;
|
|
2680
3907
|
continue;
|
|
@@ -2784,57 +4011,199 @@ function findStringEnd(source: string, start: number): number {
|
|
|
2784
4011
|
return -1;
|
|
2785
4012
|
}
|
|
2786
4013
|
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
4014
|
+
// Masking now parses with TypeScript, which is orders of magnitude more
|
|
4015
|
+
// expensive than the character walk it replaced, and six scanners call this
|
|
4016
|
+
// helper once per candidate match — the same file is masked thousands of
|
|
4017
|
+
// times in one run (measured: 1,872 calls for a 3.4k-line provider). Without
|
|
4018
|
+
// memoization that run regresses from ~8s to minutes and can exceed the CI
|
|
4019
|
+
// validation timeout. Successful results are cached by exact source text and
|
|
4020
|
+
// property-key mode; the cache stays small because a run only ever reads a
|
|
4021
|
+
// handful of files.
|
|
4022
|
+
// Parse failures are NOT cached: they throw and abort the check (fail-closed).
|
|
4023
|
+
const MASK_CACHE_LIMIT = 64;
|
|
4024
|
+
const maskCache = new Map<string, string>();
|
|
4025
|
+
|
|
4026
|
+
// Neutralize { } ( ) [ ], backtick, quotes, slash, backslash, comma, and semicolon
|
|
4027
|
+
// because scanners treat these as depth, string/regex delimiters, or top-level stops.
|
|
4028
|
+
const PRESERVED_PROPERTY_KEY_STRUCTURAL_CHARS = new Set([
|
|
4029
|
+
"{",
|
|
4030
|
+
"}",
|
|
4031
|
+
"(",
|
|
4032
|
+
")",
|
|
4033
|
+
"[",
|
|
4034
|
+
"]",
|
|
4035
|
+
"`",
|
|
4036
|
+
'"',
|
|
4037
|
+
"'",
|
|
4038
|
+
"/",
|
|
4039
|
+
"\\",
|
|
4040
|
+
",",
|
|
4041
|
+
";",
|
|
4042
|
+
]);
|
|
4043
|
+
|
|
4044
|
+
type MaskCommentsAndStringsOptions = {
|
|
4045
|
+
blankPropertyKeys?: boolean;
|
|
4046
|
+
};
|
|
4047
|
+
|
|
4048
|
+
export function maskCommentsAndStrings(
|
|
4049
|
+
source: string,
|
|
4050
|
+
fileName = "provider.ts",
|
|
4051
|
+
options: MaskCommentsAndStringsOptions = {},
|
|
4052
|
+
): string {
|
|
4053
|
+
const blankPropertyKeys = options.blankPropertyKeys === true;
|
|
4054
|
+
const cacheKey = `${blankPropertyKeys ? "blank-keys" : "preserve-keys"}\0${source}`;
|
|
4055
|
+
const cached = maskCache.get(cacheKey);
|
|
4056
|
+
if (cached !== undefined) {
|
|
4057
|
+
return cached;
|
|
4058
|
+
}
|
|
4059
|
+
const masked = computeMaskedSource(source, fileName, blankPropertyKeys);
|
|
4060
|
+
if (maskCache.size >= MASK_CACHE_LIMIT) {
|
|
4061
|
+
const oldest = maskCache.keys().next();
|
|
4062
|
+
if (!oldest.done) {
|
|
4063
|
+
maskCache.delete(oldest.value);
|
|
2799
4064
|
}
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
4065
|
+
}
|
|
4066
|
+
maskCache.set(cacheKey, masked);
|
|
4067
|
+
return masked;
|
|
4068
|
+
}
|
|
4069
|
+
|
|
4070
|
+
function computeMaskedSource(
|
|
4071
|
+
source: string,
|
|
4072
|
+
fileName: string,
|
|
4073
|
+
blankPropertyKeys: boolean,
|
|
4074
|
+
): string {
|
|
4075
|
+
const transpiled = ts.transpileModule(source, {
|
|
4076
|
+
// Declaration files trigger an internal TypeScript Debug Failure when
|
|
4077
|
+
// passed to transpileModule. Parsing is all we need here, so always use a
|
|
4078
|
+
// synthetic implementation filename while retaining the real filename
|
|
4079
|
+
// for source mapping and sanitized diagnostics below.
|
|
4080
|
+
fileName: "provider.ts",
|
|
4081
|
+
reportDiagnostics: true,
|
|
4082
|
+
compilerOptions: { target: ts.ScriptTarget.Latest },
|
|
4083
|
+
});
|
|
4084
|
+
const sourceFile = ts.createSourceFile(
|
|
4085
|
+
fileName,
|
|
4086
|
+
source,
|
|
4087
|
+
ts.ScriptTarget.Latest,
|
|
4088
|
+
true,
|
|
4089
|
+
ts.ScriptKind.TS,
|
|
4090
|
+
);
|
|
4091
|
+
const parseDiagnostic = transpiled.diagnostics?.[0];
|
|
4092
|
+
if (parseDiagnostic) {
|
|
4093
|
+
const position = parseDiagnostic.start ?? 0;
|
|
4094
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(position);
|
|
4095
|
+
throw new Error(
|
|
4096
|
+
`Cannot safely scan TypeScript source ${sanitizeDiagnosticFileName(fileName)} at ${line + 1}:${character + 1}: ${ts.flattenDiagnosticMessageText(parseDiagnostic.messageText, "\n")}`,
|
|
4097
|
+
);
|
|
4098
|
+
}
|
|
4099
|
+
|
|
4100
|
+
const chars = source.split("");
|
|
4101
|
+
const maskRange = (start: number, end: number): void => {
|
|
4102
|
+
for (let index = start; index < end; index += 1) {
|
|
4103
|
+
// Preserve the backslash of a string line continuation (\ followed
|
|
4104
|
+
// by LF, CRLF, or lone CR) and every line terminator, so masked
|
|
4105
|
+
// string bodies remain syntactically valid and the mask output can
|
|
4106
|
+
// be re-parsed (mask(mask(x)) === mask(x)).
|
|
4107
|
+
if (
|
|
4108
|
+
source[index] === "\\" &&
|
|
4109
|
+
(source[index + 1] === "\n" || source[index + 1] === "\r")
|
|
4110
|
+
) {
|
|
4111
|
+
continue;
|
|
2808
4112
|
}
|
|
2809
|
-
index
|
|
2810
|
-
continue;
|
|
2811
|
-
}
|
|
2812
|
-
const quote = source[index];
|
|
2813
|
-
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
|
2814
|
-
continue;
|
|
2815
|
-
}
|
|
2816
|
-
const end = findStringEnd(source, index);
|
|
2817
|
-
if (end === -1) {
|
|
2818
|
-
break;
|
|
4113
|
+
if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " ";
|
|
2819
4114
|
}
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
4115
|
+
};
|
|
4116
|
+
const commentRanges = new Map<string, ts.CommentRange>();
|
|
4117
|
+
const addCommentRanges = (ranges: readonly ts.CommentRange[] | undefined): void => {
|
|
4118
|
+
for (const range of ranges ?? []) {
|
|
4119
|
+
commentRanges.set(`${range.pos}:${range.end}`, range);
|
|
2825
4120
|
}
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
4121
|
+
};
|
|
4122
|
+
|
|
4123
|
+
const visit = (node: ts.Node): void => {
|
|
4124
|
+
addCommentRanges(ts.getLeadingCommentRanges(source, node.getFullStart()));
|
|
4125
|
+
addCommentRanges(ts.getTrailingCommentRanges(source, node.getEnd()));
|
|
4126
|
+
|
|
4127
|
+
const start = node.getStart(sourceFile);
|
|
4128
|
+
if (ts.isStringLiteral(node)) {
|
|
4129
|
+
// Preserve quoted property keys ("response": ...) by default so range/key
|
|
4130
|
+
// scanners can still match them. Line scanners opt into blanking key bodies
|
|
4131
|
+
// as well, preventing key text from looking like executable source.
|
|
4132
|
+
const isQuotedPropertyKey =
|
|
4133
|
+
ts.isPropertyAssignment(node.parent) && node.parent.name === node;
|
|
4134
|
+
if (blankPropertyKeys || !isQuotedPropertyKey) {
|
|
4135
|
+
maskRange(start + 1, node.end - 1);
|
|
4136
|
+
} else {
|
|
4137
|
+
for (let index = start + 1; index < node.end - 1; index += 1) {
|
|
4138
|
+
if (
|
|
4139
|
+
source[index] === "\\" &&
|
|
4140
|
+
(source[index + 1] === "\n" || source[index + 1] === "\r")
|
|
4141
|
+
) {
|
|
4142
|
+
continue;
|
|
4143
|
+
}
|
|
4144
|
+
if (PRESERVED_PROPERTY_KEY_STRUCTURAL_CHARS.has(source[index] ?? "")) {
|
|
4145
|
+
chars[index] = " ";
|
|
4146
|
+
}
|
|
2830
4147
|
}
|
|
2831
4148
|
}
|
|
2832
|
-
}
|
|
2833
|
-
|
|
4149
|
+
} else if (ts.isRegularExpressionLiteral(node)) {
|
|
4150
|
+
let closeDelimiter = node.end - 1;
|
|
4151
|
+
while (closeDelimiter > start && /[A-Za-z]/.test(source[closeDelimiter] ?? "")) {
|
|
4152
|
+
closeDelimiter -= 1;
|
|
4153
|
+
}
|
|
4154
|
+
maskRange(start + 1, closeDelimiter);
|
|
4155
|
+
} else if (ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
4156
|
+
maskRange(start + 1, node.end - 1);
|
|
4157
|
+
} else if (node.kind === ts.SyntaxKind.TemplateHead) {
|
|
4158
|
+
maskRange(start + 1, node.end - 2);
|
|
4159
|
+
} else if (node.kind === ts.SyntaxKind.TemplateMiddle) {
|
|
4160
|
+
maskRange(start + 1, node.end - 2);
|
|
4161
|
+
} else if (node.kind === ts.SyntaxKind.TemplateTail) {
|
|
4162
|
+
maskRange(start + 1, node.end - 1);
|
|
4163
|
+
}
|
|
4164
|
+
|
|
4165
|
+
for (const child of node.getChildren(sourceFile)) visit(child);
|
|
4166
|
+
};
|
|
4167
|
+
|
|
4168
|
+
visit(sourceFile);
|
|
4169
|
+
for (const comment of commentRanges.values()) {
|
|
4170
|
+
const markerLength = 2;
|
|
4171
|
+
const bodyEnd =
|
|
4172
|
+
comment.kind === ts.SyntaxKind.MultiLineCommentTrivia
|
|
4173
|
+
? comment.end - markerLength
|
|
4174
|
+
: comment.end;
|
|
4175
|
+
maskRange(comment.pos + markerLength, bodyEnd);
|
|
2834
4176
|
}
|
|
2835
4177
|
return chars.join("");
|
|
2836
4178
|
}
|
|
2837
4179
|
|
|
4180
|
+
// Escapes every non-printable or formatting character before a submitter-
|
|
4181
|
+
// controlled filename is interpolated into a diagnostic that the CLI prints.
|
|
4182
|
+
// Category-based on purpose: enumerated code-point lists kept missing members
|
|
4183
|
+
// of the same class (C1 controls, U+061C, U+2028/U+2029 were each found
|
|
4184
|
+
// individually in review), so this escapes the whole Unicode categories —
|
|
4185
|
+
// Cc (controls), Cf (formatting, includes all Bidi_Control), Zl/Zp
|
|
4186
|
+
// (line/paragraph separators). Ordinary letters, digits, spaces, and CJK
|
|
4187
|
+
// filenames pass through unchanged.
|
|
4188
|
+
const DIAGNOSTIC_UNSAFE_CHARACTER = /\p{Cc}|\p{Cf}|\p{Zl}|\p{Zp}/u;
|
|
4189
|
+
|
|
4190
|
+
function sanitizeDiagnosticFileName(fileName: string): string {
|
|
4191
|
+
let sanitized = "";
|
|
4192
|
+
for (const character of fileName) {
|
|
4193
|
+
const codePoint = character.codePointAt(0);
|
|
4194
|
+
if (codePoint === undefined) continue;
|
|
4195
|
+
if (DIAGNOSTIC_UNSAFE_CHARACTER.test(character)) {
|
|
4196
|
+
sanitized +=
|
|
4197
|
+
codePoint <= 0xff
|
|
4198
|
+
? `\\x${codePoint.toString(16).padStart(2, "0")}`
|
|
4199
|
+
: `\\u{${codePoint.toString(16)}}`;
|
|
4200
|
+
} else {
|
|
4201
|
+
sanitized += character;
|
|
4202
|
+
}
|
|
4203
|
+
}
|
|
4204
|
+
return sanitized;
|
|
4205
|
+
}
|
|
4206
|
+
|
|
2838
4207
|
function skipWhitespaceAndComments(source: string, start: number, end: number): number {
|
|
2839
4208
|
let index = start;
|
|
2840
4209
|
while (index < end) {
|
|
@@ -4229,8 +5598,9 @@ function shannonEntropy(value: string): number {
|
|
|
4229
5598
|
|
|
4230
5599
|
function guessSecretName(line: string): string {
|
|
4231
5600
|
const match =
|
|
4232
|
-
/\b(
|
|
4233
|
-
/["']?([A-Za-z_$][\w$-]*)["']?\s
|
|
5601
|
+
/\b[A-Za-z_$][\w$]*(?:\s*\.\s*([A-Za-z_$][\w$]*))+\s*=/.exec(line) ??
|
|
5602
|
+
/["']?([A-Za-z_$][\w$-]*)["']?\s*:\s*["'`]/.exec(line) ??
|
|
5603
|
+
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line);
|
|
4234
5604
|
const raw = match?.[1] ?? "SECRET";
|
|
4235
5605
|
return raw
|
|
4236
5606
|
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
@@ -4245,6 +5615,35 @@ const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/
|
|
|
4245
5615
|
// the three stay coherent.
|
|
4246
5616
|
const ENTROPY_CANDIDATE_MIN_LENGTH = 20;
|
|
4247
5617
|
|
|
5618
|
+
// A secret-ish name is only supporting evidence. Shorter values must also look
|
|
5619
|
+
// credential-like, and known config tokens/numeric values are never candidates.
|
|
5620
|
+
const SECRET_NAMED_ENV_VALUE_MIN_LENGTH = 4;
|
|
5621
|
+
const SECRET_NAMED_ENV_UNSTRUCTURED_MIN_LENGTH = 8;
|
|
5622
|
+
const NON_SECRET_ENV_VALUE_TOKENS = new Set([
|
|
5623
|
+
"true",
|
|
5624
|
+
"false",
|
|
5625
|
+
"yes",
|
|
5626
|
+
"no",
|
|
5627
|
+
"on",
|
|
5628
|
+
"off",
|
|
5629
|
+
"none",
|
|
5630
|
+
"null",
|
|
5631
|
+
"nil",
|
|
5632
|
+
"auto",
|
|
5633
|
+
"default",
|
|
5634
|
+
"local",
|
|
5635
|
+
"debug",
|
|
5636
|
+
"info",
|
|
5637
|
+
"warn",
|
|
5638
|
+
"error",
|
|
5639
|
+
"always",
|
|
5640
|
+
"never",
|
|
5641
|
+
"enabled",
|
|
5642
|
+
"disabled",
|
|
5643
|
+
"0",
|
|
5644
|
+
"1",
|
|
5645
|
+
]);
|
|
5646
|
+
|
|
4248
5647
|
const SECRET_PATTERNS: Array<[string, RegExp]> = [
|
|
4249
5648
|
["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
|
|
4250
5649
|
["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
|
|
@@ -4256,12 +5655,151 @@ const SECRET_PATTERNS: Array<[string, RegExp]> = [
|
|
|
4256
5655
|
],
|
|
4257
5656
|
];
|
|
4258
5657
|
|
|
4259
|
-
|
|
5658
|
+
type SafeLoadProviderResult =
|
|
5659
|
+
| { ok: true; provider: ProviderDefinition | undefined }
|
|
5660
|
+
| { ok: false; error: unknown };
|
|
5661
|
+
|
|
5662
|
+
async function safeLoadProvider(providerRoot: string): Promise<SafeLoadProviderResult> {
|
|
5663
|
+
try {
|
|
5664
|
+
return { ok: true, provider: await loadProvider(providerRoot) };
|
|
5665
|
+
} catch (error) {
|
|
5666
|
+
return { ok: false, error };
|
|
5667
|
+
}
|
|
5668
|
+
}
|
|
5669
|
+
|
|
5670
|
+
const UNRENDERABLE_LOAD_ERROR = "<thrown value could not be rendered>";
|
|
5671
|
+
|
|
5672
|
+
function formatLoadErrorEvidence(error: unknown, providerRoot: string): string[] {
|
|
5673
|
+
const evidence: string[] = [];
|
|
5674
|
+
const seen = new Set<object>();
|
|
5675
|
+
let current: unknown = error;
|
|
5676
|
+
let frame = 0;
|
|
5677
|
+
while (frame < MAX_LOAD_ERROR_EVIDENCE) {
|
|
5678
|
+
if (typeof current === "object" || typeof current === "function") {
|
|
5679
|
+
if (current !== null && seen.has(current)) {
|
|
5680
|
+
break;
|
|
5681
|
+
}
|
|
5682
|
+
if (current !== null) seen.add(current);
|
|
5683
|
+
}
|
|
5684
|
+
evidence.push(
|
|
5685
|
+
`${frame === 0 ? "Load error" : "Cause"}: ${sanitizeLoadErrorText(
|
|
5686
|
+
formatThrownValue(current),
|
|
5687
|
+
providerRoot,
|
|
5688
|
+
)}`,
|
|
5689
|
+
);
|
|
5690
|
+
frame += 1;
|
|
5691
|
+
const cause = readThrownValueCause(current);
|
|
5692
|
+
if (!cause.found || cause.value === undefined || cause.value === null) {
|
|
5693
|
+
break;
|
|
5694
|
+
}
|
|
5695
|
+
current = cause.value;
|
|
5696
|
+
}
|
|
5697
|
+
return evidence;
|
|
5698
|
+
}
|
|
5699
|
+
|
|
5700
|
+
function formatThrownValue(value: unknown): string {
|
|
4260
5701
|
try {
|
|
4261
|
-
|
|
5702
|
+
if (value instanceof Error) {
|
|
5703
|
+
return String(value.message);
|
|
5704
|
+
}
|
|
5705
|
+
if (
|
|
5706
|
+
value !== null &&
|
|
5707
|
+
(typeof value === "object" || typeof value === "function") &&
|
|
5708
|
+
"message" in value
|
|
5709
|
+
) {
|
|
5710
|
+
return String((value as { message?: unknown }).message);
|
|
5711
|
+
}
|
|
5712
|
+
return `<non-Error value thrown: ${String(value)}>`;
|
|
4262
5713
|
} catch {
|
|
4263
|
-
return
|
|
5714
|
+
return UNRENDERABLE_LOAD_ERROR;
|
|
5715
|
+
}
|
|
5716
|
+
}
|
|
5717
|
+
|
|
5718
|
+
function readThrownValueCause(value: unknown): { found: boolean; value?: unknown } {
|
|
5719
|
+
try {
|
|
5720
|
+
if (
|
|
5721
|
+
value === null ||
|
|
5722
|
+
(typeof value !== "object" && typeof value !== "function") ||
|
|
5723
|
+
!("cause" in value)
|
|
5724
|
+
) {
|
|
5725
|
+
return { found: false };
|
|
5726
|
+
}
|
|
5727
|
+
return { found: true, value: (value as { cause?: unknown }).cause };
|
|
5728
|
+
} catch {
|
|
5729
|
+
return { found: false };
|
|
5730
|
+
}
|
|
5731
|
+
}
|
|
5732
|
+
|
|
5733
|
+
function isPlausibleSecretNamedEnvValue(value: string): boolean {
|
|
5734
|
+
const normalized = value.trim();
|
|
5735
|
+
if (normalized.length < SECRET_NAMED_ENV_VALUE_MIN_LENGTH) return false;
|
|
5736
|
+
if (NON_SECRET_ENV_VALUE_TOKENS.has(normalized.toLowerCase())) return false;
|
|
5737
|
+
if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) return false;
|
|
5738
|
+
if (normalized.length >= SECRET_NAMED_ENV_UNSTRUCTURED_MIN_LENGTH) return true;
|
|
5739
|
+
|
|
5740
|
+
const hasLetter = /[A-Za-z]/.test(normalized);
|
|
5741
|
+
const hasDigitOrSymbol = /[^A-Za-z\s]/.test(normalized);
|
|
5742
|
+
return hasLetter && hasDigitOrSymbol;
|
|
5743
|
+
}
|
|
5744
|
+
|
|
5745
|
+
function replaceEnvValue(
|
|
5746
|
+
input: string,
|
|
5747
|
+
value: string,
|
|
5748
|
+
requireIdentifierBoundaries: boolean,
|
|
5749
|
+
): string {
|
|
5750
|
+
if (!requireIdentifierBoundaries) return input.replaceAll(value, "[REDACTED]");
|
|
5751
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5752
|
+
return input.replace(
|
|
5753
|
+
new RegExp(`(?<![A-Za-z0-9_])${escaped}(?![A-Za-z0-9_])`, "g"),
|
|
5754
|
+
"[REDACTED]",
|
|
5755
|
+
);
|
|
5756
|
+
}
|
|
5757
|
+
|
|
5758
|
+
function sanitizeLoadErrorText(value: string, providerRoot: string): string {
|
|
5759
|
+
let output = value.replace(
|
|
5760
|
+
/(^|[^A-Za-z0-9_.])((?:\/[^\s"'`]+)+)/g,
|
|
5761
|
+
(_match, prefix: string, rawPath: string) => {
|
|
5762
|
+
const pathCandidate = rawPath.replace(/[),.;:!?]+$/u, "");
|
|
5763
|
+
const relativePath = relative(providerRoot, resolve(pathCandidate));
|
|
5764
|
+
const insideProvider =
|
|
5765
|
+
relativePath === "" ||
|
|
5766
|
+
(relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
|
|
5767
|
+
return `${prefix}${insideProvider ? relativePath || "." : "[REDACTED_PATH]"}`;
|
|
5768
|
+
},
|
|
5769
|
+
);
|
|
5770
|
+
output = output.replace(/[A-Za-z]:[\\/][^\s"'`]+/g, "[REDACTED_PATH]");
|
|
5771
|
+
output = output.replace(
|
|
5772
|
+
/((?:password|token|secret|api[_-]?key|credential|authorization)\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|["'`]?[^\s,;"'`]+)/gi,
|
|
5773
|
+
"$1[REDACTED]",
|
|
5774
|
+
);
|
|
5775
|
+
const envValues: Array<{ value: string; requireIdentifierBoundaries: boolean }> = [];
|
|
5776
|
+
for (const [name, item] of Object.entries(process.env)) {
|
|
5777
|
+
if (typeof item !== "string") continue;
|
|
5778
|
+
const secretNamed = SECRETISH_IDENTIFIER_PATTERN.test(name);
|
|
5779
|
+
let shouldRedact = secretNamed;
|
|
5780
|
+
if (secretNamed) {
|
|
5781
|
+
shouldRedact = isPlausibleSecretNamedEnvValue(item);
|
|
5782
|
+
} else if (item.length >= ENTROPY_CANDIDATE_MIN_LENGTH && shouldConsiderEntropyValue(item)) {
|
|
5783
|
+
const charset = classifyEntropyCharset(item);
|
|
5784
|
+
const threshold = charset === "hex" ? 3.0 : 4.5;
|
|
5785
|
+
shouldRedact = charset !== undefined && shannonEntropy(item) >= threshold;
|
|
5786
|
+
}
|
|
5787
|
+
if (shouldRedact) {
|
|
5788
|
+
envValues.push({
|
|
5789
|
+
value: item,
|
|
5790
|
+
requireIdentifierBoundaries:
|
|
5791
|
+
secretNamed && item.trim().length < ENTROPY_CANDIDATE_MIN_LENGTH,
|
|
5792
|
+
});
|
|
5793
|
+
}
|
|
5794
|
+
}
|
|
5795
|
+
envValues.sort((a, b) => b.value.length - a.value.length);
|
|
5796
|
+
for (const envValue of envValues) {
|
|
5797
|
+
output = replaceEnvValue(output, envValue.value, envValue.requireIdentifierBoundaries);
|
|
4264
5798
|
}
|
|
5799
|
+
output = redact(output).replace(/\s+/g, " ");
|
|
5800
|
+
return output.length > MAX_LOAD_ERROR_MESSAGE_LENGTH
|
|
5801
|
+
? `${output.slice(0, MAX_LOAD_ERROR_MESSAGE_LENGTH - 1)}…`
|
|
5802
|
+
: output;
|
|
4265
5803
|
}
|
|
4266
5804
|
|
|
4267
5805
|
async function loadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
|