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