@apifuse/provider-sdk 2.2.0-beta.5 → 2.2.0-beta.50
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 +537 -33
- package/CHANGELOG.md +201 -1
- package/README.md +83 -22
- package/SUBMISSION.md +2 -2
- package/bin/apifuse-check.ts +165 -11
- package/bin/apifuse-dev.ts +57 -13
- package/bin/apifuse-migrate-operation-declaration.ts +55 -0
- package/bin/apifuse-migrate-shape.ts +202 -0
- package/bin/apifuse-pack-check.ts +22 -2
- package/bin/apifuse-pack-smoke.ts +57 -2
- package/bin/apifuse-pack-types.ts +357 -38
- package/bin/apifuse-perf.ts +14 -13
- package/bin/apifuse-record.ts +709 -72
- package/bin/apifuse-submit-check.ts +2344 -324
- 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 +27 -0
- package/dist/cli/create.d.ts +4 -1
- package/dist/cli/create.js +42 -37
- package/dist/cli/migrate-operation-declaration.d.ts +59 -0
- package/dist/cli/migrate-operation-declaration.js +1178 -0
- package/dist/cli/migrate-operation-shape.d.ts +44 -0
- package/dist/cli/migrate-operation-shape.js +113 -0
- package/dist/cli/migrate-provider-shape.d.ts +52 -0
- package/dist/cli/migrate-provider-shape.js +578 -0
- package/dist/cli/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 +5 -5
- package/dist/cli/templates/provider/index.ts.tpl +6 -3
- package/dist/cli/templates/provider/operations/ping.ts.tpl +4 -1
- package/dist/cli/templates/provider/provider.json.tpl +6 -0
- package/dist/config/loader.d.ts +179 -16
- package/dist/config/loader.js +435 -127
- package/dist/contract-serialization.js +4 -8
- package/dist/contract-types.d.ts +12 -5
- package/dist/contract.js +24 -10
- package/dist/declaration-validation.d.ts +33 -0
- package/dist/declaration-validation.js +266 -0
- package/dist/define.d.ts +66 -29
- package/dist/define.js +778 -69
- package/dist/dev.d.ts +3 -0
- package/dist/dev.js +1 -1
- package/dist/engine.d.ts +78 -0
- package/dist/engine.js +133 -0
- 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 +21 -10
- package/dist/index.js +12 -7
- package/dist/lint.d.ts +23 -11
- package/dist/lint.js +572 -69
- 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 +10 -2
- package/dist/provider.js +4 -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/chrome149-header-order.d.ts +58 -0
- package/dist/runtime/chrome149-header-order.js +289 -0
- package/dist/runtime/env.js +12 -0
- package/dist/runtime/executor.d.ts +3 -2
- package/dist/runtime/executor.js +26 -25
- package/dist/runtime/http.d.ts +1 -0
- package/dist/runtime/http.js +515 -53
- package/dist/runtime/insights.js +2 -2
- 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/otlp.d.ts +71 -2
- package/dist/runtime/otlp.js +397 -16
- 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 +14 -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 +531 -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 +408 -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 +72 -0
- package/dist/runtime/resolver.js +771 -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 +41 -5
- package/dist/runtime/stealth.js +997 -291
- 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 +62 -0
- package/dist/runtime/trace.d.ts +5 -0
- package/dist/runtime/trace.js +43 -10
- 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 +5 -2
- package/dist/server/index.js +2 -2
- 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 +14 -3
- package/dist/server/self-test.js +129 -58
- package/dist/server/serve-implementation.d.ts +230 -0
- package/dist/server/serve-implementation.js +2265 -0
- package/dist/server/serve.d.ts +1 -70
- package/dist/server/serve.js +1 -1131
- package/dist/server/trace-output.d.ts +6 -0
- package/dist/server/trace-output.js +79 -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.d.ts +9 -4
- package/dist/stealth/profiles.js +160 -211
- 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 +673 -131
- package/dist/types.js +1 -0
- package/package.json +45 -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/__tests__/fixtures/migrate-operation-declaration/approval-override.ts.txt +6 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/codemod-syntax.ts.txt +3 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/connection-precedence.ts.txt +10 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/docs-conflict.ts.txt +8 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-map.ts.txt +5 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-operation.ts.txt +16 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-map.ts.txt +3 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/hoist-all.ts.txt +31 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/hoisted-const.ts.txt +11 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/imported-spread.ts.txt +11 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-map.ts.txt +11 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-cast-tail.ts.txt +21 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-ekitan.ts.txt +11 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-override.ts.txt +14 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/missing-english-locale.ts.txt +7 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/no-safety.ts.txt +6 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/non-literal.ts.txt +7 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/redundant-approval.ts.txt +6 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/safety-conflict.ts.txt +7 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/stream.ts.txt +7 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/tool-router-spread.ts.txt +15 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/unparseable.ts.txt +4 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/verbatim-template.ts.txt +12 -0
- package/src/cli/commands.ts +33 -0
- package/src/cli/create.ts +50 -37
- package/src/cli/migrate-operation-declaration.ts +1654 -0
- package/src/cli/migrate-operation-shape.ts +184 -0
- package/src/cli/migrate-provider-shape.ts +772 -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 +5 -5
- package/src/cli/templates/provider/index.ts.tpl +6 -3
- package/src/cli/templates/provider/operations/ping.ts.tpl +4 -1
- package/src/cli/templates/provider/provider.json.tpl +6 -0
- package/src/config/loader.ts +690 -163
- package/src/contract-serialization.ts +5 -7
- package/src/contract-types.ts +12 -5
- package/src/contract.ts +24 -10
- package/src/declaration-validation.ts +330 -0
- package/src/define.ts +1048 -131
- package/src/dev.ts +4 -1
- package/src/engine.ts +279 -0
- 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 +220 -13
- package/src/lint.ts +698 -90
- 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 +161 -3
- 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/chrome149-header-order.ts +330 -0
- package/src/runtime/env.ts +13 -0
- package/src/runtime/executor.ts +43 -31
- package/src/runtime/http.ts +641 -61
- package/src/runtime/insights.ts +2 -2
- 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/otlp.ts +467 -21
- 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 +23 -0
- package/src/runtime/resolver-shared.ts +33 -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 +707 -0
- package/src/runtime/resolver-vendors/hosts.ts +38 -0
- package/src/runtime/resolver-vendors/twocaptcha.ts +540 -0
- package/src/runtime/resolver-vendors/types.ts +212 -0
- package/src/runtime/resolver.ts +1157 -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 +1280 -336
- package/src/runtime/stt.ts +1 -19
- package/src/runtime/timeout.ts +18 -0
- package/src/runtime/trace-config.ts +78 -0
- package/src/runtime/trace.ts +57 -17
- package/src/serve.ts +6 -1
- package/src/server/error-observability.ts +1 -0
- package/src/server/index.ts +34 -2
- package/src/server/self-test-input-tokens.ts +29 -14
- package/src/server/self-test.ts +191 -68
- package/src/server/serve-implementation.ts +3378 -0
- package/src/server/serve.ts +1 -1632
- package/src/server/trace-output.ts +129 -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 +202 -231
- 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 +788 -174
- 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,24 +4,44 @@ import { type ChildProcess, spawn } from "node:child_process";
|
|
|
4
4
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
5
5
|
import { writeFile } from "node:fs/promises";
|
|
6
6
|
import { createServer } from "node:net";
|
|
7
|
-
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
8
8
|
import { pathToFileURL } from "node:url";
|
|
9
9
|
|
|
10
10
|
import * as acorn from "acorn";
|
|
11
|
+
import type TS from "typescript";
|
|
11
12
|
import { z } from "zod";
|
|
12
13
|
|
|
13
14
|
import packageJson from "../package.json";
|
|
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
|
|
|
32
|
+
const ts: typeof import("typescript") = await loadTypeScript();
|
|
33
|
+
|
|
34
|
+
async function loadTypeScript(): Promise<typeof import("typescript")> {
|
|
35
|
+
try {
|
|
36
|
+
return await import("typescript");
|
|
37
|
+
} catch {
|
|
38
|
+
console.error(
|
|
39
|
+
"apifuse submit-check requires typescript; install it in the workspace running the CLI (bun add -d typescript)",
|
|
40
|
+
);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
|
|
26
46
|
const TIER_VALUES: ReadonlySet<string> = new Set(TIERS);
|
|
27
47
|
type BountyTier = (typeof TIERS)[number];
|
|
@@ -106,11 +126,14 @@ export type SmokeResult = {
|
|
|
106
126
|
type SourceFinding = {
|
|
107
127
|
file: string;
|
|
108
128
|
line: number;
|
|
129
|
+
detail?: string;
|
|
109
130
|
};
|
|
110
131
|
|
|
111
132
|
const SDK_NATIVE_CATEGORY = "sdk-native";
|
|
112
133
|
const VENDOR_SHIM_PROVIDER_ID_PREFIX = "apifuse-provider-";
|
|
113
134
|
const MAX_SOURCE_FINDING_EVIDENCE = 5;
|
|
135
|
+
const MAX_LOAD_ERROR_EVIDENCE = 5;
|
|
136
|
+
const MAX_LOAD_ERROR_MESSAGE_LENGTH = 500;
|
|
114
137
|
|
|
115
138
|
const CATEGORY_MAX_POINTS = {
|
|
116
139
|
definition: 15,
|
|
@@ -265,24 +288,49 @@ export async function buildSubmitCheckReport(
|
|
|
265
288
|
): Promise<SubmitCheckReport> {
|
|
266
289
|
const checks: SubmitCheck[] = [];
|
|
267
290
|
const baseChecks = await safeRunChecks(providerRoot);
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
291
|
+
const loadResult = await safeLoadProvider(providerRoot);
|
|
292
|
+
const provider = loadResult.ok ? loadResult.provider : undefined;
|
|
293
|
+
let indexParseError: string | undefined;
|
|
294
|
+
if (!provider) {
|
|
295
|
+
const indexPath = resolve(providerRoot, "index.ts");
|
|
296
|
+
if (existsSync(indexPath)) {
|
|
297
|
+
const relPath = toRelativeProviderPath(providerRoot, indexPath);
|
|
298
|
+
try {
|
|
299
|
+
maskCommentsAndStrings(readFileSync(indexPath, "utf8"), relPath);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
indexParseError = error instanceof Error ? error.message : String(error);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Prompt-asset freshness is reported by its own dedicated zero-point
|
|
307
|
+
// blocker below; filter the base-check duplicate so it is not double
|
|
308
|
+
// penalized under the definition category.
|
|
309
|
+
checks.push(
|
|
310
|
+
...scoreBaseChecks(
|
|
311
|
+
baseChecks.filter((result) => result.message !== PROMPT_ASSETS_CHECK_MESSAGE),
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
checks.push(scorePromptAssetFreshness(providerRoot));
|
|
315
|
+
if (!indexParseError) {
|
|
316
|
+
checks.push(scoreProviderIdSlug(providerRoot, provider));
|
|
317
|
+
checks.push(scoreNoVendorShim(providerRoot));
|
|
318
|
+
checks.push(scoreNoVendorImport(providerRoot));
|
|
319
|
+
checks.push(scoreDescribeKey(providerRoot));
|
|
320
|
+
checks.push(scoreNoRawFetch(providerRoot));
|
|
321
|
+
checks.push(scoreNoDynamicCode(providerRoot));
|
|
322
|
+
checks.push(scoreNoRedundantRuntimeGuards(providerRoot));
|
|
323
|
+
checks.push(scoreManagedBrowserRuntime(providerRoot));
|
|
324
|
+
checks.push(scoreAsAssertionCount(providerRoot));
|
|
325
|
+
checks.push(scoreUnsafeInputPassthrough(providerRoot));
|
|
326
|
+
checks.push(scoreUnjustifiedLooseSchema(providerRoot));
|
|
327
|
+
checks.push(scoreFlatOperationComposition(providerRoot));
|
|
328
|
+
}
|
|
282
329
|
|
|
283
330
|
if (provider) {
|
|
284
331
|
const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
|
|
285
332
|
checks.push(scoreCredentialUsage(providerRoot, provider));
|
|
333
|
+
checks.push(scoreSdkOwnedSecretPresence(providerRoot, provider));
|
|
286
334
|
checks.push(scoreLocaleCatalog(providerRoot, provider));
|
|
287
335
|
checks.push(scoreOperationMetadata(provider));
|
|
288
336
|
checks.push(scoreFixtureCoverage(provider));
|
|
@@ -296,15 +344,38 @@ export async function buildSubmitCheckReport(
|
|
|
296
344
|
checks.push(scoreRepositoryDx(providerRoot));
|
|
297
345
|
checks.push(scoreSecrets(providerRoot, provider));
|
|
298
346
|
} else {
|
|
347
|
+
const loadEvidence = indexParseError
|
|
348
|
+
? undefined
|
|
349
|
+
: loadResult.ok
|
|
350
|
+
? undefined
|
|
351
|
+
: formatLoadErrorEvidence(loadResult.error, providerRoot);
|
|
352
|
+
const loadRemediation = indexParseError
|
|
353
|
+
? "Fix the syntax error reported by the provider-load-parse blocker before apifuse check can load the provider."
|
|
354
|
+
: !loadResult.ok
|
|
355
|
+
? "Fix the import/initialization failure shown in evidence so apifuse check can load the provider."
|
|
356
|
+
: "Fix index.ts so it default-exports defineProvider(...).";
|
|
299
357
|
checks.push(
|
|
300
358
|
blocker(
|
|
301
359
|
"provider-load",
|
|
302
360
|
"definition",
|
|
303
361
|
"Provider could not be loaded.",
|
|
304
|
-
|
|
362
|
+
loadRemediation,
|
|
305
363
|
CATEGORY_MAX_POINTS.definition,
|
|
364
|
+
loadEvidence,
|
|
306
365
|
),
|
|
307
366
|
);
|
|
367
|
+
if (indexParseError) {
|
|
368
|
+
checks.push(
|
|
369
|
+
blocker(
|
|
370
|
+
"provider-load-parse",
|
|
371
|
+
"definition",
|
|
372
|
+
"Provider index.ts could not be parsed safely.",
|
|
373
|
+
"Fix the syntax error in index.ts before submitting.",
|
|
374
|
+
0,
|
|
375
|
+
[indexParseError],
|
|
376
|
+
),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
308
379
|
}
|
|
309
380
|
|
|
310
381
|
const total = clamp(Math.round(checks.reduce((sum, check) => sum + check.points, 0)), 0, 100);
|
|
@@ -432,7 +503,7 @@ function scoreDescribeKey(providerRoot: string): SubmitCheck {
|
|
|
432
503
|
}
|
|
433
504
|
|
|
434
505
|
function scoreNoRawFetch(providerRoot: string): SubmitCheck {
|
|
435
|
-
const findings =
|
|
506
|
+
const findings = findGlobalSinkCalls(providerRoot, FETCH_SINKS);
|
|
436
507
|
if (findings.length > 0) {
|
|
437
508
|
const evidence = formatSourceFindings(findings);
|
|
438
509
|
return blocker(
|
|
@@ -448,6 +519,435 @@ function scoreNoRawFetch(providerRoot: string): SubmitCheck {
|
|
|
448
519
|
return pass("no-raw-fetch", SDK_NATIVE_CATEGORY, "Provider source avoids raw fetch().", 0);
|
|
449
520
|
}
|
|
450
521
|
|
|
522
|
+
const DYNAMIC_CODE_SINKS: ReadonlySet<string> = new Set(["eval", "Function"]);
|
|
523
|
+
const FETCH_SINKS: ReadonlySet<string> = new Set(["fetch"]);
|
|
524
|
+
const DYNAMIC_CODE_GLOBAL_OBJECTS: ReadonlySet<string> = new Set([
|
|
525
|
+
"globalThis",
|
|
526
|
+
"window",
|
|
527
|
+
"self",
|
|
528
|
+
"global",
|
|
529
|
+
]);
|
|
530
|
+
|
|
531
|
+
function unwrapLocalExpression(expression: TS.Expression): TS.Expression {
|
|
532
|
+
let current = expression;
|
|
533
|
+
while (
|
|
534
|
+
ts.isParenthesizedExpression(current) ||
|
|
535
|
+
ts.isAsExpression(current) ||
|
|
536
|
+
ts.isSatisfiesExpression(current) ||
|
|
537
|
+
ts.isNonNullExpression(current)
|
|
538
|
+
) {
|
|
539
|
+
current = current.expression;
|
|
540
|
+
}
|
|
541
|
+
if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.CommaToken) {
|
|
542
|
+
return unwrapLocalExpression(current.right);
|
|
543
|
+
}
|
|
544
|
+
return current;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
type LocalBinding = {
|
|
548
|
+
declaration: TS.Identifier;
|
|
549
|
+
initializer?: TS.Expression;
|
|
550
|
+
destructuredProperty?: string;
|
|
551
|
+
mutable: boolean;
|
|
552
|
+
reassigned: boolean;
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
type LocalBindings = {
|
|
556
|
+
scopes: Map<TS.Node, Map<string, LocalBinding[]>>;
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
function isLexicalScope(node: TS.Node): boolean {
|
|
560
|
+
return (
|
|
561
|
+
ts.isSourceFile(node) ||
|
|
562
|
+
ts.isFunctionLike(node) ||
|
|
563
|
+
ts.isBlock(node) ||
|
|
564
|
+
ts.isModuleBlock(node) ||
|
|
565
|
+
ts.isCaseBlock(node) ||
|
|
566
|
+
ts.isCatchClause(node) ||
|
|
567
|
+
ts.isForStatement(node) ||
|
|
568
|
+
ts.isForInStatement(node) ||
|
|
569
|
+
ts.isForOfStatement(node)
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function enclosingScope(node: TS.Node, functionScoped: boolean): TS.Node {
|
|
574
|
+
let current: TS.Node | undefined = node.parent;
|
|
575
|
+
while (current !== undefined) {
|
|
576
|
+
if (
|
|
577
|
+
ts.isSourceFile(current) ||
|
|
578
|
+
(functionScoped ? ts.isFunctionLike(current) : isLexicalScope(current))
|
|
579
|
+
) {
|
|
580
|
+
return current;
|
|
581
|
+
}
|
|
582
|
+
current = current.parent;
|
|
583
|
+
}
|
|
584
|
+
return node.getSourceFile();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function collectLocalBindings(sourceFile: TS.SourceFile): LocalBindings {
|
|
588
|
+
const bindings: LocalBindings = { scopes: new Map() };
|
|
589
|
+
const addBinding = (
|
|
590
|
+
scope: TS.Node,
|
|
591
|
+
name: TS.Identifier,
|
|
592
|
+
binding: Omit<LocalBinding, "declaration" | "reassigned">,
|
|
593
|
+
): void => {
|
|
594
|
+
let scopeBindings = bindings.scopes.get(scope);
|
|
595
|
+
if (scopeBindings === undefined) {
|
|
596
|
+
scopeBindings = new Map();
|
|
597
|
+
bindings.scopes.set(scope, scopeBindings);
|
|
598
|
+
}
|
|
599
|
+
const existing = scopeBindings.get(name.text);
|
|
600
|
+
const localBinding: LocalBinding = { ...binding, declaration: name, reassigned: false };
|
|
601
|
+
if (existing === undefined) {
|
|
602
|
+
scopeBindings.set(name.text, [localBinding]);
|
|
603
|
+
} else {
|
|
604
|
+
existing.push(localBinding);
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
const addBindingName = (
|
|
608
|
+
bindingName: TS.BindingName,
|
|
609
|
+
scope: TS.Node,
|
|
610
|
+
binding: Omit<LocalBinding, "declaration" | "reassigned">,
|
|
611
|
+
): void => {
|
|
612
|
+
if (ts.isIdentifier(bindingName)) {
|
|
613
|
+
addBinding(scope, bindingName, binding);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
for (const element of bindingName.elements) {
|
|
617
|
+
if (ts.isOmittedExpression(element)) {
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (ts.isObjectBindingPattern(bindingName) && !element.dotDotDotToken) {
|
|
621
|
+
const property = element.propertyName ?? element.name;
|
|
622
|
+
const propertyName = propertyNameText(property);
|
|
623
|
+
if (propertyName !== undefined && ts.isIdentifier(element.name)) {
|
|
624
|
+
addBinding(scope, element.name, {
|
|
625
|
+
initializer: binding.initializer,
|
|
626
|
+
destructuredProperty: propertyName,
|
|
627
|
+
mutable: binding.mutable,
|
|
628
|
+
});
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
addBindingName(element.name, scope, { mutable: binding.mutable });
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
const visit = (node: TS.Node): void => {
|
|
636
|
+
if (ts.isVariableDeclaration(node)) {
|
|
637
|
+
const declarationList = ts.isVariableDeclarationList(node.parent) ? node.parent : undefined;
|
|
638
|
+
const isConstBinding =
|
|
639
|
+
declarationList !== undefined && (declarationList.flags & ts.NodeFlags.Const) !== 0;
|
|
640
|
+
const isBlockScoped =
|
|
641
|
+
declarationList !== undefined && (declarationList.flags & ts.NodeFlags.BlockScoped) !== 0;
|
|
642
|
+
addBindingName(node.name, enclosingScope(node, !isBlockScoped), {
|
|
643
|
+
initializer: node.initializer,
|
|
644
|
+
mutable: !isConstBinding,
|
|
645
|
+
});
|
|
646
|
+
} else if (ts.isFunctionDeclaration(node) && node.name !== undefined) {
|
|
647
|
+
addBinding(enclosingScope(node, false), node.name, { mutable: false });
|
|
648
|
+
} else if (ts.isFunctionExpression(node) && node.name !== undefined) {
|
|
649
|
+
addBinding(node, node.name, { mutable: false });
|
|
650
|
+
} else if (ts.isClassDeclaration(node) && node.name !== undefined) {
|
|
651
|
+
addBinding(enclosingScope(node, false), node.name, { mutable: false });
|
|
652
|
+
} else if (ts.isClassExpression(node) && node.name !== undefined) {
|
|
653
|
+
addBinding(node, node.name, { mutable: false });
|
|
654
|
+
} else if (ts.isParameter(node)) {
|
|
655
|
+
addBindingName(node.name, enclosingScope(node, true), { mutable: true });
|
|
656
|
+
} else if (ts.isImportClause(node) && node.name !== undefined) {
|
|
657
|
+
addBinding(sourceFile, node.name, { mutable: false });
|
|
658
|
+
} else if (ts.isNamespaceImport(node) || ts.isImportSpecifier(node)) {
|
|
659
|
+
addBinding(sourceFile, node.name, { mutable: false });
|
|
660
|
+
}
|
|
661
|
+
ts.forEachChild(node, visit);
|
|
662
|
+
};
|
|
663
|
+
visit(sourceFile);
|
|
664
|
+
|
|
665
|
+
const markReassigned = (identifier: TS.Identifier): void => {
|
|
666
|
+
for (const binding of lookupLocalBindings(identifier, bindings) ?? []) {
|
|
667
|
+
binding.reassigned = true;
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
const markAssignmentTarget = (node: TS.Node): void => {
|
|
671
|
+
const target = ts.isExpression(node) ? unwrapLocalExpression(node) : node;
|
|
672
|
+
if (ts.isIdentifier(target)) {
|
|
673
|
+
markReassigned(target);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
if (ts.isObjectLiteralExpression(target)) {
|
|
677
|
+
for (const element of target.properties) {
|
|
678
|
+
if (ts.isSpreadAssignment(element)) {
|
|
679
|
+
markAssignmentTarget(element.expression);
|
|
680
|
+
} else if (ts.isPropertyAssignment(element)) {
|
|
681
|
+
markAssignmentTarget(element.initializer);
|
|
682
|
+
} else if (ts.isShorthandPropertyAssignment(element)) {
|
|
683
|
+
markAssignmentTarget(element.name);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
} else if (ts.isArrayLiteralExpression(target)) {
|
|
687
|
+
for (const element of target.elements) {
|
|
688
|
+
if (!ts.isOmittedExpression(element)) {
|
|
689
|
+
markAssignmentTarget(ts.isSpreadElement(element) ? element.expression : element);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
const visitAssignments = (node: TS.Node): void => {
|
|
695
|
+
if (
|
|
696
|
+
ts.isBinaryExpression(node) &&
|
|
697
|
+
node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
698
|
+
node.operatorToken.kind <= ts.SyntaxKind.LastAssignment
|
|
699
|
+
) {
|
|
700
|
+
markAssignmentTarget(node.left);
|
|
701
|
+
} else if (
|
|
702
|
+
(ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
|
|
703
|
+
(node.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
704
|
+
node.operator === ts.SyntaxKind.MinusMinusToken)
|
|
705
|
+
) {
|
|
706
|
+
markAssignmentTarget(node.operand);
|
|
707
|
+
} else if (
|
|
708
|
+
(ts.isForInStatement(node) || ts.isForOfStatement(node)) &&
|
|
709
|
+
!ts.isVariableDeclarationList(node.initializer)
|
|
710
|
+
) {
|
|
711
|
+
markAssignmentTarget(node.initializer);
|
|
712
|
+
}
|
|
713
|
+
ts.forEachChild(node, visitAssignments);
|
|
714
|
+
};
|
|
715
|
+
visitAssignments(sourceFile);
|
|
716
|
+
return bindings;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function lookupLocalBindings(
|
|
720
|
+
reference: TS.Identifier,
|
|
721
|
+
bindings: LocalBindings,
|
|
722
|
+
): readonly LocalBinding[] | undefined {
|
|
723
|
+
let current: TS.Node | undefined = reference;
|
|
724
|
+
while (current !== undefined) {
|
|
725
|
+
const localBindings = bindings.scopes.get(current)?.get(reference.text);
|
|
726
|
+
if (localBindings !== undefined && localBindings.length > 0) {
|
|
727
|
+
return localBindings;
|
|
728
|
+
}
|
|
729
|
+
current = current.parent;
|
|
730
|
+
}
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function propertyNameText(name: TS.PropertyName | TS.BindingName): string | undefined {
|
|
735
|
+
if (
|
|
736
|
+
ts.isIdentifier(name) ||
|
|
737
|
+
ts.isStringLiteral(name) ||
|
|
738
|
+
ts.isNumericLiteral(name) ||
|
|
739
|
+
ts.isNoSubstitutionTemplateLiteral(name)
|
|
740
|
+
) {
|
|
741
|
+
return name.text;
|
|
742
|
+
}
|
|
743
|
+
if (ts.isComputedPropertyName(name)) {
|
|
744
|
+
const expression = unwrapLocalExpression(name.expression);
|
|
745
|
+
if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
|
|
746
|
+
return expression.text;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return undefined;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function isUnshadowedGlobalObject(reference: TS.Expression, bindings: LocalBindings): boolean {
|
|
753
|
+
const receiver = unwrapLocalExpression(reference);
|
|
754
|
+
return (
|
|
755
|
+
ts.isIdentifier(receiver) &&
|
|
756
|
+
DYNAMIC_CODE_GLOBAL_OBJECTS.has(receiver.text) &&
|
|
757
|
+
lookupLocalBindings(receiver, bindings) === undefined
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function isGlobalMemberSink(
|
|
762
|
+
expression: TS.Expression,
|
|
763
|
+
sinkNames: ReadonlySet<string>,
|
|
764
|
+
bindings: LocalBindings,
|
|
765
|
+
): boolean {
|
|
766
|
+
const callee = unwrapLocalExpression(expression);
|
|
767
|
+
let sinkName: string | undefined;
|
|
768
|
+
let receiver: TS.Expression | undefined;
|
|
769
|
+
if (ts.isPropertyAccessExpression(callee)) {
|
|
770
|
+
sinkName = callee.name.text;
|
|
771
|
+
receiver = callee.expression;
|
|
772
|
+
} else if (ts.isElementAccessExpression(callee)) {
|
|
773
|
+
const argument = callee.argumentExpression;
|
|
774
|
+
if (
|
|
775
|
+
argument !== undefined &&
|
|
776
|
+
(ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
|
|
777
|
+
) {
|
|
778
|
+
sinkName = argument.text;
|
|
779
|
+
receiver = callee.expression;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (sinkName === undefined || receiver === undefined || !sinkNames.has(sinkName)) {
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
return isUnshadowedGlobalObject(receiver, bindings);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function isGlobalSinkReference(
|
|
789
|
+
expression: TS.Expression,
|
|
790
|
+
sinkNames: ReadonlySet<string>,
|
|
791
|
+
bindings: LocalBindings,
|
|
792
|
+
resolving: ReadonlySet<LocalBinding>,
|
|
793
|
+
): boolean {
|
|
794
|
+
const reference = unwrapLocalExpression(expression);
|
|
795
|
+
if (isGlobalMemberSink(reference, sinkNames, bindings)) {
|
|
796
|
+
return true;
|
|
797
|
+
}
|
|
798
|
+
if (!ts.isIdentifier(reference)) {
|
|
799
|
+
return false;
|
|
800
|
+
}
|
|
801
|
+
return isGlobalSinkIdentifier(reference, sinkNames, bindings, resolving);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function isGlobalSinkIdentifier(
|
|
805
|
+
reference: TS.Identifier,
|
|
806
|
+
sinkNames: ReadonlySet<string>,
|
|
807
|
+
bindings: LocalBindings,
|
|
808
|
+
resolving: ReadonlySet<LocalBinding>,
|
|
809
|
+
): boolean {
|
|
810
|
+
const localBindings = lookupLocalBindings(reference, bindings);
|
|
811
|
+
if (localBindings === undefined || localBindings.length === 0) {
|
|
812
|
+
return sinkNames.has(reference.text);
|
|
813
|
+
}
|
|
814
|
+
return localBindings.every((binding) => {
|
|
815
|
+
if (resolving.has(binding) || binding.initializer === undefined) {
|
|
816
|
+
return false;
|
|
817
|
+
}
|
|
818
|
+
// A mutable alias is safe to resolve only when no assignment targeting
|
|
819
|
+
// this lexical binding exists in its scope (including nested closures).
|
|
820
|
+
// Reassigned let/var aliases remain unresolved to avoid false positives.
|
|
821
|
+
if (binding.mutable && binding.reassigned) {
|
|
822
|
+
return false;
|
|
823
|
+
}
|
|
824
|
+
const nextResolving = new Set(resolving);
|
|
825
|
+
nextResolving.add(binding);
|
|
826
|
+
if (binding.destructuredProperty !== undefined) {
|
|
827
|
+
return (
|
|
828
|
+
sinkNames.has(binding.destructuredProperty) &&
|
|
829
|
+
isUnshadowedGlobalObject(binding.initializer, bindings)
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
return isGlobalSinkReference(binding.initializer, sinkNames, bindings, nextResolving);
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function isGlobalSinkCallee(
|
|
837
|
+
expression: TS.Expression,
|
|
838
|
+
sinkNames: ReadonlySet<string>,
|
|
839
|
+
bindings: LocalBindings,
|
|
840
|
+
): boolean {
|
|
841
|
+
const callee = unwrapLocalExpression(expression);
|
|
842
|
+
if (isGlobalMemberSink(callee, sinkNames, bindings)) {
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
if (!ts.isIdentifier(callee)) {
|
|
846
|
+
return false;
|
|
847
|
+
}
|
|
848
|
+
return isGlobalSinkIdentifier(callee, sinkNames, bindings, new Set());
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function resolveConstStringIdentifier(
|
|
852
|
+
reference: TS.Identifier,
|
|
853
|
+
bindings: LocalBindings,
|
|
854
|
+
resolving: ReadonlySet<LocalBinding> = new Set(),
|
|
855
|
+
): string | undefined {
|
|
856
|
+
const localBindings = lookupLocalBindings(reference, bindings);
|
|
857
|
+
if (localBindings === undefined || localBindings.length === 0) {
|
|
858
|
+
return undefined;
|
|
859
|
+
}
|
|
860
|
+
let resolved: string | undefined;
|
|
861
|
+
for (const binding of localBindings) {
|
|
862
|
+
if (
|
|
863
|
+
resolving.has(binding) ||
|
|
864
|
+
binding.mutable ||
|
|
865
|
+
binding.initializer === undefined ||
|
|
866
|
+
binding.destructuredProperty !== undefined
|
|
867
|
+
) {
|
|
868
|
+
return undefined;
|
|
869
|
+
}
|
|
870
|
+
const nextResolving = new Set(resolving);
|
|
871
|
+
nextResolving.add(binding);
|
|
872
|
+
const initializer = unwrapLocalExpression(binding.initializer);
|
|
873
|
+
let value: string | undefined;
|
|
874
|
+
if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) {
|
|
875
|
+
value = initializer.text;
|
|
876
|
+
} else if (ts.isIdentifier(initializer)) {
|
|
877
|
+
value = resolveConstStringIdentifier(initializer, bindings, nextResolving);
|
|
878
|
+
}
|
|
879
|
+
if (value === undefined || (resolved !== undefined && resolved !== value)) {
|
|
880
|
+
return undefined;
|
|
881
|
+
}
|
|
882
|
+
resolved = value;
|
|
883
|
+
}
|
|
884
|
+
return resolved;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function findGlobalSinkCalls(
|
|
888
|
+
providerRoot: string,
|
|
889
|
+
sinkNames: ReadonlySet<string>,
|
|
890
|
+
includeConstructors = false,
|
|
891
|
+
): SourceFinding[] {
|
|
892
|
+
const findings: SourceFinding[] = [];
|
|
893
|
+
const seen = new Set<string>();
|
|
894
|
+
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
895
|
+
const source = readFileSync(filePath, "utf8");
|
|
896
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
897
|
+
const sourceFile = ts.createSourceFile(
|
|
898
|
+
relPath,
|
|
899
|
+
source,
|
|
900
|
+
ts.ScriptTarget.Latest,
|
|
901
|
+
true,
|
|
902
|
+
ts.ScriptKind.TS,
|
|
903
|
+
);
|
|
904
|
+
const bindings = collectLocalBindings(sourceFile);
|
|
905
|
+
const visit = (node: TS.Node): void => {
|
|
906
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (
|
|
910
|
+
(ts.isCallExpression(node) || (includeConstructors && ts.isNewExpression(node))) &&
|
|
911
|
+
isGlobalSinkCallee(node.expression, sinkNames, bindings)
|
|
912
|
+
) {
|
|
913
|
+
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
914
|
+
const key = `${relPath}:${line}`;
|
|
915
|
+
if (!seen.has(key)) {
|
|
916
|
+
seen.add(key);
|
|
917
|
+
findings.push({ file: relPath, line });
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
ts.forEachChild(node, visit);
|
|
921
|
+
};
|
|
922
|
+
visit(sourceFile);
|
|
923
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return findings;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function scoreNoDynamicCode(providerRoot: string): SubmitCheck {
|
|
931
|
+
const findings = findGlobalSinkCalls(providerRoot, DYNAMIC_CODE_SINKS, true);
|
|
932
|
+
if (findings.length > 0) {
|
|
933
|
+
return blocker(
|
|
934
|
+
"no-dynamic-code",
|
|
935
|
+
SDK_NATIVE_CATEGORY,
|
|
936
|
+
"Dynamic code evaluation is not permitted in provider source.",
|
|
937
|
+
"Remove eval() and Function constructor calls. Provider HTTP access must go through ctx.http.",
|
|
938
|
+
0,
|
|
939
|
+
formatSourceFindings(findings),
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return pass(
|
|
944
|
+
"no-dynamic-code",
|
|
945
|
+
SDK_NATIVE_CATEGORY,
|
|
946
|
+
"Provider source avoids dynamic code evaluation.",
|
|
947
|
+
0,
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
|
|
451
951
|
const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
|
|
452
952
|
/\bctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\?\./,
|
|
453
953
|
];
|
|
@@ -503,7 +1003,11 @@ function countAsAssertions(providerRoot: string): {
|
|
|
503
1003
|
|
|
504
1004
|
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
505
1005
|
const content = readFileSync(filePath, "utf8");
|
|
506
|
-
const lines =
|
|
1006
|
+
const lines = maskCommentsAndStrings(
|
|
1007
|
+
content,
|
|
1008
|
+
toRelativeProviderPath(providerRoot, filePath),
|
|
1009
|
+
{ blankPropertyKeys: true },
|
|
1010
|
+
).split(/\r?\n/);
|
|
507
1011
|
for (let index = 0; index < lines.length; index += 1) {
|
|
508
1012
|
const line = lines[index];
|
|
509
1013
|
if (
|
|
@@ -694,18 +1198,44 @@ function offsetToLine(source: string, offset: number): number {
|
|
|
694
1198
|
// between them, so `.passthrough ()` / `.passthrough\n()` are still detected.
|
|
695
1199
|
const PASSTHROUGH_CALL = /\.passthrough\s*\(\s*\)/;
|
|
696
1200
|
|
|
1201
|
+
type ExpressionSlice = {
|
|
1202
|
+
raw: string;
|
|
1203
|
+
masked: string;
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
function trimExpressionSlice(expr: ExpressionSlice): ExpressionSlice {
|
|
1207
|
+
const start = expr.raw.length - expr.raw.trimStart().length;
|
|
1208
|
+
const end = expr.raw.trimEnd().length;
|
|
1209
|
+
return {
|
|
1210
|
+
raw: expr.raw.slice(start, end),
|
|
1211
|
+
masked: expr.masked.slice(start, end),
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function balancedExpressionSlice(
|
|
1216
|
+
source: string,
|
|
1217
|
+
valueStart: number,
|
|
1218
|
+
fileName?: string,
|
|
1219
|
+
): ExpressionSlice {
|
|
1220
|
+
const raw = balancedValueExpression(source, valueStart, fileName);
|
|
1221
|
+
return trimExpressionSlice({
|
|
1222
|
+
raw,
|
|
1223
|
+
masked: maskCommentsAndStrings(source, fileName).slice(valueStart, valueStart + raw.length),
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
|
|
697
1227
|
// Strips redundant wrapping parentheses from an expression so that a value like
|
|
698
1228
|
// `(makeOperations())` or `((x))` classifies the same as `makeOperations()`.
|
|
699
1229
|
// Only unwraps when the leading `(` matches the trailing `)` at depth 0 (i.e.
|
|
700
1230
|
// the whole expression is parenthesized), preserving call expressions such as
|
|
701
1231
|
// `makeOperations()` whose first `(` is not a wrapper.
|
|
702
|
-
function unwrapParens(expr:
|
|
703
|
-
let value = expr
|
|
704
|
-
while (value.startsWith("(")) {
|
|
1232
|
+
function unwrapParens(expr: ExpressionSlice): ExpressionSlice {
|
|
1233
|
+
let value = trimExpressionSlice(expr);
|
|
1234
|
+
while (value.raw.startsWith("(")) {
|
|
705
1235
|
let depth = 0;
|
|
706
1236
|
let matchIndex = -1;
|
|
707
|
-
for (let i = 0; i < value.length; i += 1) {
|
|
708
|
-
const ch = value[i];
|
|
1237
|
+
for (let i = 0; i < value.raw.length; i += 1) {
|
|
1238
|
+
const ch = value.masked[i];
|
|
709
1239
|
if (ch === "(") {
|
|
710
1240
|
depth += 1;
|
|
711
1241
|
} else if (ch === ")") {
|
|
@@ -718,8 +1248,11 @@ function unwrapParens(expr: string): string {
|
|
|
718
1248
|
}
|
|
719
1249
|
// Only a true wrapper spans the entire expression (closing paren is the
|
|
720
1250
|
// last char). Otherwise the leading `(` belongs to a sub-expression.
|
|
721
|
-
if (matchIndex === value.length - 1) {
|
|
722
|
-
value =
|
|
1251
|
+
if (matchIndex === value.raw.length - 1) {
|
|
1252
|
+
value = trimExpressionSlice({
|
|
1253
|
+
raw: value.raw.slice(1, -1),
|
|
1254
|
+
masked: value.masked.slice(1, -1),
|
|
1255
|
+
});
|
|
723
1256
|
} else {
|
|
724
1257
|
break;
|
|
725
1258
|
}
|
|
@@ -731,8 +1264,8 @@ function unwrapParens(expr: string): string {
|
|
|
731
1264
|
// across (){}[] and stopping at the first top-level `,`/`;` or unmatched
|
|
732
1265
|
// closing bracket. This lets a property value be read across newlines, so a
|
|
733
1266
|
// multi-line `input: z.object({...})\n.passthrough()` is captured whole.
|
|
734
|
-
function balancedValueExpression(source: string, valueStart: number): string {
|
|
735
|
-
const masked = maskCommentsAndStrings(source);
|
|
1267
|
+
function balancedValueExpression(source: string, valueStart: number, fileName?: string): string {
|
|
1268
|
+
const masked = maskCommentsAndStrings(source, fileName);
|
|
736
1269
|
let depth = 0;
|
|
737
1270
|
let index = valueStart;
|
|
738
1271
|
for (; index < source.length; index += 1) {
|
|
@@ -756,14 +1289,14 @@ function balancedValueExpression(source: string, valueStart: number): string {
|
|
|
756
1289
|
// nested deeper than the outer object (inside handler bodies, nested objects,
|
|
757
1290
|
// or arrays) are ignored, so only a factory composition of the object itself
|
|
758
1291
|
// is detected. Input is expected to start at the outer `{`.
|
|
759
|
-
function hasTopLevelFactorySpread(expr:
|
|
760
|
-
const open = expr.indexOf("{");
|
|
1292
|
+
function hasTopLevelFactorySpread(expr: ExpressionSlice): boolean {
|
|
1293
|
+
const open = expr.masked.indexOf("{");
|
|
761
1294
|
if (open === -1) {
|
|
762
1295
|
return false;
|
|
763
1296
|
}
|
|
764
1297
|
let depth = 0;
|
|
765
|
-
for (let i = open; i < expr.length; i += 1) {
|
|
766
|
-
const ch = expr[i];
|
|
1298
|
+
for (let i = open; i < expr.raw.length; i += 1) {
|
|
1299
|
+
const ch = expr.masked[i];
|
|
767
1300
|
if (ch === "{" || ch === "(" || ch === "[") {
|
|
768
1301
|
depth += 1;
|
|
769
1302
|
} else if (ch === "}" || ch === ")" || ch === "]") {
|
|
@@ -771,11 +1304,11 @@ function hasTopLevelFactorySpread(expr: string): boolean {
|
|
|
771
1304
|
if (depth === 0) {
|
|
772
1305
|
break;
|
|
773
1306
|
}
|
|
774
|
-
} else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
|
|
1307
|
+
} else if (ch === "." && depth === 1 && expr.masked.startsWith("...", i)) {
|
|
775
1308
|
// A spread at the object's own level. Check whether the spread
|
|
776
1309
|
// argument is a call expression (factory) rather than a plain
|
|
777
1310
|
// identifier/member spread of an already-built object.
|
|
778
|
-
const rest = expr.slice(i + 3);
|
|
1311
|
+
const rest = expr.raw.slice(i + 3);
|
|
779
1312
|
if (/^\s*[A-Za-z_$][\w$.]*\s*\(/.test(rest)) {
|
|
780
1313
|
return true;
|
|
781
1314
|
}
|
|
@@ -790,15 +1323,15 @@ function hasTopLevelFactorySpread(expr: string): boolean {
|
|
|
790
1323
|
// hasTopLevelFactorySpread, so it is excluded here. These identifiers must be
|
|
791
1324
|
// resolved to their declarations: `const hidden = makeOperations()` spread as
|
|
792
1325
|
// `{ ...hidden }` is still a factory-composed map and must block.
|
|
793
|
-
function topLevelSpreadIdentifiers(expr:
|
|
794
|
-
const open = expr.indexOf("{");
|
|
1326
|
+
function topLevelSpreadIdentifiers(expr: ExpressionSlice): string[] {
|
|
1327
|
+
const open = expr.masked.indexOf("{");
|
|
795
1328
|
if (open === -1) {
|
|
796
1329
|
return [];
|
|
797
1330
|
}
|
|
798
1331
|
const names: string[] = [];
|
|
799
1332
|
let depth = 0;
|
|
800
|
-
for (let i = open; i < expr.length; i += 1) {
|
|
801
|
-
const ch = expr[i];
|
|
1333
|
+
for (let i = open; i < expr.raw.length; i += 1) {
|
|
1334
|
+
const ch = expr.masked[i];
|
|
802
1335
|
if (ch === "{" || ch === "(" || ch === "[") {
|
|
803
1336
|
depth += 1;
|
|
804
1337
|
} else if (ch === "}" || ch === ")" || ch === "]") {
|
|
@@ -806,8 +1339,8 @@ function topLevelSpreadIdentifiers(expr: string): string[] {
|
|
|
806
1339
|
if (depth === 0) {
|
|
807
1340
|
break;
|
|
808
1341
|
}
|
|
809
|
-
} else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
|
|
810
|
-
const rest = expr.slice(i + 3);
|
|
1342
|
+
} else if (ch === "." && depth === 1 && expr.masked.startsWith("...", i)) {
|
|
1343
|
+
const rest = expr.raw.slice(i + 3);
|
|
811
1344
|
// Bare identifier spread (no call parens) -> needs declaration
|
|
812
1345
|
// resolution. `...obj.prop` member spreads are treated as already
|
|
813
1346
|
// built and ignored (the leading identifier is captured).
|
|
@@ -840,8 +1373,8 @@ function topLevelSpreadIdentifiers(expr: string): string[] {
|
|
|
840
1373
|
// `makeOperations()` — stays classified as factory composition.
|
|
841
1374
|
const TRANSPARENT_RESHAPE_HEAD = /^Object\s*\.\s*fromEntries\s*\(/;
|
|
842
1375
|
const OBJECT_ENTRIES_HEAD = /^Object\s*\.\s*entries\s*\(/;
|
|
843
|
-
function isTransparentObjectReshape(expr:
|
|
844
|
-
const head = TRANSPARENT_RESHAPE_HEAD.exec(expr);
|
|
1376
|
+
function isTransparentObjectReshape(expr: ExpressionSlice): boolean {
|
|
1377
|
+
const head = TRANSPARENT_RESHAPE_HEAD.exec(expr.masked);
|
|
845
1378
|
if (!head) {
|
|
846
1379
|
return false;
|
|
847
1380
|
}
|
|
@@ -849,7 +1382,7 @@ function isTransparentObjectReshape(expr: string): boolean {
|
|
|
849
1382
|
// transparent only when that argument's root callee is `Object.entries(`
|
|
850
1383
|
// (optionally chained: `Object.entries(obj).filter(...)`), so the source
|
|
851
1384
|
// object is enumerable from source rather than produced by an opaque call.
|
|
852
|
-
const firstArg = expr.slice(head[0].length).trimStart();
|
|
1385
|
+
const firstArg = expr.masked.slice(head[0].length).trimStart();
|
|
853
1386
|
return OBJECT_ENTRIES_HEAD.test(firstArg);
|
|
854
1387
|
}
|
|
855
1388
|
|
|
@@ -983,7 +1516,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
983
1516
|
continue;
|
|
984
1517
|
}
|
|
985
1518
|
const valueStart = match.index + match[0].length;
|
|
986
|
-
const value = balancedValueExpression(source, valueStart);
|
|
1519
|
+
const value = balancedValueExpression(source, valueStart, relPath);
|
|
987
1520
|
if (PASSTHROUGH_CALL.test(value)) {
|
|
988
1521
|
const site: ConstSite = {
|
|
989
1522
|
file: relPath,
|
|
@@ -1012,8 +1545,8 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
1012
1545
|
// that is itself a passthrough expression, or that references a passthrough
|
|
1013
1546
|
// const by name (resolved against the provider-wide map), is a violation.
|
|
1014
1547
|
for (const filePath of files) {
|
|
1015
|
-
const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
|
|
1016
1548
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
1549
|
+
const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
|
|
1017
1550
|
|
|
1018
1551
|
const inputProp = /\binput\s*:\s*/g;
|
|
1019
1552
|
for (let match = inputProp.exec(source); match !== null; match = inputProp.exec(source)) {
|
|
@@ -1024,7 +1557,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
1024
1557
|
continue;
|
|
1025
1558
|
}
|
|
1026
1559
|
const valueStart = match.index + match[0].length;
|
|
1027
|
-
const value = balancedValueExpression(source, valueStart);
|
|
1560
|
+
const value = balancedValueExpression(source, valueStart, relPath);
|
|
1028
1561
|
if (PASSTHROUGH_CALL.test(value)) {
|
|
1029
1562
|
push({ file: relPath, line: offsetToLine(source, valueStart) });
|
|
1030
1563
|
continue;
|
|
@@ -1135,13 +1668,19 @@ function spreadIdentifierResolvesToFactory(
|
|
|
1135
1668
|
if (!existsSync(filePath)) {
|
|
1136
1669
|
continue;
|
|
1137
1670
|
}
|
|
1671
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
1138
1672
|
const fileSource = filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
|
|
1673
|
+
const maskedFileSource = maskCommentsAndStrings(fileSource, relPath, {
|
|
1674
|
+
blankPropertyKeys: true,
|
|
1675
|
+
});
|
|
1139
1676
|
const re = new RegExp(declRe.source, "g");
|
|
1140
|
-
for (let m = re.exec(
|
|
1677
|
+
for (let m = re.exec(maskedFileSource); m !== null; m = re.exec(maskedFileSource)) {
|
|
1141
1678
|
sawDeclaration = true;
|
|
1142
|
-
const expr = unwrapParens(
|
|
1679
|
+
const expr = unwrapParens(
|
|
1680
|
+
balancedExpressionSlice(fileSource, m.index + m[0].length, relPath),
|
|
1681
|
+
);
|
|
1143
1682
|
const isFactory =
|
|
1144
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
|
|
1683
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr.masked) || hasTopLevelFactorySpread(expr)) &&
|
|
1145
1684
|
!isTransparentObjectReshape(expr);
|
|
1146
1685
|
if (isFactory) {
|
|
1147
1686
|
return true;
|
|
@@ -1150,111 +1689,353 @@ function spreadIdentifierResolvesToFactory(
|
|
|
1150
1689
|
}
|
|
1151
1690
|
// No local declaration anywhere but imported into index.ts => constructed
|
|
1152
1691
|
// out of view; treat as factory (conservative, false-negative-safe).
|
|
1153
|
-
if (
|
|
1692
|
+
if (
|
|
1693
|
+
!sawDeclaration &&
|
|
1694
|
+
fileImportsBinding(
|
|
1695
|
+
maskCommentsAndStrings(indexSource, "index.ts", { blankPropertyKeys: true }),
|
|
1696
|
+
name,
|
|
1697
|
+
)
|
|
1698
|
+
) {
|
|
1154
1699
|
return true;
|
|
1155
1700
|
}
|
|
1156
1701
|
return false;
|
|
1157
1702
|
}
|
|
1158
1703
|
|
|
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
|
-
}
|
|
1704
|
+
function isDefineProviderCall(node: TS.CallExpression): boolean {
|
|
1705
|
+
return ts.isIdentifier(node.expression) && node.expression.text === "defineProvider";
|
|
1706
|
+
}
|
|
1170
1707
|
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
ruleId,
|
|
1178
|
-
SDK_NATIVE_CATEGORY,
|
|
1179
|
-
"No defineProvider call to evaluate for operation composition.",
|
|
1180
|
-
0,
|
|
1181
|
-
);
|
|
1708
|
+
function resolveDefineProviderImplementationCall(
|
|
1709
|
+
candidate: TS.CallExpression,
|
|
1710
|
+
): TS.CallExpression | undefined {
|
|
1711
|
+
let root = candidate;
|
|
1712
|
+
while (ts.isCallExpression(root.expression)) {
|
|
1713
|
+
root = root.expression;
|
|
1182
1714
|
}
|
|
1715
|
+
return isDefineProviderCall(root) ? candidate : undefined;
|
|
1716
|
+
}
|
|
1183
1717
|
|
|
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
|
-
}
|
|
1718
|
+
function findProviderImplementationCall(sourceFile: TS.SourceFile): TS.CallExpression | undefined {
|
|
1719
|
+
const defaultExports = sourceFile.statements.filter(
|
|
1720
|
+
(statement): statement is TS.ExportAssignment =>
|
|
1721
|
+
ts.isExportAssignment(statement) && !statement.isExportEquals,
|
|
1722
|
+
);
|
|
1723
|
+
|
|
1724
|
+
// Phase-separated providers export a builder invocation directly. Preserve
|
|
1725
|
+
// the previous locator's preference for that call over every defineProvider
|
|
1726
|
+
// declaration that may also appear in the file.
|
|
1727
|
+
for (const assignment of defaultExports) {
|
|
1728
|
+
const expression = unwrapImplementationExpression(assignment.expression);
|
|
1729
|
+
if (!ts.isCallExpression(expression)) {
|
|
1730
|
+
continue;
|
|
1205
1731
|
}
|
|
1206
|
-
if (
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1732
|
+
if (resolveDefineProviderImplementationCall(expression) !== undefined) {
|
|
1733
|
+
continue;
|
|
1734
|
+
}
|
|
1735
|
+
if (ts.isIdentifier(expression.expression)) {
|
|
1736
|
+
return expression;
|
|
1211
1737
|
}
|
|
1212
1738
|
}
|
|
1213
|
-
if (defineParenIndex === -1) {
|
|
1214
|
-
return pass(
|
|
1215
|
-
ruleId,
|
|
1216
|
-
SDK_NATIVE_CATEGORY,
|
|
1217
|
-
"No defineProvider call to evaluate for operation composition.",
|
|
1218
|
-
0,
|
|
1219
|
-
);
|
|
1220
|
-
}
|
|
1221
|
-
const argStart = defineParenIndex + 1;
|
|
1222
|
-
const argText = balancedValueExpression(source, argStart);
|
|
1223
1739
|
|
|
1224
|
-
//
|
|
1225
|
-
//
|
|
1226
|
-
//
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
const
|
|
1233
|
-
|
|
1234
|
-
|
|
1740
|
+
// For `defineProvider(metadata)(implementation)`, select the outer call whose
|
|
1741
|
+
// argument is the implementation. A plain `defineProvider(implementation)`
|
|
1742
|
+
// selects the single call itself.
|
|
1743
|
+
for (const assignment of defaultExports) {
|
|
1744
|
+
const defaultExpression = unwrapImplementationExpression(assignment.expression);
|
|
1745
|
+
if (!ts.isCallExpression(defaultExpression)) {
|
|
1746
|
+
continue;
|
|
1747
|
+
}
|
|
1748
|
+
const implementationCall = resolveDefineProviderImplementationCall(defaultExpression);
|
|
1749
|
+
if (implementationCall !== undefined) {
|
|
1750
|
+
return implementationCall;
|
|
1751
|
+
}
|
|
1235
1752
|
}
|
|
1236
1753
|
|
|
1237
|
-
//
|
|
1238
|
-
//
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1754
|
+
// Resolve `export default provider` to a top-level variable initialized by
|
|
1755
|
+
// defineProvider, including its curried implementation call when present.
|
|
1756
|
+
for (const assignment of defaultExports) {
|
|
1757
|
+
if (!ts.isIdentifier(assignment.expression)) {
|
|
1758
|
+
continue;
|
|
1759
|
+
}
|
|
1760
|
+
const exportedName = assignment.expression.text;
|
|
1761
|
+
for (const statement of sourceFile.statements) {
|
|
1762
|
+
if (!ts.isVariableStatement(statement)) {
|
|
1763
|
+
continue;
|
|
1764
|
+
}
|
|
1765
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1766
|
+
if (
|
|
1767
|
+
!ts.isIdentifier(declaration.name) ||
|
|
1768
|
+
declaration.name.text !== exportedName ||
|
|
1769
|
+
declaration.initializer === undefined
|
|
1770
|
+
) {
|
|
1771
|
+
continue;
|
|
1772
|
+
}
|
|
1773
|
+
if (!ts.isCallExpression(declaration.initializer)) {
|
|
1774
|
+
continue;
|
|
1775
|
+
}
|
|
1776
|
+
const implementationCall = resolveDefineProviderImplementationCall(declaration.initializer);
|
|
1777
|
+
if (implementationCall !== undefined) {
|
|
1778
|
+
return implementationCall;
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1243
1781
|
}
|
|
1244
|
-
} else if (/^[A-Za-z_$][\w$]*$/.test(opsValue)) {
|
|
1245
|
-
// `operations: ops` — a bare identifier alias to resolve.
|
|
1246
|
-
aliasName = opsValue;
|
|
1247
1782
|
}
|
|
1248
1783
|
|
|
1249
|
-
//
|
|
1250
|
-
//
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1784
|
+
// Structural fixtures without a recognizable default export retain the
|
|
1785
|
+
// previous fallback to the first defineProvider call in source order.
|
|
1786
|
+
let firstCall: TS.CallExpression | undefined;
|
|
1787
|
+
const visit = (node: TS.Node): void => {
|
|
1788
|
+
if (firstCall !== undefined) {
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
if (ts.isCallExpression(node)) {
|
|
1792
|
+
const implementationCall = resolveDefineProviderImplementationCall(node);
|
|
1793
|
+
if (implementationCall !== undefined) {
|
|
1794
|
+
firstCall = implementationCall;
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
ts.forEachChild(node, visit);
|
|
1799
|
+
};
|
|
1800
|
+
visit(sourceFile);
|
|
1801
|
+
return firstCall;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
function unwrapImplementationExpression(expression: TS.Expression): TS.Expression {
|
|
1805
|
+
let current = expression;
|
|
1806
|
+
while (
|
|
1807
|
+
ts.isSatisfiesExpression(current) ||
|
|
1808
|
+
ts.isAsExpression(current) ||
|
|
1809
|
+
ts.isParenthesizedExpression(current) ||
|
|
1810
|
+
ts.isNonNullExpression(current)
|
|
1811
|
+
) {
|
|
1812
|
+
current = current.expression;
|
|
1813
|
+
}
|
|
1814
|
+
return current;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
function isOperationsProperty(
|
|
1818
|
+
property: TS.ObjectLiteralElementLike,
|
|
1819
|
+
): property is TS.PropertyAssignment | TS.ShorthandPropertyAssignment {
|
|
1820
|
+
if (ts.isShorthandPropertyAssignment(property)) {
|
|
1821
|
+
return property.name.text === "operations";
|
|
1822
|
+
}
|
|
1823
|
+
if (!ts.isPropertyAssignment(property)) {
|
|
1824
|
+
return false;
|
|
1825
|
+
}
|
|
1826
|
+
return (
|
|
1827
|
+
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
1828
|
+
property.name.text === "operations"
|
|
1829
|
+
);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function isOperationsAccessorOrMethod(
|
|
1833
|
+
property: TS.ObjectLiteralElementLike,
|
|
1834
|
+
): property is TS.GetAccessorDeclaration | TS.SetAccessorDeclaration | TS.MethodDeclaration {
|
|
1835
|
+
if (
|
|
1836
|
+
!ts.isGetAccessorDeclaration(property) &&
|
|
1837
|
+
!ts.isSetAccessorDeclaration(property) &&
|
|
1838
|
+
!ts.isMethodDeclaration(property)
|
|
1839
|
+
) {
|
|
1840
|
+
return false;
|
|
1841
|
+
}
|
|
1842
|
+
return (
|
|
1843
|
+
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
1844
|
+
property.name.text === "operations"
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
function isSourceEnumerableStaticObjectLiteral(expression: TS.Expression): boolean {
|
|
1849
|
+
const unwrapped = unwrapImplementationExpression(expression);
|
|
1850
|
+
if (!ts.isObjectLiteralExpression(unwrapped)) {
|
|
1851
|
+
return false;
|
|
1852
|
+
}
|
|
1853
|
+
return unwrapped.properties.every((property) => {
|
|
1854
|
+
if (ts.isSpreadAssignment(property)) {
|
|
1855
|
+
return isSourceEnumerableStaticObjectLiteral(property.expression);
|
|
1856
|
+
}
|
|
1857
|
+
if (property.name === undefined || ts.isComputedPropertyName(property.name)) {
|
|
1858
|
+
return property.name === undefined;
|
|
1859
|
+
}
|
|
1860
|
+
return property.name.text !== "operations" || isOperationsProperty(property);
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
function findComputedImplementationProperty(
|
|
1865
|
+
objectLiteral: TS.ObjectLiteralExpression,
|
|
1866
|
+
): TS.ObjectLiteralElementLike | undefined {
|
|
1867
|
+
for (const property of objectLiteral.properties) {
|
|
1868
|
+
if (property.name !== undefined && ts.isComputedPropertyName(property.name)) {
|
|
1869
|
+
return property;
|
|
1870
|
+
}
|
|
1871
|
+
if (ts.isSpreadAssignment(property)) {
|
|
1872
|
+
const spreadExpression = unwrapImplementationExpression(property.expression);
|
|
1873
|
+
if (ts.isObjectLiteralExpression(spreadExpression)) {
|
|
1874
|
+
const nested = findComputedImplementationProperty(spreadExpression);
|
|
1875
|
+
if (nested !== undefined) {
|
|
1876
|
+
return nested;
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
return undefined;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
function findEffectiveOperationsProperty(
|
|
1885
|
+
objectLiteral: TS.ObjectLiteralExpression,
|
|
1886
|
+
):
|
|
1887
|
+
| TS.PropertyAssignment
|
|
1888
|
+
| TS.ShorthandPropertyAssignment
|
|
1889
|
+
| TS.GetAccessorDeclaration
|
|
1890
|
+
| TS.SetAccessorDeclaration
|
|
1891
|
+
| TS.MethodDeclaration
|
|
1892
|
+
| undefined {
|
|
1893
|
+
let effective:
|
|
1894
|
+
| TS.PropertyAssignment
|
|
1895
|
+
| TS.ShorthandPropertyAssignment
|
|
1896
|
+
| TS.GetAccessorDeclaration
|
|
1897
|
+
| TS.SetAccessorDeclaration
|
|
1898
|
+
| TS.MethodDeclaration
|
|
1899
|
+
| undefined;
|
|
1900
|
+
for (const property of objectLiteral.properties) {
|
|
1901
|
+
if (isOperationsProperty(property) || isOperationsAccessorOrMethod(property)) {
|
|
1902
|
+
effective = property;
|
|
1903
|
+
continue;
|
|
1904
|
+
}
|
|
1905
|
+
if (ts.isSpreadAssignment(property)) {
|
|
1906
|
+
const spreadExpression = unwrapImplementationExpression(property.expression);
|
|
1907
|
+
if (ts.isObjectLiteralExpression(spreadExpression)) {
|
|
1908
|
+
const nested = findEffectiveOperationsProperty(spreadExpression);
|
|
1909
|
+
if (nested !== undefined) {
|
|
1910
|
+
effective = nested;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
return effective;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
1919
|
+
const indexPath = resolve(providerRoot, "index.ts");
|
|
1920
|
+
const ruleId = "flat-operation-composition";
|
|
1921
|
+
if (!existsSync(indexPath)) {
|
|
1922
|
+
return pass(
|
|
1923
|
+
ruleId,
|
|
1924
|
+
SDK_NATIVE_CATEGORY,
|
|
1925
|
+
"Provider index.ts not found; flat-operation check skipped.",
|
|
1926
|
+
0,
|
|
1927
|
+
);
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
const source = readFileSync(indexPath, "utf8");
|
|
1931
|
+
const indexRelPath = toRelativeProviderPath(providerRoot, indexPath);
|
|
1932
|
+
// The report preflight already rejects unparseable index.ts sources before
|
|
1933
|
+
// this rule runs, so this local AST parse only replaces property location.
|
|
1934
|
+
const sourceFile = ts.createSourceFile(
|
|
1935
|
+
indexRelPath,
|
|
1936
|
+
source,
|
|
1937
|
+
ts.ScriptTarget.Latest,
|
|
1938
|
+
true,
|
|
1939
|
+
ts.ScriptKind.TS,
|
|
1940
|
+
);
|
|
1941
|
+
const implementationCall = findProviderImplementationCall(sourceFile);
|
|
1942
|
+
const rawImplementationArg = implementationCall?.arguments[0];
|
|
1943
|
+
const implementationArg =
|
|
1944
|
+
rawImplementationArg === undefined
|
|
1945
|
+
? undefined
|
|
1946
|
+
: unwrapImplementationExpression(rawImplementationArg);
|
|
1947
|
+
if (implementationArg === undefined || !ts.isObjectLiteralExpression(implementationArg)) {
|
|
1948
|
+
return pass(
|
|
1949
|
+
ruleId,
|
|
1950
|
+
SDK_NATIVE_CATEGORY,
|
|
1951
|
+
"No defineProvider call to evaluate for operation composition.",
|
|
1952
|
+
0,
|
|
1953
|
+
);
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
// Resolve the top-level operations property from the AST, then feed its raw
|
|
1957
|
+
// initializer offset into the existing expression classifier and alias
|
|
1958
|
+
// resolver unchanged.
|
|
1959
|
+
const opsProp = findEffectiveOperationsProperty(implementationArg);
|
|
1960
|
+
let opsValue: ExpressionSlice | undefined;
|
|
1961
|
+
let opsLine = 1;
|
|
1962
|
+
if (opsProp !== undefined && ts.isPropertyAssignment(opsProp)) {
|
|
1963
|
+
const valueStart = opsProp.initializer.getStart(sourceFile);
|
|
1964
|
+
opsValue = unwrapParens(balancedExpressionSlice(source, valueStart, indexRelPath));
|
|
1965
|
+
opsLine = offsetToLine(source, valueStart);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
// Property shorthand: `buildProvider({ operations })` — resolve the
|
|
1969
|
+
// local `operations` const initializer.
|
|
1970
|
+
let aliasName: string | undefined;
|
|
1971
|
+
if (opsProp !== undefined && ts.isShorthandPropertyAssignment(opsProp)) {
|
|
1972
|
+
aliasName = "operations";
|
|
1973
|
+
} else if (opsValue !== undefined && /^[A-Za-z_$][\w$]*$/.test(opsValue.masked)) {
|
|
1974
|
+
// `operations: ops` — a bare identifier alias to resolve.
|
|
1975
|
+
aliasName = opsValue.raw;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
// A computed property might resolve to `operations`, but its composition is
|
|
1979
|
+
// not source-enumerable here. Fail closed, like an unresolved imported alias.
|
|
1980
|
+
const computed = findComputedImplementationProperty(implementationArg);
|
|
1981
|
+
if (computed !== undefined) {
|
|
1982
|
+
const computedLine = offsetToLine(source, computed.getStart(sourceFile));
|
|
1983
|
+
return blocker(
|
|
1984
|
+
ruleId,
|
|
1985
|
+
SDK_NATIVE_CATEGORY,
|
|
1986
|
+
"The defineProvider operations property uses a computed property name that cannot be statically resolved.",
|
|
1987
|
+
"Declare operations with the literal property name `operations` and a static object literal value so the provider-registry AST gate can enumerate it.",
|
|
1988
|
+
0,
|
|
1989
|
+
[`${indexRelPath}:${computedLine} (computed property name)`],
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
if (opsProp !== undefined && isOperationsAccessorOrMethod(opsProp)) {
|
|
1994
|
+
const memberLine = offsetToLine(source, opsProp.getStart(sourceFile));
|
|
1995
|
+
return blocker(
|
|
1996
|
+
ruleId,
|
|
1997
|
+
SDK_NATIVE_CATEGORY,
|
|
1998
|
+
"The defineProvider operations member is an accessor/method whose value is not statically enumerable.",
|
|
1999
|
+
"Declare operations as a property assignment with a static object literal value so the provider-registry AST gate can enumerate it.",
|
|
2000
|
+
0,
|
|
2001
|
+
[`${indexRelPath}:${memberLine} (operations accessor/method)`],
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// A spread can override a preceding literal `operations` property. Only an
|
|
2006
|
+
// inline static object literal (including recursively inline static spreads)
|
|
2007
|
+
// exposes all of its property names to this source-only gate. Identifiers,
|
|
2008
|
+
// calls, and other expressions are opaque and therefore fail closed.
|
|
2009
|
+
const opaqueSpread = implementationArg.properties.find(
|
|
2010
|
+
(property): property is TS.SpreadAssignment =>
|
|
2011
|
+
ts.isSpreadAssignment(property) &&
|
|
2012
|
+
!isSourceEnumerableStaticObjectLiteral(property.expression),
|
|
2013
|
+
);
|
|
2014
|
+
if (opaqueSpread !== undefined) {
|
|
2015
|
+
const spreadLine = offsetToLine(source, opaqueSpread.getStart(sourceFile));
|
|
2016
|
+
return escapeHatchResult(
|
|
2017
|
+
providerRoot,
|
|
2018
|
+
ruleId,
|
|
2019
|
+
[{ file: indexRelPath, line: spreadLine }],
|
|
2020
|
+
{
|
|
2021
|
+
blockerMessage:
|
|
2022
|
+
"The defineProvider implementation uses a non-enumerable spread that could override operations.",
|
|
2023
|
+
remediation:
|
|
2024
|
+
"Declare operations directly with a static object literal. Implementation spreads must be inline static object literals so the provider-registry AST gate can enumerate every property name.",
|
|
2025
|
+
passMessage: "defineProvider declares operations as a static object literal.",
|
|
2026
|
+
},
|
|
2027
|
+
);
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
// Determine the effective initializer expression to classify. The alias may
|
|
2031
|
+
// be declared in index.ts OR re-exported from a sibling module (the common
|
|
2032
|
+
// generated scaffold: `import { operations } from "./operations"` where
|
|
2033
|
+
// ./operations.ts builds the map with makeOperations()/Object.fromEntries).
|
|
2034
|
+
// Resolve across every provider source file so cross-module factory
|
|
2035
|
+
// composition cannot evade the blocker.
|
|
2036
|
+
let effective = opsValue;
|
|
2037
|
+
let effectiveLine = opsLine;
|
|
2038
|
+
let effectiveFile = "index.ts";
|
|
1258
2039
|
if (aliasName !== undefined) {
|
|
1259
2040
|
const aliasDecl = new RegExp(
|
|
1260
2041
|
`(?:^|\\n)[ \t]*(?:export\\s+)?(?:const|let|var)\\s+${aliasName}\\s*(?::[^=\\n]+)?\\s*=`,
|
|
@@ -1277,7 +2058,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1277
2058
|
// do not resolve the exact import target path; "any same-named factory
|
|
1278
2059
|
// blocks" is the conservative, false-negative-avoiding choice for a gate.)
|
|
1279
2060
|
type Candidate = {
|
|
1280
|
-
expr:
|
|
2061
|
+
expr: ExpressionSlice;
|
|
1281
2062
|
line: number;
|
|
1282
2063
|
file: string;
|
|
1283
2064
|
isFactory: boolean;
|
|
@@ -1287,15 +2068,22 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1287
2068
|
if (!existsSync(filePath)) {
|
|
1288
2069
|
continue;
|
|
1289
2070
|
}
|
|
1290
|
-
const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
|
|
1291
2071
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
2072
|
+
const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
|
|
2073
|
+
const maskedFileSource = maskCommentsAndStrings(fileSource, relPath, {
|
|
2074
|
+
blankPropertyKeys: true,
|
|
2075
|
+
});
|
|
1292
2076
|
|
|
1293
2077
|
const declRe = new RegExp(aliasDecl.source, "g");
|
|
1294
|
-
for (
|
|
2078
|
+
for (
|
|
2079
|
+
let m = declRe.exec(maskedFileSource);
|
|
2080
|
+
m !== null;
|
|
2081
|
+
m = declRe.exec(maskedFileSource)
|
|
2082
|
+
) {
|
|
1295
2083
|
const valueStart = m.index + m[0].length;
|
|
1296
|
-
const expr = unwrapParens(
|
|
2084
|
+
const expr = unwrapParens(balancedExpressionSlice(fileSource, valueStart, relPath));
|
|
1297
2085
|
const isFactory =
|
|
1298
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
|
|
2086
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr.masked) || hasTopLevelFactorySpread(expr)) &&
|
|
1299
2087
|
!isTransparentObjectReshape(expr);
|
|
1300
2088
|
candidates.push({
|
|
1301
2089
|
expr,
|
|
@@ -1305,9 +2093,14 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1305
2093
|
});
|
|
1306
2094
|
}
|
|
1307
2095
|
const destructRe = new RegExp(destructured.source, "g");
|
|
1308
|
-
for (
|
|
2096
|
+
for (
|
|
2097
|
+
let m = destructRe.exec(maskedFileSource);
|
|
2098
|
+
m !== null;
|
|
2099
|
+
m = destructRe.exec(maskedFileSource)
|
|
2100
|
+
) {
|
|
2101
|
+
const raw = `${m[1]}(`;
|
|
1309
2102
|
candidates.push({
|
|
1310
|
-
expr:
|
|
2103
|
+
expr: { raw, masked: raw },
|
|
1311
2104
|
line: offsetToLine(fileSource, m.index),
|
|
1312
2105
|
file: relPath,
|
|
1313
2106
|
isFactory: true,
|
|
@@ -1333,11 +2126,15 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1333
2126
|
// the unresolved import as a factory-composed (non-static) shape rather
|
|
1334
2127
|
// than silently passing.
|
|
1335
2128
|
if (!resolved) {
|
|
1336
|
-
const
|
|
1337
|
-
|
|
1338
|
-
);
|
|
2129
|
+
const maskedIndexSource = maskCommentsAndStrings(source, indexRelPath, {
|
|
2130
|
+
blankPropertyKeys: true,
|
|
2131
|
+
});
|
|
2132
|
+
const importMatch = new RegExp(
|
|
2133
|
+
`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`,
|
|
2134
|
+
).exec(maskedIndexSource);
|
|
1339
2135
|
if (importMatch) {
|
|
1340
|
-
|
|
2136
|
+
const raw = `${aliasName}(`;
|
|
2137
|
+
effective = { raw, masked: raw };
|
|
1341
2138
|
effectiveLine = offsetToLine(source, importMatch.index);
|
|
1342
2139
|
effectiveFile = "index.ts";
|
|
1343
2140
|
}
|
|
@@ -1361,14 +2158,16 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1361
2158
|
spreadIdentifierResolvesToFactory(providerRoot, indexPath, source, name),
|
|
1362
2159
|
);
|
|
1363
2160
|
const isStaticLiteral =
|
|
1364
|
-
effective?.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
|
|
2161
|
+
effective?.masked.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
|
|
1365
2162
|
// A call expression `ident(...)` (factory) or a factory-spread literal is
|
|
1366
2163
|
// the rejected, non-static shape — UNLESS it is the stdlib
|
|
1367
2164
|
// `Object.fromEntries(Object.entries(<source-visible obj>)...)` reshape,
|
|
1368
2165
|
// whose op set is still enumerable from source (verified golden pattern).
|
|
1369
2166
|
const isFactoryCall =
|
|
1370
2167
|
effective !== undefined &&
|
|
1371
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) ||
|
|
2168
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(effective.masked) ||
|
|
2169
|
+
hasFactorySpread ||
|
|
2170
|
+
hasFactorySpreadIdentifier) &&
|
|
1372
2171
|
!isTransparentObjectReshape(effective);
|
|
1373
2172
|
|
|
1374
2173
|
if (isFactoryCall && !isStaticLiteral) {
|
|
@@ -1380,7 +2179,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1380
2179
|
blockerMessage:
|
|
1381
2180
|
"defineProvider operations are composed by a factory call instead of a static object literal.",
|
|
1382
2181
|
remediation:
|
|
1383
|
-
"Declare operations as a static literal
|
|
2182
|
+
"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
2183
|
passMessage: "defineProvider declares operations as a static object literal.",
|
|
1385
2184
|
});
|
|
1386
2185
|
}
|
|
@@ -1394,7 +2193,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1394
2193
|
}
|
|
1395
2194
|
|
|
1396
2195
|
function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
|
|
1397
|
-
const credentialReferences =
|
|
2196
|
+
const credentialReferences = findCredentialReferences(providerRoot);
|
|
1398
2197
|
const authMode = provider.auth?.mode ?? "none";
|
|
1399
2198
|
const credentialKeys = provider.credential?.keys ?? [];
|
|
1400
2199
|
const storesProviderCredential = authMode !== "none" || credentialKeys.length > 0;
|
|
@@ -1424,26 +2223,291 @@ function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition
|
|
|
1424
2223
|
);
|
|
1425
2224
|
}
|
|
1426
2225
|
|
|
2226
|
+
function isDirectCredentialReference(expression: TS.Expression): boolean {
|
|
2227
|
+
const reference = unwrapLocalExpression(expression);
|
|
2228
|
+
let receiver: TS.Expression | undefined;
|
|
2229
|
+
let memberName: string | undefined;
|
|
2230
|
+
if (ts.isPropertyAccessExpression(reference)) {
|
|
2231
|
+
receiver = reference.expression;
|
|
2232
|
+
memberName = reference.name.text;
|
|
2233
|
+
} else if (ts.isElementAccessExpression(reference)) {
|
|
2234
|
+
receiver = reference.expression;
|
|
2235
|
+
const argument = reference.argumentExpression;
|
|
2236
|
+
if (
|
|
2237
|
+
argument !== undefined &&
|
|
2238
|
+
(ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
|
|
2239
|
+
) {
|
|
2240
|
+
memberName = argument.text;
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
const unwrappedReceiver = receiver === undefined ? undefined : unwrapLocalExpression(receiver);
|
|
2244
|
+
return (
|
|
2245
|
+
memberName === "credential" &&
|
|
2246
|
+
unwrappedReceiver !== undefined &&
|
|
2247
|
+
ts.isIdentifier(unwrappedReceiver) &&
|
|
2248
|
+
unwrappedReceiver.text === "ctx"
|
|
2249
|
+
);
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
function isCredentialReference(
|
|
2253
|
+
expression: TS.Expression,
|
|
2254
|
+
bindings: LocalBindings,
|
|
2255
|
+
resolving: ReadonlySet<LocalBinding> = new Set(),
|
|
2256
|
+
): boolean {
|
|
2257
|
+
const reference = unwrapLocalExpression(expression);
|
|
2258
|
+
if (isDirectCredentialReference(reference)) {
|
|
2259
|
+
return true;
|
|
2260
|
+
}
|
|
2261
|
+
if (!ts.isIdentifier(reference)) {
|
|
2262
|
+
return false;
|
|
2263
|
+
}
|
|
2264
|
+
const localBindings = lookupLocalBindings(reference, bindings);
|
|
2265
|
+
if (localBindings === undefined || localBindings.length === 0) {
|
|
2266
|
+
return false;
|
|
2267
|
+
}
|
|
2268
|
+
return localBindings.every((binding) => {
|
|
2269
|
+
if (resolving.has(binding) || binding.mutable || binding.initializer === undefined) {
|
|
2270
|
+
return false;
|
|
2271
|
+
}
|
|
2272
|
+
const nextResolving = new Set(resolving);
|
|
2273
|
+
nextResolving.add(binding);
|
|
2274
|
+
if (binding.destructuredProperty !== undefined) {
|
|
2275
|
+
const source = unwrapLocalExpression(binding.initializer);
|
|
2276
|
+
return (
|
|
2277
|
+
binding.destructuredProperty === "credential" &&
|
|
2278
|
+
ts.isIdentifier(source) &&
|
|
2279
|
+
source.text === "ctx"
|
|
2280
|
+
);
|
|
2281
|
+
}
|
|
2282
|
+
return isCredentialReference(binding.initializer, bindings, nextResolving);
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
function findCredentialReferences(providerRoot: string): SourceFinding[] {
|
|
2287
|
+
const findings: SourceFinding[] = [];
|
|
2288
|
+
const seen = new Set<string>();
|
|
2289
|
+
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
2290
|
+
const source = readFileSync(filePath, "utf8");
|
|
2291
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
2292
|
+
const sourceFile = ts.createSourceFile(
|
|
2293
|
+
relPath,
|
|
2294
|
+
source,
|
|
2295
|
+
ts.ScriptTarget.Latest,
|
|
2296
|
+
true,
|
|
2297
|
+
ts.ScriptKind.TS,
|
|
2298
|
+
);
|
|
2299
|
+
const bindings = collectLocalBindings(sourceFile);
|
|
2300
|
+
const visit = (node: TS.Node): void => {
|
|
2301
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
if (
|
|
2305
|
+
ts.isExpression(node) &&
|
|
2306
|
+
(isDirectCredentialReference(node) ||
|
|
2307
|
+
((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) &&
|
|
2308
|
+
isCredentialReference(node.expression, bindings)))
|
|
2309
|
+
) {
|
|
2310
|
+
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
2311
|
+
const key = `${relPath}:${line}`;
|
|
2312
|
+
if (!seen.has(key)) {
|
|
2313
|
+
seen.add(key);
|
|
2314
|
+
findings.push({ file: relPath, line });
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
ts.forEachChild(node, visit);
|
|
2318
|
+
};
|
|
2319
|
+
visit(sourceFile);
|
|
2320
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
2321
|
+
break;
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
return findings;
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
// ---------------------------------------------------------------------------
|
|
2328
|
+
// sdk-owned-secret-presence (warn): provider-local double validation of
|
|
2329
|
+
// declared env secrets.
|
|
2330
|
+
//
|
|
2331
|
+
// The SDK runtime is the single source of truth for secret presence: declared
|
|
2332
|
+
// `required: true` secrets are validated before every handler/auth-flow
|
|
2333
|
+
// invocation and fail with the canonical structured MISSING_SECRET error
|
|
2334
|
+
// (HTTP 400, category credential_unavailable). Provider-local presence guards
|
|
2335
|
+
// (requireServiceKey/requireApiKey style) are dead weight that historically
|
|
2336
|
+
// diverged into inconsistent shapes (CONFIGURATION_ERROR vs MISSING_SECRET,
|
|
2337
|
+
// with/without category), which broke uniform incident attribution when nine
|
|
2338
|
+
// providers shipped with unprovisioned secrets (2026-07-22).
|
|
2339
|
+
//
|
|
2340
|
+
// Heuristic, warn-only: a line reading a declared `required: true` secret via
|
|
2341
|
+
// `.env.get(...)` (string literal or a const alias of a declared name)
|
|
2342
|
+
// followed within a small window by a falsy presence check plus a `throw`.
|
|
2343
|
+
// The rule flags duplication of the SDK gate ONLY: env names that are not
|
|
2344
|
+
// declared in defineProvider secrets[], and optional declarations
|
|
2345
|
+
// (`required: false`/omitted) that the runtime deliberately does not enforce,
|
|
2346
|
+
// are out of scope. Escape hatch:
|
|
2347
|
+
// `// @apifuse-allow sdk-owned-secret-presence: <reason>`.
|
|
2348
|
+
// ---------------------------------------------------------------------------
|
|
2349
|
+
|
|
2350
|
+
const SDK_OWNED_SECRET_PRESENCE_RULE_ID = "sdk-owned-secret-presence";
|
|
2351
|
+
const SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES = 10;
|
|
2352
|
+
|
|
2353
|
+
const ENV_GET_CALL_PATTERN =
|
|
2354
|
+
/\.env\.get\(\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`|([A-Za-z_$][\w$]*))\s*\)/;
|
|
2355
|
+
|
|
2356
|
+
const SECRET_ALIAS_CONST_PATTERN =
|
|
2357
|
+
/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`)/g;
|
|
2358
|
+
|
|
2359
|
+
const ENV_GET_ASSIGNMENT_PATTERN = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=[^;]*\.env\.get\(/;
|
|
2360
|
+
|
|
2361
|
+
// Const aliases of declared secret names (e.g. `const SERVICE_KEY_ENV =
|
|
2362
|
+
// "APIFUSE__PROVIDER__X__SERVICE_KEY"`) so aliased `.env.get(SERVICE_KEY_ENV)`
|
|
2363
|
+
// guards are detected, not just direct string literals.
|
|
2364
|
+
function buildDeclaredSecretAliasMap(
|
|
2365
|
+
providerRoot: string,
|
|
2366
|
+
declaredNames: ReadonlySet<string>,
|
|
2367
|
+
): Map<string, string> {
|
|
2368
|
+
const aliases = new Map<string, string>();
|
|
2369
|
+
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
2370
|
+
const content = readFileSync(filePath, "utf8");
|
|
2371
|
+
for (const match of content.matchAll(SECRET_ALIAS_CONST_PATTERN)) {
|
|
2372
|
+
const alias = match[1];
|
|
2373
|
+
const name = match[2] ?? match[3] ?? match[4];
|
|
2374
|
+
if (alias && name && declaredNames.has(name)) {
|
|
2375
|
+
aliases.set(alias, name);
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
return aliases;
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
function hasLocalSecretPresenceGuard(
|
|
2383
|
+
line: string,
|
|
2384
|
+
remainingLines: readonly string[],
|
|
2385
|
+
declaredNames: ReadonlySet<string>,
|
|
2386
|
+
aliases: ReadonlyMap<string, string>,
|
|
2387
|
+
): boolean {
|
|
2388
|
+
const match = ENV_GET_CALL_PATTERN.exec(line);
|
|
2389
|
+
if (!match) {
|
|
2390
|
+
return false;
|
|
2391
|
+
}
|
|
2392
|
+
const literal = match[1] ?? match[2] ?? match[3];
|
|
2393
|
+
const identifier = match[4];
|
|
2394
|
+
const readsDeclaredSecret =
|
|
2395
|
+
literal !== undefined
|
|
2396
|
+
? declaredNames.has(literal)
|
|
2397
|
+
: identifier !== undefined && aliases.has(identifier);
|
|
2398
|
+
if (!readsDeclaredSecret) {
|
|
2399
|
+
return false;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
const window = [line, ...remainingLines.slice(0, SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES)];
|
|
2403
|
+
if (!window.some((candidate) => /\bthrow\b/.test(candidate))) {
|
|
2404
|
+
return false;
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
// Assigned read (`const key = ctx.env.get(...)`): only a falsy/undefined
|
|
2408
|
+
// check on THAT variable counts as a presence guard. Anchoring on the
|
|
2409
|
+
// assigned identifier avoids false positives from unrelated guards/throws
|
|
2410
|
+
// that merely sit near the env read (mirrors the aliased runtime-guard rule).
|
|
2411
|
+
const assigned = ENV_GET_ASSIGNMENT_PATTERN.exec(line)?.[1];
|
|
2412
|
+
if (assigned) {
|
|
2413
|
+
const escaped = assigned.replace(/\$/g, "\\$");
|
|
2414
|
+
const guardPattern = new RegExp(
|
|
2415
|
+
`(?:!\\s*${escaped}\\b|\\b${escaped}\\s*===?\\s*(?:undefined|null)\\b|\\b${escaped}\\s*==\\s*null\\b|\\b${escaped}(?:\\?\\.|\\.)length\\s*===?\\s*0\\b)`,
|
|
2416
|
+
);
|
|
2417
|
+
return window.some((candidate) => guardPattern.test(candidate));
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
// Un-assigned read: only an inline presence check on the same line counts,
|
|
2421
|
+
// e.g. `if (!ctx.env.get(KEY)) throw ...`.
|
|
2422
|
+
return /(?:if\s*\(\s*!|===?\s*undefined\b|==\s*null\b)/.test(line);
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
function scoreSdkOwnedSecretPresence(
|
|
2426
|
+
providerRoot: string,
|
|
2427
|
+
provider: ProviderDefinition,
|
|
2428
|
+
): SubmitCheck {
|
|
2429
|
+
const passMessage = "Provider relies on SDK-owned secret presence validation.";
|
|
2430
|
+
// Only `required: true` declarations: those are exactly what the runtime
|
|
2431
|
+
// gate enforces. A presence guard over an optional secret is conditional
|
|
2432
|
+
// business logic the SDK will not replace, not double validation.
|
|
2433
|
+
const declaredNames: ReadonlySet<string> = new Set(
|
|
2434
|
+
(provider.secrets ?? [])
|
|
2435
|
+
.filter((secret) => secret.required === true)
|
|
2436
|
+
.map((secret) => secret.name),
|
|
2437
|
+
);
|
|
2438
|
+
if (declaredNames.size === 0) {
|
|
2439
|
+
return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
const aliases = buildDeclaredSecretAliasMap(providerRoot, declaredNames);
|
|
2443
|
+
const findings = findSourceFindings(providerRoot, (line, remainingLines) =>
|
|
2444
|
+
hasLocalSecretPresenceGuard(line, remainingLines, declaredNames, aliases),
|
|
2445
|
+
);
|
|
2446
|
+
if (findings.length === 0) {
|
|
2447
|
+
return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
const { violations, overridden } = partitionAllowOverrides(
|
|
2451
|
+
providerRoot,
|
|
2452
|
+
findings,
|
|
2453
|
+
SDK_OWNED_SECRET_PRESENCE_RULE_ID,
|
|
2454
|
+
);
|
|
2455
|
+
if (violations.length === 0) {
|
|
2456
|
+
return pass(
|
|
2457
|
+
SDK_OWNED_SECRET_PRESENCE_RULE_ID,
|
|
2458
|
+
SDK_NATIVE_CATEGORY,
|
|
2459
|
+
`${passMessage} ${overridden.length} acknowledged @apifuse-allow override(s).`,
|
|
2460
|
+
0,
|
|
2461
|
+
formatSourceFindings(overridden),
|
|
2462
|
+
);
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
return {
|
|
2466
|
+
id: SDK_OWNED_SECRET_PRESENCE_RULE_ID,
|
|
2467
|
+
category: SDK_NATIVE_CATEGORY,
|
|
2468
|
+
level: "warn",
|
|
2469
|
+
status: "warn",
|
|
2470
|
+
points: 0,
|
|
2471
|
+
maxPoints: 0,
|
|
2472
|
+
message:
|
|
2473
|
+
"Provider source re-validates declared env secret presence locally; the SDK owns this check.",
|
|
2474
|
+
remediation:
|
|
2475
|
+
"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>`.",
|
|
2476
|
+
evidence: formatSourceFindings(violations),
|
|
2477
|
+
};
|
|
2478
|
+
}
|
|
2479
|
+
|
|
1427
2480
|
function findSourceLineMatches(
|
|
1428
2481
|
providerRoot: string,
|
|
1429
2482
|
pattern: RegExp | ((line: string) => boolean),
|
|
2483
|
+
useMaskedSource = false,
|
|
1430
2484
|
): SourceFinding[] {
|
|
1431
|
-
return findSourceFindings(
|
|
2485
|
+
return findSourceFindings(
|
|
2486
|
+
providerRoot,
|
|
2487
|
+
(line) => matchesLinePattern(line, pattern),
|
|
2488
|
+
useMaskedSource,
|
|
2489
|
+
);
|
|
1432
2490
|
}
|
|
1433
2491
|
|
|
1434
2492
|
function findSourceFindings(
|
|
1435
2493
|
providerRoot: string,
|
|
1436
2494
|
matchesLine: (line: string, remainingLines: readonly string[]) => boolean,
|
|
2495
|
+
useMaskedSource = false,
|
|
1437
2496
|
): SourceFinding[] {
|
|
1438
2497
|
const findings: SourceFinding[] = [];
|
|
1439
2498
|
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
1440
2499
|
const content = readFileSync(filePath, "utf8");
|
|
1441
|
-
const
|
|
2500
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
2501
|
+
const lines = (
|
|
2502
|
+
useMaskedSource
|
|
2503
|
+
? maskCommentsAndStrings(content, relPath, { blankPropertyKeys: true })
|
|
2504
|
+
: content
|
|
2505
|
+
).split(/\r?\n/);
|
|
1442
2506
|
for (let index = 0; index < lines.length; index += 1) {
|
|
1443
2507
|
const line = lines[index];
|
|
1444
2508
|
if (line !== undefined && matchesLine(line, lines.slice(index + 1))) {
|
|
1445
2509
|
findings.push({
|
|
1446
|
-
file:
|
|
2510
|
+
file: relPath,
|
|
1447
2511
|
line: index + 1,
|
|
1448
2512
|
});
|
|
1449
2513
|
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
@@ -1523,8 +2587,25 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
|
|
|
1523
2587
|
);
|
|
1524
2588
|
}
|
|
1525
2589
|
|
|
2590
|
+
const EXCLUDED_SOURCE_DIRECTORY_SEGMENTS = new Set([
|
|
2591
|
+
".git",
|
|
2592
|
+
".worktree",
|
|
2593
|
+
"node_modules",
|
|
2594
|
+
"dist",
|
|
2595
|
+
"build",
|
|
2596
|
+
"coverage",
|
|
2597
|
+
]);
|
|
2598
|
+
|
|
2599
|
+
// `.agents`/`.apifuse` stay IN scope on purpose: managed content there is
|
|
2600
|
+
// markdown/JSON (never scannable), while a planted `.ts`/`.sh` under those
|
|
2601
|
+
// directories must not become a scan-exempt hiding place for secrets, raw
|
|
2602
|
+
// fetch, or vendor imports. Nested dependency/build output directories under
|
|
2603
|
+
// them use the same segment exclusions; `.worktree` is excluded because task
|
|
2604
|
+
// worktrees are duplicate repository checkouts, not provider source.
|
|
1526
2605
|
function shouldScanSourceDirectory(relativePath: string): boolean {
|
|
1527
|
-
return
|
|
2606
|
+
return relativePath
|
|
2607
|
+
.split("/")
|
|
2608
|
+
.every((segment) => !EXCLUDED_SOURCE_DIRECTORY_SEGMENTS.has(segment));
|
|
1528
2609
|
}
|
|
1529
2610
|
|
|
1530
2611
|
function isExcludedTestSource(relativePath: string): boolean {
|
|
@@ -1542,7 +2623,31 @@ function toRelativeProviderPath(providerRoot: string, filePath: string): string
|
|
|
1542
2623
|
}
|
|
1543
2624
|
|
|
1544
2625
|
function formatSourceFindings(findings: readonly SourceFinding[]): string[] {
|
|
1545
|
-
return findings.map(
|
|
2626
|
+
return findings.map(
|
|
2627
|
+
(finding) =>
|
|
2628
|
+
`${finding.file}:${finding.line}${finding.detail === undefined ? "" : `: ${finding.detail}`}`,
|
|
2629
|
+
);
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
function scorePromptAssetFreshness(providerRoot: string): SubmitCheck {
|
|
2633
|
+
const verification = verifyPromptAssets(providerRoot);
|
|
2634
|
+
if (verification.ok) {
|
|
2635
|
+
return pass(
|
|
2636
|
+
"prompt-assets-fresh",
|
|
2637
|
+
"docs",
|
|
2638
|
+
"SDK-managed agent prompt assets match the installed SDK version.",
|
|
2639
|
+
0,
|
|
2640
|
+
);
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
return blocker(
|
|
2644
|
+
"prompt-assets-fresh",
|
|
2645
|
+
"docs",
|
|
2646
|
+
"SDK-managed agent prompt assets are missing, stale, or modified.",
|
|
2647
|
+
"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.",
|
|
2648
|
+
0,
|
|
2649
|
+
formatPromptAssetIssues(verification),
|
|
2650
|
+
);
|
|
1546
2651
|
}
|
|
1547
2652
|
|
|
1548
2653
|
function scoreRepositoryDx(providerRoot: string): SubmitCheck {
|
|
@@ -1617,7 +2722,7 @@ async function safeRunChecks(providerRoot: string): Promise<CheckResult[]> {
|
|
|
1617
2722
|
{
|
|
1618
2723
|
message: "Base provider checks can run",
|
|
1619
2724
|
passed: false,
|
|
1620
|
-
details: [
|
|
2725
|
+
details: [formatThrownValue(error)],
|
|
1621
2726
|
},
|
|
1622
2727
|
];
|
|
1623
2728
|
}
|
|
@@ -1842,13 +2947,17 @@ function collectProviderRequiredLocaleKeys(provider: ProviderDefinition): string
|
|
|
1842
2947
|
|
|
1843
2948
|
for (const operation of Object.values(provider.operations)) {
|
|
1844
2949
|
addLocaleKeys(keys, [
|
|
2950
|
+
operation.titleKey,
|
|
1845
2951
|
operation.descriptionKey,
|
|
1846
|
-
operation.
|
|
1847
|
-
operation.
|
|
1848
|
-
operation.docs?.summaryKey,
|
|
1849
|
-
operation.docs?.markdownKey,
|
|
2952
|
+
operation.summaryKey,
|
|
2953
|
+
operation.markdownKey,
|
|
1850
2954
|
...(operation.whenToUseKeys ?? []),
|
|
1851
2955
|
...(operation.whenNotToUseKeys ?? []),
|
|
2956
|
+
...(operation.normalizationNotesKeys ?? []),
|
|
2957
|
+
...(operation.examples ?? []).flatMap((example) => [
|
|
2958
|
+
example.scenarioKey,
|
|
2959
|
+
example.rationaleKey,
|
|
2960
|
+
]),
|
|
1852
2961
|
...collectSchemaDescriptionKeys(operation.input),
|
|
1853
2962
|
...collectSchemaDescriptionKeys(operation.output),
|
|
1854
2963
|
]);
|
|
@@ -1915,8 +3024,8 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
|
|
|
1915
3024
|
return true;
|
|
1916
3025
|
})
|
|
1917
3026
|
.map(([operationId]) => operationId);
|
|
1918
|
-
const
|
|
1919
|
-
.filter(([, operation]) => !operation.
|
|
3027
|
+
const missingRiskClass = operations
|
|
3028
|
+
.filter(([, operation]) => !operation.riskClass)
|
|
1920
3029
|
.map(([operationId]) => operationId);
|
|
1921
3030
|
|
|
1922
3031
|
if (weakDescriptions.length > 0) {
|
|
@@ -1928,30 +3037,30 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
|
|
|
1928
3037
|
points: 0,
|
|
1929
3038
|
maxPoints: CATEGORY_MAX_POINTS.operations,
|
|
1930
3039
|
message: "One or more operations have weak descriptions.",
|
|
1931
|
-
remediation: `For ${weakDescriptions.join(", ")}, add an operation \`descriptionKey\` backed by \`locales/en.json\` and \`locales/ko.json
|
|
3040
|
+
remediation: `For ${weakDescriptions.join(", ")}, add an operation \`descriptionKey\` backed by \`locales/en.json\` and \`locales/ko.json\`.`,
|
|
1932
3041
|
evidence: weakDescriptions,
|
|
1933
3042
|
};
|
|
1934
3043
|
}
|
|
1935
3044
|
|
|
1936
|
-
const points =
|
|
3045
|
+
const points = missingRiskClass.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
|
|
1937
3046
|
return {
|
|
1938
3047
|
id: "operation-metadata",
|
|
1939
3048
|
category: "operations",
|
|
1940
|
-
level:
|
|
1941
|
-
status:
|
|
3049
|
+
level: missingRiskClass.length > 0 ? "warn" : "info",
|
|
3050
|
+
status: missingRiskClass.length > 0 ? "warn" : "pass",
|
|
1942
3051
|
points,
|
|
1943
3052
|
maxPoints: CATEGORY_MAX_POINTS.operations,
|
|
1944
3053
|
message:
|
|
1945
|
-
|
|
1946
|
-
? "Operations are described, but some are missing safety
|
|
3054
|
+
missingRiskClass.length > 0
|
|
3055
|
+
? "Operations are described, but some are missing a safety classification."
|
|
1947
3056
|
: "Operation descriptions and metadata are review-ready.",
|
|
1948
3057
|
remediation:
|
|
1949
|
-
|
|
1950
|
-
? `For ${
|
|
3058
|
+
missingRiskClass.length > 0
|
|
3059
|
+
? `For ${missingRiskClass.join(", ")}, declare the operation's required \`riskClass\` as \`read\`, \`write\`, \`destructive\`, or \`external-send\`.`
|
|
1951
3060
|
: undefined,
|
|
1952
3061
|
evidence:
|
|
1953
|
-
|
|
1954
|
-
?
|
|
3062
|
+
missingRiskClass.length > 0
|
|
3063
|
+
? missingRiskClass.map((operationId) => `${operationId}: missing riskClass`)
|
|
1955
3064
|
: operations.map(([operationId]) => operationId),
|
|
1956
3065
|
};
|
|
1957
3066
|
}
|
|
@@ -1983,11 +3092,16 @@ const GENERATED_LOCAL_ONLY_SCAFFOLD_REASON = /generated local-only scaffold/i;
|
|
|
1983
3092
|
function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
|
|
1984
3093
|
const rawPath = resolve(providerRoot, "__fixtures__", "raw.json");
|
|
1985
3094
|
let hasRecordedEvidence = false;
|
|
3095
|
+
let fixtureValidationError: string | undefined;
|
|
1986
3096
|
if (existsSync(rawPath)) {
|
|
1987
3097
|
try {
|
|
1988
|
-
hasRecordedEvidence =
|
|
1989
|
-
|
|
3098
|
+
hasRecordedEvidence = recordedFixtureStats(
|
|
3099
|
+
JSON.parse(readFileSync(rawPath, "utf8")),
|
|
3100
|
+
0,
|
|
3101
|
+
).hasNestedSubstance;
|
|
3102
|
+
} catch (error) {
|
|
1990
3103
|
hasRecordedEvidence = false;
|
|
3104
|
+
fixtureValidationError = error instanceof Error ? error.message : String(error);
|
|
1991
3105
|
}
|
|
1992
3106
|
}
|
|
1993
3107
|
|
|
@@ -1999,6 +3113,16 @@ function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefiniti
|
|
|
1999
3113
|
0,
|
|
2000
3114
|
);
|
|
2001
3115
|
}
|
|
3116
|
+
if (fixtureValidationError) {
|
|
3117
|
+
return blocker(
|
|
3118
|
+
"fixture-provenance",
|
|
3119
|
+
"fixtures",
|
|
3120
|
+
`Malformed recorded fixture evidence in __fixtures__/raw.json: ${fixtureValidationError}`,
|
|
3121
|
+
"Re-run `bun run record` to replace the malformed stream evidence, or repair the named field using the stream evidence contract.",
|
|
3122
|
+
0,
|
|
3123
|
+
["__fixtures__/raw.json"],
|
|
3124
|
+
);
|
|
3125
|
+
}
|
|
2002
3126
|
|
|
2003
3127
|
if (allOperationsAreGeneratedLocalScaffold(provider)) {
|
|
2004
3128
|
return {
|
|
@@ -2026,14 +3150,32 @@ function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefiniti
|
|
|
2026
3150
|
);
|
|
2027
3151
|
}
|
|
2028
3152
|
|
|
2029
|
-
function hasNonEmptyRecordedFixture(value: unknown): boolean {
|
|
2030
|
-
|
|
3153
|
+
export function hasNonEmptyRecordedFixture(value: unknown): boolean {
|
|
3154
|
+
try {
|
|
3155
|
+
return recordedFixtureStats(value, 0).hasNestedSubstance;
|
|
3156
|
+
} catch {
|
|
3157
|
+
return false;
|
|
3158
|
+
}
|
|
2031
3159
|
}
|
|
2032
3160
|
|
|
2033
3161
|
function recordedFixtureStats(
|
|
2034
3162
|
value: unknown,
|
|
2035
3163
|
depth: number,
|
|
2036
3164
|
): { hasNestedSubstance: boolean; leafValues: number } {
|
|
3165
|
+
if (
|
|
3166
|
+
value !== null &&
|
|
3167
|
+
typeof value === "object" &&
|
|
3168
|
+
!Array.isArray(value) &&
|
|
3169
|
+
(value as Record<string, unknown>).__apifuse_capture__ === true
|
|
3170
|
+
) {
|
|
3171
|
+
const group = findStreamCaptureGroup(value);
|
|
3172
|
+
if (!group) throw new Error("Stream capture envelope is invalid.");
|
|
3173
|
+
return { hasNestedSubstance: true, leafValues: group.items.length };
|
|
3174
|
+
}
|
|
3175
|
+
if (hasStreamEvidenceMarker(value)) {
|
|
3176
|
+
parseStreamEvidenceRecord(value);
|
|
3177
|
+
return { hasNestedSubstance: true, leafValues: 1 };
|
|
3178
|
+
}
|
|
2037
3179
|
if (value === null || value === undefined) {
|
|
2038
3180
|
return { hasNestedSubstance: false, leafValues: 0 };
|
|
2039
3181
|
}
|
|
@@ -2068,10 +3210,14 @@ function recordedFixtureStats(
|
|
|
2068
3210
|
if (value.length === 0) {
|
|
2069
3211
|
return { hasNestedSubstance: false, leafValues: 0 };
|
|
2070
3212
|
}
|
|
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 {
|
|
3213
|
+
// A recorded operation value may be a raw XML or delimited-text success
|
|
3214
|
+
// payload. Treat a substantive one as nested evidence while still counting
|
|
3215
|
+
// the string as a leaf so existing JSON provenance heuristics are unchanged.
|
|
3216
|
+
return {
|
|
3217
|
+
hasNestedSubstance:
|
|
3218
|
+
hasSubstantiveXmlStructure(value) || hasSubstantiveDelimitedTextStructure(value),
|
|
3219
|
+
leafValues: 1,
|
|
3220
|
+
};
|
|
2075
3221
|
}
|
|
2076
3222
|
return { hasNestedSubstance: false, leafValues: 1 };
|
|
2077
3223
|
}
|
|
@@ -2136,68 +3282,326 @@ function findVendorKeyLeakFindings(providerRoot: string): SourceFinding[] {
|
|
|
2136
3282
|
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
2137
3283
|
const source = readFileSync(filePath, "utf8");
|
|
2138
3284
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
3285
|
+
maskCommentsAndStrings(source, relPath);
|
|
3286
|
+
const sourceFile = ts.createSourceFile(
|
|
3287
|
+
relPath,
|
|
3288
|
+
source,
|
|
3289
|
+
ts.ScriptTarget.Latest,
|
|
3290
|
+
true,
|
|
3291
|
+
ts.ScriptKind.TS,
|
|
3292
|
+
);
|
|
3293
|
+
const bindings = collectLocalBindings(sourceFile);
|
|
2139
3294
|
const upstreamRanges = findUpstreamMarkedConstRanges(source);
|
|
2140
3295
|
for (const zObject of findZObjectLiterals(source)) {
|
|
2141
3296
|
if (rangeContainsOffset(upstreamRanges, zObject.callStart)) {
|
|
2142
3297
|
continue;
|
|
2143
3298
|
}
|
|
2144
|
-
if (!zObjectAppearsPublicOutput(
|
|
2145
|
-
continue;
|
|
3299
|
+
if (!zObjectAppearsPublicOutput(sourceFile, zObject, relPath)) {
|
|
3300
|
+
continue;
|
|
3301
|
+
}
|
|
3302
|
+
for (const keyFinding of vendorKeyFindingsForObject(
|
|
3303
|
+
source,
|
|
3304
|
+
sourceFile,
|
|
3305
|
+
zObject,
|
|
3306
|
+
bindings,
|
|
3307
|
+
)) {
|
|
3308
|
+
const key = `${relPath}:${keyFinding.line}:${keyFinding.key}`;
|
|
3309
|
+
if (!seen.has(key)) {
|
|
3310
|
+
seen.add(key);
|
|
3311
|
+
findings.push({
|
|
3312
|
+
file: relPath,
|
|
3313
|
+
line: keyFinding.line,
|
|
3314
|
+
detail: keyFinding.key,
|
|
3315
|
+
});
|
|
3316
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
3317
|
+
return findings;
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
return findings;
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
function findZObjectLiterals(source: string): ZObjectLiteral[] {
|
|
3328
|
+
const literals: ZObjectLiteral[] = [];
|
|
3329
|
+
const masked = maskCommentsAndStrings(source);
|
|
3330
|
+
const callPattern = /\bz\s*\.\s*object\s*\(/g;
|
|
3331
|
+
for (let match = callPattern.exec(masked); match !== null; match = callPattern.exec(masked)) {
|
|
3332
|
+
const parenIndex = masked.indexOf("(", match.index);
|
|
3333
|
+
const objectStart = findNextNonWhitespace(masked, parenIndex + 1);
|
|
3334
|
+
if (objectStart === -1 || masked[objectStart] !== "{") {
|
|
3335
|
+
continue;
|
|
3336
|
+
}
|
|
3337
|
+
const objectEnd = findMatchingBracket(masked, objectStart);
|
|
3338
|
+
if (objectEnd === -1) {
|
|
3339
|
+
continue;
|
|
3340
|
+
}
|
|
3341
|
+
literals.push({
|
|
3342
|
+
objectStart,
|
|
3343
|
+
objectEnd,
|
|
3344
|
+
callStart: match.index,
|
|
3345
|
+
});
|
|
3346
|
+
callPattern.lastIndex = objectEnd;
|
|
3347
|
+
}
|
|
3348
|
+
return literals;
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
function zObjectAppearsPublicOutput(
|
|
3352
|
+
sourceFile: TS.SourceFile,
|
|
3353
|
+
zObject: ZObjectLiteral,
|
|
3354
|
+
relPath: string,
|
|
3355
|
+
): boolean {
|
|
3356
|
+
let zObjectCall: TS.CallExpression | undefined;
|
|
3357
|
+
const findCall = (node: TS.Node): void => {
|
|
3358
|
+
if (
|
|
3359
|
+
zObjectCall === undefined &&
|
|
3360
|
+
ts.isCallExpression(node) &&
|
|
3361
|
+
node.getStart(sourceFile) === zObject.callStart
|
|
3362
|
+
) {
|
|
3363
|
+
zObjectCall = node;
|
|
3364
|
+
return;
|
|
3365
|
+
}
|
|
3366
|
+
ts.forEachChild(node, findCall);
|
|
3367
|
+
};
|
|
3368
|
+
findCall(sourceFile);
|
|
3369
|
+
if (zObjectCall === undefined) {
|
|
3370
|
+
return true;
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
let expression: TS.Expression = zObjectCall;
|
|
3374
|
+
while (
|
|
3375
|
+
ts.isParenthesizedExpression(expression.parent) ||
|
|
3376
|
+
ts.isAsExpression(expression.parent) ||
|
|
3377
|
+
ts.isSatisfiesExpression(expression.parent) ||
|
|
3378
|
+
ts.isNonNullExpression(expression.parent)
|
|
3379
|
+
) {
|
|
3380
|
+
expression = expression.parent;
|
|
3381
|
+
}
|
|
3382
|
+
if (
|
|
3383
|
+
ts.isPropertyAssignment(expression.parent) &&
|
|
3384
|
+
isPublicOutputPropertyName(expression.parent.name)
|
|
3385
|
+
) {
|
|
3386
|
+
return true;
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
const declaration = findEnclosingVariableDeclaration(expression);
|
|
3390
|
+
if (
|
|
3391
|
+
declaration === undefined ||
|
|
3392
|
+
!ts.isIdentifier(declaration.name) ||
|
|
3393
|
+
declaration.initializer === undefined ||
|
|
3394
|
+
!ts.isVariableDeclarationList(declaration.parent) ||
|
|
3395
|
+
(declaration.parent.flags & ts.NodeFlags.Const) === 0
|
|
3396
|
+
) {
|
|
3397
|
+
return true;
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
const reachability = localSchemaBindingReachability(
|
|
3401
|
+
sourceFile,
|
|
3402
|
+
declaration.name.text,
|
|
3403
|
+
declaration,
|
|
3404
|
+
new Set(),
|
|
3405
|
+
);
|
|
3406
|
+
if (reachability === "public") {
|
|
3407
|
+
return true;
|
|
3408
|
+
}
|
|
3409
|
+
if (reachability === "unknown") {
|
|
3410
|
+
return true;
|
|
3411
|
+
}
|
|
3412
|
+
// Sibling modules are scanned independently and therefore remain
|
|
3413
|
+
// fail-closed. An exported binding in index.ts is likewise consumable by
|
|
3414
|
+
// another module, so local inertness does not prove it is private.
|
|
3415
|
+
if (relPath !== "index.ts") {
|
|
3416
|
+
return true;
|
|
3417
|
+
}
|
|
3418
|
+
return isExportedVariableBinding(sourceFile, declaration);
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
type SchemaReachability = "public" | "internal" | "unknown";
|
|
3422
|
+
|
|
3423
|
+
const PARSE_LIKE_SCHEMA_METHODS: ReadonlySet<string> = new Set([
|
|
3424
|
+
"parse",
|
|
3425
|
+
"safeParse",
|
|
3426
|
+
"parseAsync",
|
|
3427
|
+
"safeParseAsync",
|
|
3428
|
+
]);
|
|
3429
|
+
|
|
3430
|
+
function isExportedVariableBinding(
|
|
3431
|
+
sourceFile: TS.SourceFile,
|
|
3432
|
+
declaration: TS.VariableDeclaration,
|
|
3433
|
+
): boolean {
|
|
3434
|
+
const declarationStatement = declaration.parent.parent;
|
|
3435
|
+
if (
|
|
3436
|
+
ts.isVariableStatement(declarationStatement) &&
|
|
3437
|
+
declarationStatement.modifiers?.some(
|
|
3438
|
+
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
|
|
3439
|
+
) === true
|
|
3440
|
+
) {
|
|
3441
|
+
return true;
|
|
3442
|
+
}
|
|
3443
|
+
if (!ts.isIdentifier(declaration.name)) {
|
|
3444
|
+
return false;
|
|
3445
|
+
}
|
|
3446
|
+
const name = declaration.name.text;
|
|
3447
|
+
let exported = false;
|
|
3448
|
+
const visit = (node: TS.Node): void => {
|
|
3449
|
+
if (exported) {
|
|
3450
|
+
return;
|
|
3451
|
+
}
|
|
3452
|
+
if (ts.isExportSpecifier(node)) {
|
|
3453
|
+
const localName = node.propertyName ?? node.name;
|
|
3454
|
+
if (ts.isIdentifier(localName) && localName.text === name) {
|
|
3455
|
+
exported = true;
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
if (ts.isExportAssignment(node)) {
|
|
3460
|
+
const expression = unwrapLocalExpression(node.expression);
|
|
3461
|
+
if (ts.isIdentifier(expression) && expression.text === name) {
|
|
3462
|
+
exported = true;
|
|
3463
|
+
return;
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
ts.forEachChild(node, visit);
|
|
3467
|
+
};
|
|
3468
|
+
for (const statement of sourceFile.statements) {
|
|
3469
|
+
visit(statement);
|
|
3470
|
+
if (exported) {
|
|
3471
|
+
break;
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
3474
|
+
return exported;
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
function isPublicOutputPropertyName(name: TS.PropertyName): boolean {
|
|
3478
|
+
const text = propertyNameText(name);
|
|
3479
|
+
return text === "output" || text === "response";
|
|
3480
|
+
}
|
|
3481
|
+
|
|
3482
|
+
function findEnclosingVariableDeclaration(node: TS.Node): TS.VariableDeclaration | undefined {
|
|
3483
|
+
let current: TS.Node | undefined = node;
|
|
3484
|
+
while (current !== undefined && !ts.isSourceFile(current)) {
|
|
3485
|
+
if (ts.isVariableDeclaration(current)) {
|
|
3486
|
+
return current;
|
|
3487
|
+
}
|
|
3488
|
+
current = current.parent;
|
|
3489
|
+
}
|
|
3490
|
+
return undefined;
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
function localSchemaBindingReachability(
|
|
3494
|
+
sourceFile: TS.SourceFile,
|
|
3495
|
+
name: string,
|
|
3496
|
+
declaration: TS.VariableDeclaration,
|
|
3497
|
+
resolving: ReadonlySet<string>,
|
|
3498
|
+
): SchemaReachability {
|
|
3499
|
+
if (resolving.has(name)) {
|
|
3500
|
+
return "unknown";
|
|
3501
|
+
}
|
|
3502
|
+
const nextResolving = new Set(resolving);
|
|
3503
|
+
nextResolving.add(name);
|
|
3504
|
+
let result: SchemaReachability = "internal";
|
|
3505
|
+
const visit = (node: TS.Node): void => {
|
|
3506
|
+
if (result === "public") {
|
|
3507
|
+
return;
|
|
3508
|
+
}
|
|
3509
|
+
if (ts.isIdentifier(node) && node.text === name && node !== declaration.name) {
|
|
3510
|
+
const parent = node.parent;
|
|
3511
|
+
if (isParseLikeSchemaReceiver(node)) {
|
|
3512
|
+
return;
|
|
3513
|
+
}
|
|
3514
|
+
if (ts.isExportSpecifier(parent)) {
|
|
3515
|
+
const localName = parent.propertyName ?? parent.name;
|
|
3516
|
+
if (localName === node) {
|
|
3517
|
+
result = "public";
|
|
3518
|
+
}
|
|
3519
|
+
return;
|
|
3520
|
+
}
|
|
3521
|
+
if (
|
|
3522
|
+
ts.isPropertyAssignment(parent) &&
|
|
3523
|
+
parent.initializer === node &&
|
|
3524
|
+
isPublicOutputPropertyName(parent.name)
|
|
3525
|
+
) {
|
|
3526
|
+
result = "public";
|
|
3527
|
+
return;
|
|
3528
|
+
}
|
|
3529
|
+
if (ts.isShorthandPropertyAssignment(parent)) {
|
|
3530
|
+
result = isPublicOutputPropertyName(parent.name) ? "public" : "unknown";
|
|
3531
|
+
return;
|
|
2146
3532
|
}
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
3533
|
+
if (
|
|
3534
|
+
ts.isVariableDeclaration(parent) &&
|
|
3535
|
+
parent.initializer === node &&
|
|
3536
|
+
ts.isIdentifier(parent.name) &&
|
|
3537
|
+
ts.isVariableDeclarationList(parent.parent) &&
|
|
3538
|
+
(parent.parent.flags & ts.NodeFlags.Const) !== 0
|
|
3539
|
+
) {
|
|
3540
|
+
const aliasReachability = localSchemaBindingReachability(
|
|
3541
|
+
sourceFile,
|
|
3542
|
+
parent.name.text,
|
|
3543
|
+
parent,
|
|
3544
|
+
nextResolving,
|
|
3545
|
+
);
|
|
3546
|
+
if (aliasReachability === "public" || result === "internal") {
|
|
3547
|
+
result = aliasReachability;
|
|
2155
3548
|
}
|
|
3549
|
+
return;
|
|
3550
|
+
}
|
|
3551
|
+
if (ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) {
|
|
3552
|
+
return;
|
|
2156
3553
|
}
|
|
3554
|
+
if (
|
|
3555
|
+
(ts.isPropertyAccessExpression(parent) && parent.name === node) ||
|
|
3556
|
+
(ts.isPropertyAssignment(parent) && parent.name === node) ||
|
|
3557
|
+
ts.isBindingElement(parent) ||
|
|
3558
|
+
ts.isImportSpecifier(parent)
|
|
3559
|
+
) {
|
|
3560
|
+
return;
|
|
3561
|
+
}
|
|
3562
|
+
result = "unknown";
|
|
2157
3563
|
}
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
3564
|
+
ts.forEachChild(node, visit);
|
|
3565
|
+
};
|
|
3566
|
+
visit(sourceFile);
|
|
3567
|
+
return result;
|
|
2161
3568
|
}
|
|
2162
3569
|
|
|
2163
|
-
function
|
|
2164
|
-
const
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
const
|
|
2170
|
-
if (
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
continue;
|
|
3570
|
+
function isParseLikeSchemaReceiver(identifier: TS.Identifier): boolean {
|
|
3571
|
+
const access = identifier.parent;
|
|
3572
|
+
let methodName: string | undefined;
|
|
3573
|
+
if (ts.isPropertyAccessExpression(access) && access.expression === identifier) {
|
|
3574
|
+
methodName = access.name.text;
|
|
3575
|
+
} else if (ts.isElementAccessExpression(access) && access.expression === identifier) {
|
|
3576
|
+
const argument = access.argumentExpression;
|
|
3577
|
+
if (
|
|
3578
|
+
argument !== undefined &&
|
|
3579
|
+
(ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
|
|
3580
|
+
) {
|
|
3581
|
+
methodName = argument.text;
|
|
2176
3582
|
}
|
|
2177
|
-
literals.push({
|
|
2178
|
-
objectStart,
|
|
2179
|
-
objectEnd,
|
|
2180
|
-
callStart: match.index,
|
|
2181
|
-
});
|
|
2182
|
-
callPattern.lastIndex = objectEnd;
|
|
2183
|
-
}
|
|
2184
|
-
return literals;
|
|
2185
|
-
}
|
|
2186
|
-
|
|
2187
|
-
function zObjectAppearsPublicOutput(source: string, zObject: ZObjectLiteral): boolean {
|
|
2188
|
-
const enclosingConst = findConstValueRangeContaining(source, zObject.callStart);
|
|
2189
|
-
if (enclosingConst && /output|response|result/i.test(enclosingConst.name)) {
|
|
2190
|
-
return true;
|
|
2191
3583
|
}
|
|
2192
|
-
|
|
2193
|
-
|
|
3584
|
+
return (
|
|
3585
|
+
methodName !== undefined &&
|
|
3586
|
+
PARSE_LIKE_SCHEMA_METHODS.has(methodName) &&
|
|
3587
|
+
ts.isCallExpression(access.parent) &&
|
|
3588
|
+
access.parent.expression === access
|
|
3589
|
+
);
|
|
2194
3590
|
}
|
|
2195
3591
|
|
|
2196
3592
|
function vendorKeyFindingsForObject(
|
|
2197
3593
|
source: string,
|
|
3594
|
+
sourceFile: TS.SourceFile,
|
|
2198
3595
|
zObject: ZObjectLiteral,
|
|
3596
|
+
bindings: LocalBindings,
|
|
2199
3597
|
): Array<{ key: string; line: number }> {
|
|
2200
|
-
const keys = collectTopLevelObjectKeys(
|
|
3598
|
+
const keys = collectTopLevelObjectKeys(
|
|
3599
|
+
source,
|
|
3600
|
+
sourceFile,
|
|
3601
|
+
zObject.objectStart,
|
|
3602
|
+
zObject.objectEnd,
|
|
3603
|
+
bindings,
|
|
3604
|
+
);
|
|
2201
3605
|
const digitFamilies = new Map<string, Set<string>>();
|
|
2202
3606
|
for (const key of keys) {
|
|
2203
3607
|
const member = numberedFamilyMember(key.name);
|
|
@@ -2253,8 +3657,10 @@ function isAllowedPublicOutputKeyName(name: string): boolean {
|
|
|
2253
3657
|
|
|
2254
3658
|
function collectTopLevelObjectKeys(
|
|
2255
3659
|
source: string,
|
|
3660
|
+
sourceFile: TS.SourceFile,
|
|
2256
3661
|
objectStart: number,
|
|
2257
3662
|
objectEnd: number,
|
|
3663
|
+
bindings: LocalBindings,
|
|
2258
3664
|
): Array<{ name: string; offset: number }> {
|
|
2259
3665
|
const keys: Array<{ name: string; offset: number }> = [];
|
|
2260
3666
|
const masked = maskCommentsAndStrings(source);
|
|
@@ -2276,17 +3682,16 @@ function collectTopLevelObjectKeys(
|
|
|
2276
3682
|
index = endQuote + 1;
|
|
2277
3683
|
} else if (masked[index] === "[") {
|
|
2278
3684
|
const computedEnd = findMatchingBracket(masked, index);
|
|
2279
|
-
|
|
2280
|
-
if (computedEnd === -1 || literalStart === -1) {
|
|
3685
|
+
if (computedEnd === -1) {
|
|
2281
3686
|
break;
|
|
2282
3687
|
}
|
|
2283
|
-
const
|
|
2284
|
-
if (
|
|
2285
|
-
const
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
if (
|
|
2289
|
-
key =
|
|
3688
|
+
const expression = computedPropertyExpressionAtOffset(sourceFile, index);
|
|
3689
|
+
if (expression !== undefined) {
|
|
3690
|
+
const unwrapped = unwrapLocalExpression(expression);
|
|
3691
|
+
if (ts.isStringLiteral(unwrapped) || ts.isNoSubstitutionTemplateLiteral(unwrapped)) {
|
|
3692
|
+
key = unwrapped.text;
|
|
3693
|
+
} else if (ts.isIdentifier(unwrapped)) {
|
|
3694
|
+
key = resolveConstStringIdentifier(unwrapped, bindings);
|
|
2290
3695
|
}
|
|
2291
3696
|
}
|
|
2292
3697
|
index = computedEnd + 1;
|
|
@@ -2313,6 +3718,25 @@ function collectTopLevelObjectKeys(
|
|
|
2313
3718
|
return keys;
|
|
2314
3719
|
}
|
|
2315
3720
|
|
|
3721
|
+
function computedPropertyExpressionAtOffset(
|
|
3722
|
+
sourceFile: TS.SourceFile,
|
|
3723
|
+
offset: number,
|
|
3724
|
+
): TS.Expression | undefined {
|
|
3725
|
+
let expression: TS.Expression | undefined;
|
|
3726
|
+
const visit = (node: TS.Node): void => {
|
|
3727
|
+
if (expression !== undefined || offset < node.getFullStart() || offset >= node.getEnd()) {
|
|
3728
|
+
return;
|
|
3729
|
+
}
|
|
3730
|
+
if (ts.isComputedPropertyName(node) && node.getStart(sourceFile) === offset) {
|
|
3731
|
+
expression = node.expression;
|
|
3732
|
+
return;
|
|
3733
|
+
}
|
|
3734
|
+
ts.forEachChild(node, visit);
|
|
3735
|
+
};
|
|
3736
|
+
visit(sourceFile);
|
|
3737
|
+
return expression;
|
|
3738
|
+
}
|
|
3739
|
+
|
|
2316
3740
|
function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[] {
|
|
2317
3741
|
const findings: SourceFinding[] = [];
|
|
2318
3742
|
const seen = new Set<string>();
|
|
@@ -2320,6 +3744,15 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
|
|
|
2320
3744
|
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
2321
3745
|
const source = readFileSync(filePath, "utf8");
|
|
2322
3746
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
3747
|
+
maskCommentsAndStrings(source, relPath);
|
|
3748
|
+
const sourceFile = ts.createSourceFile(
|
|
3749
|
+
relPath,
|
|
3750
|
+
source,
|
|
3751
|
+
ts.ScriptTarget.Latest,
|
|
3752
|
+
true,
|
|
3753
|
+
ts.ScriptKind.TS,
|
|
3754
|
+
);
|
|
3755
|
+
const bindings = collectLocalBindings(sourceFile);
|
|
2323
3756
|
const zObjectRanges = findZObjectLiterals(source).map((zObject) => ({
|
|
2324
3757
|
start: zObject.callStart,
|
|
2325
3758
|
end: zObject.objectEnd,
|
|
@@ -2332,7 +3765,7 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
|
|
|
2332
3765
|
].filter((range) => rangeContainedInRanges(fixtureRanges, range));
|
|
2333
3766
|
|
|
2334
3767
|
for (const range of fixtureResponseRanges) {
|
|
2335
|
-
for (const literal of findStringLiteralsInRange(source, range)) {
|
|
3768
|
+
for (const literal of findStringLiteralsInRange(source, range, relPath)) {
|
|
2336
3769
|
if (
|
|
2337
3770
|
rangeContainsOffset(zObjectRanges, literal.offset) ||
|
|
2338
3771
|
rangeContainsOffset(upstreamRanges, literal.offset) ||
|
|
@@ -2353,12 +3786,86 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
|
|
|
2353
3786
|
}
|
|
2354
3787
|
}
|
|
2355
3788
|
}
|
|
3789
|
+
|
|
3790
|
+
const visitAliases = (node: TS.Node): void => {
|
|
3791
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
3792
|
+
return;
|
|
3793
|
+
}
|
|
3794
|
+
const offset = node.getStart(sourceFile);
|
|
3795
|
+
if (
|
|
3796
|
+
ts.isIdentifier(node) &&
|
|
3797
|
+
isIdentifierValueUse(node) &&
|
|
3798
|
+
offset >= range.start &&
|
|
3799
|
+
offset <= range.end &&
|
|
3800
|
+
!rangeContainsOffset(zObjectRanges, offset) &&
|
|
3801
|
+
!rangeContainsOffset(upstreamRanges, offset)
|
|
3802
|
+
) {
|
|
3803
|
+
const value = resolveConstStringIdentifier(node, bindings);
|
|
3804
|
+
if (
|
|
3805
|
+
value !== undefined &&
|
|
3806
|
+
isVendorTimestampCandidate(value, propertyNameForValueExpression(node))
|
|
3807
|
+
) {
|
|
3808
|
+
const line = offsetToLine(source, offset);
|
|
3809
|
+
const key = `${relPath}:${line}:${value}`;
|
|
3810
|
+
if (!seen.has(key)) {
|
|
3811
|
+
seen.add(key);
|
|
3812
|
+
findings.push({ file: relPath, line });
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
ts.forEachChild(node, visitAliases);
|
|
3817
|
+
};
|
|
3818
|
+
visitAliases(sourceFile);
|
|
3819
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
3820
|
+
return findings;
|
|
3821
|
+
}
|
|
2356
3822
|
}
|
|
2357
3823
|
}
|
|
2358
3824
|
|
|
2359
3825
|
return findings;
|
|
2360
3826
|
}
|
|
2361
3827
|
|
|
3828
|
+
function isIdentifierValueUse(identifier: TS.Identifier): boolean {
|
|
3829
|
+
const parent = identifier.parent;
|
|
3830
|
+
return !(
|
|
3831
|
+
(ts.isPropertyAssignment(parent) && parent.name === identifier) ||
|
|
3832
|
+
(ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
|
|
3833
|
+
ts.isBindingElement(parent) ||
|
|
3834
|
+
ts.isVariableDeclaration(parent) ||
|
|
3835
|
+
ts.isParameter(parent) ||
|
|
3836
|
+
ts.isImportClause(parent) ||
|
|
3837
|
+
ts.isImportSpecifier(parent) ||
|
|
3838
|
+
ts.isNamespaceImport(parent) ||
|
|
3839
|
+
ts.isFunctionDeclaration(parent) ||
|
|
3840
|
+
ts.isFunctionExpression(parent) ||
|
|
3841
|
+
ts.isClassDeclaration(parent) ||
|
|
3842
|
+
ts.isClassExpression(parent) ||
|
|
3843
|
+
ts.isPropertyDeclaration(parent) ||
|
|
3844
|
+
ts.isPropertySignature(parent) ||
|
|
3845
|
+
ts.isMethodDeclaration(parent) ||
|
|
3846
|
+
ts.isMethodSignature(parent)
|
|
3847
|
+
);
|
|
3848
|
+
}
|
|
3849
|
+
|
|
3850
|
+
function propertyNameForValueExpression(expression: TS.Expression): string | undefined {
|
|
3851
|
+
let current: TS.Expression = expression;
|
|
3852
|
+
while (
|
|
3853
|
+
ts.isParenthesizedExpression(current.parent) ||
|
|
3854
|
+
ts.isAsExpression(current.parent) ||
|
|
3855
|
+
ts.isSatisfiesExpression(current.parent) ||
|
|
3856
|
+
ts.isNonNullExpression(current.parent)
|
|
3857
|
+
) {
|
|
3858
|
+
current = current.parent;
|
|
3859
|
+
}
|
|
3860
|
+
if (ts.isPropertyAssignment(current.parent) && current.parent.initializer === current) {
|
|
3861
|
+
return propertyNameText(current.parent.name);
|
|
3862
|
+
}
|
|
3863
|
+
if (ts.isShorthandPropertyAssignment(current.parent)) {
|
|
3864
|
+
return current.parent.name.text;
|
|
3865
|
+
}
|
|
3866
|
+
return undefined;
|
|
3867
|
+
}
|
|
3868
|
+
|
|
2362
3869
|
function findPropertyObjectRanges(source: string, propertyName: string): ObjectRange[] {
|
|
2363
3870
|
const ranges: ObjectRange[] = [];
|
|
2364
3871
|
const masked = maskCommentsAndStrings(source);
|
|
@@ -2402,23 +3909,16 @@ function findNamedConstValueRanges(source: string): NamedObjectRange[] {
|
|
|
2402
3909
|
return ranges;
|
|
2403
3910
|
}
|
|
2404
3911
|
|
|
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
3912
|
function findStringLiteralsInRange(
|
|
2415
3913
|
source: string,
|
|
2416
3914
|
range: ObjectRange,
|
|
3915
|
+
fileName: string,
|
|
2417
3916
|
): Array<{ value: string; offset: number }> {
|
|
2418
3917
|
const literals: Array<{ value: string; offset: number }> = [];
|
|
3918
|
+
const masked = maskCommentsAndStrings(source, fileName);
|
|
2419
3919
|
let index = range.start;
|
|
2420
3920
|
while (index <= range.end) {
|
|
2421
|
-
const quote =
|
|
3921
|
+
const quote = masked[index];
|
|
2422
3922
|
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
|
2423
3923
|
index += 1;
|
|
2424
3924
|
continue;
|
|
@@ -2528,57 +4028,199 @@ function findStringEnd(source: string, start: number): number {
|
|
|
2528
4028
|
return -1;
|
|
2529
4029
|
}
|
|
2530
4030
|
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
4031
|
+
// Masking now parses with TypeScript, which is orders of magnitude more
|
|
4032
|
+
// expensive than the character walk it replaced, and six scanners call this
|
|
4033
|
+
// helper once per candidate match — the same file is masked thousands of
|
|
4034
|
+
// times in one run (measured: 1,872 calls for a 3.4k-line provider). Without
|
|
4035
|
+
// memoization that run regresses from ~8s to minutes and can exceed the CI
|
|
4036
|
+
// validation timeout. Successful results are cached by exact source text and
|
|
4037
|
+
// property-key mode; the cache stays small because a run only ever reads a
|
|
4038
|
+
// handful of files.
|
|
4039
|
+
// Parse failures are NOT cached: they throw and abort the check (fail-closed).
|
|
4040
|
+
const MASK_CACHE_LIMIT = 64;
|
|
4041
|
+
const maskCache = new Map<string, string>();
|
|
4042
|
+
|
|
4043
|
+
// Neutralize { } ( ) [ ], backtick, quotes, slash, backslash, comma, and semicolon
|
|
4044
|
+
// because scanners treat these as depth, string/regex delimiters, or top-level stops.
|
|
4045
|
+
const PRESERVED_PROPERTY_KEY_STRUCTURAL_CHARS = new Set([
|
|
4046
|
+
"{",
|
|
4047
|
+
"}",
|
|
4048
|
+
"(",
|
|
4049
|
+
")",
|
|
4050
|
+
"[",
|
|
4051
|
+
"]",
|
|
4052
|
+
"`",
|
|
4053
|
+
'"',
|
|
4054
|
+
"'",
|
|
4055
|
+
"/",
|
|
4056
|
+
"\\",
|
|
4057
|
+
",",
|
|
4058
|
+
";",
|
|
4059
|
+
]);
|
|
4060
|
+
|
|
4061
|
+
type MaskCommentsAndStringsOptions = {
|
|
4062
|
+
blankPropertyKeys?: boolean;
|
|
4063
|
+
};
|
|
4064
|
+
|
|
4065
|
+
export function maskCommentsAndStrings(
|
|
4066
|
+
source: string,
|
|
4067
|
+
fileName = "provider.ts",
|
|
4068
|
+
options: MaskCommentsAndStringsOptions = {},
|
|
4069
|
+
): string {
|
|
4070
|
+
const blankPropertyKeys = options.blankPropertyKeys === true;
|
|
4071
|
+
const cacheKey = `${blankPropertyKeys ? "blank-keys" : "preserve-keys"}\0${source}`;
|
|
4072
|
+
const cached = maskCache.get(cacheKey);
|
|
4073
|
+
if (cached !== undefined) {
|
|
4074
|
+
return cached;
|
|
4075
|
+
}
|
|
4076
|
+
const masked = computeMaskedSource(source, fileName, blankPropertyKeys);
|
|
4077
|
+
if (maskCache.size >= MASK_CACHE_LIMIT) {
|
|
4078
|
+
const oldest = maskCache.keys().next();
|
|
4079
|
+
if (!oldest.done) {
|
|
4080
|
+
maskCache.delete(oldest.value);
|
|
2543
4081
|
}
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
4082
|
+
}
|
|
4083
|
+
maskCache.set(cacheKey, masked);
|
|
4084
|
+
return masked;
|
|
4085
|
+
}
|
|
4086
|
+
|
|
4087
|
+
function computeMaskedSource(
|
|
4088
|
+
source: string,
|
|
4089
|
+
fileName: string,
|
|
4090
|
+
blankPropertyKeys: boolean,
|
|
4091
|
+
): string {
|
|
4092
|
+
const transpiled = ts.transpileModule(source, {
|
|
4093
|
+
// Declaration files trigger an internal TypeScript Debug Failure when
|
|
4094
|
+
// passed to transpileModule. Parsing is all we need here, so always use a
|
|
4095
|
+
// synthetic implementation filename while retaining the real filename
|
|
4096
|
+
// for source mapping and sanitized diagnostics below.
|
|
4097
|
+
fileName: "provider.ts",
|
|
4098
|
+
reportDiagnostics: true,
|
|
4099
|
+
compilerOptions: { target: ts.ScriptTarget.Latest },
|
|
4100
|
+
});
|
|
4101
|
+
const sourceFile = ts.createSourceFile(
|
|
4102
|
+
fileName,
|
|
4103
|
+
source,
|
|
4104
|
+
ts.ScriptTarget.Latest,
|
|
4105
|
+
true,
|
|
4106
|
+
ts.ScriptKind.TS,
|
|
4107
|
+
);
|
|
4108
|
+
const parseDiagnostic = transpiled.diagnostics?.[0];
|
|
4109
|
+
if (parseDiagnostic) {
|
|
4110
|
+
const position = parseDiagnostic.start ?? 0;
|
|
4111
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(position);
|
|
4112
|
+
throw new Error(
|
|
4113
|
+
`Cannot safely scan TypeScript source ${sanitizeDiagnosticFileName(fileName)} at ${line + 1}:${character + 1}: ${ts.flattenDiagnosticMessageText(parseDiagnostic.messageText, "\n")}`,
|
|
4114
|
+
);
|
|
4115
|
+
}
|
|
4116
|
+
|
|
4117
|
+
const chars = source.split("");
|
|
4118
|
+
const maskRange = (start: number, end: number): void => {
|
|
4119
|
+
for (let index = start; index < end; index += 1) {
|
|
4120
|
+
// Preserve the backslash of a string line continuation (\ followed
|
|
4121
|
+
// by LF, CRLF, or lone CR) and every line terminator, so masked
|
|
4122
|
+
// string bodies remain syntactically valid and the mask output can
|
|
4123
|
+
// be re-parsed (mask(mask(x)) === mask(x)).
|
|
4124
|
+
if (
|
|
4125
|
+
source[index] === "\\" &&
|
|
4126
|
+
(source[index + 1] === "\n" || source[index + 1] === "\r")
|
|
4127
|
+
) {
|
|
4128
|
+
continue;
|
|
2552
4129
|
}
|
|
2553
|
-
index
|
|
2554
|
-
continue;
|
|
2555
|
-
}
|
|
2556
|
-
const quote = source[index];
|
|
2557
|
-
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
|
2558
|
-
continue;
|
|
2559
|
-
}
|
|
2560
|
-
const end = findStringEnd(source, index);
|
|
2561
|
-
if (end === -1) {
|
|
2562
|
-
break;
|
|
4130
|
+
if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " ";
|
|
2563
4131
|
}
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
4132
|
+
};
|
|
4133
|
+
const commentRanges = new Map<string, TS.CommentRange>();
|
|
4134
|
+
const addCommentRanges = (ranges: readonly TS.CommentRange[] | undefined): void => {
|
|
4135
|
+
for (const range of ranges ?? []) {
|
|
4136
|
+
commentRanges.set(`${range.pos}:${range.end}`, range);
|
|
2569
4137
|
}
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
4138
|
+
};
|
|
4139
|
+
|
|
4140
|
+
const visit = (node: TS.Node): void => {
|
|
4141
|
+
addCommentRanges(ts.getLeadingCommentRanges(source, node.getFullStart()));
|
|
4142
|
+
addCommentRanges(ts.getTrailingCommentRanges(source, node.getEnd()));
|
|
4143
|
+
|
|
4144
|
+
const start = node.getStart(sourceFile);
|
|
4145
|
+
if (ts.isStringLiteral(node)) {
|
|
4146
|
+
// Preserve quoted property keys ("response": ...) by default so range/key
|
|
4147
|
+
// scanners can still match them. Line scanners opt into blanking key bodies
|
|
4148
|
+
// as well, preventing key text from looking like executable source.
|
|
4149
|
+
const isQuotedPropertyKey =
|
|
4150
|
+
ts.isPropertyAssignment(node.parent) && node.parent.name === node;
|
|
4151
|
+
if (blankPropertyKeys || !isQuotedPropertyKey) {
|
|
4152
|
+
maskRange(start + 1, node.end - 1);
|
|
4153
|
+
} else {
|
|
4154
|
+
for (let index = start + 1; index < node.end - 1; index += 1) {
|
|
4155
|
+
if (
|
|
4156
|
+
source[index] === "\\" &&
|
|
4157
|
+
(source[index + 1] === "\n" || source[index + 1] === "\r")
|
|
4158
|
+
) {
|
|
4159
|
+
continue;
|
|
4160
|
+
}
|
|
4161
|
+
if (PRESERVED_PROPERTY_KEY_STRUCTURAL_CHARS.has(source[index] ?? "")) {
|
|
4162
|
+
chars[index] = " ";
|
|
4163
|
+
}
|
|
2574
4164
|
}
|
|
2575
4165
|
}
|
|
4166
|
+
} else if (ts.isRegularExpressionLiteral(node)) {
|
|
4167
|
+
let closeDelimiter = node.end - 1;
|
|
4168
|
+
while (closeDelimiter > start && /[A-Za-z]/.test(source[closeDelimiter] ?? "")) {
|
|
4169
|
+
closeDelimiter -= 1;
|
|
4170
|
+
}
|
|
4171
|
+
maskRange(start + 1, closeDelimiter);
|
|
4172
|
+
} else if (ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
4173
|
+
maskRange(start + 1, node.end - 1);
|
|
4174
|
+
} else if (node.kind === ts.SyntaxKind.TemplateHead) {
|
|
4175
|
+
maskRange(start + 1, node.end - 2);
|
|
4176
|
+
} else if (node.kind === ts.SyntaxKind.TemplateMiddle) {
|
|
4177
|
+
maskRange(start + 1, node.end - 2);
|
|
4178
|
+
} else if (node.kind === ts.SyntaxKind.TemplateTail) {
|
|
4179
|
+
maskRange(start + 1, node.end - 1);
|
|
2576
4180
|
}
|
|
2577
|
-
|
|
4181
|
+
|
|
4182
|
+
for (const child of node.getChildren(sourceFile)) visit(child);
|
|
4183
|
+
};
|
|
4184
|
+
|
|
4185
|
+
visit(sourceFile);
|
|
4186
|
+
for (const comment of commentRanges.values()) {
|
|
4187
|
+
const markerLength = 2;
|
|
4188
|
+
const bodyEnd =
|
|
4189
|
+
comment.kind === ts.SyntaxKind.MultiLineCommentTrivia
|
|
4190
|
+
? comment.end - markerLength
|
|
4191
|
+
: comment.end;
|
|
4192
|
+
maskRange(comment.pos + markerLength, bodyEnd);
|
|
2578
4193
|
}
|
|
2579
4194
|
return chars.join("");
|
|
2580
4195
|
}
|
|
2581
4196
|
|
|
4197
|
+
// Escapes every non-printable or formatting character before a submitter-
|
|
4198
|
+
// controlled filename is interpolated into a diagnostic that the CLI prints.
|
|
4199
|
+
// Category-based on purpose: enumerated code-point lists kept missing members
|
|
4200
|
+
// of the same class (C1 controls, U+061C, U+2028/U+2029 were each found
|
|
4201
|
+
// individually in review), so this escapes the whole Unicode categories —
|
|
4202
|
+
// Cc (controls), Cf (formatting, includes all Bidi_Control), Zl/Zp
|
|
4203
|
+
// (line/paragraph separators). Ordinary letters, digits, spaces, and CJK
|
|
4204
|
+
// filenames pass through unchanged.
|
|
4205
|
+
const DIAGNOSTIC_UNSAFE_CHARACTER = /\p{Cc}|\p{Cf}|\p{Zl}|\p{Zp}/u;
|
|
4206
|
+
|
|
4207
|
+
function sanitizeDiagnosticFileName(fileName: string): string {
|
|
4208
|
+
let sanitized = "";
|
|
4209
|
+
for (const character of fileName) {
|
|
4210
|
+
const codePoint = character.codePointAt(0);
|
|
4211
|
+
if (codePoint === undefined) continue;
|
|
4212
|
+
if (DIAGNOSTIC_UNSAFE_CHARACTER.test(character)) {
|
|
4213
|
+
sanitized +=
|
|
4214
|
+
codePoint <= 0xff
|
|
4215
|
+
? `\\x${codePoint.toString(16).padStart(2, "0")}`
|
|
4216
|
+
: `\\u{${codePoint.toString(16)}}`;
|
|
4217
|
+
} else {
|
|
4218
|
+
sanitized += character;
|
|
4219
|
+
}
|
|
4220
|
+
}
|
|
4221
|
+
return sanitized;
|
|
4222
|
+
}
|
|
4223
|
+
|
|
2582
4224
|
function skipWhitespaceAndComments(source: string, start: number, end: number): number {
|
|
2583
4225
|
let index = start;
|
|
2584
4226
|
while (index < end) {
|
|
@@ -2674,6 +4316,7 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2674
4316
|
const operations = Object.entries(provider.operations);
|
|
2675
4317
|
const missing: string[] = [];
|
|
2676
4318
|
const vacuous: string[] = [];
|
|
4319
|
+
const evidenceFreeScenario: string[] = [];
|
|
2677
4320
|
const placeholder: string[] = [];
|
|
2678
4321
|
const unsupported: string[] = [];
|
|
2679
4322
|
const generatedStarter: string[] = [];
|
|
@@ -2685,8 +4328,13 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2685
4328
|
missing.push(operationId);
|
|
2686
4329
|
continue;
|
|
2687
4330
|
}
|
|
2688
|
-
if (hasCheck && !hasUnsupported
|
|
2689
|
-
|
|
4331
|
+
if (hasCheck && !hasUnsupported) {
|
|
4332
|
+
const vacuousClass = vacuousHealthCaseClass(operation.healthCheck);
|
|
4333
|
+
if (vacuousClass === "empty-assertions") {
|
|
4334
|
+
vacuous.push(operationId);
|
|
4335
|
+
} else if (vacuousClass === "evidence-free-scenario") {
|
|
4336
|
+
evidenceFreeScenario.push(operationId);
|
|
4337
|
+
}
|
|
2690
4338
|
}
|
|
2691
4339
|
if (hasUnsupported) {
|
|
2692
4340
|
const reason = operation.healthCheckUnsupported?.reason ?? "";
|
|
@@ -2713,14 +4361,36 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2713
4361
|
);
|
|
2714
4362
|
}
|
|
2715
4363
|
|
|
2716
|
-
if (vacuous.length > 0) {
|
|
4364
|
+
if (vacuous.length > 0 || evidenceFreeScenario.length > 0) {
|
|
4365
|
+
const remediationParts: string[] = [];
|
|
4366
|
+
if (vacuous.length > 0) {
|
|
4367
|
+
remediationParts.push(
|
|
4368
|
+
`healthCheck.assertions for ${vacuous.join(", ")} is empty — assert on status and response shape (e.g. throw or return {status:'degraded'} when the upstream contract breaks), or declare healthCheckUnsupported with a specific reason if the operation genuinely cannot be probed.`,
|
|
4369
|
+
);
|
|
4370
|
+
}
|
|
4371
|
+
if (evidenceFreeScenario.length > 0) {
|
|
4372
|
+
remediationParts.push(
|
|
4373
|
+
`healthCheck scenario for ${evidenceFreeScenario.join(", ")} asserts nothing beyond transport — add an assert step whose expression reads the probe response (a field under data, an extracted value, or a quantifier over response rows), not just status_2xx or a bare response-envelope check.`,
|
|
4374
|
+
);
|
|
4375
|
+
}
|
|
4376
|
+
const message =
|
|
4377
|
+
evidenceFreeScenario.length === 0
|
|
4378
|
+
? "One or more operations have healthCheck cases with empty assertions."
|
|
4379
|
+
: vacuous.length === 0
|
|
4380
|
+
? "One or more operations have healthCheck scenarios without response evidence."
|
|
4381
|
+
: "One or more operations have healthCheck cases with empty assertions or evidence-free scenarios.";
|
|
2717
4382
|
return blocker(
|
|
2718
4383
|
"health-coverage",
|
|
2719
4384
|
"health",
|
|
2720
|
-
|
|
2721
|
-
|
|
4385
|
+
message,
|
|
4386
|
+
remediationParts.join(" "),
|
|
2722
4387
|
CATEGORY_MAX_POINTS.health,
|
|
2723
|
-
|
|
4388
|
+
[
|
|
4389
|
+
...vacuous.map((operationId) => `${operationId}: empty healthCheck.assertions`),
|
|
4390
|
+
...evidenceFreeScenario.map(
|
|
4391
|
+
(operationId) => `${operationId}: healthCheck scenario asserts nothing beyond transport`,
|
|
4392
|
+
),
|
|
4393
|
+
],
|
|
2724
4394
|
);
|
|
2725
4395
|
}
|
|
2726
4396
|
|
|
@@ -2775,14 +4445,216 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2775
4445
|
);
|
|
2776
4446
|
}
|
|
2777
4447
|
|
|
2778
|
-
|
|
4448
|
+
type VacuousHealthCaseClass = "empty-assertions" | "evidence-free-scenario";
|
|
4449
|
+
|
|
4450
|
+
/**
|
|
4451
|
+
* Classify an operation whose every health case is vacuous. Returns undefined
|
|
4452
|
+
* when at least one case carries real coverage: an imperative case with a
|
|
4453
|
+
* non-vacuous assertions function, or a declarative case whose scenario reads
|
|
4454
|
+
* the probe response. When all cases are vacuous, the class picks the blocker
|
|
4455
|
+
* wording — "empty-assertions" whenever any vacuous case is imperative (the
|
|
4456
|
+
* pre-scenario behavior, kept byte-identical), "evidence-free-scenario" when
|
|
4457
|
+
* the suite is scenario-only.
|
|
4458
|
+
*/
|
|
4459
|
+
function vacuousHealthCaseClass(
|
|
2779
4460
|
healthCheck: ProviderDefinition["operations"][string]["healthCheck"],
|
|
2780
|
-
):
|
|
4461
|
+
): VacuousHealthCaseClass | undefined {
|
|
2781
4462
|
const cases = healthCheck?.cases;
|
|
2782
4463
|
if (!Array.isArray(cases) || cases.length === 0) {
|
|
4464
|
+
return "empty-assertions";
|
|
4465
|
+
}
|
|
4466
|
+
let sawImperativeCase = false;
|
|
4467
|
+
for (const healthCase of cases) {
|
|
4468
|
+
if (healthCase?.scenario !== undefined) {
|
|
4469
|
+
if (scenarioCarriesResponseEvidence(healthCase.scenario)) {
|
|
4470
|
+
return undefined;
|
|
4471
|
+
}
|
|
4472
|
+
continue;
|
|
4473
|
+
}
|
|
4474
|
+
sawImperativeCase = true;
|
|
4475
|
+
if (!isVacuousAssertionFunction(healthCase?.assertions)) {
|
|
4476
|
+
return undefined;
|
|
4477
|
+
}
|
|
4478
|
+
}
|
|
4479
|
+
return sawImperativeCase ? "empty-assertions" : "evidence-free-scenario";
|
|
4480
|
+
}
|
|
4481
|
+
|
|
4482
|
+
/**
|
|
4483
|
+
* A declarative case (`scenario`) carries real coverage when at least one
|
|
4484
|
+
* `assert` step reads the probe response beyond the transport envelope. The
|
|
4485
|
+
* SDK's own definitions drive this judgement:
|
|
4486
|
+
*
|
|
4487
|
+
* - The assert step is the scenario's only verdict-bearing construct
|
|
4488
|
+
* (`AssertResult.passed`). A guard can only attribute
|
|
4489
|
+
* degraded/expected_absence and stop the run (an escape hatch, not a health
|
|
4490
|
+
* verdict), and an operation step does not intrinsically fail on a non-2xx
|
|
4491
|
+
* response — that is what the explicit `status_2xx` predicate exists for. So
|
|
4492
|
+
* a scenario without a semantic assert step proves nothing beyond the
|
|
4493
|
+
* operation merely executing, which is the same emptiness the imperative
|
|
4494
|
+
* vacuity check flags.
|
|
4495
|
+
* - Leaves that cannot observe upstream content carry no evidence: predicates
|
|
4496
|
+
* whose operands read nothing produced by a step (pure literals,
|
|
4497
|
+
* attempt/credential metadata, and the `status_code`/`request_id`/`kind`
|
|
4498
|
+
* envelope fields of a step result — this is what makes the legacy transport
|
|
4499
|
+
* pin `status_2xx` on `["status_code"]` non-evidence), predicates comparing
|
|
4500
|
+
* a reference against an identical copy of itself (`equals(x, x)` holds for
|
|
4501
|
+
* any response), and positive-polarity presence/type pins (`exists`,
|
|
4502
|
+
* `type_is`, `not(not_exists(...))`, zero-bound `array_length_gte`) on the
|
|
4503
|
+
* bare `data` root — the declarative spellings of the legacy transport-only
|
|
4504
|
+
* assertion (`status === 200` + `typeof data === "object"`), satisfied by
|
|
4505
|
+
* any well-formed response body. Polarity matters: `not(exists(data))`
|
|
4506
|
+
* ("this probe must return no body") is falsified by ordinary responses, so
|
|
4507
|
+
* the carve-out only applies where the pin appears un-negated.
|
|
4508
|
+
* - Everything else counts: paths under `data`, extract-step results,
|
|
4509
|
+
* `duration_ms` (parity with imperative `ctx.durationMs` reads),
|
|
4510
|
+
* `status_2xx` applied to a body path (envelope-style upstreams answer HTTP
|
|
4511
|
+
* 200 and carry the real code in the body), and quantifiers, which iterate
|
|
4512
|
+
* response rows by construction.
|
|
4513
|
+
*
|
|
4514
|
+
* Fail-open contract (same as isVacuousAssertionFunction): the provider module
|
|
4515
|
+
* already validated this scenario against HealthScenarioSchema in its own SDK
|
|
4516
|
+
* at import time, so any shape this walk does not recognize (unknown step
|
|
4517
|
+
* kind, expression kind, operator, or reference namespace) indicates a newer
|
|
4518
|
+
* grammar, not an empty probe — treat it as evidence rather than flag it.
|
|
4519
|
+
* The inverse direction is accepted and documented: a determined author can
|
|
4520
|
+
* still fabricate a read that never fails (a decorative deep path inside a
|
|
4521
|
+
* disjunction, a degenerate bound). That effort is comparable to writing the
|
|
4522
|
+
* real one-line assertion — the same accepted limitation as the imperative
|
|
4523
|
+
* check's decorative-ctx-read — and the live `--smoke` probe remains the
|
|
4524
|
+
* runtime defense.
|
|
4525
|
+
*/
|
|
4526
|
+
export function scenarioCarriesResponseEvidence(scenario: unknown): boolean {
|
|
4527
|
+
if (!isRecord(scenario) || !Array.isArray(scenario.steps)) {
|
|
4528
|
+
return true;
|
|
4529
|
+
}
|
|
4530
|
+
let sawUnknownStepKind = false;
|
|
4531
|
+
for (const step of scenario.steps) {
|
|
4532
|
+
if (!isRecord(step)) {
|
|
4533
|
+
sawUnknownStepKind = true;
|
|
4534
|
+
continue;
|
|
4535
|
+
}
|
|
4536
|
+
if (step.kind === "assert") {
|
|
4537
|
+
if (expressionReadsResponse(step.expression)) {
|
|
4538
|
+
return true;
|
|
4539
|
+
}
|
|
4540
|
+
continue;
|
|
4541
|
+
}
|
|
4542
|
+
if (step.kind !== "operation" && step.kind !== "extract" && step.kind !== "guard") {
|
|
4543
|
+
sawUnknownStepKind = true;
|
|
4544
|
+
}
|
|
4545
|
+
}
|
|
4546
|
+
return sawUnknownStepKind;
|
|
4547
|
+
}
|
|
4548
|
+
|
|
4549
|
+
/**
|
|
4550
|
+
* Predicate operators of the current scenario grammar. An operator outside
|
|
4551
|
+
* this set indicates a newer grammar and fails open (counts as evidence),
|
|
4552
|
+
* because a future operator may source the response through fields this walk
|
|
4553
|
+
* does not know about.
|
|
4554
|
+
*/
|
|
4555
|
+
const KNOWN_SCENARIO_PREDICATE_OPERATORS: ReadonlySet<string> = new Set([
|
|
4556
|
+
"exists",
|
|
4557
|
+
"not_exists",
|
|
4558
|
+
"non_empty",
|
|
4559
|
+
"is_true",
|
|
4560
|
+
"equals",
|
|
4561
|
+
"not_equals",
|
|
4562
|
+
"contains",
|
|
4563
|
+
"matches",
|
|
4564
|
+
"number_gt",
|
|
4565
|
+
"number_gte",
|
|
4566
|
+
"number_lt",
|
|
4567
|
+
"number_lte",
|
|
4568
|
+
"array_length_eq",
|
|
4569
|
+
"array_length_gte",
|
|
4570
|
+
"array_length_lte",
|
|
4571
|
+
"status_2xx",
|
|
4572
|
+
"type_is",
|
|
4573
|
+
]);
|
|
4574
|
+
|
|
4575
|
+
function expressionReadsResponse(expression: unknown, negationDepth = 0): boolean {
|
|
4576
|
+
if (!isRecord(expression)) {
|
|
4577
|
+
return true;
|
|
4578
|
+
}
|
|
4579
|
+
if (expression.kind === "all" || expression.kind === "any") {
|
|
4580
|
+
return Array.isArray(expression.clauses)
|
|
4581
|
+
? expression.clauses.some((clause) => expressionReadsResponse(clause, negationDepth))
|
|
4582
|
+
: true;
|
|
4583
|
+
}
|
|
4584
|
+
if (expression.kind === "not") {
|
|
4585
|
+
return expressionReadsResponse(expression.clause, negationDepth + 1);
|
|
4586
|
+
}
|
|
4587
|
+
if (expression.kind === "quantifier") {
|
|
4588
|
+
return true;
|
|
4589
|
+
}
|
|
4590
|
+
if (expression.kind !== "predicate" || typeof expression.operator !== "string") {
|
|
4591
|
+
return true;
|
|
4592
|
+
}
|
|
4593
|
+
const operator = expression.operator;
|
|
4594
|
+
if (!KNOWN_SCENARIO_PREDICATE_OPERATORS.has(operator)) {
|
|
4595
|
+
return true;
|
|
4596
|
+
}
|
|
4597
|
+
const actualReads = operandReadsStepOutput(expression.actual);
|
|
4598
|
+
const expectedReads = operandReadsStepOutput(expression.expected);
|
|
4599
|
+
if (!actualReads && !expectedReads) {
|
|
4600
|
+
return false;
|
|
4601
|
+
}
|
|
4602
|
+
// A predicate comparing a reference against an identical copy of itself
|
|
4603
|
+
// (equals(x, x) and friends) holds or fails identically for every response,
|
|
4604
|
+
// so the reads discriminate nothing.
|
|
4605
|
+
if (
|
|
4606
|
+
actualReads &&
|
|
4607
|
+
expectedReads &&
|
|
4608
|
+
JSON.stringify(expression.actual) === JSON.stringify(expression.expected)
|
|
4609
|
+
) {
|
|
4610
|
+
return false;
|
|
4611
|
+
}
|
|
4612
|
+
// Presence/type pins on the whole `data` root are satisfied by any
|
|
4613
|
+
// well-formed JSON body — but only in positive polarity. Under an odd
|
|
4614
|
+
// number of `not` wrappers the same pin means "this probe must return no
|
|
4615
|
+
// body / a different shape", which ordinary responses falsify, so it is a
|
|
4616
|
+
// real (if unusual) constraint and stays evidence.
|
|
4617
|
+
const positivePolarity = negationDepth % 2 === 0;
|
|
4618
|
+
const bareEnvelopePin = positivePolarity
|
|
4619
|
+
? operator === "exists" ||
|
|
4620
|
+
operator === "type_is" ||
|
|
4621
|
+
(operator === "array_length_gte" && expression.expected === 0)
|
|
4622
|
+
: operator === "not_exists";
|
|
4623
|
+
if (bareEnvelopePin && !expectedReads && isBareResponseEnvelope(expression.actual)) {
|
|
4624
|
+
return false;
|
|
4625
|
+
}
|
|
4626
|
+
return true;
|
|
4627
|
+
}
|
|
4628
|
+
|
|
4629
|
+
function operandReadsStepOutput(operand: unknown): boolean {
|
|
4630
|
+
if (!isRecord(operand) || !isRecord(operand.ref)) {
|
|
4631
|
+
return false;
|
|
4632
|
+
}
|
|
4633
|
+
const reference = operand.ref;
|
|
4634
|
+
if (reference.namespace === "attempt" || reference.namespace === "credentials") {
|
|
4635
|
+
return false;
|
|
4636
|
+
}
|
|
4637
|
+
if (reference.namespace !== "steps") {
|
|
4638
|
+
return true;
|
|
4639
|
+
}
|
|
4640
|
+
if (!Array.isArray(reference.path) || reference.path.length === 0) {
|
|
2783
4641
|
return true;
|
|
2784
4642
|
}
|
|
2785
|
-
|
|
4643
|
+
const head = reference.path[0];
|
|
4644
|
+
return head !== "status_code" && head !== "request_id" && head !== "kind";
|
|
4645
|
+
}
|
|
4646
|
+
|
|
4647
|
+
function isBareResponseEnvelope(operand: unknown): boolean {
|
|
4648
|
+
if (!isRecord(operand) || !isRecord(operand.ref)) {
|
|
4649
|
+
return false;
|
|
4650
|
+
}
|
|
4651
|
+
const reference = operand.ref;
|
|
4652
|
+
return (
|
|
4653
|
+
reference.namespace === "steps" &&
|
|
4654
|
+
Array.isArray(reference.path) &&
|
|
4655
|
+
reference.path.length === 1 &&
|
|
4656
|
+
reference.path[0] === "data"
|
|
4657
|
+
);
|
|
2786
4658
|
}
|
|
2787
4659
|
|
|
2788
4660
|
function isVacuousAssertionFunction(assertions: unknown): boolean {
|
|
@@ -3464,25 +5336,6 @@ function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
|
|
|
3464
5336
|
};
|
|
3465
5337
|
}
|
|
3466
5338
|
|
|
3467
|
-
if (authMode === "none") {
|
|
3468
|
-
const securedOperations = Object.entries(provider.operations).filter(
|
|
3469
|
-
([, operation]) => operation.annotations?.openWorld === false,
|
|
3470
|
-
);
|
|
3471
|
-
if (securedOperations.length > 0) {
|
|
3472
|
-
return {
|
|
3473
|
-
id: "auth-safety",
|
|
3474
|
-
category: "auth",
|
|
3475
|
-
level: "warn",
|
|
3476
|
-
status: "warn",
|
|
3477
|
-
points: 7,
|
|
3478
|
-
maxPoints: CATEGORY_MAX_POINTS.auth,
|
|
3479
|
-
message: "Provider is no-auth but at least one operation is not marked openWorld.",
|
|
3480
|
-
remediation: `Either set \`auth.mode\` to the upstream auth model, or mark these public no-auth operations with \`annotations.openWorld: true\`: ${securedOperations.map(([operationId]) => operationId).join(", ")}.`,
|
|
3481
|
-
evidence: securedOperations.map(([operationId]) => operationId),
|
|
3482
|
-
};
|
|
3483
|
-
}
|
|
3484
|
-
}
|
|
3485
|
-
|
|
3486
5339
|
return pass(
|
|
3487
5340
|
"auth-safety",
|
|
3488
5341
|
"auth",
|
|
@@ -3832,9 +5685,7 @@ type LineStringLiteral = {
|
|
|
3832
5685
|
// Stable identity for the container a literal sits in ("top" when the
|
|
3833
5686
|
// literal is not inside any bracket on the line).
|
|
3834
5687
|
function literalContainerKey(literal: LineStringLiteral): string {
|
|
3835
|
-
return literal.container
|
|
3836
|
-
? `${literal.container.bracket}${literal.container.index}`
|
|
3837
|
-
: "top";
|
|
5688
|
+
return literal.container ? `${literal.container.bracket}${literal.container.index}` : "top";
|
|
3838
5689
|
}
|
|
3839
5690
|
|
|
3840
5691
|
// Single-pass line tokenizer: extracts every string literal with its span and
|
|
@@ -3975,8 +5826,9 @@ function shannonEntropy(value: string): number {
|
|
|
3975
5826
|
|
|
3976
5827
|
function guessSecretName(line: string): string {
|
|
3977
5828
|
const match =
|
|
3978
|
-
/\b(
|
|
3979
|
-
/["']?([A-Za-z_$][\w$-]*)["']?\s
|
|
5829
|
+
/\b[A-Za-z_$][\w$]*(?:\s*\.\s*([A-Za-z_$][\w$]*))+\s*=/.exec(line) ??
|
|
5830
|
+
/["']?([A-Za-z_$][\w$-]*)["']?\s*:\s*["'`]/.exec(line) ??
|
|
5831
|
+
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line);
|
|
3980
5832
|
const raw = match?.[1] ?? "SECRET";
|
|
3981
5833
|
return raw
|
|
3982
5834
|
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
@@ -3991,6 +5843,35 @@ const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/
|
|
|
3991
5843
|
// the three stay coherent.
|
|
3992
5844
|
const ENTROPY_CANDIDATE_MIN_LENGTH = 20;
|
|
3993
5845
|
|
|
5846
|
+
// A secret-ish name is only supporting evidence. Shorter values must also look
|
|
5847
|
+
// credential-like, and known config tokens/numeric values are never candidates.
|
|
5848
|
+
const SECRET_NAMED_ENV_VALUE_MIN_LENGTH = 4;
|
|
5849
|
+
const SECRET_NAMED_ENV_UNSTRUCTURED_MIN_LENGTH = 8;
|
|
5850
|
+
const NON_SECRET_ENV_VALUE_TOKENS = new Set([
|
|
5851
|
+
"true",
|
|
5852
|
+
"false",
|
|
5853
|
+
"yes",
|
|
5854
|
+
"no",
|
|
5855
|
+
"on",
|
|
5856
|
+
"off",
|
|
5857
|
+
"none",
|
|
5858
|
+
"null",
|
|
5859
|
+
"nil",
|
|
5860
|
+
"auto",
|
|
5861
|
+
"default",
|
|
5862
|
+
"local",
|
|
5863
|
+
"debug",
|
|
5864
|
+
"info",
|
|
5865
|
+
"warn",
|
|
5866
|
+
"error",
|
|
5867
|
+
"always",
|
|
5868
|
+
"never",
|
|
5869
|
+
"enabled",
|
|
5870
|
+
"disabled",
|
|
5871
|
+
"0",
|
|
5872
|
+
"1",
|
|
5873
|
+
]);
|
|
5874
|
+
|
|
3994
5875
|
const SECRET_PATTERNS: Array<[string, RegExp]> = [
|
|
3995
5876
|
["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
|
|
3996
5877
|
["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
|
|
@@ -4002,12 +5883,151 @@ const SECRET_PATTERNS: Array<[string, RegExp]> = [
|
|
|
4002
5883
|
],
|
|
4003
5884
|
];
|
|
4004
5885
|
|
|
4005
|
-
|
|
5886
|
+
type SafeLoadProviderResult =
|
|
5887
|
+
| { ok: true; provider: ProviderDefinition | undefined }
|
|
5888
|
+
| { ok: false; error: unknown };
|
|
5889
|
+
|
|
5890
|
+
async function safeLoadProvider(providerRoot: string): Promise<SafeLoadProviderResult> {
|
|
5891
|
+
try {
|
|
5892
|
+
return { ok: true, provider: await loadProvider(providerRoot) };
|
|
5893
|
+
} catch (error) {
|
|
5894
|
+
return { ok: false, error };
|
|
5895
|
+
}
|
|
5896
|
+
}
|
|
5897
|
+
|
|
5898
|
+
const UNRENDERABLE_LOAD_ERROR = "<thrown value could not be rendered>";
|
|
5899
|
+
|
|
5900
|
+
function formatLoadErrorEvidence(error: unknown, providerRoot: string): string[] {
|
|
5901
|
+
const evidence: string[] = [];
|
|
5902
|
+
const seen = new Set<object>();
|
|
5903
|
+
let current: unknown = error;
|
|
5904
|
+
let frame = 0;
|
|
5905
|
+
while (frame < MAX_LOAD_ERROR_EVIDENCE) {
|
|
5906
|
+
if (typeof current === "object" || typeof current === "function") {
|
|
5907
|
+
if (current !== null && seen.has(current)) {
|
|
5908
|
+
break;
|
|
5909
|
+
}
|
|
5910
|
+
if (current !== null) seen.add(current);
|
|
5911
|
+
}
|
|
5912
|
+
evidence.push(
|
|
5913
|
+
`${frame === 0 ? "Load error" : "Cause"}: ${sanitizeLoadErrorText(
|
|
5914
|
+
formatThrownValue(current),
|
|
5915
|
+
providerRoot,
|
|
5916
|
+
)}`,
|
|
5917
|
+
);
|
|
5918
|
+
frame += 1;
|
|
5919
|
+
const cause = readThrownValueCause(current);
|
|
5920
|
+
if (!cause.found || cause.value === undefined || cause.value === null) {
|
|
5921
|
+
break;
|
|
5922
|
+
}
|
|
5923
|
+
current = cause.value;
|
|
5924
|
+
}
|
|
5925
|
+
return evidence;
|
|
5926
|
+
}
|
|
5927
|
+
|
|
5928
|
+
function formatThrownValue(value: unknown): string {
|
|
4006
5929
|
try {
|
|
4007
|
-
|
|
5930
|
+
if (value instanceof Error) {
|
|
5931
|
+
return String(value.message);
|
|
5932
|
+
}
|
|
5933
|
+
if (
|
|
5934
|
+
value !== null &&
|
|
5935
|
+
(typeof value === "object" || typeof value === "function") &&
|
|
5936
|
+
"message" in value
|
|
5937
|
+
) {
|
|
5938
|
+
return String((value as { message?: unknown }).message);
|
|
5939
|
+
}
|
|
5940
|
+
return `<non-Error value thrown: ${String(value)}>`;
|
|
4008
5941
|
} catch {
|
|
4009
|
-
return
|
|
5942
|
+
return UNRENDERABLE_LOAD_ERROR;
|
|
5943
|
+
}
|
|
5944
|
+
}
|
|
5945
|
+
|
|
5946
|
+
function readThrownValueCause(value: unknown): { found: boolean; value?: unknown } {
|
|
5947
|
+
try {
|
|
5948
|
+
if (
|
|
5949
|
+
value === null ||
|
|
5950
|
+
(typeof value !== "object" && typeof value !== "function") ||
|
|
5951
|
+
!("cause" in value)
|
|
5952
|
+
) {
|
|
5953
|
+
return { found: false };
|
|
5954
|
+
}
|
|
5955
|
+
return { found: true, value: (value as { cause?: unknown }).cause };
|
|
5956
|
+
} catch {
|
|
5957
|
+
return { found: false };
|
|
5958
|
+
}
|
|
5959
|
+
}
|
|
5960
|
+
|
|
5961
|
+
function isPlausibleSecretNamedEnvValue(value: string): boolean {
|
|
5962
|
+
const normalized = value.trim();
|
|
5963
|
+
if (normalized.length < SECRET_NAMED_ENV_VALUE_MIN_LENGTH) return false;
|
|
5964
|
+
if (NON_SECRET_ENV_VALUE_TOKENS.has(normalized.toLowerCase())) return false;
|
|
5965
|
+
if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) return false;
|
|
5966
|
+
if (normalized.length >= SECRET_NAMED_ENV_UNSTRUCTURED_MIN_LENGTH) return true;
|
|
5967
|
+
|
|
5968
|
+
const hasLetter = /[A-Za-z]/.test(normalized);
|
|
5969
|
+
const hasDigitOrSymbol = /[^A-Za-z\s]/.test(normalized);
|
|
5970
|
+
return hasLetter && hasDigitOrSymbol;
|
|
5971
|
+
}
|
|
5972
|
+
|
|
5973
|
+
function replaceEnvValue(
|
|
5974
|
+
input: string,
|
|
5975
|
+
value: string,
|
|
5976
|
+
requireIdentifierBoundaries: boolean,
|
|
5977
|
+
): string {
|
|
5978
|
+
if (!requireIdentifierBoundaries) return input.replaceAll(value, "[REDACTED]");
|
|
5979
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5980
|
+
return input.replace(
|
|
5981
|
+
new RegExp(`(?<![A-Za-z0-9_])${escaped}(?![A-Za-z0-9_])`, "g"),
|
|
5982
|
+
"[REDACTED]",
|
|
5983
|
+
);
|
|
5984
|
+
}
|
|
5985
|
+
|
|
5986
|
+
function sanitizeLoadErrorText(value: string, providerRoot: string): string {
|
|
5987
|
+
let output = value.replace(
|
|
5988
|
+
/(^|[^A-Za-z0-9_.])((?:\/[^\s"'`]+)+)/g,
|
|
5989
|
+
(_match, prefix: string, rawPath: string) => {
|
|
5990
|
+
const pathCandidate = rawPath.replace(/[),.;:!?]+$/u, "");
|
|
5991
|
+
const relativePath = relative(providerRoot, resolve(pathCandidate));
|
|
5992
|
+
const insideProvider =
|
|
5993
|
+
relativePath === "" ||
|
|
5994
|
+
(relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
|
|
5995
|
+
return `${prefix}${insideProvider ? relativePath || "." : "[REDACTED_PATH]"}`;
|
|
5996
|
+
},
|
|
5997
|
+
);
|
|
5998
|
+
output = output.replace(/[A-Za-z]:[\\/][^\s"'`]+/g, "[REDACTED_PATH]");
|
|
5999
|
+
output = output.replace(
|
|
6000
|
+
/((?:password|token|secret|api[_-]?key|credential|authorization)\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|["'`]?[^\s,;"'`]+)/gi,
|
|
6001
|
+
"$1[REDACTED]",
|
|
6002
|
+
);
|
|
6003
|
+
const envValues: Array<{ value: string; requireIdentifierBoundaries: boolean }> = [];
|
|
6004
|
+
for (const [name, item] of Object.entries(process.env)) {
|
|
6005
|
+
if (typeof item !== "string") continue;
|
|
6006
|
+
const secretNamed = SECRETISH_IDENTIFIER_PATTERN.test(name);
|
|
6007
|
+
let shouldRedact = secretNamed;
|
|
6008
|
+
if (secretNamed) {
|
|
6009
|
+
shouldRedact = isPlausibleSecretNamedEnvValue(item);
|
|
6010
|
+
} else if (item.length >= ENTROPY_CANDIDATE_MIN_LENGTH && shouldConsiderEntropyValue(item)) {
|
|
6011
|
+
const charset = classifyEntropyCharset(item);
|
|
6012
|
+
const threshold = charset === "hex" ? 3.0 : 4.5;
|
|
6013
|
+
shouldRedact = charset !== undefined && shannonEntropy(item) >= threshold;
|
|
6014
|
+
}
|
|
6015
|
+
if (shouldRedact) {
|
|
6016
|
+
envValues.push({
|
|
6017
|
+
value: item,
|
|
6018
|
+
requireIdentifierBoundaries:
|
|
6019
|
+
secretNamed && item.trim().length < ENTROPY_CANDIDATE_MIN_LENGTH,
|
|
6020
|
+
});
|
|
6021
|
+
}
|
|
6022
|
+
}
|
|
6023
|
+
envValues.sort((a, b) => b.value.length - a.value.length);
|
|
6024
|
+
for (const envValue of envValues) {
|
|
6025
|
+
output = replaceEnvValue(output, envValue.value, envValue.requireIdentifierBoundaries);
|
|
4010
6026
|
}
|
|
6027
|
+
output = redact(output).replace(/\s+/g, " ");
|
|
6028
|
+
return output.length > MAX_LOAD_ERROR_MESSAGE_LENGTH
|
|
6029
|
+
? `${output.slice(0, MAX_LOAD_ERROR_MESSAGE_LENGTH - 1)}…`
|
|
6030
|
+
: output;
|
|
4011
6031
|
}
|
|
4012
6032
|
|
|
4013
6033
|
async function loadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
|