@apifuse/provider-sdk 2.2.0-beta.4 → 2.2.0-beta.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUTHORING.md +501 -13
- package/CHANGELOG.md +169 -1
- package/README.md +72 -14
- package/SUBMISSION.md +1 -1
- package/bin/apifuse-check.ts +150 -5
- package/bin/apifuse-dev.ts +38 -5
- package/bin/apifuse-migrate-shape.ts +84 -0
- package/bin/apifuse-pack-check.ts +22 -2
- package/bin/apifuse-pack-smoke.ts +57 -2
- package/bin/apifuse-pack-types.ts +356 -38
- package/bin/apifuse-perf.ts +14 -13
- package/bin/apifuse-record.ts +691 -68
- package/bin/apifuse-submit-check.ts +2301 -282
- package/bin/apifuse-sync-assets.ts +117 -0
- package/bin/submit-check-delimited-text.ts +50 -0
- package/dist/auth-turn/index.d.ts +3 -3
- package/dist/auth-turn/index.js +1 -1
- package/dist/auth.d.ts +14 -0
- package/dist/auth.js +67 -0
- package/dist/ceremonies/index.d.ts +16 -0
- package/dist/ceremonies/index.js +141 -36
- package/dist/cli/commands.d.ts +1 -1
- package/dist/cli/commands.js +16 -0
- package/dist/cli/create.d.ts +3 -0
- package/dist/cli/create.js +40 -35
- package/dist/cli/migrate-provider-shape.d.ts +52 -0
- package/dist/cli/migrate-provider-shape.js +515 -0
- package/dist/cli/prompt-assets.d.ts +80 -0
- package/dist/cli/prompt-assets.js +743 -0
- package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
- package/dist/cli/templates/provider/Dockerfile.tpl +1 -1
- package/dist/cli/templates/provider/README.md.tpl +4 -4
- package/dist/cli/templates/provider/index.ts.tpl +6 -3
- package/dist/cli/templates/provider/operations/ping.ts.tpl +2 -1
- package/dist/cli/templates/provider/provider.json.tpl +6 -0
- package/dist/config/loader.d.ts +177 -16
- package/dist/config/loader.js +424 -127
- package/dist/contract-serialization.js +4 -8
- package/dist/contract-types.d.ts +1 -0
- package/dist/contract.js +3 -0
- package/dist/declaration-validation.d.ts +32 -0
- package/dist/declaration-validation.js +207 -0
- package/dist/define.d.ts +51 -25
- package/dist/define.js +774 -39
- package/dist/error-observability.d.ts +7 -0
- package/dist/error-observability.js +61 -0
- package/dist/error-resolution.d.ts +4 -0
- package/dist/error-resolution.js +122 -0
- package/dist/errors.d.ts +33 -0
- package/dist/errors.js +40 -0
- package/dist/fixture-sanitization.d.ts +28 -0
- package/dist/fixture-sanitization.js +227 -0
- package/dist/health-scenario.d.ts +1842 -0
- package/dist/health-scenario.js +624 -0
- package/dist/index.d.ts +19 -9
- package/dist/index.js +10 -6
- package/dist/lint.d.ts +6 -1
- package/dist/lint.js +362 -3
- package/dist/native-address.d.ts +43 -0
- package/dist/native-address.js +281 -0
- package/dist/native-egress-policy.d.ts +31 -0
- package/dist/native-egress-policy.js +288 -0
- package/dist/observability.d.ts +5 -2
- package/dist/observability.js +48 -1
- package/dist/provider.d.ts +8 -2
- package/dist/provider.js +3 -1
- package/dist/runtime/auth-flow.d.ts +5 -1
- package/dist/runtime/auth-flow.js +6 -0
- package/dist/runtime/browser.d.ts +1 -0
- package/dist/runtime/browser.js +492 -49
- package/dist/runtime/cache.d.ts +1 -0
- package/dist/runtime/cache.js +169 -15
- package/dist/runtime/choice-wordlist.d.ts +9 -0
- package/dist/runtime/choice-wordlist.js +138 -0
- package/dist/runtime/choice.d.ts +13 -1
- package/dist/runtime/choice.js +490 -102
- package/dist/runtime/executor.d.ts +2 -2
- package/dist/runtime/executor.js +37 -3
- package/dist/runtime/http.d.ts +1 -0
- package/dist/runtime/http.js +515 -53
- package/dist/runtime/instrumentation.d.ts +2 -2
- package/dist/runtime/instrumentation.js +366 -8
- package/dist/runtime/native-network-errors.d.ts +33 -0
- package/dist/runtime/native-network-errors.js +69 -0
- package/dist/runtime/native-network.d.ts +96 -0
- package/dist/runtime/native-network.js +1232 -0
- package/dist/runtime/ocr.d.ts +29 -0
- package/dist/runtime/ocr.js +440 -0
- package/dist/runtime/proxy-errors.js +6 -2
- package/dist/runtime/proxy-nodemaven.d.ts +56 -0
- package/dist/runtime/proxy-nodemaven.js +146 -0
- package/dist/runtime/proxy-telemetry.d.ts +80 -1
- package/dist/runtime/proxy-telemetry.js +154 -47
- package/dist/runtime/redirects.d.ts +29 -0
- package/dist/runtime/redirects.js +36 -0
- package/dist/runtime/redis.d.ts +1 -1
- package/dist/runtime/redis.js +4 -2
- package/dist/runtime/request-options.d.ts +68 -1
- package/dist/runtime/request-options.js +548 -0
- package/dist/runtime/resolver-config.d.ts +6 -0
- package/dist/runtime/resolver-config.js +6 -0
- package/dist/runtime/resolver-public.d.ts +1 -0
- package/dist/runtime/resolver-public.js +1 -0
- package/dist/runtime/resolver-shared.d.ts +3 -0
- package/dist/runtime/resolver-shared.js +12 -0
- package/dist/runtime/resolver-vendors/bindings.d.ts +48 -0
- package/dist/runtime/resolver-vendors/bindings.js +40 -0
- package/dist/runtime/resolver-vendors/browser.d.ts +22 -0
- package/dist/runtime/resolver-vendors/browser.js +377 -0
- package/dist/runtime/resolver-vendors/capsolver.d.ts +22 -0
- package/dist/runtime/resolver-vendors/capsolver.js +526 -0
- package/dist/runtime/resolver-vendors/hosts.d.ts +2 -0
- package/dist/runtime/resolver-vendors/hosts.js +33 -0
- package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
- package/dist/runtime/resolver-vendors/twocaptcha.js +407 -0
- package/dist/runtime/resolver-vendors/types.d.ts +94 -0
- package/dist/runtime/resolver-vendors/types.js +96 -0
- package/dist/runtime/resolver.d.ts +60 -0
- package/dist/runtime/resolver.js +737 -0
- package/dist/runtime/secrets.d.ts +27 -0
- package/dist/runtime/secrets.js +51 -0
- package/dist/runtime/state.d.ts +3 -0
- package/dist/runtime/state.js +277 -71
- package/dist/runtime/stealth-cookies.d.ts +20 -0
- package/dist/runtime/stealth-cookies.js +111 -0
- package/dist/runtime/stealth.d.ts +28 -3
- package/dist/runtime/stealth.js +519 -255
- package/dist/runtime/stt.js +1 -12
- package/dist/runtime/timeout.d.ts +5 -0
- package/dist/runtime/timeout.js +12 -0
- package/dist/runtime/trace-config.d.ts +12 -0
- package/dist/runtime/trace-config.js +61 -0
- package/dist/serve.d.ts +1 -1
- package/dist/serve.js +1 -1
- package/dist/server/error-observability.d.ts +1 -0
- package/dist/server/error-observability.js +1 -0
- package/dist/server/index.d.ts +6 -3
- package/dist/server/index.js +3 -3
- package/dist/server/self-test-input-tokens.d.ts +2 -1
- package/dist/server/self-test-input-tokens.js +18 -14
- package/dist/server/self-test.d.ts +114 -0
- package/dist/server/self-test.js +787 -148
- package/dist/server/serve-implementation.d.ts +225 -0
- package/dist/server/serve-implementation.js +2242 -0
- package/dist/server/serve.d.ts +1 -70
- package/dist/server/serve.js +1 -1130
- package/dist/server/trace-output.d.ts +4 -0
- package/dist/server/trace-output.js +20 -0
- package/dist/server/types.d.ts +30 -5
- package/dist/server/types.js +13 -1
- package/dist/stateful/errors.d.ts +19 -0
- package/dist/stateful/errors.js +24 -0
- package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
- package/dist/stateful/http-provider-event-emitter.js +237 -0
- package/dist/stateful/http-session-owner-registry.d.ts +44 -0
- package/dist/stateful/http-session-owner-registry.js +210 -0
- package/dist/stateful/index.d.ts +18 -0
- package/dist/stateful/index.js +18 -0
- package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
- package/dist/stateful/provider-event-delivery-failures.js +43 -0
- package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
- package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
- package/dist/stateful/provider-event-pipeline.d.ts +50 -0
- package/dist/stateful/provider-event-pipeline.js +1 -0
- package/dist/stateful/provider-events.d.ts +101 -0
- package/dist/stateful/provider-events.js +289 -0
- package/dist/stateful/session-key.d.ts +15 -0
- package/dist/stateful/session-key.js +86 -0
- package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
- package/dist/stateful/stateful-provider-adapter-context.js +42 -0
- package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
- package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
- package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
- package/dist/stateful/stateful-provider-adapter.js +287 -0
- package/dist/stateful/stateful-provider-observability.d.ts +62 -0
- package/dist/stateful/stateful-provider-observability.js +161 -0
- package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
- package/dist/stateful/stateful-provider-owner-forwarder.js +215 -0
- package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
- package/dist/stateful/stateful-provider-runtime-context.js +60 -0
- package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
- package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
- package/dist/stateful/stateful-provider-session-routing.d.ts +67 -0
- package/dist/stateful/stateful-provider-session-routing.js +345 -0
- package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
- package/dist/stateful/stateful-provider-session-runtime.js +245 -0
- package/dist/stateful-signing.d.ts +18 -0
- package/dist/stateful-signing.js +27 -0
- package/dist/stealth/profiles.js +16 -7
- package/dist/stream-evidence.d.ts +74 -0
- package/dist/stream-evidence.js +785 -0
- package/dist/stream.js +7 -1
- package/dist/testing/index.d.ts +2 -1
- package/dist/testing/index.js +2 -1
- package/dist/testing/run.d.ts +32 -2
- package/dist/testing/run.js +489 -21
- package/dist/trace-sanitization.d.ts +5 -0
- package/dist/trace-sanitization.js +45 -0
- package/dist/types.d.ts +563 -33
- package/dist/types.js +1 -0
- package/package.json +44 -5
- package/src/auth-turn/index.ts +1 -1
- package/src/auth.ts +118 -0
- package/src/ceremonies/index.ts +189 -46
- package/src/cli/commands.ts +20 -0
- package/src/cli/create.ts +48 -35
- package/src/cli/migrate-provider-shape.ts +701 -0
- package/src/cli/prompt-assets.ts +865 -0
- package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
- package/src/cli/templates/provider/Dockerfile.tpl +1 -1
- package/src/cli/templates/provider/README.md.tpl +4 -4
- package/src/cli/templates/provider/index.ts.tpl +6 -3
- package/src/cli/templates/provider/operations/ping.ts.tpl +2 -1
- package/src/cli/templates/provider/provider.json.tpl +6 -0
- package/src/config/loader.ts +665 -163
- package/src/contract-serialization.ts +5 -7
- package/src/contract-types.ts +1 -0
- package/src/contract.ts +3 -0
- package/src/declaration-validation.ts +266 -0
- package/src/define.ts +1003 -88
- package/src/error-observability.ts +64 -0
- package/src/error-resolution.ts +127 -0
- package/src/errors.ts +68 -0
- package/src/fixture-sanitization.ts +264 -0
- package/src/health-scenario.ts +875 -0
- package/src/index.ts +205 -8
- package/src/lint.ts +408 -4
- package/src/native-address.ts +340 -0
- package/src/native-egress-policy.ts +358 -0
- package/src/observability.ts +51 -1
- package/src/provider.ts +134 -0
- package/src/runtime/auth-flow.ts +12 -0
- package/src/runtime/browser.ts +661 -63
- package/src/runtime/cache.ts +189 -14
- package/src/runtime/choice-wordlist.ts +145 -0
- package/src/runtime/choice.ts +631 -120
- package/src/runtime/executor.ts +53 -8
- package/src/runtime/http.ts +641 -61
- package/src/runtime/instrumentation.ts +520 -15
- package/src/runtime/native-network-errors.ts +99 -0
- package/src/runtime/native-network.ts +1605 -0
- package/src/runtime/ocr.ts +523 -0
- package/src/runtime/proxy-errors.ts +12 -4
- package/src/runtime/proxy-nodemaven.ts +221 -0
- package/src/runtime/proxy-telemetry.ts +244 -75
- package/src/runtime/redirects.ts +66 -0
- package/src/runtime/redis.ts +7 -2
- package/src/runtime/request-options.ts +680 -1
- package/src/runtime/resolver-config.ts +6 -0
- package/src/runtime/resolver-public.ts +20 -0
- package/src/runtime/resolver-shared.ts +17 -0
- package/src/runtime/resolver-vendors/bindings.ts +56 -0
- package/src/runtime/resolver-vendors/browser.ts +533 -0
- package/src/runtime/resolver-vendors/capsolver.ts +700 -0
- package/src/runtime/resolver-vendors/hosts.ts +38 -0
- package/src/runtime/resolver-vendors/twocaptcha.ts +539 -0
- package/src/runtime/resolver-vendors/types.ts +212 -0
- package/src/runtime/resolver.ts +1103 -0
- package/src/runtime/secrets.ts +64 -0
- package/src/runtime/state.ts +394 -77
- package/src/runtime/stealth-cookies.ts +132 -0
- package/src/runtime/stealth.ts +675 -289
- package/src/runtime/stt.ts +1 -19
- package/src/runtime/timeout.ts +18 -0
- package/src/runtime/trace-config.ts +77 -0
- package/src/serve.ts +6 -1
- package/src/server/error-observability.ts +1 -0
- package/src/server/index.ts +39 -2
- package/src/server/self-test-input-tokens.ts +29 -14
- package/src/server/self-test.ts +1030 -175
- package/src/server/serve-implementation.ts +3338 -0
- package/src/server/serve.ts +1 -1626
- package/src/server/trace-output.ts +32 -0
- package/src/server/types.ts +13 -1
- package/src/stateful/README.md +146 -0
- package/src/stateful/errors.ts +35 -0
- package/src/stateful/http-provider-event-emitter.ts +314 -0
- package/src/stateful/http-session-owner-registry.ts +306 -0
- package/src/stateful/index.ts +18 -0
- package/src/stateful/provider-event-delivery-failures.ts +80 -0
- package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
- package/src/stateful/provider-event-pipeline.ts +61 -0
- package/src/stateful/provider-events.ts +462 -0
- package/src/stateful/session-key.ts +111 -0
- package/src/stateful/stateful-provider-adapter-context.ts +59 -0
- package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
- package/src/stateful/stateful-provider-adapter.ts +562 -0
- package/src/stateful/stateful-provider-observability.ts +261 -0
- package/src/stateful/stateful-provider-owner-forwarder.ts +287 -0
- package/src/stateful/stateful-provider-runtime-context.ts +92 -0
- package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
- package/src/stateful/stateful-provider-session-routing.ts +546 -0
- package/src/stateful/stateful-provider-session-runtime.ts +403 -0
- package/src/stateful-signing.ts +46 -0
- package/src/stealth/profiles.ts +17 -7
- package/src/stream-evidence.ts +988 -0
- package/src/stream.ts +8 -1
- package/src/testing/index.ts +10 -1
- package/src/testing/run.ts +658 -15
- package/src/trace-sanitization.ts +63 -0
- package/src/types.ts +698 -65
- package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
- package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
- /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
- /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
- /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
|
@@ -4,22 +4,29 @@ import { type ChildProcess, spawn } from "node:child_process";
|
|
|
4
4
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
5
5
|
import { writeFile } from "node:fs/promises";
|
|
6
6
|
import { createServer } from "node:net";
|
|
7
|
-
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
8
8
|
import { pathToFileURL } from "node:url";
|
|
9
9
|
|
|
10
10
|
import * as acorn from "acorn";
|
|
11
|
+
import ts from "typescript";
|
|
11
12
|
import { z } from "zod";
|
|
12
13
|
|
|
13
14
|
import packageJson from "../package.json";
|
|
14
|
-
import
|
|
15
|
+
import { formatPromptAssetIssues, verifyPromptAssets } from "../src/cli/prompt-assets.js";
|
|
15
16
|
import {
|
|
16
17
|
loadProviderLocaleCatalogs,
|
|
17
18
|
type ProviderLocale,
|
|
18
19
|
validateProviderLocaleCatalogs,
|
|
19
20
|
} from "../src/i18n/index.js";
|
|
20
|
-
import {
|
|
21
|
-
import { safeParseSchemaSync } from "../src/schema.js";
|
|
22
|
-
import {
|
|
21
|
+
import type { ProviderDefinition } from "../src/index.js";
|
|
22
|
+
import { APIFUSE_DESCRIPTION_KEY_META_KEY, safeParseSchemaSync } from "../src/schema.js";
|
|
23
|
+
import {
|
|
24
|
+
findStreamCaptureGroup,
|
|
25
|
+
hasStreamEvidenceMarker,
|
|
26
|
+
parseStreamEvidenceRecord,
|
|
27
|
+
} from "../src/stream-evidence.js";
|
|
28
|
+
import { type CheckResult, PROMPT_ASSETS_CHECK_MESSAGE, runChecks } from "./apifuse-check.js";
|
|
29
|
+
import { hasSubstantiveDelimitedTextStructure } from "./submit-check-delimited-text.js";
|
|
23
30
|
import { hasSubstantiveXmlStructure } from "./submit-check-xml.js";
|
|
24
31
|
|
|
25
32
|
const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
|
|
@@ -106,11 +113,14 @@ export type SmokeResult = {
|
|
|
106
113
|
type SourceFinding = {
|
|
107
114
|
file: string;
|
|
108
115
|
line: number;
|
|
116
|
+
detail?: string;
|
|
109
117
|
};
|
|
110
118
|
|
|
111
119
|
const SDK_NATIVE_CATEGORY = "sdk-native";
|
|
112
120
|
const VENDOR_SHIM_PROVIDER_ID_PREFIX = "apifuse-provider-";
|
|
113
121
|
const MAX_SOURCE_FINDING_EVIDENCE = 5;
|
|
122
|
+
const MAX_LOAD_ERROR_EVIDENCE = 5;
|
|
123
|
+
const MAX_LOAD_ERROR_MESSAGE_LENGTH = 500;
|
|
114
124
|
|
|
115
125
|
const CATEGORY_MAX_POINTS = {
|
|
116
126
|
definition: 15,
|
|
@@ -265,24 +275,49 @@ export async function buildSubmitCheckReport(
|
|
|
265
275
|
): Promise<SubmitCheckReport> {
|
|
266
276
|
const checks: SubmitCheck[] = [];
|
|
267
277
|
const baseChecks = await safeRunChecks(providerRoot);
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
278
|
+
const loadResult = await safeLoadProvider(providerRoot);
|
|
279
|
+
const provider = loadResult.ok ? loadResult.provider : undefined;
|
|
280
|
+
let indexParseError: string | undefined;
|
|
281
|
+
if (!provider) {
|
|
282
|
+
const indexPath = resolve(providerRoot, "index.ts");
|
|
283
|
+
if (existsSync(indexPath)) {
|
|
284
|
+
const relPath = toRelativeProviderPath(providerRoot, indexPath);
|
|
285
|
+
try {
|
|
286
|
+
maskCommentsAndStrings(readFileSync(indexPath, "utf8"), relPath);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
indexParseError = error instanceof Error ? error.message : String(error);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Prompt-asset freshness is reported by its own dedicated zero-point
|
|
294
|
+
// blocker below; filter the base-check duplicate so it is not double
|
|
295
|
+
// penalized under the definition category.
|
|
296
|
+
checks.push(
|
|
297
|
+
...scoreBaseChecks(
|
|
298
|
+
baseChecks.filter((result) => result.message !== PROMPT_ASSETS_CHECK_MESSAGE),
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
checks.push(scorePromptAssetFreshness(providerRoot));
|
|
302
|
+
if (!indexParseError) {
|
|
303
|
+
checks.push(scoreProviderIdSlug(providerRoot, provider));
|
|
304
|
+
checks.push(scoreNoVendorShim(providerRoot));
|
|
305
|
+
checks.push(scoreNoVendorImport(providerRoot));
|
|
306
|
+
checks.push(scoreDescribeKey(providerRoot));
|
|
307
|
+
checks.push(scoreNoRawFetch(providerRoot));
|
|
308
|
+
checks.push(scoreNoDynamicCode(providerRoot));
|
|
309
|
+
checks.push(scoreNoRedundantRuntimeGuards(providerRoot));
|
|
310
|
+
checks.push(scoreManagedBrowserRuntime(providerRoot));
|
|
311
|
+
checks.push(scoreAsAssertionCount(providerRoot));
|
|
312
|
+
checks.push(scoreUnsafeInputPassthrough(providerRoot));
|
|
313
|
+
checks.push(scoreUnjustifiedLooseSchema(providerRoot));
|
|
314
|
+
checks.push(scoreFlatOperationComposition(providerRoot));
|
|
315
|
+
}
|
|
282
316
|
|
|
283
317
|
if (provider) {
|
|
284
318
|
const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
|
|
285
319
|
checks.push(scoreCredentialUsage(providerRoot, provider));
|
|
320
|
+
checks.push(scoreSdkOwnedSecretPresence(providerRoot, provider));
|
|
286
321
|
checks.push(scoreLocaleCatalog(providerRoot, provider));
|
|
287
322
|
checks.push(scoreOperationMetadata(provider));
|
|
288
323
|
checks.push(scoreFixtureCoverage(provider));
|
|
@@ -296,15 +331,38 @@ export async function buildSubmitCheckReport(
|
|
|
296
331
|
checks.push(scoreRepositoryDx(providerRoot));
|
|
297
332
|
checks.push(scoreSecrets(providerRoot, provider));
|
|
298
333
|
} else {
|
|
334
|
+
const loadEvidence = indexParseError
|
|
335
|
+
? undefined
|
|
336
|
+
: loadResult.ok
|
|
337
|
+
? undefined
|
|
338
|
+
: formatLoadErrorEvidence(loadResult.error, providerRoot);
|
|
339
|
+
const loadRemediation = indexParseError
|
|
340
|
+
? "Fix the syntax error reported by the provider-load-parse blocker before apifuse check can load the provider."
|
|
341
|
+
: !loadResult.ok
|
|
342
|
+
? "Fix the import/initialization failure shown in evidence so apifuse check can load the provider."
|
|
343
|
+
: "Fix index.ts so it default-exports defineProvider(...).";
|
|
299
344
|
checks.push(
|
|
300
345
|
blocker(
|
|
301
346
|
"provider-load",
|
|
302
347
|
"definition",
|
|
303
348
|
"Provider could not be loaded.",
|
|
304
|
-
|
|
349
|
+
loadRemediation,
|
|
305
350
|
CATEGORY_MAX_POINTS.definition,
|
|
351
|
+
loadEvidence,
|
|
306
352
|
),
|
|
307
353
|
);
|
|
354
|
+
if (indexParseError) {
|
|
355
|
+
checks.push(
|
|
356
|
+
blocker(
|
|
357
|
+
"provider-load-parse",
|
|
358
|
+
"definition",
|
|
359
|
+
"Provider index.ts could not be parsed safely.",
|
|
360
|
+
"Fix the syntax error in index.ts before submitting.",
|
|
361
|
+
0,
|
|
362
|
+
[indexParseError],
|
|
363
|
+
),
|
|
364
|
+
);
|
|
365
|
+
}
|
|
308
366
|
}
|
|
309
367
|
|
|
310
368
|
const total = clamp(Math.round(checks.reduce((sum, check) => sum + check.points, 0)), 0, 100);
|
|
@@ -432,7 +490,7 @@ function scoreDescribeKey(providerRoot: string): SubmitCheck {
|
|
|
432
490
|
}
|
|
433
491
|
|
|
434
492
|
function scoreNoRawFetch(providerRoot: string): SubmitCheck {
|
|
435
|
-
const findings =
|
|
493
|
+
const findings = findGlobalSinkCalls(providerRoot, FETCH_SINKS);
|
|
436
494
|
if (findings.length > 0) {
|
|
437
495
|
const evidence = formatSourceFindings(findings);
|
|
438
496
|
return blocker(
|
|
@@ -448,6 +506,435 @@ function scoreNoRawFetch(providerRoot: string): SubmitCheck {
|
|
|
448
506
|
return pass("no-raw-fetch", SDK_NATIVE_CATEGORY, "Provider source avoids raw fetch().", 0);
|
|
449
507
|
}
|
|
450
508
|
|
|
509
|
+
const DYNAMIC_CODE_SINKS: ReadonlySet<string> = new Set(["eval", "Function"]);
|
|
510
|
+
const FETCH_SINKS: ReadonlySet<string> = new Set(["fetch"]);
|
|
511
|
+
const DYNAMIC_CODE_GLOBAL_OBJECTS: ReadonlySet<string> = new Set([
|
|
512
|
+
"globalThis",
|
|
513
|
+
"window",
|
|
514
|
+
"self",
|
|
515
|
+
"global",
|
|
516
|
+
]);
|
|
517
|
+
|
|
518
|
+
function unwrapLocalExpression(expression: ts.Expression): ts.Expression {
|
|
519
|
+
let current = expression;
|
|
520
|
+
while (
|
|
521
|
+
ts.isParenthesizedExpression(current) ||
|
|
522
|
+
ts.isAsExpression(current) ||
|
|
523
|
+
ts.isSatisfiesExpression(current) ||
|
|
524
|
+
ts.isNonNullExpression(current)
|
|
525
|
+
) {
|
|
526
|
+
current = current.expression;
|
|
527
|
+
}
|
|
528
|
+
if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.CommaToken) {
|
|
529
|
+
return unwrapLocalExpression(current.right);
|
|
530
|
+
}
|
|
531
|
+
return current;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
type LocalBinding = {
|
|
535
|
+
declaration: ts.Identifier;
|
|
536
|
+
initializer?: ts.Expression;
|
|
537
|
+
destructuredProperty?: string;
|
|
538
|
+
mutable: boolean;
|
|
539
|
+
reassigned: boolean;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
type LocalBindings = {
|
|
543
|
+
scopes: Map<ts.Node, Map<string, LocalBinding[]>>;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
function isLexicalScope(node: ts.Node): boolean {
|
|
547
|
+
return (
|
|
548
|
+
ts.isSourceFile(node) ||
|
|
549
|
+
ts.isFunctionLike(node) ||
|
|
550
|
+
ts.isBlock(node) ||
|
|
551
|
+
ts.isModuleBlock(node) ||
|
|
552
|
+
ts.isCaseBlock(node) ||
|
|
553
|
+
ts.isCatchClause(node) ||
|
|
554
|
+
ts.isForStatement(node) ||
|
|
555
|
+
ts.isForInStatement(node) ||
|
|
556
|
+
ts.isForOfStatement(node)
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function enclosingScope(node: ts.Node, functionScoped: boolean): ts.Node {
|
|
561
|
+
let current: ts.Node | undefined = node.parent;
|
|
562
|
+
while (current !== undefined) {
|
|
563
|
+
if (
|
|
564
|
+
ts.isSourceFile(current) ||
|
|
565
|
+
(functionScoped ? ts.isFunctionLike(current) : isLexicalScope(current))
|
|
566
|
+
) {
|
|
567
|
+
return current;
|
|
568
|
+
}
|
|
569
|
+
current = current.parent;
|
|
570
|
+
}
|
|
571
|
+
return node.getSourceFile();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function collectLocalBindings(sourceFile: ts.SourceFile): LocalBindings {
|
|
575
|
+
const bindings: LocalBindings = { scopes: new Map() };
|
|
576
|
+
const addBinding = (
|
|
577
|
+
scope: ts.Node,
|
|
578
|
+
name: ts.Identifier,
|
|
579
|
+
binding: Omit<LocalBinding, "declaration" | "reassigned">,
|
|
580
|
+
): void => {
|
|
581
|
+
let scopeBindings = bindings.scopes.get(scope);
|
|
582
|
+
if (scopeBindings === undefined) {
|
|
583
|
+
scopeBindings = new Map();
|
|
584
|
+
bindings.scopes.set(scope, scopeBindings);
|
|
585
|
+
}
|
|
586
|
+
const existing = scopeBindings.get(name.text);
|
|
587
|
+
const localBinding: LocalBinding = { ...binding, declaration: name, reassigned: false };
|
|
588
|
+
if (existing === undefined) {
|
|
589
|
+
scopeBindings.set(name.text, [localBinding]);
|
|
590
|
+
} else {
|
|
591
|
+
existing.push(localBinding);
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
const addBindingName = (
|
|
595
|
+
bindingName: ts.BindingName,
|
|
596
|
+
scope: ts.Node,
|
|
597
|
+
binding: Omit<LocalBinding, "declaration" | "reassigned">,
|
|
598
|
+
): void => {
|
|
599
|
+
if (ts.isIdentifier(bindingName)) {
|
|
600
|
+
addBinding(scope, bindingName, binding);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
for (const element of bindingName.elements) {
|
|
604
|
+
if (ts.isOmittedExpression(element)) {
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (ts.isObjectBindingPattern(bindingName) && !element.dotDotDotToken) {
|
|
608
|
+
const property = element.propertyName ?? element.name;
|
|
609
|
+
const propertyName = propertyNameText(property);
|
|
610
|
+
if (propertyName !== undefined && ts.isIdentifier(element.name)) {
|
|
611
|
+
addBinding(scope, element.name, {
|
|
612
|
+
initializer: binding.initializer,
|
|
613
|
+
destructuredProperty: propertyName,
|
|
614
|
+
mutable: binding.mutable,
|
|
615
|
+
});
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
addBindingName(element.name, scope, { mutable: binding.mutable });
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
const visit = (node: ts.Node): void => {
|
|
623
|
+
if (ts.isVariableDeclaration(node)) {
|
|
624
|
+
const declarationList = ts.isVariableDeclarationList(node.parent) ? node.parent : undefined;
|
|
625
|
+
const isConstBinding =
|
|
626
|
+
declarationList !== undefined && (declarationList.flags & ts.NodeFlags.Const) !== 0;
|
|
627
|
+
const isBlockScoped =
|
|
628
|
+
declarationList !== undefined && (declarationList.flags & ts.NodeFlags.BlockScoped) !== 0;
|
|
629
|
+
addBindingName(node.name, enclosingScope(node, !isBlockScoped), {
|
|
630
|
+
initializer: node.initializer,
|
|
631
|
+
mutable: !isConstBinding,
|
|
632
|
+
});
|
|
633
|
+
} else if (ts.isFunctionDeclaration(node) && node.name !== undefined) {
|
|
634
|
+
addBinding(enclosingScope(node, false), node.name, { mutable: false });
|
|
635
|
+
} else if (ts.isFunctionExpression(node) && node.name !== undefined) {
|
|
636
|
+
addBinding(node, node.name, { mutable: false });
|
|
637
|
+
} else if (ts.isClassDeclaration(node) && node.name !== undefined) {
|
|
638
|
+
addBinding(enclosingScope(node, false), node.name, { mutable: false });
|
|
639
|
+
} else if (ts.isClassExpression(node) && node.name !== undefined) {
|
|
640
|
+
addBinding(node, node.name, { mutable: false });
|
|
641
|
+
} else if (ts.isParameter(node)) {
|
|
642
|
+
addBindingName(node.name, enclosingScope(node, true), { mutable: true });
|
|
643
|
+
} else if (ts.isImportClause(node) && node.name !== undefined) {
|
|
644
|
+
addBinding(sourceFile, node.name, { mutable: false });
|
|
645
|
+
} else if (ts.isNamespaceImport(node) || ts.isImportSpecifier(node)) {
|
|
646
|
+
addBinding(sourceFile, node.name, { mutable: false });
|
|
647
|
+
}
|
|
648
|
+
ts.forEachChild(node, visit);
|
|
649
|
+
};
|
|
650
|
+
visit(sourceFile);
|
|
651
|
+
|
|
652
|
+
const markReassigned = (identifier: ts.Identifier): void => {
|
|
653
|
+
for (const binding of lookupLocalBindings(identifier, bindings) ?? []) {
|
|
654
|
+
binding.reassigned = true;
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
const markAssignmentTarget = (node: ts.Node): void => {
|
|
658
|
+
const target = ts.isExpression(node) ? unwrapLocalExpression(node) : node;
|
|
659
|
+
if (ts.isIdentifier(target)) {
|
|
660
|
+
markReassigned(target);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (ts.isObjectLiteralExpression(target)) {
|
|
664
|
+
for (const element of target.properties) {
|
|
665
|
+
if (ts.isSpreadAssignment(element)) {
|
|
666
|
+
markAssignmentTarget(element.expression);
|
|
667
|
+
} else if (ts.isPropertyAssignment(element)) {
|
|
668
|
+
markAssignmentTarget(element.initializer);
|
|
669
|
+
} else if (ts.isShorthandPropertyAssignment(element)) {
|
|
670
|
+
markAssignmentTarget(element.name);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
} else if (ts.isArrayLiteralExpression(target)) {
|
|
674
|
+
for (const element of target.elements) {
|
|
675
|
+
if (!ts.isOmittedExpression(element)) {
|
|
676
|
+
markAssignmentTarget(ts.isSpreadElement(element) ? element.expression : element);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
const visitAssignments = (node: ts.Node): void => {
|
|
682
|
+
if (
|
|
683
|
+
ts.isBinaryExpression(node) &&
|
|
684
|
+
node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
685
|
+
node.operatorToken.kind <= ts.SyntaxKind.LastAssignment
|
|
686
|
+
) {
|
|
687
|
+
markAssignmentTarget(node.left);
|
|
688
|
+
} else if (
|
|
689
|
+
(ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
|
|
690
|
+
(node.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
691
|
+
node.operator === ts.SyntaxKind.MinusMinusToken)
|
|
692
|
+
) {
|
|
693
|
+
markAssignmentTarget(node.operand);
|
|
694
|
+
} else if (
|
|
695
|
+
(ts.isForInStatement(node) || ts.isForOfStatement(node)) &&
|
|
696
|
+
!ts.isVariableDeclarationList(node.initializer)
|
|
697
|
+
) {
|
|
698
|
+
markAssignmentTarget(node.initializer);
|
|
699
|
+
}
|
|
700
|
+
ts.forEachChild(node, visitAssignments);
|
|
701
|
+
};
|
|
702
|
+
visitAssignments(sourceFile);
|
|
703
|
+
return bindings;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function lookupLocalBindings(
|
|
707
|
+
reference: ts.Identifier,
|
|
708
|
+
bindings: LocalBindings,
|
|
709
|
+
): readonly LocalBinding[] | undefined {
|
|
710
|
+
let current: ts.Node | undefined = reference;
|
|
711
|
+
while (current !== undefined) {
|
|
712
|
+
const localBindings = bindings.scopes.get(current)?.get(reference.text);
|
|
713
|
+
if (localBindings !== undefined && localBindings.length > 0) {
|
|
714
|
+
return localBindings;
|
|
715
|
+
}
|
|
716
|
+
current = current.parent;
|
|
717
|
+
}
|
|
718
|
+
return undefined;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function propertyNameText(name: ts.PropertyName | ts.BindingName): string | undefined {
|
|
722
|
+
if (
|
|
723
|
+
ts.isIdentifier(name) ||
|
|
724
|
+
ts.isStringLiteral(name) ||
|
|
725
|
+
ts.isNumericLiteral(name) ||
|
|
726
|
+
ts.isNoSubstitutionTemplateLiteral(name)
|
|
727
|
+
) {
|
|
728
|
+
return name.text;
|
|
729
|
+
}
|
|
730
|
+
if (ts.isComputedPropertyName(name)) {
|
|
731
|
+
const expression = unwrapLocalExpression(name.expression);
|
|
732
|
+
if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
|
|
733
|
+
return expression.text;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return undefined;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function isUnshadowedGlobalObject(reference: ts.Expression, bindings: LocalBindings): boolean {
|
|
740
|
+
const receiver = unwrapLocalExpression(reference);
|
|
741
|
+
return (
|
|
742
|
+
ts.isIdentifier(receiver) &&
|
|
743
|
+
DYNAMIC_CODE_GLOBAL_OBJECTS.has(receiver.text) &&
|
|
744
|
+
lookupLocalBindings(receiver, bindings) === undefined
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function isGlobalMemberSink(
|
|
749
|
+
expression: ts.Expression,
|
|
750
|
+
sinkNames: ReadonlySet<string>,
|
|
751
|
+
bindings: LocalBindings,
|
|
752
|
+
): boolean {
|
|
753
|
+
const callee = unwrapLocalExpression(expression);
|
|
754
|
+
let sinkName: string | undefined;
|
|
755
|
+
let receiver: ts.Expression | undefined;
|
|
756
|
+
if (ts.isPropertyAccessExpression(callee)) {
|
|
757
|
+
sinkName = callee.name.text;
|
|
758
|
+
receiver = callee.expression;
|
|
759
|
+
} else if (ts.isElementAccessExpression(callee)) {
|
|
760
|
+
const argument = callee.argumentExpression;
|
|
761
|
+
if (
|
|
762
|
+
argument !== undefined &&
|
|
763
|
+
(ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
|
|
764
|
+
) {
|
|
765
|
+
sinkName = argument.text;
|
|
766
|
+
receiver = callee.expression;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if (sinkName === undefined || receiver === undefined || !sinkNames.has(sinkName)) {
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
return isUnshadowedGlobalObject(receiver, bindings);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function isGlobalSinkReference(
|
|
776
|
+
expression: ts.Expression,
|
|
777
|
+
sinkNames: ReadonlySet<string>,
|
|
778
|
+
bindings: LocalBindings,
|
|
779
|
+
resolving: ReadonlySet<LocalBinding>,
|
|
780
|
+
): boolean {
|
|
781
|
+
const reference = unwrapLocalExpression(expression);
|
|
782
|
+
if (isGlobalMemberSink(reference, sinkNames, bindings)) {
|
|
783
|
+
return true;
|
|
784
|
+
}
|
|
785
|
+
if (!ts.isIdentifier(reference)) {
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
return isGlobalSinkIdentifier(reference, sinkNames, bindings, resolving);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function isGlobalSinkIdentifier(
|
|
792
|
+
reference: ts.Identifier,
|
|
793
|
+
sinkNames: ReadonlySet<string>,
|
|
794
|
+
bindings: LocalBindings,
|
|
795
|
+
resolving: ReadonlySet<LocalBinding>,
|
|
796
|
+
): boolean {
|
|
797
|
+
const localBindings = lookupLocalBindings(reference, bindings);
|
|
798
|
+
if (localBindings === undefined || localBindings.length === 0) {
|
|
799
|
+
return sinkNames.has(reference.text);
|
|
800
|
+
}
|
|
801
|
+
return localBindings.every((binding) => {
|
|
802
|
+
if (resolving.has(binding) || binding.initializer === undefined) {
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
// A mutable alias is safe to resolve only when no assignment targeting
|
|
806
|
+
// this lexical binding exists in its scope (including nested closures).
|
|
807
|
+
// Reassigned let/var aliases remain unresolved to avoid false positives.
|
|
808
|
+
if (binding.mutable && binding.reassigned) {
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
const nextResolving = new Set(resolving);
|
|
812
|
+
nextResolving.add(binding);
|
|
813
|
+
if (binding.destructuredProperty !== undefined) {
|
|
814
|
+
return (
|
|
815
|
+
sinkNames.has(binding.destructuredProperty) &&
|
|
816
|
+
isUnshadowedGlobalObject(binding.initializer, bindings)
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
return isGlobalSinkReference(binding.initializer, sinkNames, bindings, nextResolving);
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function isGlobalSinkCallee(
|
|
824
|
+
expression: ts.Expression,
|
|
825
|
+
sinkNames: ReadonlySet<string>,
|
|
826
|
+
bindings: LocalBindings,
|
|
827
|
+
): boolean {
|
|
828
|
+
const callee = unwrapLocalExpression(expression);
|
|
829
|
+
if (isGlobalMemberSink(callee, sinkNames, bindings)) {
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
if (!ts.isIdentifier(callee)) {
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
return isGlobalSinkIdentifier(callee, sinkNames, bindings, new Set());
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function resolveConstStringIdentifier(
|
|
839
|
+
reference: ts.Identifier,
|
|
840
|
+
bindings: LocalBindings,
|
|
841
|
+
resolving: ReadonlySet<LocalBinding> = new Set(),
|
|
842
|
+
): string | undefined {
|
|
843
|
+
const localBindings = lookupLocalBindings(reference, bindings);
|
|
844
|
+
if (localBindings === undefined || localBindings.length === 0) {
|
|
845
|
+
return undefined;
|
|
846
|
+
}
|
|
847
|
+
let resolved: string | undefined;
|
|
848
|
+
for (const binding of localBindings) {
|
|
849
|
+
if (
|
|
850
|
+
resolving.has(binding) ||
|
|
851
|
+
binding.mutable ||
|
|
852
|
+
binding.initializer === undefined ||
|
|
853
|
+
binding.destructuredProperty !== undefined
|
|
854
|
+
) {
|
|
855
|
+
return undefined;
|
|
856
|
+
}
|
|
857
|
+
const nextResolving = new Set(resolving);
|
|
858
|
+
nextResolving.add(binding);
|
|
859
|
+
const initializer = unwrapLocalExpression(binding.initializer);
|
|
860
|
+
let value: string | undefined;
|
|
861
|
+
if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) {
|
|
862
|
+
value = initializer.text;
|
|
863
|
+
} else if (ts.isIdentifier(initializer)) {
|
|
864
|
+
value = resolveConstStringIdentifier(initializer, bindings, nextResolving);
|
|
865
|
+
}
|
|
866
|
+
if (value === undefined || (resolved !== undefined && resolved !== value)) {
|
|
867
|
+
return undefined;
|
|
868
|
+
}
|
|
869
|
+
resolved = value;
|
|
870
|
+
}
|
|
871
|
+
return resolved;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function findGlobalSinkCalls(
|
|
875
|
+
providerRoot: string,
|
|
876
|
+
sinkNames: ReadonlySet<string>,
|
|
877
|
+
includeConstructors = false,
|
|
878
|
+
): SourceFinding[] {
|
|
879
|
+
const findings: SourceFinding[] = [];
|
|
880
|
+
const seen = new Set<string>();
|
|
881
|
+
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
882
|
+
const source = readFileSync(filePath, "utf8");
|
|
883
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
884
|
+
const sourceFile = ts.createSourceFile(
|
|
885
|
+
relPath,
|
|
886
|
+
source,
|
|
887
|
+
ts.ScriptTarget.Latest,
|
|
888
|
+
true,
|
|
889
|
+
ts.ScriptKind.TS,
|
|
890
|
+
);
|
|
891
|
+
const bindings = collectLocalBindings(sourceFile);
|
|
892
|
+
const visit = (node: ts.Node): void => {
|
|
893
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (
|
|
897
|
+
(ts.isCallExpression(node) || (includeConstructors && ts.isNewExpression(node))) &&
|
|
898
|
+
isGlobalSinkCallee(node.expression, sinkNames, bindings)
|
|
899
|
+
) {
|
|
900
|
+
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
901
|
+
const key = `${relPath}:${line}`;
|
|
902
|
+
if (!seen.has(key)) {
|
|
903
|
+
seen.add(key);
|
|
904
|
+
findings.push({ file: relPath, line });
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
ts.forEachChild(node, visit);
|
|
908
|
+
};
|
|
909
|
+
visit(sourceFile);
|
|
910
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
911
|
+
break;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return findings;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function scoreNoDynamicCode(providerRoot: string): SubmitCheck {
|
|
918
|
+
const findings = findGlobalSinkCalls(providerRoot, DYNAMIC_CODE_SINKS, true);
|
|
919
|
+
if (findings.length > 0) {
|
|
920
|
+
return blocker(
|
|
921
|
+
"no-dynamic-code",
|
|
922
|
+
SDK_NATIVE_CATEGORY,
|
|
923
|
+
"Dynamic code evaluation is not permitted in provider source.",
|
|
924
|
+
"Remove eval() and Function constructor calls. Provider HTTP access must go through ctx.http.",
|
|
925
|
+
0,
|
|
926
|
+
formatSourceFindings(findings),
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
return pass(
|
|
931
|
+
"no-dynamic-code",
|
|
932
|
+
SDK_NATIVE_CATEGORY,
|
|
933
|
+
"Provider source avoids dynamic code evaluation.",
|
|
934
|
+
0,
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
|
|
451
938
|
const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
|
|
452
939
|
/\bctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\?\./,
|
|
453
940
|
];
|
|
@@ -503,7 +990,11 @@ function countAsAssertions(providerRoot: string): {
|
|
|
503
990
|
|
|
504
991
|
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
505
992
|
const content = readFileSync(filePath, "utf8");
|
|
506
|
-
const lines =
|
|
993
|
+
const lines = maskCommentsAndStrings(
|
|
994
|
+
content,
|
|
995
|
+
toRelativeProviderPath(providerRoot, filePath),
|
|
996
|
+
{ blankPropertyKeys: true },
|
|
997
|
+
).split(/\r?\n/);
|
|
507
998
|
for (let index = 0; index < lines.length; index += 1) {
|
|
508
999
|
const line = lines[index];
|
|
509
1000
|
if (
|
|
@@ -694,18 +1185,44 @@ function offsetToLine(source: string, offset: number): number {
|
|
|
694
1185
|
// between them, so `.passthrough ()` / `.passthrough\n()` are still detected.
|
|
695
1186
|
const PASSTHROUGH_CALL = /\.passthrough\s*\(\s*\)/;
|
|
696
1187
|
|
|
1188
|
+
type ExpressionSlice = {
|
|
1189
|
+
raw: string;
|
|
1190
|
+
masked: string;
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1193
|
+
function trimExpressionSlice(expr: ExpressionSlice): ExpressionSlice {
|
|
1194
|
+
const start = expr.raw.length - expr.raw.trimStart().length;
|
|
1195
|
+
const end = expr.raw.trimEnd().length;
|
|
1196
|
+
return {
|
|
1197
|
+
raw: expr.raw.slice(start, end),
|
|
1198
|
+
masked: expr.masked.slice(start, end),
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function balancedExpressionSlice(
|
|
1203
|
+
source: string,
|
|
1204
|
+
valueStart: number,
|
|
1205
|
+
fileName?: string,
|
|
1206
|
+
): ExpressionSlice {
|
|
1207
|
+
const raw = balancedValueExpression(source, valueStart, fileName);
|
|
1208
|
+
return trimExpressionSlice({
|
|
1209
|
+
raw,
|
|
1210
|
+
masked: maskCommentsAndStrings(source, fileName).slice(valueStart, valueStart + raw.length),
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
|
|
697
1214
|
// Strips redundant wrapping parentheses from an expression so that a value like
|
|
698
1215
|
// `(makeOperations())` or `((x))` classifies the same as `makeOperations()`.
|
|
699
1216
|
// Only unwraps when the leading `(` matches the trailing `)` at depth 0 (i.e.
|
|
700
1217
|
// the whole expression is parenthesized), preserving call expressions such as
|
|
701
1218
|
// `makeOperations()` whose first `(` is not a wrapper.
|
|
702
|
-
function unwrapParens(expr:
|
|
703
|
-
let value = expr
|
|
704
|
-
while (value.startsWith("(")) {
|
|
1219
|
+
function unwrapParens(expr: ExpressionSlice): ExpressionSlice {
|
|
1220
|
+
let value = trimExpressionSlice(expr);
|
|
1221
|
+
while (value.raw.startsWith("(")) {
|
|
705
1222
|
let depth = 0;
|
|
706
1223
|
let matchIndex = -1;
|
|
707
|
-
for (let i = 0; i < value.length; i += 1) {
|
|
708
|
-
const ch = value[i];
|
|
1224
|
+
for (let i = 0; i < value.raw.length; i += 1) {
|
|
1225
|
+
const ch = value.masked[i];
|
|
709
1226
|
if (ch === "(") {
|
|
710
1227
|
depth += 1;
|
|
711
1228
|
} else if (ch === ")") {
|
|
@@ -718,8 +1235,11 @@ function unwrapParens(expr: string): string {
|
|
|
718
1235
|
}
|
|
719
1236
|
// Only a true wrapper spans the entire expression (closing paren is the
|
|
720
1237
|
// last char). Otherwise the leading `(` belongs to a sub-expression.
|
|
721
|
-
if (matchIndex === value.length - 1) {
|
|
722
|
-
value =
|
|
1238
|
+
if (matchIndex === value.raw.length - 1) {
|
|
1239
|
+
value = trimExpressionSlice({
|
|
1240
|
+
raw: value.raw.slice(1, -1),
|
|
1241
|
+
masked: value.masked.slice(1, -1),
|
|
1242
|
+
});
|
|
723
1243
|
} else {
|
|
724
1244
|
break;
|
|
725
1245
|
}
|
|
@@ -731,8 +1251,8 @@ function unwrapParens(expr: string): string {
|
|
|
731
1251
|
// across (){}[] and stopping at the first top-level `,`/`;` or unmatched
|
|
732
1252
|
// closing bracket. This lets a property value be read across newlines, so a
|
|
733
1253
|
// multi-line `input: z.object({...})\n.passthrough()` is captured whole.
|
|
734
|
-
function balancedValueExpression(source: string, valueStart: number): string {
|
|
735
|
-
const masked = maskCommentsAndStrings(source);
|
|
1254
|
+
function balancedValueExpression(source: string, valueStart: number, fileName?: string): string {
|
|
1255
|
+
const masked = maskCommentsAndStrings(source, fileName);
|
|
736
1256
|
let depth = 0;
|
|
737
1257
|
let index = valueStart;
|
|
738
1258
|
for (; index < source.length; index += 1) {
|
|
@@ -756,14 +1276,14 @@ function balancedValueExpression(source: string, valueStart: number): string {
|
|
|
756
1276
|
// nested deeper than the outer object (inside handler bodies, nested objects,
|
|
757
1277
|
// or arrays) are ignored, so only a factory composition of the object itself
|
|
758
1278
|
// is detected. Input is expected to start at the outer `{`.
|
|
759
|
-
function hasTopLevelFactorySpread(expr:
|
|
760
|
-
const open = expr.indexOf("{");
|
|
1279
|
+
function hasTopLevelFactorySpread(expr: ExpressionSlice): boolean {
|
|
1280
|
+
const open = expr.masked.indexOf("{");
|
|
761
1281
|
if (open === -1) {
|
|
762
1282
|
return false;
|
|
763
1283
|
}
|
|
764
1284
|
let depth = 0;
|
|
765
|
-
for (let i = open; i < expr.length; i += 1) {
|
|
766
|
-
const ch = expr[i];
|
|
1285
|
+
for (let i = open; i < expr.raw.length; i += 1) {
|
|
1286
|
+
const ch = expr.masked[i];
|
|
767
1287
|
if (ch === "{" || ch === "(" || ch === "[") {
|
|
768
1288
|
depth += 1;
|
|
769
1289
|
} else if (ch === "}" || ch === ")" || ch === "]") {
|
|
@@ -771,11 +1291,11 @@ function hasTopLevelFactorySpread(expr: string): boolean {
|
|
|
771
1291
|
if (depth === 0) {
|
|
772
1292
|
break;
|
|
773
1293
|
}
|
|
774
|
-
} else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
|
|
1294
|
+
} else if (ch === "." && depth === 1 && expr.masked.startsWith("...", i)) {
|
|
775
1295
|
// A spread at the object's own level. Check whether the spread
|
|
776
1296
|
// argument is a call expression (factory) rather than a plain
|
|
777
1297
|
// identifier/member spread of an already-built object.
|
|
778
|
-
const rest = expr.slice(i + 3);
|
|
1298
|
+
const rest = expr.raw.slice(i + 3);
|
|
779
1299
|
if (/^\s*[A-Za-z_$][\w$.]*\s*\(/.test(rest)) {
|
|
780
1300
|
return true;
|
|
781
1301
|
}
|
|
@@ -790,15 +1310,15 @@ function hasTopLevelFactorySpread(expr: string): boolean {
|
|
|
790
1310
|
// hasTopLevelFactorySpread, so it is excluded here. These identifiers must be
|
|
791
1311
|
// resolved to their declarations: `const hidden = makeOperations()` spread as
|
|
792
1312
|
// `{ ...hidden }` is still a factory-composed map and must block.
|
|
793
|
-
function topLevelSpreadIdentifiers(expr:
|
|
794
|
-
const open = expr.indexOf("{");
|
|
1313
|
+
function topLevelSpreadIdentifiers(expr: ExpressionSlice): string[] {
|
|
1314
|
+
const open = expr.masked.indexOf("{");
|
|
795
1315
|
if (open === -1) {
|
|
796
1316
|
return [];
|
|
797
1317
|
}
|
|
798
1318
|
const names: string[] = [];
|
|
799
1319
|
let depth = 0;
|
|
800
|
-
for (let i = open; i < expr.length; i += 1) {
|
|
801
|
-
const ch = expr[i];
|
|
1320
|
+
for (let i = open; i < expr.raw.length; i += 1) {
|
|
1321
|
+
const ch = expr.masked[i];
|
|
802
1322
|
if (ch === "{" || ch === "(" || ch === "[") {
|
|
803
1323
|
depth += 1;
|
|
804
1324
|
} else if (ch === "}" || ch === ")" || ch === "]") {
|
|
@@ -806,8 +1326,8 @@ function topLevelSpreadIdentifiers(expr: string): string[] {
|
|
|
806
1326
|
if (depth === 0) {
|
|
807
1327
|
break;
|
|
808
1328
|
}
|
|
809
|
-
} else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
|
|
810
|
-
const rest = expr.slice(i + 3);
|
|
1329
|
+
} else if (ch === "." && depth === 1 && expr.masked.startsWith("...", i)) {
|
|
1330
|
+
const rest = expr.raw.slice(i + 3);
|
|
811
1331
|
// Bare identifier spread (no call parens) -> needs declaration
|
|
812
1332
|
// resolution. `...obj.prop` member spreads are treated as already
|
|
813
1333
|
// built and ignored (the leading identifier is captured).
|
|
@@ -840,8 +1360,8 @@ function topLevelSpreadIdentifiers(expr: string): string[] {
|
|
|
840
1360
|
// `makeOperations()` — stays classified as factory composition.
|
|
841
1361
|
const TRANSPARENT_RESHAPE_HEAD = /^Object\s*\.\s*fromEntries\s*\(/;
|
|
842
1362
|
const OBJECT_ENTRIES_HEAD = /^Object\s*\.\s*entries\s*\(/;
|
|
843
|
-
function isTransparentObjectReshape(expr:
|
|
844
|
-
const head = TRANSPARENT_RESHAPE_HEAD.exec(expr);
|
|
1363
|
+
function isTransparentObjectReshape(expr: ExpressionSlice): boolean {
|
|
1364
|
+
const head = TRANSPARENT_RESHAPE_HEAD.exec(expr.masked);
|
|
845
1365
|
if (!head) {
|
|
846
1366
|
return false;
|
|
847
1367
|
}
|
|
@@ -849,7 +1369,7 @@ function isTransparentObjectReshape(expr: string): boolean {
|
|
|
849
1369
|
// transparent only when that argument's root callee is `Object.entries(`
|
|
850
1370
|
// (optionally chained: `Object.entries(obj).filter(...)`), so the source
|
|
851
1371
|
// object is enumerable from source rather than produced by an opaque call.
|
|
852
|
-
const firstArg = expr.slice(head[0].length).trimStart();
|
|
1372
|
+
const firstArg = expr.masked.slice(head[0].length).trimStart();
|
|
853
1373
|
return OBJECT_ENTRIES_HEAD.test(firstArg);
|
|
854
1374
|
}
|
|
855
1375
|
|
|
@@ -983,7 +1503,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
983
1503
|
continue;
|
|
984
1504
|
}
|
|
985
1505
|
const valueStart = match.index + match[0].length;
|
|
986
|
-
const value = balancedValueExpression(source, valueStart);
|
|
1506
|
+
const value = balancedValueExpression(source, valueStart, relPath);
|
|
987
1507
|
if (PASSTHROUGH_CALL.test(value)) {
|
|
988
1508
|
const site: ConstSite = {
|
|
989
1509
|
file: relPath,
|
|
@@ -1012,8 +1532,8 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
1012
1532
|
// that is itself a passthrough expression, or that references a passthrough
|
|
1013
1533
|
// const by name (resolved against the provider-wide map), is a violation.
|
|
1014
1534
|
for (const filePath of files) {
|
|
1015
|
-
const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
|
|
1016
1535
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
1536
|
+
const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
|
|
1017
1537
|
|
|
1018
1538
|
const inputProp = /\binput\s*:\s*/g;
|
|
1019
1539
|
for (let match = inputProp.exec(source); match !== null; match = inputProp.exec(source)) {
|
|
@@ -1024,7 +1544,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
1024
1544
|
continue;
|
|
1025
1545
|
}
|
|
1026
1546
|
const valueStart = match.index + match[0].length;
|
|
1027
|
-
const value = balancedValueExpression(source, valueStart);
|
|
1547
|
+
const value = balancedValueExpression(source, valueStart, relPath);
|
|
1028
1548
|
if (PASSTHROUGH_CALL.test(value)) {
|
|
1029
1549
|
push({ file: relPath, line: offsetToLine(source, valueStart) });
|
|
1030
1550
|
continue;
|
|
@@ -1135,13 +1655,19 @@ function spreadIdentifierResolvesToFactory(
|
|
|
1135
1655
|
if (!existsSync(filePath)) {
|
|
1136
1656
|
continue;
|
|
1137
1657
|
}
|
|
1658
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
1138
1659
|
const fileSource = filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
|
|
1660
|
+
const maskedFileSource = maskCommentsAndStrings(fileSource, relPath, {
|
|
1661
|
+
blankPropertyKeys: true,
|
|
1662
|
+
});
|
|
1139
1663
|
const re = new RegExp(declRe.source, "g");
|
|
1140
|
-
for (let m = re.exec(
|
|
1664
|
+
for (let m = re.exec(maskedFileSource); m !== null; m = re.exec(maskedFileSource)) {
|
|
1141
1665
|
sawDeclaration = true;
|
|
1142
|
-
const expr = unwrapParens(
|
|
1666
|
+
const expr = unwrapParens(
|
|
1667
|
+
balancedExpressionSlice(fileSource, m.index + m[0].length, relPath),
|
|
1668
|
+
);
|
|
1143
1669
|
const isFactory =
|
|
1144
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
|
|
1670
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr.masked) || hasTopLevelFactorySpread(expr)) &&
|
|
1145
1671
|
!isTransparentObjectReshape(expr);
|
|
1146
1672
|
if (isFactory) {
|
|
1147
1673
|
return true;
|
|
@@ -1150,109 +1676,351 @@ function spreadIdentifierResolvesToFactory(
|
|
|
1150
1676
|
}
|
|
1151
1677
|
// No local declaration anywhere but imported into index.ts => constructed
|
|
1152
1678
|
// out of view; treat as factory (conservative, false-negative-safe).
|
|
1153
|
-
if (
|
|
1679
|
+
if (
|
|
1680
|
+
!sawDeclaration &&
|
|
1681
|
+
fileImportsBinding(
|
|
1682
|
+
maskCommentsAndStrings(indexSource, "index.ts", { blankPropertyKeys: true }),
|
|
1683
|
+
name,
|
|
1684
|
+
)
|
|
1685
|
+
) {
|
|
1154
1686
|
return true;
|
|
1155
1687
|
}
|
|
1156
1688
|
return false;
|
|
1157
1689
|
}
|
|
1158
1690
|
|
|
1159
|
-
function
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
if (!existsSync(indexPath)) {
|
|
1163
|
-
return pass(
|
|
1164
|
-
ruleId,
|
|
1165
|
-
SDK_NATIVE_CATEGORY,
|
|
1166
|
-
"Provider index.ts not found; flat-operation check skipped.",
|
|
1167
|
-
0,
|
|
1168
|
-
);
|
|
1169
|
-
}
|
|
1691
|
+
function isDefineProviderCall(node: ts.CallExpression): boolean {
|
|
1692
|
+
return ts.isIdentifier(node.expression) && node.expression.text === "defineProvider";
|
|
1693
|
+
}
|
|
1170
1694
|
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
ruleId,
|
|
1178
|
-
SDK_NATIVE_CATEGORY,
|
|
1179
|
-
"No defineProvider call to evaluate for operation composition.",
|
|
1180
|
-
0,
|
|
1181
|
-
);
|
|
1695
|
+
function resolveDefineProviderImplementationCall(
|
|
1696
|
+
candidate: ts.CallExpression,
|
|
1697
|
+
): ts.CallExpression | undefined {
|
|
1698
|
+
let root = candidate;
|
|
1699
|
+
while (ts.isCallExpression(root.expression)) {
|
|
1700
|
+
root = root.expression;
|
|
1182
1701
|
}
|
|
1702
|
+
return isDefineProviderCall(root) ? candidate : undefined;
|
|
1703
|
+
}
|
|
1183
1704
|
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
//
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
const exportedName = namedDefault?.[1];
|
|
1198
|
-
if (exportedName !== undefined) {
|
|
1199
|
-
const namedDecl = new RegExp(
|
|
1200
|
-
`(?:^|\\n)[ \t]*(?:export\\s+)?(?:const|let|var)\\s+${exportedName}\\s*(?::[^=\\n]+)?\\s*=\\s*defineProvider\\s*\\(`,
|
|
1201
|
-
).exec(source);
|
|
1202
|
-
if (namedDecl) {
|
|
1203
|
-
defineParenIndex = namedDecl.index + namedDecl[0].length - 1;
|
|
1204
|
-
}
|
|
1705
|
+
function findProviderImplementationCall(sourceFile: ts.SourceFile): ts.CallExpression | undefined {
|
|
1706
|
+
const defaultExports = sourceFile.statements.filter(
|
|
1707
|
+
(statement): statement is ts.ExportAssignment =>
|
|
1708
|
+
ts.isExportAssignment(statement) && !statement.isExportEquals,
|
|
1709
|
+
);
|
|
1710
|
+
|
|
1711
|
+
// Phase-separated providers export a builder invocation directly. Preserve
|
|
1712
|
+
// the previous locator's preference for that call over every defineProvider
|
|
1713
|
+
// declaration that may also appear in the file.
|
|
1714
|
+
for (const assignment of defaultExports) {
|
|
1715
|
+
const expression = unwrapImplementationExpression(assignment.expression);
|
|
1716
|
+
if (!ts.isCallExpression(expression)) {
|
|
1717
|
+
continue;
|
|
1205
1718
|
}
|
|
1206
|
-
if (
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1719
|
+
if (resolveDefineProviderImplementationCall(expression) !== undefined) {
|
|
1720
|
+
continue;
|
|
1721
|
+
}
|
|
1722
|
+
if (ts.isIdentifier(expression.expression)) {
|
|
1723
|
+
return expression;
|
|
1211
1724
|
}
|
|
1212
1725
|
}
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
)
|
|
1726
|
+
|
|
1727
|
+
// For `defineProvider(metadata)(implementation)`, select the outer call whose
|
|
1728
|
+
// argument is the implementation. A plain `defineProvider(implementation)`
|
|
1729
|
+
// selects the single call itself.
|
|
1730
|
+
for (const assignment of defaultExports) {
|
|
1731
|
+
const defaultExpression = unwrapImplementationExpression(assignment.expression);
|
|
1732
|
+
if (!ts.isCallExpression(defaultExpression)) {
|
|
1733
|
+
continue;
|
|
1734
|
+
}
|
|
1735
|
+
const implementationCall = resolveDefineProviderImplementationCall(defaultExpression);
|
|
1736
|
+
if (implementationCall !== undefined) {
|
|
1737
|
+
return implementationCall;
|
|
1738
|
+
}
|
|
1220
1739
|
}
|
|
1221
|
-
const argStart = defineParenIndex + 1;
|
|
1222
|
-
const argText = balancedValueExpression(source, argStart);
|
|
1223
1740
|
|
|
1224
|
-
// Resolve
|
|
1225
|
-
//
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1741
|
+
// Resolve `export default provider` to a top-level variable initialized by
|
|
1742
|
+
// defineProvider, including its curried implementation call when present.
|
|
1743
|
+
for (const assignment of defaultExports) {
|
|
1744
|
+
if (!ts.isIdentifier(assignment.expression)) {
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
const exportedName = assignment.expression.text;
|
|
1748
|
+
for (const statement of sourceFile.statements) {
|
|
1749
|
+
if (!ts.isVariableStatement(statement)) {
|
|
1750
|
+
continue;
|
|
1751
|
+
}
|
|
1752
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1753
|
+
if (
|
|
1754
|
+
!ts.isIdentifier(declaration.name) ||
|
|
1755
|
+
declaration.name.text !== exportedName ||
|
|
1756
|
+
declaration.initializer === undefined
|
|
1757
|
+
) {
|
|
1758
|
+
continue;
|
|
1759
|
+
}
|
|
1760
|
+
if (!ts.isCallExpression(declaration.initializer)) {
|
|
1761
|
+
continue;
|
|
1762
|
+
}
|
|
1763
|
+
const implementationCall = resolveDefineProviderImplementationCall(declaration.initializer);
|
|
1764
|
+
if (implementationCall !== undefined) {
|
|
1765
|
+
return implementationCall;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1235
1769
|
}
|
|
1236
1770
|
|
|
1237
|
-
//
|
|
1238
|
-
//
|
|
1239
|
-
let
|
|
1240
|
-
|
|
1241
|
-
if (
|
|
1242
|
-
|
|
1771
|
+
// Structural fixtures without a recognizable default export retain the
|
|
1772
|
+
// previous fallback to the first defineProvider call in source order.
|
|
1773
|
+
let firstCall: ts.CallExpression | undefined;
|
|
1774
|
+
const visit = (node: ts.Node): void => {
|
|
1775
|
+
if (firstCall !== undefined) {
|
|
1776
|
+
return;
|
|
1243
1777
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1778
|
+
if (ts.isCallExpression(node)) {
|
|
1779
|
+
const implementationCall = resolveDefineProviderImplementationCall(node);
|
|
1780
|
+
if (implementationCall !== undefined) {
|
|
1781
|
+
firstCall = implementationCall;
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
ts.forEachChild(node, visit);
|
|
1786
|
+
};
|
|
1787
|
+
visit(sourceFile);
|
|
1788
|
+
return firstCall;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
function unwrapImplementationExpression(expression: ts.Expression): ts.Expression {
|
|
1792
|
+
let current = expression;
|
|
1793
|
+
while (
|
|
1794
|
+
ts.isSatisfiesExpression(current) ||
|
|
1795
|
+
ts.isAsExpression(current) ||
|
|
1796
|
+
ts.isParenthesizedExpression(current) ||
|
|
1797
|
+
ts.isNonNullExpression(current)
|
|
1798
|
+
) {
|
|
1799
|
+
current = current.expression;
|
|
1247
1800
|
}
|
|
1801
|
+
return current;
|
|
1802
|
+
}
|
|
1248
1803
|
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1804
|
+
function isOperationsProperty(
|
|
1805
|
+
property: ts.ObjectLiteralElementLike,
|
|
1806
|
+
): property is ts.PropertyAssignment | ts.ShorthandPropertyAssignment {
|
|
1807
|
+
if (ts.isShorthandPropertyAssignment(property)) {
|
|
1808
|
+
return property.name.text === "operations";
|
|
1809
|
+
}
|
|
1810
|
+
if (!ts.isPropertyAssignment(property)) {
|
|
1811
|
+
return false;
|
|
1812
|
+
}
|
|
1813
|
+
return (
|
|
1814
|
+
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
1815
|
+
property.name.text === "operations"
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
function isOperationsAccessorOrMethod(
|
|
1820
|
+
property: ts.ObjectLiteralElementLike,
|
|
1821
|
+
): property is ts.GetAccessorDeclaration | ts.SetAccessorDeclaration | ts.MethodDeclaration {
|
|
1822
|
+
if (
|
|
1823
|
+
!ts.isGetAccessorDeclaration(property) &&
|
|
1824
|
+
!ts.isSetAccessorDeclaration(property) &&
|
|
1825
|
+
!ts.isMethodDeclaration(property)
|
|
1826
|
+
) {
|
|
1827
|
+
return false;
|
|
1828
|
+
}
|
|
1829
|
+
return (
|
|
1830
|
+
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
1831
|
+
property.name.text === "operations"
|
|
1832
|
+
);
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
function isSourceEnumerableStaticObjectLiteral(expression: ts.Expression): boolean {
|
|
1836
|
+
const unwrapped = unwrapImplementationExpression(expression);
|
|
1837
|
+
if (!ts.isObjectLiteralExpression(unwrapped)) {
|
|
1838
|
+
return false;
|
|
1839
|
+
}
|
|
1840
|
+
return unwrapped.properties.every((property) => {
|
|
1841
|
+
if (ts.isSpreadAssignment(property)) {
|
|
1842
|
+
return isSourceEnumerableStaticObjectLiteral(property.expression);
|
|
1843
|
+
}
|
|
1844
|
+
if (property.name === undefined || ts.isComputedPropertyName(property.name)) {
|
|
1845
|
+
return property.name === undefined;
|
|
1846
|
+
}
|
|
1847
|
+
return property.name.text !== "operations" || isOperationsProperty(property);
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
function findComputedImplementationProperty(
|
|
1852
|
+
objectLiteral: ts.ObjectLiteralExpression,
|
|
1853
|
+
): ts.ObjectLiteralElementLike | undefined {
|
|
1854
|
+
for (const property of objectLiteral.properties) {
|
|
1855
|
+
if (property.name !== undefined && ts.isComputedPropertyName(property.name)) {
|
|
1856
|
+
return property;
|
|
1857
|
+
}
|
|
1858
|
+
if (ts.isSpreadAssignment(property)) {
|
|
1859
|
+
const spreadExpression = unwrapImplementationExpression(property.expression);
|
|
1860
|
+
if (ts.isObjectLiteralExpression(spreadExpression)) {
|
|
1861
|
+
const nested = findComputedImplementationProperty(spreadExpression);
|
|
1862
|
+
if (nested !== undefined) {
|
|
1863
|
+
return nested;
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
return undefined;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function findEffectiveOperationsProperty(
|
|
1872
|
+
objectLiteral: ts.ObjectLiteralExpression,
|
|
1873
|
+
):
|
|
1874
|
+
| ts.PropertyAssignment
|
|
1875
|
+
| ts.ShorthandPropertyAssignment
|
|
1876
|
+
| ts.GetAccessorDeclaration
|
|
1877
|
+
| ts.SetAccessorDeclaration
|
|
1878
|
+
| ts.MethodDeclaration
|
|
1879
|
+
| undefined {
|
|
1880
|
+
let effective:
|
|
1881
|
+
| ts.PropertyAssignment
|
|
1882
|
+
| ts.ShorthandPropertyAssignment
|
|
1883
|
+
| ts.GetAccessorDeclaration
|
|
1884
|
+
| ts.SetAccessorDeclaration
|
|
1885
|
+
| ts.MethodDeclaration
|
|
1886
|
+
| undefined;
|
|
1887
|
+
for (const property of objectLiteral.properties) {
|
|
1888
|
+
if (isOperationsProperty(property) || isOperationsAccessorOrMethod(property)) {
|
|
1889
|
+
effective = property;
|
|
1890
|
+
continue;
|
|
1891
|
+
}
|
|
1892
|
+
if (ts.isSpreadAssignment(property)) {
|
|
1893
|
+
const spreadExpression = unwrapImplementationExpression(property.expression);
|
|
1894
|
+
if (ts.isObjectLiteralExpression(spreadExpression)) {
|
|
1895
|
+
const nested = findEffectiveOperationsProperty(spreadExpression);
|
|
1896
|
+
if (nested !== undefined) {
|
|
1897
|
+
effective = nested;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
return effective;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
1906
|
+
const indexPath = resolve(providerRoot, "index.ts");
|
|
1907
|
+
const ruleId = "flat-operation-composition";
|
|
1908
|
+
if (!existsSync(indexPath)) {
|
|
1909
|
+
return pass(
|
|
1910
|
+
ruleId,
|
|
1911
|
+
SDK_NATIVE_CATEGORY,
|
|
1912
|
+
"Provider index.ts not found; flat-operation check skipped.",
|
|
1913
|
+
0,
|
|
1914
|
+
);
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
const source = readFileSync(indexPath, "utf8");
|
|
1918
|
+
const indexRelPath = toRelativeProviderPath(providerRoot, indexPath);
|
|
1919
|
+
// The report preflight already rejects unparseable index.ts sources before
|
|
1920
|
+
// this rule runs, so this local AST parse only replaces property location.
|
|
1921
|
+
const sourceFile = ts.createSourceFile(
|
|
1922
|
+
indexRelPath,
|
|
1923
|
+
source,
|
|
1924
|
+
ts.ScriptTarget.Latest,
|
|
1925
|
+
true,
|
|
1926
|
+
ts.ScriptKind.TS,
|
|
1927
|
+
);
|
|
1928
|
+
const implementationCall = findProviderImplementationCall(sourceFile);
|
|
1929
|
+
const rawImplementationArg = implementationCall?.arguments[0];
|
|
1930
|
+
const implementationArg =
|
|
1931
|
+
rawImplementationArg === undefined
|
|
1932
|
+
? undefined
|
|
1933
|
+
: unwrapImplementationExpression(rawImplementationArg);
|
|
1934
|
+
if (implementationArg === undefined || !ts.isObjectLiteralExpression(implementationArg)) {
|
|
1935
|
+
return pass(
|
|
1936
|
+
ruleId,
|
|
1937
|
+
SDK_NATIVE_CATEGORY,
|
|
1938
|
+
"No defineProvider call to evaluate for operation composition.",
|
|
1939
|
+
0,
|
|
1940
|
+
);
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// Resolve the top-level operations property from the AST, then feed its raw
|
|
1944
|
+
// initializer offset into the existing expression classifier and alias
|
|
1945
|
+
// resolver unchanged.
|
|
1946
|
+
const opsProp = findEffectiveOperationsProperty(implementationArg);
|
|
1947
|
+
let opsValue: ExpressionSlice | undefined;
|
|
1948
|
+
let opsLine = 1;
|
|
1949
|
+
if (opsProp !== undefined && ts.isPropertyAssignment(opsProp)) {
|
|
1950
|
+
const valueStart = opsProp.initializer.getStart(sourceFile);
|
|
1951
|
+
opsValue = unwrapParens(balancedExpressionSlice(source, valueStart, indexRelPath));
|
|
1952
|
+
opsLine = offsetToLine(source, valueStart);
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
// Property shorthand: `buildProvider({ operations })` — resolve the
|
|
1956
|
+
// local `operations` const initializer.
|
|
1957
|
+
let aliasName: string | undefined;
|
|
1958
|
+
if (opsProp !== undefined && ts.isShorthandPropertyAssignment(opsProp)) {
|
|
1959
|
+
aliasName = "operations";
|
|
1960
|
+
} else if (opsValue !== undefined && /^[A-Za-z_$][\w$]*$/.test(opsValue.masked)) {
|
|
1961
|
+
// `operations: ops` — a bare identifier alias to resolve.
|
|
1962
|
+
aliasName = opsValue.raw;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
// A computed property might resolve to `operations`, but its composition is
|
|
1966
|
+
// not source-enumerable here. Fail closed, like an unresolved imported alias.
|
|
1967
|
+
const computed = findComputedImplementationProperty(implementationArg);
|
|
1968
|
+
if (computed !== undefined) {
|
|
1969
|
+
const computedLine = offsetToLine(source, computed.getStart(sourceFile));
|
|
1970
|
+
return blocker(
|
|
1971
|
+
ruleId,
|
|
1972
|
+
SDK_NATIVE_CATEGORY,
|
|
1973
|
+
"The defineProvider operations property uses a computed property name that cannot be statically resolved.",
|
|
1974
|
+
"Declare operations with the literal property name `operations` and a static object literal value so the provider-registry AST gate can enumerate it.",
|
|
1975
|
+
0,
|
|
1976
|
+
[`${indexRelPath}:${computedLine} (computed property name)`],
|
|
1977
|
+
);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
if (opsProp !== undefined && isOperationsAccessorOrMethod(opsProp)) {
|
|
1981
|
+
const memberLine = offsetToLine(source, opsProp.getStart(sourceFile));
|
|
1982
|
+
return blocker(
|
|
1983
|
+
ruleId,
|
|
1984
|
+
SDK_NATIVE_CATEGORY,
|
|
1985
|
+
"The defineProvider operations member is an accessor/method whose value is not statically enumerable.",
|
|
1986
|
+
"Declare operations as a property assignment with a static object literal value so the provider-registry AST gate can enumerate it.",
|
|
1987
|
+
0,
|
|
1988
|
+
[`${indexRelPath}:${memberLine} (operations accessor/method)`],
|
|
1989
|
+
);
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// A spread can override a preceding literal `operations` property. Only an
|
|
1993
|
+
// inline static object literal (including recursively inline static spreads)
|
|
1994
|
+
// exposes all of its property names to this source-only gate. Identifiers,
|
|
1995
|
+
// calls, and other expressions are opaque and therefore fail closed.
|
|
1996
|
+
const opaqueSpread = implementationArg.properties.find(
|
|
1997
|
+
(property): property is ts.SpreadAssignment =>
|
|
1998
|
+
ts.isSpreadAssignment(property) &&
|
|
1999
|
+
!isSourceEnumerableStaticObjectLiteral(property.expression),
|
|
2000
|
+
);
|
|
2001
|
+
if (opaqueSpread !== undefined) {
|
|
2002
|
+
const spreadLine = offsetToLine(source, opaqueSpread.getStart(sourceFile));
|
|
2003
|
+
return escapeHatchResult(
|
|
2004
|
+
providerRoot,
|
|
2005
|
+
ruleId,
|
|
2006
|
+
[{ file: indexRelPath, line: spreadLine }],
|
|
2007
|
+
{
|
|
2008
|
+
blockerMessage:
|
|
2009
|
+
"The defineProvider implementation uses a non-enumerable spread that could override operations.",
|
|
2010
|
+
remediation:
|
|
2011
|
+
"Declare operations directly with a static object literal. Implementation spreads must be inline static object literals so the provider-registry AST gate can enumerate every property name.",
|
|
2012
|
+
passMessage: "defineProvider declares operations as a static object literal.",
|
|
2013
|
+
},
|
|
2014
|
+
);
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
// Determine the effective initializer expression to classify. The alias may
|
|
2018
|
+
// be declared in index.ts OR re-exported from a sibling module (the common
|
|
2019
|
+
// generated scaffold: `import { operations } from "./operations"` where
|
|
2020
|
+
// ./operations.ts builds the map with makeOperations()/Object.fromEntries).
|
|
2021
|
+
// Resolve across every provider source file so cross-module factory
|
|
2022
|
+
// composition cannot evade the blocker.
|
|
2023
|
+
let effective = opsValue;
|
|
1256
2024
|
let effectiveLine = opsLine;
|
|
1257
2025
|
let effectiveFile = "index.ts";
|
|
1258
2026
|
if (aliasName !== undefined) {
|
|
@@ -1277,7 +2045,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1277
2045
|
// do not resolve the exact import target path; "any same-named factory
|
|
1278
2046
|
// blocks" is the conservative, false-negative-avoiding choice for a gate.)
|
|
1279
2047
|
type Candidate = {
|
|
1280
|
-
expr:
|
|
2048
|
+
expr: ExpressionSlice;
|
|
1281
2049
|
line: number;
|
|
1282
2050
|
file: string;
|
|
1283
2051
|
isFactory: boolean;
|
|
@@ -1287,15 +2055,22 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1287
2055
|
if (!existsSync(filePath)) {
|
|
1288
2056
|
continue;
|
|
1289
2057
|
}
|
|
1290
|
-
const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
|
|
1291
2058
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
2059
|
+
const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
|
|
2060
|
+
const maskedFileSource = maskCommentsAndStrings(fileSource, relPath, {
|
|
2061
|
+
blankPropertyKeys: true,
|
|
2062
|
+
});
|
|
1292
2063
|
|
|
1293
2064
|
const declRe = new RegExp(aliasDecl.source, "g");
|
|
1294
|
-
for (
|
|
2065
|
+
for (
|
|
2066
|
+
let m = declRe.exec(maskedFileSource);
|
|
2067
|
+
m !== null;
|
|
2068
|
+
m = declRe.exec(maskedFileSource)
|
|
2069
|
+
) {
|
|
1295
2070
|
const valueStart = m.index + m[0].length;
|
|
1296
|
-
const expr = unwrapParens(
|
|
2071
|
+
const expr = unwrapParens(balancedExpressionSlice(fileSource, valueStart, relPath));
|
|
1297
2072
|
const isFactory =
|
|
1298
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
|
|
2073
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr.masked) || hasTopLevelFactorySpread(expr)) &&
|
|
1299
2074
|
!isTransparentObjectReshape(expr);
|
|
1300
2075
|
candidates.push({
|
|
1301
2076
|
expr,
|
|
@@ -1305,9 +2080,14 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1305
2080
|
});
|
|
1306
2081
|
}
|
|
1307
2082
|
const destructRe = new RegExp(destructured.source, "g");
|
|
1308
|
-
for (
|
|
2083
|
+
for (
|
|
2084
|
+
let m = destructRe.exec(maskedFileSource);
|
|
2085
|
+
m !== null;
|
|
2086
|
+
m = destructRe.exec(maskedFileSource)
|
|
2087
|
+
) {
|
|
2088
|
+
const raw = `${m[1]}(`;
|
|
1309
2089
|
candidates.push({
|
|
1310
|
-
expr:
|
|
2090
|
+
expr: { raw, masked: raw },
|
|
1311
2091
|
line: offsetToLine(fileSource, m.index),
|
|
1312
2092
|
file: relPath,
|
|
1313
2093
|
isFactory: true,
|
|
@@ -1333,11 +2113,15 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1333
2113
|
// the unresolved import as a factory-composed (non-static) shape rather
|
|
1334
2114
|
// than silently passing.
|
|
1335
2115
|
if (!resolved) {
|
|
1336
|
-
const
|
|
1337
|
-
|
|
1338
|
-
);
|
|
2116
|
+
const maskedIndexSource = maskCommentsAndStrings(source, indexRelPath, {
|
|
2117
|
+
blankPropertyKeys: true,
|
|
2118
|
+
});
|
|
2119
|
+
const importMatch = new RegExp(
|
|
2120
|
+
`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`,
|
|
2121
|
+
).exec(maskedIndexSource);
|
|
1339
2122
|
if (importMatch) {
|
|
1340
|
-
|
|
2123
|
+
const raw = `${aliasName}(`;
|
|
2124
|
+
effective = { raw, masked: raw };
|
|
1341
2125
|
effectiveLine = offsetToLine(source, importMatch.index);
|
|
1342
2126
|
effectiveFile = "index.ts";
|
|
1343
2127
|
}
|
|
@@ -1361,14 +2145,16 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1361
2145
|
spreadIdentifierResolvesToFactory(providerRoot, indexPath, source, name),
|
|
1362
2146
|
);
|
|
1363
2147
|
const isStaticLiteral =
|
|
1364
|
-
effective?.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
|
|
2148
|
+
effective?.masked.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
|
|
1365
2149
|
// A call expression `ident(...)` (factory) or a factory-spread literal is
|
|
1366
2150
|
// the rejected, non-static shape — UNLESS it is the stdlib
|
|
1367
2151
|
// `Object.fromEntries(Object.entries(<source-visible obj>)...)` reshape,
|
|
1368
2152
|
// whose op set is still enumerable from source (verified golden pattern).
|
|
1369
2153
|
const isFactoryCall =
|
|
1370
2154
|
effective !== undefined &&
|
|
1371
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) ||
|
|
2155
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(effective.masked) ||
|
|
2156
|
+
hasFactorySpread ||
|
|
2157
|
+
hasFactorySpreadIdentifier) &&
|
|
1372
2158
|
!isTransparentObjectReshape(effective);
|
|
1373
2159
|
|
|
1374
2160
|
if (isFactoryCall && !isStaticLiteral) {
|
|
@@ -1380,7 +2166,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1380
2166
|
blockerMessage:
|
|
1381
2167
|
"defineProvider operations are composed by a factory call instead of a static object literal.",
|
|
1382
2168
|
remediation:
|
|
1383
|
-
"Declare operations as a static literal
|
|
2169
|
+
"Declare operations as a static literal in the provider builder call. The provider-registry AST gate requires static runtime/operations; factory composition fails the registry build. If composition is unavoidable, add `// @apifuse-allow flat-operation-composition: <reason>`.",
|
|
1384
2170
|
passMessage: "defineProvider declares operations as a static object literal.",
|
|
1385
2171
|
});
|
|
1386
2172
|
}
|
|
@@ -1394,7 +2180,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1394
2180
|
}
|
|
1395
2181
|
|
|
1396
2182
|
function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
|
|
1397
|
-
const credentialReferences =
|
|
2183
|
+
const credentialReferences = findCredentialReferences(providerRoot);
|
|
1398
2184
|
const authMode = provider.auth?.mode ?? "none";
|
|
1399
2185
|
const credentialKeys = provider.credential?.keys ?? [];
|
|
1400
2186
|
const storesProviderCredential = authMode !== "none" || credentialKeys.length > 0;
|
|
@@ -1424,26 +2210,291 @@ function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition
|
|
|
1424
2210
|
);
|
|
1425
2211
|
}
|
|
1426
2212
|
|
|
2213
|
+
function isDirectCredentialReference(expression: ts.Expression): boolean {
|
|
2214
|
+
const reference = unwrapLocalExpression(expression);
|
|
2215
|
+
let receiver: ts.Expression | undefined;
|
|
2216
|
+
let memberName: string | undefined;
|
|
2217
|
+
if (ts.isPropertyAccessExpression(reference)) {
|
|
2218
|
+
receiver = reference.expression;
|
|
2219
|
+
memberName = reference.name.text;
|
|
2220
|
+
} else if (ts.isElementAccessExpression(reference)) {
|
|
2221
|
+
receiver = reference.expression;
|
|
2222
|
+
const argument = reference.argumentExpression;
|
|
2223
|
+
if (
|
|
2224
|
+
argument !== undefined &&
|
|
2225
|
+
(ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
|
|
2226
|
+
) {
|
|
2227
|
+
memberName = argument.text;
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
const unwrappedReceiver = receiver === undefined ? undefined : unwrapLocalExpression(receiver);
|
|
2231
|
+
return (
|
|
2232
|
+
memberName === "credential" &&
|
|
2233
|
+
unwrappedReceiver !== undefined &&
|
|
2234
|
+
ts.isIdentifier(unwrappedReceiver) &&
|
|
2235
|
+
unwrappedReceiver.text === "ctx"
|
|
2236
|
+
);
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
function isCredentialReference(
|
|
2240
|
+
expression: ts.Expression,
|
|
2241
|
+
bindings: LocalBindings,
|
|
2242
|
+
resolving: ReadonlySet<LocalBinding> = new Set(),
|
|
2243
|
+
): boolean {
|
|
2244
|
+
const reference = unwrapLocalExpression(expression);
|
|
2245
|
+
if (isDirectCredentialReference(reference)) {
|
|
2246
|
+
return true;
|
|
2247
|
+
}
|
|
2248
|
+
if (!ts.isIdentifier(reference)) {
|
|
2249
|
+
return false;
|
|
2250
|
+
}
|
|
2251
|
+
const localBindings = lookupLocalBindings(reference, bindings);
|
|
2252
|
+
if (localBindings === undefined || localBindings.length === 0) {
|
|
2253
|
+
return false;
|
|
2254
|
+
}
|
|
2255
|
+
return localBindings.every((binding) => {
|
|
2256
|
+
if (resolving.has(binding) || binding.mutable || binding.initializer === undefined) {
|
|
2257
|
+
return false;
|
|
2258
|
+
}
|
|
2259
|
+
const nextResolving = new Set(resolving);
|
|
2260
|
+
nextResolving.add(binding);
|
|
2261
|
+
if (binding.destructuredProperty !== undefined) {
|
|
2262
|
+
const source = unwrapLocalExpression(binding.initializer);
|
|
2263
|
+
return (
|
|
2264
|
+
binding.destructuredProperty === "credential" &&
|
|
2265
|
+
ts.isIdentifier(source) &&
|
|
2266
|
+
source.text === "ctx"
|
|
2267
|
+
);
|
|
2268
|
+
}
|
|
2269
|
+
return isCredentialReference(binding.initializer, bindings, nextResolving);
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
function findCredentialReferences(providerRoot: string): SourceFinding[] {
|
|
2274
|
+
const findings: SourceFinding[] = [];
|
|
2275
|
+
const seen = new Set<string>();
|
|
2276
|
+
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
2277
|
+
const source = readFileSync(filePath, "utf8");
|
|
2278
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
2279
|
+
const sourceFile = ts.createSourceFile(
|
|
2280
|
+
relPath,
|
|
2281
|
+
source,
|
|
2282
|
+
ts.ScriptTarget.Latest,
|
|
2283
|
+
true,
|
|
2284
|
+
ts.ScriptKind.TS,
|
|
2285
|
+
);
|
|
2286
|
+
const bindings = collectLocalBindings(sourceFile);
|
|
2287
|
+
const visit = (node: ts.Node): void => {
|
|
2288
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
if (
|
|
2292
|
+
ts.isExpression(node) &&
|
|
2293
|
+
(isDirectCredentialReference(node) ||
|
|
2294
|
+
((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) &&
|
|
2295
|
+
isCredentialReference(node.expression, bindings)))
|
|
2296
|
+
) {
|
|
2297
|
+
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
2298
|
+
const key = `${relPath}:${line}`;
|
|
2299
|
+
if (!seen.has(key)) {
|
|
2300
|
+
seen.add(key);
|
|
2301
|
+
findings.push({ file: relPath, line });
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
ts.forEachChild(node, visit);
|
|
2305
|
+
};
|
|
2306
|
+
visit(sourceFile);
|
|
2307
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
2308
|
+
break;
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
return findings;
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
// ---------------------------------------------------------------------------
|
|
2315
|
+
// sdk-owned-secret-presence (warn): provider-local double validation of
|
|
2316
|
+
// declared env secrets.
|
|
2317
|
+
//
|
|
2318
|
+
// The SDK runtime is the single source of truth for secret presence: declared
|
|
2319
|
+
// `required: true` secrets are validated before every handler/auth-flow
|
|
2320
|
+
// invocation and fail with the canonical structured MISSING_SECRET error
|
|
2321
|
+
// (HTTP 400, category credential_unavailable). Provider-local presence guards
|
|
2322
|
+
// (requireServiceKey/requireApiKey style) are dead weight that historically
|
|
2323
|
+
// diverged into inconsistent shapes (CONFIGURATION_ERROR vs MISSING_SECRET,
|
|
2324
|
+
// with/without category), which broke uniform incident attribution when nine
|
|
2325
|
+
// providers shipped with unprovisioned secrets (2026-07-22).
|
|
2326
|
+
//
|
|
2327
|
+
// Heuristic, warn-only: a line reading a declared `required: true` secret via
|
|
2328
|
+
// `.env.get(...)` (string literal or a const alias of a declared name)
|
|
2329
|
+
// followed within a small window by a falsy presence check plus a `throw`.
|
|
2330
|
+
// The rule flags duplication of the SDK gate ONLY: env names that are not
|
|
2331
|
+
// declared in defineProvider secrets[], and optional declarations
|
|
2332
|
+
// (`required: false`/omitted) that the runtime deliberately does not enforce,
|
|
2333
|
+
// are out of scope. Escape hatch:
|
|
2334
|
+
// `// @apifuse-allow sdk-owned-secret-presence: <reason>`.
|
|
2335
|
+
// ---------------------------------------------------------------------------
|
|
2336
|
+
|
|
2337
|
+
const SDK_OWNED_SECRET_PRESENCE_RULE_ID = "sdk-owned-secret-presence";
|
|
2338
|
+
const SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES = 10;
|
|
2339
|
+
|
|
2340
|
+
const ENV_GET_CALL_PATTERN =
|
|
2341
|
+
/\.env\.get\(\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`|([A-Za-z_$][\w$]*))\s*\)/;
|
|
2342
|
+
|
|
2343
|
+
const SECRET_ALIAS_CONST_PATTERN =
|
|
2344
|
+
/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`)/g;
|
|
2345
|
+
|
|
2346
|
+
const ENV_GET_ASSIGNMENT_PATTERN = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=[^;]*\.env\.get\(/;
|
|
2347
|
+
|
|
2348
|
+
// Const aliases of declared secret names (e.g. `const SERVICE_KEY_ENV =
|
|
2349
|
+
// "APIFUSE__PROVIDER__X__SERVICE_KEY"`) so aliased `.env.get(SERVICE_KEY_ENV)`
|
|
2350
|
+
// guards are detected, not just direct string literals.
|
|
2351
|
+
function buildDeclaredSecretAliasMap(
|
|
2352
|
+
providerRoot: string,
|
|
2353
|
+
declaredNames: ReadonlySet<string>,
|
|
2354
|
+
): Map<string, string> {
|
|
2355
|
+
const aliases = new Map<string, string>();
|
|
2356
|
+
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
2357
|
+
const content = readFileSync(filePath, "utf8");
|
|
2358
|
+
for (const match of content.matchAll(SECRET_ALIAS_CONST_PATTERN)) {
|
|
2359
|
+
const alias = match[1];
|
|
2360
|
+
const name = match[2] ?? match[3] ?? match[4];
|
|
2361
|
+
if (alias && name && declaredNames.has(name)) {
|
|
2362
|
+
aliases.set(alias, name);
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
return aliases;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
function hasLocalSecretPresenceGuard(
|
|
2370
|
+
line: string,
|
|
2371
|
+
remainingLines: readonly string[],
|
|
2372
|
+
declaredNames: ReadonlySet<string>,
|
|
2373
|
+
aliases: ReadonlyMap<string, string>,
|
|
2374
|
+
): boolean {
|
|
2375
|
+
const match = ENV_GET_CALL_PATTERN.exec(line);
|
|
2376
|
+
if (!match) {
|
|
2377
|
+
return false;
|
|
2378
|
+
}
|
|
2379
|
+
const literal = match[1] ?? match[2] ?? match[3];
|
|
2380
|
+
const identifier = match[4];
|
|
2381
|
+
const readsDeclaredSecret =
|
|
2382
|
+
literal !== undefined
|
|
2383
|
+
? declaredNames.has(literal)
|
|
2384
|
+
: identifier !== undefined && aliases.has(identifier);
|
|
2385
|
+
if (!readsDeclaredSecret) {
|
|
2386
|
+
return false;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
const window = [line, ...remainingLines.slice(0, SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES)];
|
|
2390
|
+
if (!window.some((candidate) => /\bthrow\b/.test(candidate))) {
|
|
2391
|
+
return false;
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
// Assigned read (`const key = ctx.env.get(...)`): only a falsy/undefined
|
|
2395
|
+
// check on THAT variable counts as a presence guard. Anchoring on the
|
|
2396
|
+
// assigned identifier avoids false positives from unrelated guards/throws
|
|
2397
|
+
// that merely sit near the env read (mirrors the aliased runtime-guard rule).
|
|
2398
|
+
const assigned = ENV_GET_ASSIGNMENT_PATTERN.exec(line)?.[1];
|
|
2399
|
+
if (assigned) {
|
|
2400
|
+
const escaped = assigned.replace(/\$/g, "\\$");
|
|
2401
|
+
const guardPattern = new RegExp(
|
|
2402
|
+
`(?:!\\s*${escaped}\\b|\\b${escaped}\\s*===?\\s*(?:undefined|null)\\b|\\b${escaped}\\s*==\\s*null\\b|\\b${escaped}(?:\\?\\.|\\.)length\\s*===?\\s*0\\b)`,
|
|
2403
|
+
);
|
|
2404
|
+
return window.some((candidate) => guardPattern.test(candidate));
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
// Un-assigned read: only an inline presence check on the same line counts,
|
|
2408
|
+
// e.g. `if (!ctx.env.get(KEY)) throw ...`.
|
|
2409
|
+
return /(?:if\s*\(\s*!|===?\s*undefined\b|==\s*null\b)/.test(line);
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
function scoreSdkOwnedSecretPresence(
|
|
2413
|
+
providerRoot: string,
|
|
2414
|
+
provider: ProviderDefinition,
|
|
2415
|
+
): SubmitCheck {
|
|
2416
|
+
const passMessage = "Provider relies on SDK-owned secret presence validation.";
|
|
2417
|
+
// Only `required: true` declarations: those are exactly what the runtime
|
|
2418
|
+
// gate enforces. A presence guard over an optional secret is conditional
|
|
2419
|
+
// business logic the SDK will not replace, not double validation.
|
|
2420
|
+
const declaredNames: ReadonlySet<string> = new Set(
|
|
2421
|
+
(provider.secrets ?? [])
|
|
2422
|
+
.filter((secret) => secret.required === true)
|
|
2423
|
+
.map((secret) => secret.name),
|
|
2424
|
+
);
|
|
2425
|
+
if (declaredNames.size === 0) {
|
|
2426
|
+
return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
const aliases = buildDeclaredSecretAliasMap(providerRoot, declaredNames);
|
|
2430
|
+
const findings = findSourceFindings(providerRoot, (line, remainingLines) =>
|
|
2431
|
+
hasLocalSecretPresenceGuard(line, remainingLines, declaredNames, aliases),
|
|
2432
|
+
);
|
|
2433
|
+
if (findings.length === 0) {
|
|
2434
|
+
return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
const { violations, overridden } = partitionAllowOverrides(
|
|
2438
|
+
providerRoot,
|
|
2439
|
+
findings,
|
|
2440
|
+
SDK_OWNED_SECRET_PRESENCE_RULE_ID,
|
|
2441
|
+
);
|
|
2442
|
+
if (violations.length === 0) {
|
|
2443
|
+
return pass(
|
|
2444
|
+
SDK_OWNED_SECRET_PRESENCE_RULE_ID,
|
|
2445
|
+
SDK_NATIVE_CATEGORY,
|
|
2446
|
+
`${passMessage} ${overridden.length} acknowledged @apifuse-allow override(s).`,
|
|
2447
|
+
0,
|
|
2448
|
+
formatSourceFindings(overridden),
|
|
2449
|
+
);
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
return {
|
|
2453
|
+
id: SDK_OWNED_SECRET_PRESENCE_RULE_ID,
|
|
2454
|
+
category: SDK_NATIVE_CATEGORY,
|
|
2455
|
+
level: "warn",
|
|
2456
|
+
status: "warn",
|
|
2457
|
+
points: 0,
|
|
2458
|
+
maxPoints: 0,
|
|
2459
|
+
message:
|
|
2460
|
+
"Provider source re-validates declared env secret presence locally; the SDK owns this check.",
|
|
2461
|
+
remediation:
|
|
2462
|
+
"The provider SDK validates declared required secrets before handlers and auth flows run and returns the canonical MISSING_SECRET error (HTTP 400, category credential_unavailable). Declare the secret with required: true in defineProvider({ secrets: [...] }), delete the provider-local presence guard (requireServiceKey/requireApiKey style), and read the value directly with ctx.env.get(); the guard is dead weight and its divergent CONFIGURATION_ERROR-style shape is deprecated. Acknowledge intentional exceptions with `// @apifuse-allow sdk-owned-secret-presence: <reason>`.",
|
|
2463
|
+
evidence: formatSourceFindings(violations),
|
|
2464
|
+
};
|
|
2465
|
+
}
|
|
2466
|
+
|
|
1427
2467
|
function findSourceLineMatches(
|
|
1428
2468
|
providerRoot: string,
|
|
1429
2469
|
pattern: RegExp | ((line: string) => boolean),
|
|
2470
|
+
useMaskedSource = false,
|
|
1430
2471
|
): SourceFinding[] {
|
|
1431
|
-
return findSourceFindings(
|
|
2472
|
+
return findSourceFindings(
|
|
2473
|
+
providerRoot,
|
|
2474
|
+
(line) => matchesLinePattern(line, pattern),
|
|
2475
|
+
useMaskedSource,
|
|
2476
|
+
);
|
|
1432
2477
|
}
|
|
1433
2478
|
|
|
1434
2479
|
function findSourceFindings(
|
|
1435
2480
|
providerRoot: string,
|
|
1436
2481
|
matchesLine: (line: string, remainingLines: readonly string[]) => boolean,
|
|
2482
|
+
useMaskedSource = false,
|
|
1437
2483
|
): SourceFinding[] {
|
|
1438
2484
|
const findings: SourceFinding[] = [];
|
|
1439
2485
|
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
1440
2486
|
const content = readFileSync(filePath, "utf8");
|
|
1441
|
-
const
|
|
2487
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
2488
|
+
const lines = (
|
|
2489
|
+
useMaskedSource
|
|
2490
|
+
? maskCommentsAndStrings(content, relPath, { blankPropertyKeys: true })
|
|
2491
|
+
: content
|
|
2492
|
+
).split(/\r?\n/);
|
|
1442
2493
|
for (let index = 0; index < lines.length; index += 1) {
|
|
1443
2494
|
const line = lines[index];
|
|
1444
2495
|
if (line !== undefined && matchesLine(line, lines.slice(index + 1))) {
|
|
1445
2496
|
findings.push({
|
|
1446
|
-
file:
|
|
2497
|
+
file: relPath,
|
|
1447
2498
|
line: index + 1,
|
|
1448
2499
|
});
|
|
1449
2500
|
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
@@ -1523,8 +2574,25 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
|
|
|
1523
2574
|
);
|
|
1524
2575
|
}
|
|
1525
2576
|
|
|
2577
|
+
const EXCLUDED_SOURCE_DIRECTORY_SEGMENTS = new Set([
|
|
2578
|
+
".git",
|
|
2579
|
+
".worktree",
|
|
2580
|
+
"node_modules",
|
|
2581
|
+
"dist",
|
|
2582
|
+
"build",
|
|
2583
|
+
"coverage",
|
|
2584
|
+
]);
|
|
2585
|
+
|
|
2586
|
+
// `.agents`/`.apifuse` stay IN scope on purpose: managed content there is
|
|
2587
|
+
// markdown/JSON (never scannable), while a planted `.ts`/`.sh` under those
|
|
2588
|
+
// directories must not become a scan-exempt hiding place for secrets, raw
|
|
2589
|
+
// fetch, or vendor imports. Nested dependency/build output directories under
|
|
2590
|
+
// them use the same segment exclusions; `.worktree` is excluded because task
|
|
2591
|
+
// worktrees are duplicate repository checkouts, not provider source.
|
|
1526
2592
|
function shouldScanSourceDirectory(relativePath: string): boolean {
|
|
1527
|
-
return
|
|
2593
|
+
return relativePath
|
|
2594
|
+
.split("/")
|
|
2595
|
+
.every((segment) => !EXCLUDED_SOURCE_DIRECTORY_SEGMENTS.has(segment));
|
|
1528
2596
|
}
|
|
1529
2597
|
|
|
1530
2598
|
function isExcludedTestSource(relativePath: string): boolean {
|
|
@@ -1542,7 +2610,31 @@ function toRelativeProviderPath(providerRoot: string, filePath: string): string
|
|
|
1542
2610
|
}
|
|
1543
2611
|
|
|
1544
2612
|
function formatSourceFindings(findings: readonly SourceFinding[]): string[] {
|
|
1545
|
-
return findings.map(
|
|
2613
|
+
return findings.map(
|
|
2614
|
+
(finding) =>
|
|
2615
|
+
`${finding.file}:${finding.line}${finding.detail === undefined ? "" : `: ${finding.detail}`}`,
|
|
2616
|
+
);
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
function scorePromptAssetFreshness(providerRoot: string): SubmitCheck {
|
|
2620
|
+
const verification = verifyPromptAssets(providerRoot);
|
|
2621
|
+
if (verification.ok) {
|
|
2622
|
+
return pass(
|
|
2623
|
+
"prompt-assets-fresh",
|
|
2624
|
+
"docs",
|
|
2625
|
+
"SDK-managed agent prompt assets match the installed SDK version.",
|
|
2626
|
+
0,
|
|
2627
|
+
);
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
return blocker(
|
|
2631
|
+
"prompt-assets-fresh",
|
|
2632
|
+
"docs",
|
|
2633
|
+
"SDK-managed agent prompt assets are missing, stale, or modified.",
|
|
2634
|
+
"Run `bun run sync-assets` (or `bunx apifuse sync-assets .`) to regenerate AGENTS.md, .agents/skills/**, the CLAUDE.md/.claude/.codex symlinks, and .apifuse/prompt-assets.json for the installed SDK version.",
|
|
2635
|
+
0,
|
|
2636
|
+
formatPromptAssetIssues(verification),
|
|
2637
|
+
);
|
|
1546
2638
|
}
|
|
1547
2639
|
|
|
1548
2640
|
function scoreRepositoryDx(providerRoot: string): SubmitCheck {
|
|
@@ -1617,7 +2709,7 @@ async function safeRunChecks(providerRoot: string): Promise<CheckResult[]> {
|
|
|
1617
2709
|
{
|
|
1618
2710
|
message: "Base provider checks can run",
|
|
1619
2711
|
passed: false,
|
|
1620
|
-
details: [
|
|
2712
|
+
details: [formatThrownValue(error)],
|
|
1621
2713
|
},
|
|
1622
2714
|
];
|
|
1623
2715
|
}
|
|
@@ -1983,11 +3075,16 @@ const GENERATED_LOCAL_ONLY_SCAFFOLD_REASON = /generated local-only scaffold/i;
|
|
|
1983
3075
|
function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
|
|
1984
3076
|
const rawPath = resolve(providerRoot, "__fixtures__", "raw.json");
|
|
1985
3077
|
let hasRecordedEvidence = false;
|
|
3078
|
+
let fixtureValidationError: string | undefined;
|
|
1986
3079
|
if (existsSync(rawPath)) {
|
|
1987
3080
|
try {
|
|
1988
|
-
hasRecordedEvidence =
|
|
1989
|
-
|
|
3081
|
+
hasRecordedEvidence = recordedFixtureStats(
|
|
3082
|
+
JSON.parse(readFileSync(rawPath, "utf8")),
|
|
3083
|
+
0,
|
|
3084
|
+
).hasNestedSubstance;
|
|
3085
|
+
} catch (error) {
|
|
1990
3086
|
hasRecordedEvidence = false;
|
|
3087
|
+
fixtureValidationError = error instanceof Error ? error.message : String(error);
|
|
1991
3088
|
}
|
|
1992
3089
|
}
|
|
1993
3090
|
|
|
@@ -1999,6 +3096,16 @@ function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefiniti
|
|
|
1999
3096
|
0,
|
|
2000
3097
|
);
|
|
2001
3098
|
}
|
|
3099
|
+
if (fixtureValidationError) {
|
|
3100
|
+
return blocker(
|
|
3101
|
+
"fixture-provenance",
|
|
3102
|
+
"fixtures",
|
|
3103
|
+
`Malformed recorded fixture evidence in __fixtures__/raw.json: ${fixtureValidationError}`,
|
|
3104
|
+
"Re-run `bun run record` to replace the malformed stream evidence, or repair the named field using the stream evidence contract.",
|
|
3105
|
+
0,
|
|
3106
|
+
["__fixtures__/raw.json"],
|
|
3107
|
+
);
|
|
3108
|
+
}
|
|
2002
3109
|
|
|
2003
3110
|
if (allOperationsAreGeneratedLocalScaffold(provider)) {
|
|
2004
3111
|
return {
|
|
@@ -2026,14 +3133,32 @@ function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefiniti
|
|
|
2026
3133
|
);
|
|
2027
3134
|
}
|
|
2028
3135
|
|
|
2029
|
-
function hasNonEmptyRecordedFixture(value: unknown): boolean {
|
|
2030
|
-
|
|
3136
|
+
export function hasNonEmptyRecordedFixture(value: unknown): boolean {
|
|
3137
|
+
try {
|
|
3138
|
+
return recordedFixtureStats(value, 0).hasNestedSubstance;
|
|
3139
|
+
} catch {
|
|
3140
|
+
return false;
|
|
3141
|
+
}
|
|
2031
3142
|
}
|
|
2032
3143
|
|
|
2033
3144
|
function recordedFixtureStats(
|
|
2034
3145
|
value: unknown,
|
|
2035
3146
|
depth: number,
|
|
2036
3147
|
): { hasNestedSubstance: boolean; leafValues: number } {
|
|
3148
|
+
if (
|
|
3149
|
+
value !== null &&
|
|
3150
|
+
typeof value === "object" &&
|
|
3151
|
+
!Array.isArray(value) &&
|
|
3152
|
+
(value as Record<string, unknown>).__apifuse_capture__ === true
|
|
3153
|
+
) {
|
|
3154
|
+
const group = findStreamCaptureGroup(value);
|
|
3155
|
+
if (!group) throw new Error("Stream capture envelope is invalid.");
|
|
3156
|
+
return { hasNestedSubstance: true, leafValues: group.items.length };
|
|
3157
|
+
}
|
|
3158
|
+
if (hasStreamEvidenceMarker(value)) {
|
|
3159
|
+
parseStreamEvidenceRecord(value);
|
|
3160
|
+
return { hasNestedSubstance: true, leafValues: 1 };
|
|
3161
|
+
}
|
|
2037
3162
|
if (value === null || value === undefined) {
|
|
2038
3163
|
return { hasNestedSubstance: false, leafValues: 0 };
|
|
2039
3164
|
}
|
|
@@ -2068,10 +3193,14 @@ function recordedFixtureStats(
|
|
|
2068
3193
|
if (value.length === 0) {
|
|
2069
3194
|
return { hasNestedSubstance: false, leafValues: 0 };
|
|
2070
3195
|
}
|
|
2071
|
-
// A recorded operation value may be a raw XML
|
|
2072
|
-
// substantive
|
|
2073
|
-
// string as a leaf so existing JSON provenance heuristics are unchanged.
|
|
2074
|
-
return {
|
|
3196
|
+
// A recorded operation value may be a raw XML or delimited-text success
|
|
3197
|
+
// payload. Treat a substantive one as nested evidence while still counting
|
|
3198
|
+
// the string as a leaf so existing JSON provenance heuristics are unchanged.
|
|
3199
|
+
return {
|
|
3200
|
+
hasNestedSubstance:
|
|
3201
|
+
hasSubstantiveXmlStructure(value) || hasSubstantiveDelimitedTextStructure(value),
|
|
3202
|
+
leafValues: 1,
|
|
3203
|
+
};
|
|
2075
3204
|
}
|
|
2076
3205
|
return { hasNestedSubstance: false, leafValues: 1 };
|
|
2077
3206
|
}
|
|
@@ -2136,68 +3265,326 @@ function findVendorKeyLeakFindings(providerRoot: string): SourceFinding[] {
|
|
|
2136
3265
|
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
2137
3266
|
const source = readFileSync(filePath, "utf8");
|
|
2138
3267
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
3268
|
+
maskCommentsAndStrings(source, relPath);
|
|
3269
|
+
const sourceFile = ts.createSourceFile(
|
|
3270
|
+
relPath,
|
|
3271
|
+
source,
|
|
3272
|
+
ts.ScriptTarget.Latest,
|
|
3273
|
+
true,
|
|
3274
|
+
ts.ScriptKind.TS,
|
|
3275
|
+
);
|
|
3276
|
+
const bindings = collectLocalBindings(sourceFile);
|
|
2139
3277
|
const upstreamRanges = findUpstreamMarkedConstRanges(source);
|
|
2140
3278
|
for (const zObject of findZObjectLiterals(source)) {
|
|
2141
3279
|
if (rangeContainsOffset(upstreamRanges, zObject.callStart)) {
|
|
2142
3280
|
continue;
|
|
2143
3281
|
}
|
|
2144
|
-
if (!zObjectAppearsPublicOutput(
|
|
3282
|
+
if (!zObjectAppearsPublicOutput(sourceFile, zObject, relPath)) {
|
|
2145
3283
|
continue;
|
|
2146
3284
|
}
|
|
2147
|
-
for (const keyFinding of vendorKeyFindingsForObject(
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
3285
|
+
for (const keyFinding of vendorKeyFindingsForObject(
|
|
3286
|
+
source,
|
|
3287
|
+
sourceFile,
|
|
3288
|
+
zObject,
|
|
3289
|
+
bindings,
|
|
3290
|
+
)) {
|
|
3291
|
+
const key = `${relPath}:${keyFinding.line}:${keyFinding.key}`;
|
|
3292
|
+
if (!seen.has(key)) {
|
|
3293
|
+
seen.add(key);
|
|
3294
|
+
findings.push({
|
|
3295
|
+
file: relPath,
|
|
3296
|
+
line: keyFinding.line,
|
|
3297
|
+
detail: keyFinding.key,
|
|
3298
|
+
});
|
|
3299
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
3300
|
+
return findings;
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
return findings;
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
function findZObjectLiterals(source: string): ZObjectLiteral[] {
|
|
3311
|
+
const literals: ZObjectLiteral[] = [];
|
|
3312
|
+
const masked = maskCommentsAndStrings(source);
|
|
3313
|
+
const callPattern = /\bz\s*\.\s*object\s*\(/g;
|
|
3314
|
+
for (let match = callPattern.exec(masked); match !== null; match = callPattern.exec(masked)) {
|
|
3315
|
+
const parenIndex = masked.indexOf("(", match.index);
|
|
3316
|
+
const objectStart = findNextNonWhitespace(masked, parenIndex + 1);
|
|
3317
|
+
if (objectStart === -1 || masked[objectStart] !== "{") {
|
|
3318
|
+
continue;
|
|
3319
|
+
}
|
|
3320
|
+
const objectEnd = findMatchingBracket(masked, objectStart);
|
|
3321
|
+
if (objectEnd === -1) {
|
|
3322
|
+
continue;
|
|
3323
|
+
}
|
|
3324
|
+
literals.push({
|
|
3325
|
+
objectStart,
|
|
3326
|
+
objectEnd,
|
|
3327
|
+
callStart: match.index,
|
|
3328
|
+
});
|
|
3329
|
+
callPattern.lastIndex = objectEnd;
|
|
3330
|
+
}
|
|
3331
|
+
return literals;
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
function zObjectAppearsPublicOutput(
|
|
3335
|
+
sourceFile: ts.SourceFile,
|
|
3336
|
+
zObject: ZObjectLiteral,
|
|
3337
|
+
relPath: string,
|
|
3338
|
+
): boolean {
|
|
3339
|
+
let zObjectCall: ts.CallExpression | undefined;
|
|
3340
|
+
const findCall = (node: ts.Node): void => {
|
|
3341
|
+
if (
|
|
3342
|
+
zObjectCall === undefined &&
|
|
3343
|
+
ts.isCallExpression(node) &&
|
|
3344
|
+
node.getStart(sourceFile) === zObject.callStart
|
|
3345
|
+
) {
|
|
3346
|
+
zObjectCall = node;
|
|
3347
|
+
return;
|
|
3348
|
+
}
|
|
3349
|
+
ts.forEachChild(node, findCall);
|
|
3350
|
+
};
|
|
3351
|
+
findCall(sourceFile);
|
|
3352
|
+
if (zObjectCall === undefined) {
|
|
3353
|
+
return true;
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
let expression: ts.Expression = zObjectCall;
|
|
3357
|
+
while (
|
|
3358
|
+
ts.isParenthesizedExpression(expression.parent) ||
|
|
3359
|
+
ts.isAsExpression(expression.parent) ||
|
|
3360
|
+
ts.isSatisfiesExpression(expression.parent) ||
|
|
3361
|
+
ts.isNonNullExpression(expression.parent)
|
|
3362
|
+
) {
|
|
3363
|
+
expression = expression.parent;
|
|
3364
|
+
}
|
|
3365
|
+
if (
|
|
3366
|
+
ts.isPropertyAssignment(expression.parent) &&
|
|
3367
|
+
isPublicOutputPropertyName(expression.parent.name)
|
|
3368
|
+
) {
|
|
3369
|
+
return true;
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
const declaration = findEnclosingVariableDeclaration(expression);
|
|
3373
|
+
if (
|
|
3374
|
+
declaration === undefined ||
|
|
3375
|
+
!ts.isIdentifier(declaration.name) ||
|
|
3376
|
+
declaration.initializer === undefined ||
|
|
3377
|
+
!ts.isVariableDeclarationList(declaration.parent) ||
|
|
3378
|
+
(declaration.parent.flags & ts.NodeFlags.Const) === 0
|
|
3379
|
+
) {
|
|
3380
|
+
return true;
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
const reachability = localSchemaBindingReachability(
|
|
3384
|
+
sourceFile,
|
|
3385
|
+
declaration.name.text,
|
|
3386
|
+
declaration,
|
|
3387
|
+
new Set(),
|
|
3388
|
+
);
|
|
3389
|
+
if (reachability === "public") {
|
|
3390
|
+
return true;
|
|
3391
|
+
}
|
|
3392
|
+
if (reachability === "unknown") {
|
|
3393
|
+
return true;
|
|
3394
|
+
}
|
|
3395
|
+
// Sibling modules are scanned independently and therefore remain
|
|
3396
|
+
// fail-closed. An exported binding in index.ts is likewise consumable by
|
|
3397
|
+
// another module, so local inertness does not prove it is private.
|
|
3398
|
+
if (relPath !== "index.ts") {
|
|
3399
|
+
return true;
|
|
3400
|
+
}
|
|
3401
|
+
return isExportedVariableBinding(sourceFile, declaration);
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
type SchemaReachability = "public" | "internal" | "unknown";
|
|
3405
|
+
|
|
3406
|
+
const PARSE_LIKE_SCHEMA_METHODS: ReadonlySet<string> = new Set([
|
|
3407
|
+
"parse",
|
|
3408
|
+
"safeParse",
|
|
3409
|
+
"parseAsync",
|
|
3410
|
+
"safeParseAsync",
|
|
3411
|
+
]);
|
|
3412
|
+
|
|
3413
|
+
function isExportedVariableBinding(
|
|
3414
|
+
sourceFile: ts.SourceFile,
|
|
3415
|
+
declaration: ts.VariableDeclaration,
|
|
3416
|
+
): boolean {
|
|
3417
|
+
const declarationStatement = declaration.parent.parent;
|
|
3418
|
+
if (
|
|
3419
|
+
ts.isVariableStatement(declarationStatement) &&
|
|
3420
|
+
declarationStatement.modifiers?.some(
|
|
3421
|
+
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
|
|
3422
|
+
) === true
|
|
3423
|
+
) {
|
|
3424
|
+
return true;
|
|
3425
|
+
}
|
|
3426
|
+
if (!ts.isIdentifier(declaration.name)) {
|
|
3427
|
+
return false;
|
|
3428
|
+
}
|
|
3429
|
+
const name = declaration.name.text;
|
|
3430
|
+
let exported = false;
|
|
3431
|
+
const visit = (node: ts.Node): void => {
|
|
3432
|
+
if (exported) {
|
|
3433
|
+
return;
|
|
3434
|
+
}
|
|
3435
|
+
if (ts.isExportSpecifier(node)) {
|
|
3436
|
+
const localName = node.propertyName ?? node.name;
|
|
3437
|
+
if (ts.isIdentifier(localName) && localName.text === name) {
|
|
3438
|
+
exported = true;
|
|
3439
|
+
return;
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
if (ts.isExportAssignment(node)) {
|
|
3443
|
+
const expression = unwrapLocalExpression(node.expression);
|
|
3444
|
+
if (ts.isIdentifier(expression) && expression.text === name) {
|
|
3445
|
+
exported = true;
|
|
3446
|
+
return;
|
|
2156
3447
|
}
|
|
2157
3448
|
}
|
|
3449
|
+
ts.forEachChild(node, visit);
|
|
3450
|
+
};
|
|
3451
|
+
for (const statement of sourceFile.statements) {
|
|
3452
|
+
visit(statement);
|
|
3453
|
+
if (exported) {
|
|
3454
|
+
break;
|
|
3455
|
+
}
|
|
2158
3456
|
}
|
|
3457
|
+
return exported;
|
|
3458
|
+
}
|
|
2159
3459
|
|
|
2160
|
-
|
|
3460
|
+
function isPublicOutputPropertyName(name: ts.PropertyName): boolean {
|
|
3461
|
+
const text = propertyNameText(name);
|
|
3462
|
+
return text === "output" || text === "response";
|
|
2161
3463
|
}
|
|
2162
3464
|
|
|
2163
|
-
function
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
const parenIndex = masked.indexOf("(", match.index);
|
|
2169
|
-
const objectStart = findNextNonWhitespace(masked, parenIndex + 1);
|
|
2170
|
-
if (objectStart === -1 || masked[objectStart] !== "{") {
|
|
2171
|
-
continue;
|
|
3465
|
+
function findEnclosingVariableDeclaration(node: ts.Node): ts.VariableDeclaration | undefined {
|
|
3466
|
+
let current: ts.Node | undefined = node;
|
|
3467
|
+
while (current !== undefined && !ts.isSourceFile(current)) {
|
|
3468
|
+
if (ts.isVariableDeclaration(current)) {
|
|
3469
|
+
return current;
|
|
2172
3470
|
}
|
|
2173
|
-
|
|
2174
|
-
if (objectEnd === -1) {
|
|
2175
|
-
continue;
|
|
2176
|
-
}
|
|
2177
|
-
literals.push({
|
|
2178
|
-
objectStart,
|
|
2179
|
-
objectEnd,
|
|
2180
|
-
callStart: match.index,
|
|
2181
|
-
});
|
|
2182
|
-
callPattern.lastIndex = objectEnd;
|
|
3471
|
+
current = current.parent;
|
|
2183
3472
|
}
|
|
2184
|
-
return
|
|
3473
|
+
return undefined;
|
|
2185
3474
|
}
|
|
2186
3475
|
|
|
2187
|
-
function
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
3476
|
+
function localSchemaBindingReachability(
|
|
3477
|
+
sourceFile: ts.SourceFile,
|
|
3478
|
+
name: string,
|
|
3479
|
+
declaration: ts.VariableDeclaration,
|
|
3480
|
+
resolving: ReadonlySet<string>,
|
|
3481
|
+
): SchemaReachability {
|
|
3482
|
+
if (resolving.has(name)) {
|
|
3483
|
+
return "unknown";
|
|
3484
|
+
}
|
|
3485
|
+
const nextResolving = new Set(resolving);
|
|
3486
|
+
nextResolving.add(name);
|
|
3487
|
+
let result: SchemaReachability = "internal";
|
|
3488
|
+
const visit = (node: ts.Node): void => {
|
|
3489
|
+
if (result === "public") {
|
|
3490
|
+
return;
|
|
3491
|
+
}
|
|
3492
|
+
if (ts.isIdentifier(node) && node.text === name && node !== declaration.name) {
|
|
3493
|
+
const parent = node.parent;
|
|
3494
|
+
if (isParseLikeSchemaReceiver(node)) {
|
|
3495
|
+
return;
|
|
3496
|
+
}
|
|
3497
|
+
if (ts.isExportSpecifier(parent)) {
|
|
3498
|
+
const localName = parent.propertyName ?? parent.name;
|
|
3499
|
+
if (localName === node) {
|
|
3500
|
+
result = "public";
|
|
3501
|
+
}
|
|
3502
|
+
return;
|
|
3503
|
+
}
|
|
3504
|
+
if (
|
|
3505
|
+
ts.isPropertyAssignment(parent) &&
|
|
3506
|
+
parent.initializer === node &&
|
|
3507
|
+
isPublicOutputPropertyName(parent.name)
|
|
3508
|
+
) {
|
|
3509
|
+
result = "public";
|
|
3510
|
+
return;
|
|
3511
|
+
}
|
|
3512
|
+
if (ts.isShorthandPropertyAssignment(parent)) {
|
|
3513
|
+
result = isPublicOutputPropertyName(parent.name) ? "public" : "unknown";
|
|
3514
|
+
return;
|
|
3515
|
+
}
|
|
3516
|
+
if (
|
|
3517
|
+
ts.isVariableDeclaration(parent) &&
|
|
3518
|
+
parent.initializer === node &&
|
|
3519
|
+
ts.isIdentifier(parent.name) &&
|
|
3520
|
+
ts.isVariableDeclarationList(parent.parent) &&
|
|
3521
|
+
(parent.parent.flags & ts.NodeFlags.Const) !== 0
|
|
3522
|
+
) {
|
|
3523
|
+
const aliasReachability = localSchemaBindingReachability(
|
|
3524
|
+
sourceFile,
|
|
3525
|
+
parent.name.text,
|
|
3526
|
+
parent,
|
|
3527
|
+
nextResolving,
|
|
3528
|
+
);
|
|
3529
|
+
if (aliasReachability === "public" || result === "internal") {
|
|
3530
|
+
result = aliasReachability;
|
|
3531
|
+
}
|
|
3532
|
+
return;
|
|
3533
|
+
}
|
|
3534
|
+
if (ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) {
|
|
3535
|
+
return;
|
|
3536
|
+
}
|
|
3537
|
+
if (
|
|
3538
|
+
(ts.isPropertyAccessExpression(parent) && parent.name === node) ||
|
|
3539
|
+
(ts.isPropertyAssignment(parent) && parent.name === node) ||
|
|
3540
|
+
ts.isBindingElement(parent) ||
|
|
3541
|
+
ts.isImportSpecifier(parent)
|
|
3542
|
+
) {
|
|
3543
|
+
return;
|
|
3544
|
+
}
|
|
3545
|
+
result = "unknown";
|
|
3546
|
+
}
|
|
3547
|
+
ts.forEachChild(node, visit);
|
|
3548
|
+
};
|
|
3549
|
+
visit(sourceFile);
|
|
3550
|
+
return result;
|
|
3551
|
+
}
|
|
3552
|
+
|
|
3553
|
+
function isParseLikeSchemaReceiver(identifier: ts.Identifier): boolean {
|
|
3554
|
+
const access = identifier.parent;
|
|
3555
|
+
let methodName: string | undefined;
|
|
3556
|
+
if (ts.isPropertyAccessExpression(access) && access.expression === identifier) {
|
|
3557
|
+
methodName = access.name.text;
|
|
3558
|
+
} else if (ts.isElementAccessExpression(access) && access.expression === identifier) {
|
|
3559
|
+
const argument = access.argumentExpression;
|
|
3560
|
+
if (
|
|
3561
|
+
argument !== undefined &&
|
|
3562
|
+
(ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
|
|
3563
|
+
) {
|
|
3564
|
+
methodName = argument.text;
|
|
3565
|
+
}
|
|
2191
3566
|
}
|
|
2192
|
-
|
|
2193
|
-
|
|
3567
|
+
return (
|
|
3568
|
+
methodName !== undefined &&
|
|
3569
|
+
PARSE_LIKE_SCHEMA_METHODS.has(methodName) &&
|
|
3570
|
+
ts.isCallExpression(access.parent) &&
|
|
3571
|
+
access.parent.expression === access
|
|
3572
|
+
);
|
|
2194
3573
|
}
|
|
2195
3574
|
|
|
2196
3575
|
function vendorKeyFindingsForObject(
|
|
2197
3576
|
source: string,
|
|
3577
|
+
sourceFile: ts.SourceFile,
|
|
2198
3578
|
zObject: ZObjectLiteral,
|
|
3579
|
+
bindings: LocalBindings,
|
|
2199
3580
|
): Array<{ key: string; line: number }> {
|
|
2200
|
-
const keys = collectTopLevelObjectKeys(
|
|
3581
|
+
const keys = collectTopLevelObjectKeys(
|
|
3582
|
+
source,
|
|
3583
|
+
sourceFile,
|
|
3584
|
+
zObject.objectStart,
|
|
3585
|
+
zObject.objectEnd,
|
|
3586
|
+
bindings,
|
|
3587
|
+
);
|
|
2201
3588
|
const digitFamilies = new Map<string, Set<string>>();
|
|
2202
3589
|
for (const key of keys) {
|
|
2203
3590
|
const member = numberedFamilyMember(key.name);
|
|
@@ -2253,8 +3640,10 @@ function isAllowedPublicOutputKeyName(name: string): boolean {
|
|
|
2253
3640
|
|
|
2254
3641
|
function collectTopLevelObjectKeys(
|
|
2255
3642
|
source: string,
|
|
3643
|
+
sourceFile: ts.SourceFile,
|
|
2256
3644
|
objectStart: number,
|
|
2257
3645
|
objectEnd: number,
|
|
3646
|
+
bindings: LocalBindings,
|
|
2258
3647
|
): Array<{ name: string; offset: number }> {
|
|
2259
3648
|
const keys: Array<{ name: string; offset: number }> = [];
|
|
2260
3649
|
const masked = maskCommentsAndStrings(source);
|
|
@@ -2276,17 +3665,16 @@ function collectTopLevelObjectKeys(
|
|
|
2276
3665
|
index = endQuote + 1;
|
|
2277
3666
|
} else if (masked[index] === "[") {
|
|
2278
3667
|
const computedEnd = findMatchingBracket(masked, index);
|
|
2279
|
-
|
|
2280
|
-
if (computedEnd === -1 || literalStart === -1) {
|
|
3668
|
+
if (computedEnd === -1) {
|
|
2281
3669
|
break;
|
|
2282
3670
|
}
|
|
2283
|
-
const
|
|
2284
|
-
if (
|
|
2285
|
-
const
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
if (
|
|
2289
|
-
key =
|
|
3671
|
+
const expression = computedPropertyExpressionAtOffset(sourceFile, index);
|
|
3672
|
+
if (expression !== undefined) {
|
|
3673
|
+
const unwrapped = unwrapLocalExpression(expression);
|
|
3674
|
+
if (ts.isStringLiteral(unwrapped) || ts.isNoSubstitutionTemplateLiteral(unwrapped)) {
|
|
3675
|
+
key = unwrapped.text;
|
|
3676
|
+
} else if (ts.isIdentifier(unwrapped)) {
|
|
3677
|
+
key = resolveConstStringIdentifier(unwrapped, bindings);
|
|
2290
3678
|
}
|
|
2291
3679
|
}
|
|
2292
3680
|
index = computedEnd + 1;
|
|
@@ -2313,6 +3701,25 @@ function collectTopLevelObjectKeys(
|
|
|
2313
3701
|
return keys;
|
|
2314
3702
|
}
|
|
2315
3703
|
|
|
3704
|
+
function computedPropertyExpressionAtOffset(
|
|
3705
|
+
sourceFile: ts.SourceFile,
|
|
3706
|
+
offset: number,
|
|
3707
|
+
): ts.Expression | undefined {
|
|
3708
|
+
let expression: ts.Expression | undefined;
|
|
3709
|
+
const visit = (node: ts.Node): void => {
|
|
3710
|
+
if (expression !== undefined || offset < node.getFullStart() || offset >= node.getEnd()) {
|
|
3711
|
+
return;
|
|
3712
|
+
}
|
|
3713
|
+
if (ts.isComputedPropertyName(node) && node.getStart(sourceFile) === offset) {
|
|
3714
|
+
expression = node.expression;
|
|
3715
|
+
return;
|
|
3716
|
+
}
|
|
3717
|
+
ts.forEachChild(node, visit);
|
|
3718
|
+
};
|
|
3719
|
+
visit(sourceFile);
|
|
3720
|
+
return expression;
|
|
3721
|
+
}
|
|
3722
|
+
|
|
2316
3723
|
function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[] {
|
|
2317
3724
|
const findings: SourceFinding[] = [];
|
|
2318
3725
|
const seen = new Set<string>();
|
|
@@ -2320,6 +3727,15 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
|
|
|
2320
3727
|
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
2321
3728
|
const source = readFileSync(filePath, "utf8");
|
|
2322
3729
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
3730
|
+
maskCommentsAndStrings(source, relPath);
|
|
3731
|
+
const sourceFile = ts.createSourceFile(
|
|
3732
|
+
relPath,
|
|
3733
|
+
source,
|
|
3734
|
+
ts.ScriptTarget.Latest,
|
|
3735
|
+
true,
|
|
3736
|
+
ts.ScriptKind.TS,
|
|
3737
|
+
);
|
|
3738
|
+
const bindings = collectLocalBindings(sourceFile);
|
|
2323
3739
|
const zObjectRanges = findZObjectLiterals(source).map((zObject) => ({
|
|
2324
3740
|
start: zObject.callStart,
|
|
2325
3741
|
end: zObject.objectEnd,
|
|
@@ -2332,7 +3748,7 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
|
|
|
2332
3748
|
].filter((range) => rangeContainedInRanges(fixtureRanges, range));
|
|
2333
3749
|
|
|
2334
3750
|
for (const range of fixtureResponseRanges) {
|
|
2335
|
-
for (const literal of findStringLiteralsInRange(source, range)) {
|
|
3751
|
+
for (const literal of findStringLiteralsInRange(source, range, relPath)) {
|
|
2336
3752
|
if (
|
|
2337
3753
|
rangeContainsOffset(zObjectRanges, literal.offset) ||
|
|
2338
3754
|
rangeContainsOffset(upstreamRanges, literal.offset) ||
|
|
@@ -2353,12 +3769,86 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
|
|
|
2353
3769
|
}
|
|
2354
3770
|
}
|
|
2355
3771
|
}
|
|
3772
|
+
|
|
3773
|
+
const visitAliases = (node: ts.Node): void => {
|
|
3774
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
3775
|
+
return;
|
|
3776
|
+
}
|
|
3777
|
+
const offset = node.getStart(sourceFile);
|
|
3778
|
+
if (
|
|
3779
|
+
ts.isIdentifier(node) &&
|
|
3780
|
+
isIdentifierValueUse(node) &&
|
|
3781
|
+
offset >= range.start &&
|
|
3782
|
+
offset <= range.end &&
|
|
3783
|
+
!rangeContainsOffset(zObjectRanges, offset) &&
|
|
3784
|
+
!rangeContainsOffset(upstreamRanges, offset)
|
|
3785
|
+
) {
|
|
3786
|
+
const value = resolveConstStringIdentifier(node, bindings);
|
|
3787
|
+
if (
|
|
3788
|
+
value !== undefined &&
|
|
3789
|
+
isVendorTimestampCandidate(value, propertyNameForValueExpression(node))
|
|
3790
|
+
) {
|
|
3791
|
+
const line = offsetToLine(source, offset);
|
|
3792
|
+
const key = `${relPath}:${line}:${value}`;
|
|
3793
|
+
if (!seen.has(key)) {
|
|
3794
|
+
seen.add(key);
|
|
3795
|
+
findings.push({ file: relPath, line });
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
}
|
|
3799
|
+
ts.forEachChild(node, visitAliases);
|
|
3800
|
+
};
|
|
3801
|
+
visitAliases(sourceFile);
|
|
3802
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
3803
|
+
return findings;
|
|
3804
|
+
}
|
|
2356
3805
|
}
|
|
2357
3806
|
}
|
|
2358
3807
|
|
|
2359
3808
|
return findings;
|
|
2360
3809
|
}
|
|
2361
3810
|
|
|
3811
|
+
function isIdentifierValueUse(identifier: ts.Identifier): boolean {
|
|
3812
|
+
const parent = identifier.parent;
|
|
3813
|
+
return !(
|
|
3814
|
+
(ts.isPropertyAssignment(parent) && parent.name === identifier) ||
|
|
3815
|
+
(ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
|
|
3816
|
+
ts.isBindingElement(parent) ||
|
|
3817
|
+
ts.isVariableDeclaration(parent) ||
|
|
3818
|
+
ts.isParameter(parent) ||
|
|
3819
|
+
ts.isImportClause(parent) ||
|
|
3820
|
+
ts.isImportSpecifier(parent) ||
|
|
3821
|
+
ts.isNamespaceImport(parent) ||
|
|
3822
|
+
ts.isFunctionDeclaration(parent) ||
|
|
3823
|
+
ts.isFunctionExpression(parent) ||
|
|
3824
|
+
ts.isClassDeclaration(parent) ||
|
|
3825
|
+
ts.isClassExpression(parent) ||
|
|
3826
|
+
ts.isPropertyDeclaration(parent) ||
|
|
3827
|
+
ts.isPropertySignature(parent) ||
|
|
3828
|
+
ts.isMethodDeclaration(parent) ||
|
|
3829
|
+
ts.isMethodSignature(parent)
|
|
3830
|
+
);
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3833
|
+
function propertyNameForValueExpression(expression: ts.Expression): string | undefined {
|
|
3834
|
+
let current: ts.Expression = expression;
|
|
3835
|
+
while (
|
|
3836
|
+
ts.isParenthesizedExpression(current.parent) ||
|
|
3837
|
+
ts.isAsExpression(current.parent) ||
|
|
3838
|
+
ts.isSatisfiesExpression(current.parent) ||
|
|
3839
|
+
ts.isNonNullExpression(current.parent)
|
|
3840
|
+
) {
|
|
3841
|
+
current = current.parent;
|
|
3842
|
+
}
|
|
3843
|
+
if (ts.isPropertyAssignment(current.parent) && current.parent.initializer === current) {
|
|
3844
|
+
return propertyNameText(current.parent.name);
|
|
3845
|
+
}
|
|
3846
|
+
if (ts.isShorthandPropertyAssignment(current.parent)) {
|
|
3847
|
+
return current.parent.name.text;
|
|
3848
|
+
}
|
|
3849
|
+
return undefined;
|
|
3850
|
+
}
|
|
3851
|
+
|
|
2362
3852
|
function findPropertyObjectRanges(source: string, propertyName: string): ObjectRange[] {
|
|
2363
3853
|
const ranges: ObjectRange[] = [];
|
|
2364
3854
|
const masked = maskCommentsAndStrings(source);
|
|
@@ -2402,23 +3892,16 @@ function findNamedConstValueRanges(source: string): NamedObjectRange[] {
|
|
|
2402
3892
|
return ranges;
|
|
2403
3893
|
}
|
|
2404
3894
|
|
|
2405
|
-
function findConstValueRangeContaining(
|
|
2406
|
-
source: string,
|
|
2407
|
-
offset: number,
|
|
2408
|
-
): NamedObjectRange | undefined {
|
|
2409
|
-
return findNamedConstValueRanges(source).find(
|
|
2410
|
-
(range) => offset >= range.start && offset <= range.end,
|
|
2411
|
-
);
|
|
2412
|
-
}
|
|
2413
|
-
|
|
2414
3895
|
function findStringLiteralsInRange(
|
|
2415
3896
|
source: string,
|
|
2416
3897
|
range: ObjectRange,
|
|
3898
|
+
fileName: string,
|
|
2417
3899
|
): Array<{ value: string; offset: number }> {
|
|
2418
3900
|
const literals: Array<{ value: string; offset: number }> = [];
|
|
3901
|
+
const masked = maskCommentsAndStrings(source, fileName);
|
|
2419
3902
|
let index = range.start;
|
|
2420
3903
|
while (index <= range.end) {
|
|
2421
|
-
const quote =
|
|
3904
|
+
const quote = masked[index];
|
|
2422
3905
|
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
|
2423
3906
|
index += 1;
|
|
2424
3907
|
continue;
|
|
@@ -2528,57 +4011,199 @@ function findStringEnd(source: string, start: number): number {
|
|
|
2528
4011
|
return -1;
|
|
2529
4012
|
}
|
|
2530
4013
|
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
4014
|
+
// Masking now parses with TypeScript, which is orders of magnitude more
|
|
4015
|
+
// expensive than the character walk it replaced, and six scanners call this
|
|
4016
|
+
// helper once per candidate match — the same file is masked thousands of
|
|
4017
|
+
// times in one run (measured: 1,872 calls for a 3.4k-line provider). Without
|
|
4018
|
+
// memoization that run regresses from ~8s to minutes and can exceed the CI
|
|
4019
|
+
// validation timeout. Successful results are cached by exact source text and
|
|
4020
|
+
// property-key mode; the cache stays small because a run only ever reads a
|
|
4021
|
+
// handful of files.
|
|
4022
|
+
// Parse failures are NOT cached: they throw and abort the check (fail-closed).
|
|
4023
|
+
const MASK_CACHE_LIMIT = 64;
|
|
4024
|
+
const maskCache = new Map<string, string>();
|
|
4025
|
+
|
|
4026
|
+
// Neutralize { } ( ) [ ], backtick, quotes, slash, backslash, comma, and semicolon
|
|
4027
|
+
// because scanners treat these as depth, string/regex delimiters, or top-level stops.
|
|
4028
|
+
const PRESERVED_PROPERTY_KEY_STRUCTURAL_CHARS = new Set([
|
|
4029
|
+
"{",
|
|
4030
|
+
"}",
|
|
4031
|
+
"(",
|
|
4032
|
+
")",
|
|
4033
|
+
"[",
|
|
4034
|
+
"]",
|
|
4035
|
+
"`",
|
|
4036
|
+
'"',
|
|
4037
|
+
"'",
|
|
4038
|
+
"/",
|
|
4039
|
+
"\\",
|
|
4040
|
+
",",
|
|
4041
|
+
";",
|
|
4042
|
+
]);
|
|
4043
|
+
|
|
4044
|
+
type MaskCommentsAndStringsOptions = {
|
|
4045
|
+
blankPropertyKeys?: boolean;
|
|
4046
|
+
};
|
|
4047
|
+
|
|
4048
|
+
export function maskCommentsAndStrings(
|
|
4049
|
+
source: string,
|
|
4050
|
+
fileName = "provider.ts",
|
|
4051
|
+
options: MaskCommentsAndStringsOptions = {},
|
|
4052
|
+
): string {
|
|
4053
|
+
const blankPropertyKeys = options.blankPropertyKeys === true;
|
|
4054
|
+
const cacheKey = `${blankPropertyKeys ? "blank-keys" : "preserve-keys"}\0${source}`;
|
|
4055
|
+
const cached = maskCache.get(cacheKey);
|
|
4056
|
+
if (cached !== undefined) {
|
|
4057
|
+
return cached;
|
|
4058
|
+
}
|
|
4059
|
+
const masked = computeMaskedSource(source, fileName, blankPropertyKeys);
|
|
4060
|
+
if (maskCache.size >= MASK_CACHE_LIMIT) {
|
|
4061
|
+
const oldest = maskCache.keys().next();
|
|
4062
|
+
if (!oldest.done) {
|
|
4063
|
+
maskCache.delete(oldest.value);
|
|
2543
4064
|
}
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
4065
|
+
}
|
|
4066
|
+
maskCache.set(cacheKey, masked);
|
|
4067
|
+
return masked;
|
|
4068
|
+
}
|
|
4069
|
+
|
|
4070
|
+
function computeMaskedSource(
|
|
4071
|
+
source: string,
|
|
4072
|
+
fileName: string,
|
|
4073
|
+
blankPropertyKeys: boolean,
|
|
4074
|
+
): string {
|
|
4075
|
+
const transpiled = ts.transpileModule(source, {
|
|
4076
|
+
// Declaration files trigger an internal TypeScript Debug Failure when
|
|
4077
|
+
// passed to transpileModule. Parsing is all we need here, so always use a
|
|
4078
|
+
// synthetic implementation filename while retaining the real filename
|
|
4079
|
+
// for source mapping and sanitized diagnostics below.
|
|
4080
|
+
fileName: "provider.ts",
|
|
4081
|
+
reportDiagnostics: true,
|
|
4082
|
+
compilerOptions: { target: ts.ScriptTarget.Latest },
|
|
4083
|
+
});
|
|
4084
|
+
const sourceFile = ts.createSourceFile(
|
|
4085
|
+
fileName,
|
|
4086
|
+
source,
|
|
4087
|
+
ts.ScriptTarget.Latest,
|
|
4088
|
+
true,
|
|
4089
|
+
ts.ScriptKind.TS,
|
|
4090
|
+
);
|
|
4091
|
+
const parseDiagnostic = transpiled.diagnostics?.[0];
|
|
4092
|
+
if (parseDiagnostic) {
|
|
4093
|
+
const position = parseDiagnostic.start ?? 0;
|
|
4094
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(position);
|
|
4095
|
+
throw new Error(
|
|
4096
|
+
`Cannot safely scan TypeScript source ${sanitizeDiagnosticFileName(fileName)} at ${line + 1}:${character + 1}: ${ts.flattenDiagnosticMessageText(parseDiagnostic.messageText, "\n")}`,
|
|
4097
|
+
);
|
|
4098
|
+
}
|
|
4099
|
+
|
|
4100
|
+
const chars = source.split("");
|
|
4101
|
+
const maskRange = (start: number, end: number): void => {
|
|
4102
|
+
for (let index = start; index < end; index += 1) {
|
|
4103
|
+
// Preserve the backslash of a string line continuation (\ followed
|
|
4104
|
+
// by LF, CRLF, or lone CR) and every line terminator, so masked
|
|
4105
|
+
// string bodies remain syntactically valid and the mask output can
|
|
4106
|
+
// be re-parsed (mask(mask(x)) === mask(x)).
|
|
4107
|
+
if (
|
|
4108
|
+
source[index] === "\\" &&
|
|
4109
|
+
(source[index + 1] === "\n" || source[index + 1] === "\r")
|
|
4110
|
+
) {
|
|
4111
|
+
continue;
|
|
2552
4112
|
}
|
|
2553
|
-
index
|
|
2554
|
-
continue;
|
|
2555
|
-
}
|
|
2556
|
-
const quote = source[index];
|
|
2557
|
-
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
|
2558
|
-
continue;
|
|
4113
|
+
if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " ";
|
|
2559
4114
|
}
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
// can still match them; only string VALUES are blanked.
|
|
2566
|
-
let probe = end + 1;
|
|
2567
|
-
while (probe < source.length && /\s/.test(source[probe] ?? "")) {
|
|
2568
|
-
probe += 1;
|
|
4115
|
+
};
|
|
4116
|
+
const commentRanges = new Map<string, ts.CommentRange>();
|
|
4117
|
+
const addCommentRanges = (ranges: readonly ts.CommentRange[] | undefined): void => {
|
|
4118
|
+
for (const range of ranges ?? []) {
|
|
4119
|
+
commentRanges.set(`${range.pos}:${range.end}`, range);
|
|
2569
4120
|
}
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
4121
|
+
};
|
|
4122
|
+
|
|
4123
|
+
const visit = (node: ts.Node): void => {
|
|
4124
|
+
addCommentRanges(ts.getLeadingCommentRanges(source, node.getFullStart()));
|
|
4125
|
+
addCommentRanges(ts.getTrailingCommentRanges(source, node.getEnd()));
|
|
4126
|
+
|
|
4127
|
+
const start = node.getStart(sourceFile);
|
|
4128
|
+
if (ts.isStringLiteral(node)) {
|
|
4129
|
+
// Preserve quoted property keys ("response": ...) by default so range/key
|
|
4130
|
+
// scanners can still match them. Line scanners opt into blanking key bodies
|
|
4131
|
+
// as well, preventing key text from looking like executable source.
|
|
4132
|
+
const isQuotedPropertyKey =
|
|
4133
|
+
ts.isPropertyAssignment(node.parent) && node.parent.name === node;
|
|
4134
|
+
if (blankPropertyKeys || !isQuotedPropertyKey) {
|
|
4135
|
+
maskRange(start + 1, node.end - 1);
|
|
4136
|
+
} else {
|
|
4137
|
+
for (let index = start + 1; index < node.end - 1; index += 1) {
|
|
4138
|
+
if (
|
|
4139
|
+
source[index] === "\\" &&
|
|
4140
|
+
(source[index + 1] === "\n" || source[index + 1] === "\r")
|
|
4141
|
+
) {
|
|
4142
|
+
continue;
|
|
4143
|
+
}
|
|
4144
|
+
if (PRESERVED_PROPERTY_KEY_STRUCTURAL_CHARS.has(source[index] ?? "")) {
|
|
4145
|
+
chars[index] = " ";
|
|
4146
|
+
}
|
|
2574
4147
|
}
|
|
2575
4148
|
}
|
|
4149
|
+
} else if (ts.isRegularExpressionLiteral(node)) {
|
|
4150
|
+
let closeDelimiter = node.end - 1;
|
|
4151
|
+
while (closeDelimiter > start && /[A-Za-z]/.test(source[closeDelimiter] ?? "")) {
|
|
4152
|
+
closeDelimiter -= 1;
|
|
4153
|
+
}
|
|
4154
|
+
maskRange(start + 1, closeDelimiter);
|
|
4155
|
+
} else if (ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
4156
|
+
maskRange(start + 1, node.end - 1);
|
|
4157
|
+
} else if (node.kind === ts.SyntaxKind.TemplateHead) {
|
|
4158
|
+
maskRange(start + 1, node.end - 2);
|
|
4159
|
+
} else if (node.kind === ts.SyntaxKind.TemplateMiddle) {
|
|
4160
|
+
maskRange(start + 1, node.end - 2);
|
|
4161
|
+
} else if (node.kind === ts.SyntaxKind.TemplateTail) {
|
|
4162
|
+
maskRange(start + 1, node.end - 1);
|
|
2576
4163
|
}
|
|
2577
|
-
|
|
4164
|
+
|
|
4165
|
+
for (const child of node.getChildren(sourceFile)) visit(child);
|
|
4166
|
+
};
|
|
4167
|
+
|
|
4168
|
+
visit(sourceFile);
|
|
4169
|
+
for (const comment of commentRanges.values()) {
|
|
4170
|
+
const markerLength = 2;
|
|
4171
|
+
const bodyEnd =
|
|
4172
|
+
comment.kind === ts.SyntaxKind.MultiLineCommentTrivia
|
|
4173
|
+
? comment.end - markerLength
|
|
4174
|
+
: comment.end;
|
|
4175
|
+
maskRange(comment.pos + markerLength, bodyEnd);
|
|
2578
4176
|
}
|
|
2579
4177
|
return chars.join("");
|
|
2580
4178
|
}
|
|
2581
4179
|
|
|
4180
|
+
// Escapes every non-printable or formatting character before a submitter-
|
|
4181
|
+
// controlled filename is interpolated into a diagnostic that the CLI prints.
|
|
4182
|
+
// Category-based on purpose: enumerated code-point lists kept missing members
|
|
4183
|
+
// of the same class (C1 controls, U+061C, U+2028/U+2029 were each found
|
|
4184
|
+
// individually in review), so this escapes the whole Unicode categories —
|
|
4185
|
+
// Cc (controls), Cf (formatting, includes all Bidi_Control), Zl/Zp
|
|
4186
|
+
// (line/paragraph separators). Ordinary letters, digits, spaces, and CJK
|
|
4187
|
+
// filenames pass through unchanged.
|
|
4188
|
+
const DIAGNOSTIC_UNSAFE_CHARACTER = /\p{Cc}|\p{Cf}|\p{Zl}|\p{Zp}/u;
|
|
4189
|
+
|
|
4190
|
+
function sanitizeDiagnosticFileName(fileName: string): string {
|
|
4191
|
+
let sanitized = "";
|
|
4192
|
+
for (const character of fileName) {
|
|
4193
|
+
const codePoint = character.codePointAt(0);
|
|
4194
|
+
if (codePoint === undefined) continue;
|
|
4195
|
+
if (DIAGNOSTIC_UNSAFE_CHARACTER.test(character)) {
|
|
4196
|
+
sanitized +=
|
|
4197
|
+
codePoint <= 0xff
|
|
4198
|
+
? `\\x${codePoint.toString(16).padStart(2, "0")}`
|
|
4199
|
+
: `\\u{${codePoint.toString(16)}}`;
|
|
4200
|
+
} else {
|
|
4201
|
+
sanitized += character;
|
|
4202
|
+
}
|
|
4203
|
+
}
|
|
4204
|
+
return sanitized;
|
|
4205
|
+
}
|
|
4206
|
+
|
|
2582
4207
|
function skipWhitespaceAndComments(source: string, start: number, end: number): number {
|
|
2583
4208
|
let index = start;
|
|
2584
4209
|
while (index < end) {
|
|
@@ -3546,9 +5171,47 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
|
|
|
3546
5171
|
];
|
|
3547
5172
|
}
|
|
3548
5173
|
|
|
5174
|
+
// Splits secret findings into still-active findings and acknowledged
|
|
5175
|
+
// `// @apifuse-allow secret-scan` overrides, mirroring partitionAllowOverrides
|
|
5176
|
+
// (same pragma placement: the finding line or the line directly above it).
|
|
5177
|
+
// Every finding source carries a line number (entropy candidates and located
|
|
5178
|
+
// SECRET_PATTERNS matches); a finding that somehow lacks one stays active
|
|
5179
|
+
// defensively.
|
|
5180
|
+
function partitionSecretScanAllowOverrides(
|
|
5181
|
+
providerRoot: string,
|
|
5182
|
+
findings: readonly SecretFinding[],
|
|
5183
|
+
): { active: SecretFinding[]; overridden: SecretFinding[] } {
|
|
5184
|
+
const fileLineCache = new Map<string, string[]>();
|
|
5185
|
+
const active: SecretFinding[] = [];
|
|
5186
|
+
const overridden: SecretFinding[] = [];
|
|
5187
|
+
|
|
5188
|
+
for (const finding of findings) {
|
|
5189
|
+
if (finding.line === undefined) {
|
|
5190
|
+
active.push(finding);
|
|
5191
|
+
continue;
|
|
5192
|
+
}
|
|
5193
|
+
const absolute = resolve(providerRoot, finding.file);
|
|
5194
|
+
let lines = fileLineCache.get(absolute);
|
|
5195
|
+
if (lines === undefined) {
|
|
5196
|
+
lines = existsSync(absolute) ? readFileSync(absolute, "utf8").split(/\r?\n/) : [];
|
|
5197
|
+
fileLineCache.set(absolute, lines);
|
|
5198
|
+
}
|
|
5199
|
+
if (hasAllowOverride(lines, finding.line, "secret-scan")) {
|
|
5200
|
+
overridden.push(finding);
|
|
5201
|
+
} else {
|
|
5202
|
+
active.push(finding);
|
|
5203
|
+
}
|
|
5204
|
+
}
|
|
5205
|
+
|
|
5206
|
+
return { active, overridden };
|
|
5207
|
+
}
|
|
5208
|
+
|
|
3549
5209
|
function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): SubmitCheck {
|
|
3550
|
-
const
|
|
3551
|
-
|
|
5210
|
+
const { active, overridden } = partitionSecretScanAllowOverrides(
|
|
5211
|
+
providerRoot,
|
|
5212
|
+
findSecretFindings(providerRoot, provider?.id),
|
|
5213
|
+
);
|
|
5214
|
+
const blockerFindings = active.filter((finding) => finding.level !== "warn");
|
|
3552
5215
|
if (blockerFindings.length > 0) {
|
|
3553
5216
|
return {
|
|
3554
5217
|
id: "secret-scan",
|
|
@@ -3568,7 +5231,15 @@ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): Subm
|
|
|
3568
5231
|
),
|
|
3569
5232
|
};
|
|
3570
5233
|
}
|
|
3571
|
-
if (
|
|
5234
|
+
if (active.length > 0 || overridden.length > 0) {
|
|
5235
|
+
const messageBase =
|
|
5236
|
+
active.length > 0
|
|
5237
|
+
? "High-entropy source strings were found without secret-like identifier context; they may be false positives."
|
|
5238
|
+
: "Potential credential-like strings were found in shareable files.";
|
|
5239
|
+
const message =
|
|
5240
|
+
overridden.length > 0
|
|
5241
|
+
? `${messageBase} ${overridden.length} acknowledged @apifuse-allow override(s).`
|
|
5242
|
+
: messageBase;
|
|
3572
5243
|
return {
|
|
3573
5244
|
id: "secret-scan",
|
|
3574
5245
|
category: "security",
|
|
@@ -3576,11 +5247,10 @@ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): Subm
|
|
|
3576
5247
|
status: "warn",
|
|
3577
5248
|
points: 8,
|
|
3578
5249
|
maxPoints: CATEGORY_MAX_POINTS.security,
|
|
3579
|
-
message
|
|
3580
|
-
"High-entropy source strings were found without secret-like identifier context; they may be false positives.",
|
|
5250
|
+
message,
|
|
3581
5251
|
remediation:
|
|
3582
|
-
'Review the listed strings. If any are credentials, move them to env vars read via `ctx.env.get("APIFUSE__PROVIDER__<ID>__<NAME>")` and rotate the leaked credential; otherwise keep generated blobs in fixtures/tests or document why they are public
|
|
3583
|
-
evidence:
|
|
5252
|
+
'Review the listed strings. If any are credentials, move them to env vars read via `ctx.env.get("APIFUSE__PROVIDER__<ID>__<NAME>")` and rotate the leaked credential; otherwise keep generated blobs in fixtures/tests or document why they are public with `// @apifuse-allow secret-scan: <reason>`.',
|
|
5253
|
+
evidence: [...active, ...overridden].map(
|
|
3584
5254
|
(finding) =>
|
|
3585
5255
|
finding.evidence ??
|
|
3586
5256
|
`${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
|
|
@@ -3611,8 +5281,19 @@ function findSecretFindings(providerRoot: string, providerId = "<ID>"): SecretFi
|
|
|
3611
5281
|
if (!existsSync(filePath)) continue;
|
|
3612
5282
|
const content = readFileSync(filePath, "utf8");
|
|
3613
5283
|
for (const [label, pattern] of SECRET_PATTERNS) {
|
|
3614
|
-
|
|
3615
|
-
|
|
5284
|
+
// Locate every match to its line so pattern findings carry the line
|
|
5285
|
+
// information hasAllowOverride needs: `// @apifuse-allow secret-scan`
|
|
5286
|
+
// must behave uniformly across entropy findings and pattern findings.
|
|
5287
|
+
const globalPattern = new RegExp(
|
|
5288
|
+
pattern.source,
|
|
5289
|
+
pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
|
|
5290
|
+
);
|
|
5291
|
+
const seenLines = new Set<number>();
|
|
5292
|
+
for (const match of content.matchAll(globalPattern)) {
|
|
5293
|
+
const line = offsetToLine(content, match.index);
|
|
5294
|
+
if (seenLines.has(line)) continue;
|
|
5295
|
+
seenLines.add(line);
|
|
5296
|
+
findings.push({ label, file: relativePath, line });
|
|
3616
5297
|
}
|
|
3617
5298
|
}
|
|
3618
5299
|
}
|
|
@@ -3670,7 +5351,7 @@ export function extractStringLiteralCandidates(line: string): string[] {
|
|
|
3670
5351
|
continue;
|
|
3671
5352
|
}
|
|
3672
5353
|
if (char === quote) {
|
|
3673
|
-
if (cursor - contentStart >=
|
|
5354
|
+
if (cursor - contentStart >= ENTROPY_CANDIDATE_MIN_LENGTH) {
|
|
3674
5355
|
candidates.push(line.slice(contentStart, cursor));
|
|
3675
5356
|
}
|
|
3676
5357
|
index = cursor;
|
|
@@ -3695,6 +5376,25 @@ function classifyEntropyCandidate(input: {
|
|
|
3695
5376
|
if (!charset) return undefined;
|
|
3696
5377
|
const entropy = shannonEntropy(value);
|
|
3697
5378
|
const secretishContext = SECRETISH_IDENTIFIER_PATTERN.test(input.line);
|
|
5379
|
+
// Word-like SCREAMING_SNAKE values (e.g. error-code constants such as
|
|
5380
|
+
// "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED") may contain secret-ish words
|
|
5381
|
+
// (AUTH/PASSWORD/...) in their own text and would otherwise be permanently
|
|
5382
|
+
// blocker-flagged. They are never skipped — entropy classification always
|
|
5383
|
+
// runs — but when the secret-ish context comes solely from identifier-
|
|
5384
|
+
// constant-shaped literal text (the line with those literals stripped
|
|
5385
|
+
// carries no secret-ish identifier), the finding is capped at a
|
|
5386
|
+
// non-blocking warning instead of a blocker. Stripping constant-shaped
|
|
5387
|
+
// siblings — not just the candidate — matters for lines holding several
|
|
5388
|
+
// constants (e.g. an ERROR_CODES array), while quoted property keys and
|
|
5389
|
+
// header names ("Authorization", "apiKey") stay visible as genuine
|
|
5390
|
+
// external context. Assignments to `apiKey`/`token`/`secret`-style names
|
|
5391
|
+
// still escalate to blockers via the identifier side, and
|
|
5392
|
+
// `// @apifuse-allow secret-scan` remains the reviewed way to silence the
|
|
5393
|
+
// warning.
|
|
5394
|
+
const selfContextOnlyConstant =
|
|
5395
|
+
secretishContext &&
|
|
5396
|
+
isScreamingSnakeConstantValue(value) &&
|
|
5397
|
+
!SECRETISH_IDENTIFIER_PATTERN.test(stripIdentifierConstantLiterals(input.line, value));
|
|
3698
5398
|
const threshold = charset === "hex" ? 3.0 : secretishContext ? 4.0 : 4.5;
|
|
3699
5399
|
if (entropy < threshold) return undefined;
|
|
3700
5400
|
|
|
@@ -3705,16 +5405,161 @@ function classifyEntropyCandidate(input: {
|
|
|
3705
5405
|
charset === "hex"
|
|
3706
5406
|
? `high-entropy hex string (${entropy.toFixed(2)} bits/char)`
|
|
3707
5407
|
: `high-entropy base64-like string (${entropy.toFixed(2)} bits/char)`;
|
|
5408
|
+
const contextNote = selfContextOnlyConstant
|
|
5409
|
+
? "; identifier-like constant (downgraded to warning)"
|
|
5410
|
+
: secretishContext
|
|
5411
|
+
? ""
|
|
5412
|
+
: "; may be a false positive";
|
|
3708
5413
|
return {
|
|
3709
5414
|
label,
|
|
3710
5415
|
file: input.file,
|
|
3711
5416
|
line: input.lineNumber,
|
|
3712
|
-
level: secretishContext ? "blocker" : "warn",
|
|
5417
|
+
level: secretishContext && !selfContextOnlyConstant ? "blocker" : "warn",
|
|
3713
5418
|
remediation: `Move ${location} to an env var read via \`ctx.env.get("${envName}")\` and rotate the leaked credential.`,
|
|
3714
|
-
evidence: `${location}: ${label}; preview ${preview}${
|
|
5419
|
+
evidence: `${location}: ${label}; preview ${preview}${contextNote}`,
|
|
3715
5420
|
};
|
|
3716
5421
|
}
|
|
3717
5422
|
|
|
5423
|
+
// Word-like SCREAMING_SNAKE identifier shape: at least two underscore-
|
|
5424
|
+
// separated segments, each essentially pure alphabetic — letters optionally
|
|
5425
|
+
// followed by a SHORT digit suffix (at most 2, e.g. version markers like
|
|
5426
|
+
// "V2") — and at most 15% digits across the whole value. Dictionary-style
|
|
5427
|
+
// constants like "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED" or
|
|
5428
|
+
// "PROVIDER_CONTRACT_V2_REQUIRED" match; digit-heavy segmented material
|
|
5429
|
+
// (e.g. license/credential shapes like "ABCD1234_EFGH5678_IJKL9012"),
|
|
5430
|
+
// uppercase blobs ("XK9J_Q2ZP_M7VN"), and underscore-free hex-like values
|
|
5431
|
+
// ("A1B2C3D4...") do not. This shape gate never skips entropy classification;
|
|
5432
|
+
// it only decides whether a finding whose secret-ish context comes solely
|
|
5433
|
+
// from the literal's own text is downgraded from blocker to warning, so it
|
|
5434
|
+
// deliberately stays strict: values that merely contain a secret-ish word but
|
|
5435
|
+
// are not word-like constants keep full blocker severity.
|
|
5436
|
+
function isScreamingSnakeConstantValue(value: string): boolean {
|
|
5437
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(value) || !value.includes("_")) return false;
|
|
5438
|
+
const segments = value.split("_");
|
|
5439
|
+
if (segments.length < 2) return false;
|
|
5440
|
+
if (!segments.every((segment) => /^[A-Z]+[0-9]{0,2}$/.test(segment))) return false;
|
|
5441
|
+
const digitCount = value.match(/[0-9]/g)?.length ?? 0;
|
|
5442
|
+
return digitCount / value.length <= 0.15;
|
|
5443
|
+
}
|
|
5444
|
+
|
|
5445
|
+
type LineStringLiteral = {
|
|
5446
|
+
// Index of the opening quote.
|
|
5447
|
+
start: number;
|
|
5448
|
+
// Index just past the closing quote (line end when unterminated).
|
|
5449
|
+
end: number;
|
|
5450
|
+
content: string;
|
|
5451
|
+
closed: boolean;
|
|
5452
|
+
role: "key" | "value";
|
|
5453
|
+
// Nearest unclosed bracket enclosing the literal's start, if any.
|
|
5454
|
+
container?: { bracket: "[" | "(" | "{"; index: number };
|
|
5455
|
+
};
|
|
5456
|
+
|
|
5457
|
+
// Stable identity for the container a literal sits in ("top" when the
|
|
5458
|
+
// literal is not inside any bracket on the line).
|
|
5459
|
+
function literalContainerKey(literal: LineStringLiteral): string {
|
|
5460
|
+
return literal.container ? `${literal.container.bracket}${literal.container.index}` : "top";
|
|
5461
|
+
}
|
|
5462
|
+
|
|
5463
|
+
// Single-pass line tokenizer: extracts every string literal with its span and
|
|
5464
|
+
// classifies its syntactic role once. A literal is a KEY when it is preceded
|
|
5465
|
+
// (ignoring whitespace) by "{", ",", "(", or the line start AND followed
|
|
5466
|
+
// (ignoring whitespace) by ":" — i.e. it names the value next to it. Every
|
|
5467
|
+
// other literal is a VALUE: ternary arms (preceded by "?" or ":"), array
|
|
5468
|
+
// elements, call arguments, and assignment right-hand sides, even when a
|
|
5469
|
+
// ternary's ":" happens to follow them. Uses the same quote/escape walking as
|
|
5470
|
+
// extractStringLiteralCandidates.
|
|
5471
|
+
function tokenizeLineStringLiterals(line: string): LineStringLiteral[] {
|
|
5472
|
+
const literals: LineStringLiteral[] = [];
|
|
5473
|
+
const bracketStack: Array<{ bracket: "[" | "(" | "{"; index: number }> = [];
|
|
5474
|
+
let index = 0;
|
|
5475
|
+
while (index < line.length) {
|
|
5476
|
+
const char = line[index];
|
|
5477
|
+
if (char !== '"' && char !== "'" && char !== "`") {
|
|
5478
|
+
if (char === "[" || char === "(" || char === "{") {
|
|
5479
|
+
bracketStack.push({ bracket: char, index });
|
|
5480
|
+
} else if (char === "]" || char === ")" || char === "}") {
|
|
5481
|
+
bracketStack.pop();
|
|
5482
|
+
}
|
|
5483
|
+
index += 1;
|
|
5484
|
+
continue;
|
|
5485
|
+
}
|
|
5486
|
+
const quote = char;
|
|
5487
|
+
const start = index;
|
|
5488
|
+
const contentStart = index + 1;
|
|
5489
|
+
let cursor = contentStart;
|
|
5490
|
+
let closed = false;
|
|
5491
|
+
while (cursor < line.length) {
|
|
5492
|
+
const inner = line[cursor];
|
|
5493
|
+
if (inner === "\\") {
|
|
5494
|
+
cursor += 2;
|
|
5495
|
+
continue;
|
|
5496
|
+
}
|
|
5497
|
+
if (inner === quote) {
|
|
5498
|
+
closed = true;
|
|
5499
|
+
break;
|
|
5500
|
+
}
|
|
5501
|
+
cursor += 1;
|
|
5502
|
+
}
|
|
5503
|
+
const contentEnd = Math.min(cursor, line.length);
|
|
5504
|
+
const end = closed ? cursor + 1 : line.length;
|
|
5505
|
+
const before = line.slice(0, start).trimEnd();
|
|
5506
|
+
const keyPreceded = before === "" || /[{,(]$/.test(before);
|
|
5507
|
+
const keyFollowed = closed && /^\s*:/.test(line.slice(end));
|
|
5508
|
+
literals.push({
|
|
5509
|
+
start,
|
|
5510
|
+
end,
|
|
5511
|
+
content: line.slice(contentStart, contentEnd),
|
|
5512
|
+
closed,
|
|
5513
|
+
role: keyPreceded && keyFollowed ? "key" : "value",
|
|
5514
|
+
container: bracketStack[bracketStack.length - 1],
|
|
5515
|
+
});
|
|
5516
|
+
index = end;
|
|
5517
|
+
}
|
|
5518
|
+
return literals;
|
|
5519
|
+
}
|
|
5520
|
+
|
|
5521
|
+
// Builds the context text used to decide whether a candidate's secret-ish
|
|
5522
|
+
// context is genuine. The candidate's own literal is ALWAYS stripped
|
|
5523
|
+
// (self-context rule). SIBLING identifier-constant-shaped candidate literals
|
|
5524
|
+
// (VALUE role, SCREAMING_SNAKE shape, >= ENTROPY_CANDIDATE_MIN_LENGTH) are
|
|
5525
|
+
// stripped only when they share the candidate's non-call container — the same
|
|
5526
|
+
// `[...]` array, the same `{...}` object value list, or the bracket-free top
|
|
5527
|
+
// level (ternary arms) — so a value list of error codes cannot poison its own
|
|
5528
|
+
// members' context. Call-argument siblings (inside `(...)`) always keep their
|
|
5529
|
+
// context: in `headers.set("X_LONG_AUTH_TOKEN_NAME", "QWERTY_...")` the first
|
|
5530
|
+
// argument genuinely describes the second, so stripping it would erase real
|
|
5531
|
+
// auth/token context. KEY-role literals are never stripped.
|
|
5532
|
+
function stripIdentifierConstantLiterals(line: string, candidate: string): string {
|
|
5533
|
+
const literals = tokenizeLineStringLiterals(line);
|
|
5534
|
+
const candidateContainers = new Set<string>();
|
|
5535
|
+
for (const literal of literals) {
|
|
5536
|
+
if (literal.content === candidate) {
|
|
5537
|
+
candidateContainers.add(literalContainerKey(literal));
|
|
5538
|
+
}
|
|
5539
|
+
}
|
|
5540
|
+
let result = "";
|
|
5541
|
+
let previousEnd = 0;
|
|
5542
|
+
for (const literal of literals) {
|
|
5543
|
+
result += line.slice(previousEnd, literal.start);
|
|
5544
|
+
const isSelf = literal.content === candidate;
|
|
5545
|
+
const isSameNonCallContainerSibling =
|
|
5546
|
+
literal.role === "value" &&
|
|
5547
|
+
literal.content.length >= ENTROPY_CANDIDATE_MIN_LENGTH &&
|
|
5548
|
+
isScreamingSnakeConstantValue(literal.content) &&
|
|
5549
|
+
literal.container?.bracket !== "(" &&
|
|
5550
|
+
candidateContainers.has(literalContainerKey(literal));
|
|
5551
|
+
if (isSelf || isSameNonCallContainerSibling) {
|
|
5552
|
+
const quote = line[literal.start] ?? "";
|
|
5553
|
+
result += quote + (literal.closed ? quote : "");
|
|
5554
|
+
} else {
|
|
5555
|
+
result += line.slice(literal.start, literal.end);
|
|
5556
|
+
}
|
|
5557
|
+
previousEnd = literal.end;
|
|
5558
|
+
}
|
|
5559
|
+
result += line.slice(previousEnd);
|
|
5560
|
+
return result;
|
|
5561
|
+
}
|
|
5562
|
+
|
|
3718
5563
|
function shouldConsiderEntropyValue(value: string): boolean {
|
|
3719
5564
|
const lower = value.toLowerCase();
|
|
3720
5565
|
if (/^(?:dev-only|local|example|sample|your-|replace|<)/i.test(value)) {
|
|
@@ -3730,7 +5575,7 @@ function shouldConsiderEntropyValue(value: string): boolean {
|
|
|
3730
5575
|
if (lower.includes("/") && /\.[a-z0-9]{1,8}(?:$|[/?#])/i.test(value)) {
|
|
3731
5576
|
return false;
|
|
3732
5577
|
}
|
|
3733
|
-
return value.length >=
|
|
5578
|
+
return value.length >= ENTROPY_CANDIDATE_MIN_LENGTH;
|
|
3734
5579
|
}
|
|
3735
5580
|
|
|
3736
5581
|
function classifyEntropyCharset(value: string): "base64" | "hex" | undefined {
|
|
@@ -3753,8 +5598,9 @@ function shannonEntropy(value: string): number {
|
|
|
3753
5598
|
|
|
3754
5599
|
function guessSecretName(line: string): string {
|
|
3755
5600
|
const match =
|
|
3756
|
-
/\b(
|
|
3757
|
-
/["']?([A-Za-z_$][\w$-]*)["']?\s
|
|
5601
|
+
/\b[A-Za-z_$][\w$]*(?:\s*\.\s*([A-Za-z_$][\w$]*))+\s*=/.exec(line) ??
|
|
5602
|
+
/["']?([A-Za-z_$][\w$-]*)["']?\s*:\s*["'`]/.exec(line) ??
|
|
5603
|
+
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line);
|
|
3758
5604
|
const raw = match?.[1] ?? "SECRET";
|
|
3759
5605
|
return raw
|
|
3760
5606
|
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
@@ -3764,6 +5610,40 @@ function guessSecretName(line: string): string {
|
|
|
3764
5610
|
|
|
3765
5611
|
const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/i;
|
|
3766
5612
|
|
|
5613
|
+
// Minimum length for a string literal to be considered an entropy candidate.
|
|
5614
|
+
// Shared by candidate extraction, entropy screening, and the context strip so
|
|
5615
|
+
// the three stay coherent.
|
|
5616
|
+
const ENTROPY_CANDIDATE_MIN_LENGTH = 20;
|
|
5617
|
+
|
|
5618
|
+
// A secret-ish name is only supporting evidence. Shorter values must also look
|
|
5619
|
+
// credential-like, and known config tokens/numeric values are never candidates.
|
|
5620
|
+
const SECRET_NAMED_ENV_VALUE_MIN_LENGTH = 4;
|
|
5621
|
+
const SECRET_NAMED_ENV_UNSTRUCTURED_MIN_LENGTH = 8;
|
|
5622
|
+
const NON_SECRET_ENV_VALUE_TOKENS = new Set([
|
|
5623
|
+
"true",
|
|
5624
|
+
"false",
|
|
5625
|
+
"yes",
|
|
5626
|
+
"no",
|
|
5627
|
+
"on",
|
|
5628
|
+
"off",
|
|
5629
|
+
"none",
|
|
5630
|
+
"null",
|
|
5631
|
+
"nil",
|
|
5632
|
+
"auto",
|
|
5633
|
+
"default",
|
|
5634
|
+
"local",
|
|
5635
|
+
"debug",
|
|
5636
|
+
"info",
|
|
5637
|
+
"warn",
|
|
5638
|
+
"error",
|
|
5639
|
+
"always",
|
|
5640
|
+
"never",
|
|
5641
|
+
"enabled",
|
|
5642
|
+
"disabled",
|
|
5643
|
+
"0",
|
|
5644
|
+
"1",
|
|
5645
|
+
]);
|
|
5646
|
+
|
|
3767
5647
|
const SECRET_PATTERNS: Array<[string, RegExp]> = [
|
|
3768
5648
|
["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
|
|
3769
5649
|
["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
|
|
@@ -3775,12 +5655,151 @@ const SECRET_PATTERNS: Array<[string, RegExp]> = [
|
|
|
3775
5655
|
],
|
|
3776
5656
|
];
|
|
3777
5657
|
|
|
3778
|
-
|
|
5658
|
+
type SafeLoadProviderResult =
|
|
5659
|
+
| { ok: true; provider: ProviderDefinition | undefined }
|
|
5660
|
+
| { ok: false; error: unknown };
|
|
5661
|
+
|
|
5662
|
+
async function safeLoadProvider(providerRoot: string): Promise<SafeLoadProviderResult> {
|
|
5663
|
+
try {
|
|
5664
|
+
return { ok: true, provider: await loadProvider(providerRoot) };
|
|
5665
|
+
} catch (error) {
|
|
5666
|
+
return { ok: false, error };
|
|
5667
|
+
}
|
|
5668
|
+
}
|
|
5669
|
+
|
|
5670
|
+
const UNRENDERABLE_LOAD_ERROR = "<thrown value could not be rendered>";
|
|
5671
|
+
|
|
5672
|
+
function formatLoadErrorEvidence(error: unknown, providerRoot: string): string[] {
|
|
5673
|
+
const evidence: string[] = [];
|
|
5674
|
+
const seen = new Set<object>();
|
|
5675
|
+
let current: unknown = error;
|
|
5676
|
+
let frame = 0;
|
|
5677
|
+
while (frame < MAX_LOAD_ERROR_EVIDENCE) {
|
|
5678
|
+
if (typeof current === "object" || typeof current === "function") {
|
|
5679
|
+
if (current !== null && seen.has(current)) {
|
|
5680
|
+
break;
|
|
5681
|
+
}
|
|
5682
|
+
if (current !== null) seen.add(current);
|
|
5683
|
+
}
|
|
5684
|
+
evidence.push(
|
|
5685
|
+
`${frame === 0 ? "Load error" : "Cause"}: ${sanitizeLoadErrorText(
|
|
5686
|
+
formatThrownValue(current),
|
|
5687
|
+
providerRoot,
|
|
5688
|
+
)}`,
|
|
5689
|
+
);
|
|
5690
|
+
frame += 1;
|
|
5691
|
+
const cause = readThrownValueCause(current);
|
|
5692
|
+
if (!cause.found || cause.value === undefined || cause.value === null) {
|
|
5693
|
+
break;
|
|
5694
|
+
}
|
|
5695
|
+
current = cause.value;
|
|
5696
|
+
}
|
|
5697
|
+
return evidence;
|
|
5698
|
+
}
|
|
5699
|
+
|
|
5700
|
+
function formatThrownValue(value: unknown): string {
|
|
3779
5701
|
try {
|
|
3780
|
-
|
|
5702
|
+
if (value instanceof Error) {
|
|
5703
|
+
return String(value.message);
|
|
5704
|
+
}
|
|
5705
|
+
if (
|
|
5706
|
+
value !== null &&
|
|
5707
|
+
(typeof value === "object" || typeof value === "function") &&
|
|
5708
|
+
"message" in value
|
|
5709
|
+
) {
|
|
5710
|
+
return String((value as { message?: unknown }).message);
|
|
5711
|
+
}
|
|
5712
|
+
return `<non-Error value thrown: ${String(value)}>`;
|
|
3781
5713
|
} catch {
|
|
3782
|
-
return
|
|
5714
|
+
return UNRENDERABLE_LOAD_ERROR;
|
|
5715
|
+
}
|
|
5716
|
+
}
|
|
5717
|
+
|
|
5718
|
+
function readThrownValueCause(value: unknown): { found: boolean; value?: unknown } {
|
|
5719
|
+
try {
|
|
5720
|
+
if (
|
|
5721
|
+
value === null ||
|
|
5722
|
+
(typeof value !== "object" && typeof value !== "function") ||
|
|
5723
|
+
!("cause" in value)
|
|
5724
|
+
) {
|
|
5725
|
+
return { found: false };
|
|
5726
|
+
}
|
|
5727
|
+
return { found: true, value: (value as { cause?: unknown }).cause };
|
|
5728
|
+
} catch {
|
|
5729
|
+
return { found: false };
|
|
5730
|
+
}
|
|
5731
|
+
}
|
|
5732
|
+
|
|
5733
|
+
function isPlausibleSecretNamedEnvValue(value: string): boolean {
|
|
5734
|
+
const normalized = value.trim();
|
|
5735
|
+
if (normalized.length < SECRET_NAMED_ENV_VALUE_MIN_LENGTH) return false;
|
|
5736
|
+
if (NON_SECRET_ENV_VALUE_TOKENS.has(normalized.toLowerCase())) return false;
|
|
5737
|
+
if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) return false;
|
|
5738
|
+
if (normalized.length >= SECRET_NAMED_ENV_UNSTRUCTURED_MIN_LENGTH) return true;
|
|
5739
|
+
|
|
5740
|
+
const hasLetter = /[A-Za-z]/.test(normalized);
|
|
5741
|
+
const hasDigitOrSymbol = /[^A-Za-z\s]/.test(normalized);
|
|
5742
|
+
return hasLetter && hasDigitOrSymbol;
|
|
5743
|
+
}
|
|
5744
|
+
|
|
5745
|
+
function replaceEnvValue(
|
|
5746
|
+
input: string,
|
|
5747
|
+
value: string,
|
|
5748
|
+
requireIdentifierBoundaries: boolean,
|
|
5749
|
+
): string {
|
|
5750
|
+
if (!requireIdentifierBoundaries) return input.replaceAll(value, "[REDACTED]");
|
|
5751
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5752
|
+
return input.replace(
|
|
5753
|
+
new RegExp(`(?<![A-Za-z0-9_])${escaped}(?![A-Za-z0-9_])`, "g"),
|
|
5754
|
+
"[REDACTED]",
|
|
5755
|
+
);
|
|
5756
|
+
}
|
|
5757
|
+
|
|
5758
|
+
function sanitizeLoadErrorText(value: string, providerRoot: string): string {
|
|
5759
|
+
let output = value.replace(
|
|
5760
|
+
/(^|[^A-Za-z0-9_.])((?:\/[^\s"'`]+)+)/g,
|
|
5761
|
+
(_match, prefix: string, rawPath: string) => {
|
|
5762
|
+
const pathCandidate = rawPath.replace(/[),.;:!?]+$/u, "");
|
|
5763
|
+
const relativePath = relative(providerRoot, resolve(pathCandidate));
|
|
5764
|
+
const insideProvider =
|
|
5765
|
+
relativePath === "" ||
|
|
5766
|
+
(relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
|
|
5767
|
+
return `${prefix}${insideProvider ? relativePath || "." : "[REDACTED_PATH]"}`;
|
|
5768
|
+
},
|
|
5769
|
+
);
|
|
5770
|
+
output = output.replace(/[A-Za-z]:[\\/][^\s"'`]+/g, "[REDACTED_PATH]");
|
|
5771
|
+
output = output.replace(
|
|
5772
|
+
/((?:password|token|secret|api[_-]?key|credential|authorization)\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|["'`]?[^\s,;"'`]+)/gi,
|
|
5773
|
+
"$1[REDACTED]",
|
|
5774
|
+
);
|
|
5775
|
+
const envValues: Array<{ value: string; requireIdentifierBoundaries: boolean }> = [];
|
|
5776
|
+
for (const [name, item] of Object.entries(process.env)) {
|
|
5777
|
+
if (typeof item !== "string") continue;
|
|
5778
|
+
const secretNamed = SECRETISH_IDENTIFIER_PATTERN.test(name);
|
|
5779
|
+
let shouldRedact = secretNamed;
|
|
5780
|
+
if (secretNamed) {
|
|
5781
|
+
shouldRedact = isPlausibleSecretNamedEnvValue(item);
|
|
5782
|
+
} else if (item.length >= ENTROPY_CANDIDATE_MIN_LENGTH && shouldConsiderEntropyValue(item)) {
|
|
5783
|
+
const charset = classifyEntropyCharset(item);
|
|
5784
|
+
const threshold = charset === "hex" ? 3.0 : 4.5;
|
|
5785
|
+
shouldRedact = charset !== undefined && shannonEntropy(item) >= threshold;
|
|
5786
|
+
}
|
|
5787
|
+
if (shouldRedact) {
|
|
5788
|
+
envValues.push({
|
|
5789
|
+
value: item,
|
|
5790
|
+
requireIdentifierBoundaries:
|
|
5791
|
+
secretNamed && item.trim().length < ENTROPY_CANDIDATE_MIN_LENGTH,
|
|
5792
|
+
});
|
|
5793
|
+
}
|
|
5794
|
+
}
|
|
5795
|
+
envValues.sort((a, b) => b.value.length - a.value.length);
|
|
5796
|
+
for (const envValue of envValues) {
|
|
5797
|
+
output = replaceEnvValue(output, envValue.value, envValue.requireIdentifierBoundaries);
|
|
3783
5798
|
}
|
|
5799
|
+
output = redact(output).replace(/\s+/g, " ");
|
|
5800
|
+
return output.length > MAX_LOAD_ERROR_MESSAGE_LENGTH
|
|
5801
|
+
? `${output.slice(0, MAX_LOAD_ERROR_MESSAGE_LENGTH - 1)}…`
|
|
5802
|
+
: output;
|
|
3784
5803
|
}
|
|
3785
5804
|
|
|
3786
5805
|
async function loadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
|