@apifuse/provider-sdk 2.2.0-beta.7 → 2.2.0-beta.70
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 +943 -51
- package/CHANGELOG.md +273 -1
- package/LICENSE +21 -0
- package/README.md +117 -29
- package/SUBMISSION.md +3 -2
- package/bin/apifuse-check.ts +293 -13
- package/bin/apifuse-dev.ts +42 -19
- package/bin/apifuse-migrate-deployment.ts +200 -0
- package/bin/apifuse-migrate-operation-declaration.ts +55 -0
- package/bin/apifuse-migrate-shape.ts +202 -0
- package/bin/apifuse-pack-check.ts +19 -6
- package/bin/apifuse-pack-smoke.ts +57 -2
- package/bin/apifuse-pack-types.ts +736 -38
- package/bin/apifuse-perf.ts +59 -23
- package/bin/apifuse-record.ts +923 -94
- package/bin/apifuse-submit-check.ts +2313 -281
- package/bin/apifuse-test.ts +3 -1
- 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 +30 -0
- package/dist/cli/create.d.ts +1 -1
- package/dist/cli/create.js +8 -2
- package/dist/cli/deployment-intent.d.ts +178 -0
- package/dist/cli/deployment-intent.js +277 -0
- package/dist/cli/local-connection.d.ts +111 -0
- package/dist/cli/local-connection.js +151 -0
- package/dist/cli/migrate-deployment-intent.d.ts +132 -0
- package/dist/cli/migrate-deployment-intent.js +1552 -0
- package/dist/cli/migrate-operation-declaration.d.ts +70 -0
- package/dist/cli/migrate-operation-declaration.js +2206 -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 +56 -0
- package/dist/cli/migrate-provider-shape.js +580 -0
- package/dist/cli/resolver-runtime.d.ts +21 -0
- package/dist/cli/resolver-runtime.js +31 -0
- package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +141 -23
- package/dist/cli/templates/provider/Dockerfile.tpl +1 -1
- package/dist/cli/templates/provider/README.md.tpl +41 -19
- 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 +202 -16
- package/dist/config/loader.js +491 -141
- 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 +34 -0
- package/dist/declaration-validation.js +305 -0
- package/dist/define.d.ts +71 -29
- package/dist/define.js +807 -69
- package/dist/dev.d.ts +5 -0
- package/dist/dev.js +8 -1
- package/dist/engine.d.ts +129 -0
- package/dist/engine.js +209 -0
- package/dist/error-observability.d.ts +7 -0
- package/dist/error-observability.js +61 -0
- package/dist/error-resolution.d.ts +18 -0
- package/dist/error-resolution.js +243 -0
- package/dist/errors.d.ts +74 -0
- package/dist/errors.js +99 -0
- package/dist/fixture-sanitization.d.ts +28 -0
- package/dist/fixture-sanitization.js +371 -0
- package/dist/handle-meta.d.ts +90 -0
- package/dist/handle-meta.js +73 -0
- package/dist/handle.d.ts +226 -0
- package/dist/handle.js +345 -0
- package/dist/health-scenario.d.ts +1901 -0
- package/dist/health-scenario.js +653 -0
- package/dist/i18n/catalog.js +21 -1
- package/dist/i18n/error-messages.d.ts +86 -0
- package/dist/i18n/error-messages.js +192 -0
- package/dist/i18n/index.d.ts +1 -0
- package/dist/i18n/index.js +1 -0
- package/dist/i18n/keys.d.ts +12 -0
- package/dist/i18n/keys.js +76 -5
- package/dist/i18n/operation-locale-namespace.d.ts +2 -0
- package/dist/i18n/operation-locale-namespace.js +7 -0
- package/dist/index.d.ts +40 -15
- package/dist/index.js +29 -11
- package/dist/lint.d.ts +43 -11
- package/dist/lint.js +2512 -177
- 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 +18 -8
- package/dist/provider.js +11 -6
- package/dist/runtime/auth-flow.d.ts +6 -1
- package/dist/runtime/auth-flow.js +8 -0
- package/dist/runtime/bounded-expiring-map.d.ts +21 -0
- package/dist/runtime/bounded-expiring-map.js +64 -0
- package/dist/runtime/browser-telemetry-binding.d.ts +5 -0
- package/dist/runtime/browser-telemetry-binding.js +83 -0
- package/dist/runtime/browser-telemetry.d.ts +76 -0
- package/dist/runtime/browser-telemetry.js +165 -0
- package/dist/runtime/browser.d.ts +4 -0
- package/dist/runtime/browser.js +688 -83
- package/dist/runtime/cache-telemetry.d.ts +71 -0
- package/dist/runtime/cache-telemetry.js +177 -0
- package/dist/runtime/cache.d.ts +3 -0
- package/dist/runtime/cache.js +245 -23
- package/dist/runtime/capability-response.d.ts +3 -0
- package/dist/runtime/capability-response.js +56 -0
- package/dist/runtime/capability-telemetry.d.ts +46 -0
- package/dist/runtime/capability-telemetry.js +68 -0
- package/dist/runtime/chrome149-header-order.d.ts +58 -0
- package/dist/runtime/chrome149-header-order.js +289 -0
- package/dist/runtime/diagnostic-env-values.d.ts +5 -0
- package/dist/runtime/diagnostic-env-values.js +57 -0
- package/dist/runtime/diagnostic-env.d.ts +14 -0
- package/dist/runtime/diagnostic-env.js +44 -0
- package/dist/runtime/diagnostic-redactor.d.ts +54 -0
- package/dist/runtime/diagnostic-redactor.js +571 -0
- package/dist/runtime/egress-lease.d.ts +65 -0
- package/dist/runtime/egress-lease.js +236 -0
- package/dist/runtime/engine-mode.d.ts +74 -0
- package/dist/runtime/engine-mode.js +152 -0
- package/dist/runtime/env.js +14 -1
- package/dist/runtime/executor.d.ts +3 -2
- package/dist/runtime/executor.js +23 -26
- package/dist/runtime/handle-wordlist.d.ts +43 -0
- package/dist/runtime/handle-wordlist.js +256 -0
- package/dist/runtime/handle.d.ts +71 -0
- package/dist/runtime/handle.js +908 -0
- package/dist/runtime/http-telemetry-binding.d.ts +10 -0
- package/dist/runtime/http-telemetry-binding.js +152 -0
- package/dist/runtime/http-telemetry-guard.d.ts +16 -0
- package/dist/runtime/http-telemetry-guard.js +202 -0
- package/dist/runtime/http-telemetry.d.ts +104 -0
- package/dist/runtime/http-telemetry.js +190 -0
- package/dist/runtime/http.d.ts +34 -0
- package/dist/runtime/http.js +680 -121
- package/dist/runtime/insights.js +2 -2
- package/dist/runtime/instrumentation.d.ts +2 -2
- package/dist/runtime/instrumentation.js +462 -19
- 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 +105 -0
- package/dist/runtime/native-network.js +1433 -0
- package/dist/runtime/native-telemetry.d.ts +142 -0
- package/dist/runtime/native-telemetry.js +231 -0
- package/dist/runtime/ocr-telemetry.d.ts +82 -0
- package/dist/runtime/ocr-telemetry.js +192 -0
- package/dist/runtime/ocr.d.ts +29 -0
- package/dist/runtime/ocr.js +468 -0
- package/dist/runtime/otlp.d.ts +71 -2
- package/dist/runtime/otlp.js +398 -16
- package/dist/runtime/provider.d.ts +7 -0
- package/dist/runtime/provider.js +14 -0
- package/dist/runtime/proxy-errors.js +6 -2
- package/dist/runtime/proxy-nodemaven.d.ts +56 -0
- package/dist/runtime/proxy-nodemaven.js +147 -0
- package/dist/runtime/proxy-retry-policy.d.ts +4 -1
- package/dist/runtime/proxy-retry-policy.js +11 -2
- package/dist/runtime/proxy-telemetry.d.ts +133 -3
- package/dist/runtime/proxy-telemetry.js +218 -70
- package/dist/runtime/redirect-hop-headers.d.ts +19 -0
- package/dist/runtime/redirect-hop-headers.js +58 -0
- package/dist/runtime/redirects.d.ts +61 -0
- package/dist/runtime/redirects.js +149 -0
- package/dist/runtime/redis.d.ts +1 -1
- package/dist/runtime/redis.js +4 -2
- package/dist/runtime/request-options.d.ts +75 -1
- package/dist/runtime/request-options.js +588 -0
- package/dist/runtime/request-telemetry.d.ts +70 -0
- package/dist/runtime/request-telemetry.js +229 -0
- package/dist/runtime/resolver-config.d.ts +13 -0
- package/dist/runtime/resolver-config.js +14 -0
- package/dist/runtime/resolver-public.d.ts +3 -0
- package/dist/runtime/resolver-public.js +1 -0
- package/dist/runtime/resolver-runtime-options.d.ts +14 -0
- package/dist/runtime/resolver-runtime-options.js +33 -0
- package/dist/runtime/resolver-shared.d.ts +24 -0
- package/dist/runtime/resolver-shared.js +69 -0
- package/dist/runtime/resolver-span-attributes.d.ts +34 -0
- package/dist/runtime/resolver-span-attributes.js +93 -0
- package/dist/runtime/resolver-telemetry.d.ts +135 -0
- package/dist/runtime/resolver-telemetry.js +204 -0
- package/dist/runtime/resolver-usage.d.ts +32 -0
- package/dist/runtime/resolver-usage.js +54 -0
- package/dist/runtime/resolver-vendors/bindings.d.ts +53 -0
- package/dist/runtime/resolver-vendors/bindings.js +45 -0
- package/dist/runtime/resolver-vendors/browser.d.ts +24 -0
- package/dist/runtime/resolver-vendors/browser.js +423 -0
- package/dist/runtime/resolver-vendors/capsolver.d.ts +22 -0
- package/dist/runtime/resolver-vendors/capsolver.js +544 -0
- package/dist/runtime/resolver-vendors/hosts.d.ts +23 -0
- package/dist/runtime/resolver-vendors/hosts.js +68 -0
- package/dist/runtime/resolver-vendors/hypersolutions.d.ts +27 -0
- package/dist/runtime/resolver-vendors/hypersolutions.js +369 -0
- package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
- package/dist/runtime/resolver-vendors/twocaptcha.js +425 -0
- package/dist/runtime/resolver-vendors/types.d.ts +125 -0
- package/dist/runtime/resolver-vendors/types.js +106 -0
- package/dist/runtime/resolver.d.ts +79 -0
- package/dist/runtime/resolver.js +1094 -0
- package/dist/runtime/state-telemetry.d.ts +79 -0
- package/dist/runtime/state-telemetry.js +194 -0
- package/dist/runtime/state.d.ts +10 -1
- package/dist/runtime/state.js +322 -82
- package/dist/runtime/stealth-akamai-sbsd.d.ts +54 -0
- package/dist/runtime/stealth-akamai-sbsd.js +142 -0
- package/dist/runtime/stealth-cookies.d.ts +24 -0
- package/dist/runtime/stealth-cookies.js +131 -0
- package/dist/runtime/stealth-owned-headers.d.ts +39 -0
- package/dist/runtime/stealth-owned-headers.js +57 -0
- package/dist/runtime/stealth-telemetry.d.ts +92 -0
- package/dist/runtime/stealth-telemetry.js +179 -0
- package/dist/runtime/stealth.d.ts +45 -6
- package/dist/runtime/stealth.js +1775 -320
- package/dist/runtime/stt-telemetry.d.ts +77 -0
- package/dist/runtime/stt-telemetry.js +176 -0
- package/dist/runtime/stt.js +18 -19
- 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 +17 -0
- package/dist/runtime/trace.js +176 -25
- package/dist/runtime-boundary-lint.d.ts +137 -0
- package/dist/runtime-boundary-lint.js +590 -0
- package/dist/schema.d.ts +20 -0
- package/dist/schema.js +72 -2
- 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/error-stack-frames.d.ts +35 -0
- package/dist/server/error-stack-frames.js +219 -0
- package/dist/server/index.d.ts +21 -2
- package/dist/server/index.js +11 -2
- package/dist/server/runtime-diagnostics.d.ts +165 -0
- package/dist/server/runtime-diagnostics.js +360 -0
- package/dist/server/sdk-version.d.ts +3 -0
- package/dist/server/sdk-version.js +19 -0
- 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-redaction.js +8 -33
- package/dist/server/self-test.d.ts +18 -3
- package/dist/server/self-test.js +139 -76
- package/dist/server/sensitive-values.d.ts +26 -0
- package/dist/server/sensitive-values.js +137 -0
- package/dist/server/serve-implementation.d.ts +285 -0
- package/dist/server/serve-implementation.js +3172 -0
- package/dist/server/serve.d.ts +1 -75
- package/dist/server/serve.js +1 -1170
- package/dist/server/state-backend-report.d.ts +70 -0
- package/dist/server/state-backend-report.js +113 -0
- package/dist/server/trace-output.d.ts +7 -0
- package/dist/server/trace-output.js +111 -0
- package/dist/server/types.d.ts +37 -5
- package/dist/server/types.js +34 -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 +237 -0
- package/dist/stateful/stateful-provider-runtime-context.d.ts +35 -0
- package/dist/stateful/stateful-provider-runtime-context.js +61 -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 +15 -4
- package/dist/stealth/profiles.js +169 -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 +509 -25
- package/dist/trace-sanitization.d.ts +6 -0
- package/dist/trace-sanitization.js +68 -0
- package/dist/types.d.ts +899 -210
- package/dist/types.js +1 -0
- package/dist/typescript-module.d.ts +39 -0
- package/dist/typescript-module.js +72 -0
- package/package.json +52 -6
- package/src/auth-turn/index.ts +1 -1
- package/src/auth.ts +118 -0
- package/src/ceremonies/index.ts +189 -46
- package/src/cli/commands.ts +37 -0
- package/src/cli/create.ts +8 -2
- package/src/cli/deployment-intent.ts +364 -0
- package/src/cli/local-connection.ts +221 -0
- package/src/cli/migrate-deployment-intent.ts +1908 -0
- package/src/cli/migrate-operation-declaration.ts +3026 -0
- package/src/cli/migrate-operation-shape.ts +184 -0
- package/src/cli/migrate-provider-shape.ts +778 -0
- package/src/cli/resolver-runtime.ts +72 -0
- package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +141 -23
- package/src/cli/templates/provider/Dockerfile.tpl +1 -1
- package/src/cli/templates/provider/README.md.tpl +41 -19
- 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 +774 -181
- 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 +376 -0
- package/src/define.ts +1105 -131
- package/src/dev.ts +13 -1
- package/src/engine.ts +390 -0
- package/src/error-observability.ts +64 -0
- package/src/error-resolution.ts +269 -0
- package/src/errors.ts +157 -0
- package/src/fixture-sanitization.ts +422 -0
- package/src/handle-meta.ts +143 -0
- package/src/handle.ts +694 -0
- package/src/health-scenario.ts +960 -0
- package/src/i18n/catalog.ts +23 -1
- package/src/i18n/error-messages.ts +255 -0
- package/src/i18n/index.ts +1 -0
- package/src/i18n/keys.ts +80 -6
- package/src/i18n/operation-locale-namespace.ts +10 -0
- package/src/index.ts +390 -23
- package/src/lint.ts +2975 -234
- 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 +207 -27
- package/src/runtime/auth-flow.ts +15 -0
- package/src/runtime/bounded-expiring-map.ts +67 -0
- package/src/runtime/browser-telemetry-binding.ts +92 -0
- package/src/runtime/browser-telemetry.ts +289 -0
- package/src/runtime/browser.ts +882 -99
- package/src/runtime/cache-telemetry.ts +247 -0
- package/src/runtime/cache.ts +258 -22
- package/src/runtime/capability-response.ts +53 -0
- package/src/runtime/capability-telemetry.ts +101 -0
- package/src/runtime/chrome149-header-order.ts +330 -0
- package/src/runtime/diagnostic-env-values.ts +56 -0
- package/src/runtime/diagnostic-env.ts +65 -0
- package/src/runtime/diagnostic-redactor.ts +668 -0
- package/src/runtime/egress-lease.ts +341 -0
- package/src/runtime/engine-mode.ts +233 -0
- package/src/runtime/env.ts +15 -1
- package/src/runtime/executor.ts +39 -32
- package/src/runtime/handle-wordlist.ts +266 -0
- package/src/runtime/handle.ts +1180 -0
- package/src/runtime/http-telemetry-binding.ts +162 -0
- package/src/runtime/http-telemetry-guard.ts +208 -0
- package/src/runtime/http-telemetry.ts +292 -0
- package/src/runtime/http.ts +896 -150
- package/src/runtime/insights.ts +2 -2
- package/src/runtime/instrumentation.ts +628 -28
- package/src/runtime/native-network-errors.ts +99 -0
- package/src/runtime/native-network.ts +1853 -0
- package/src/runtime/native-telemetry.ts +366 -0
- package/src/runtime/ocr-telemetry.ts +308 -0
- package/src/runtime/ocr.ts +564 -0
- package/src/runtime/otlp.ts +468 -21
- package/src/runtime/provider.ts +15 -0
- package/src/runtime/proxy-errors.ts +12 -4
- package/src/runtime/proxy-nodemaven.ts +222 -0
- package/src/runtime/proxy-retry-policy.ts +12 -1
- package/src/runtime/proxy-telemetry.ts +358 -103
- package/src/runtime/redirect-hop-headers.ts +71 -0
- package/src/runtime/redirects.ts +215 -0
- package/src/runtime/redis.ts +7 -2
- package/src/runtime/request-options.ts +728 -1
- package/src/runtime/request-telemetry.ts +375 -0
- package/src/runtime/resolver-config.ts +18 -0
- package/src/runtime/resolver-public.ts +44 -0
- package/src/runtime/resolver-runtime-options.ts +50 -0
- package/src/runtime/resolver-shared.ts +113 -0
- package/src/runtime/resolver-span-attributes.ts +139 -0
- package/src/runtime/resolver-telemetry.ts +362 -0
- package/src/runtime/resolver-usage.ts +99 -0
- package/src/runtime/resolver-vendors/bindings.ts +61 -0
- package/src/runtime/resolver-vendors/browser.ts +591 -0
- package/src/runtime/resolver-vendors/capsolver.ts +727 -0
- package/src/runtime/resolver-vendors/hosts.ts +81 -0
- package/src/runtime/resolver-vendors/hypersolutions.ts +480 -0
- package/src/runtime/resolver-vendors/twocaptcha.ts +555 -0
- package/src/runtime/resolver-vendors/types.ts +261 -0
- package/src/runtime/resolver.ts +1543 -0
- package/src/runtime/state-telemetry.ts +298 -0
- package/src/runtime/state.ts +435 -89
- package/src/runtime/stealth-akamai-sbsd.ts +207 -0
- package/src/runtime/stealth-cookies.ts +155 -0
- package/src/runtime/stealth-owned-headers.ts +67 -0
- package/src/runtime/stealth-telemetry.ts +309 -0
- package/src/runtime/stealth.ts +2275 -372
- package/src/runtime/stt-telemetry.ts +282 -0
- package/src/runtime/stt.ts +96 -91
- package/src/runtime/timeout.ts +18 -0
- package/src/runtime/trace-config.ts +78 -0
- package/src/runtime/trace.ts +257 -35
- package/src/runtime-boundary-lint.ts +707 -0
- package/src/schema.ts +80 -2
- package/src/serve.ts +6 -1
- package/src/server/error-observability.ts +1 -0
- package/src/server/error-stack-frames.ts +214 -0
- package/src/server/index.ts +184 -2
- package/src/server/runtime-diagnostics.ts +522 -0
- package/src/server/sdk-version.ts +22 -0
- package/src/server/self-test-input-tokens.ts +29 -14
- package/src/server/self-test-redaction.ts +10 -40
- package/src/server/self-test.ts +205 -89
- package/src/server/sensitive-values.ts +211 -0
- package/src/server/serve-implementation.ts +4596 -0
- package/src/server/serve.ts +1 -1685
- package/src/server/state-backend-report.ts +185 -0
- package/src/server/trace-output.ts +189 -0
- package/src/server/types.ts +34 -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 +310 -0
- package/src/stateful/stateful-provider-runtime-context.ts +96 -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 +212 -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 +685 -19
- package/src/trace-sanitization.ts +110 -0
- package/src/types.ts +1014 -273
- package/src/typescript-module.ts +100 -0
- package/dist/choice-token.d.ts +0 -24
- package/dist/choice-token.js +0 -74
- package/dist/runtime/choice.d.ts +0 -15
- package/dist/runtime/choice.js +0 -413
- package/src/choice-token.ts +0 -165
- package/src/runtime/choice.ts +0 -686
|
@@ -4,28 +4,53 @@ 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
15
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
LOCAL_CONNECTION_SECRETS_ENV,
|
|
17
|
+
providerRequiresConnection,
|
|
18
|
+
resolveLocalConnection,
|
|
19
|
+
} from "../src/cli/local-connection.js";
|
|
20
|
+
import { formatPromptAssetIssues, verifyPromptAssets } from "../src/cli/prompt-assets.js";
|
|
19
21
|
import {
|
|
20
22
|
loadProviderLocaleCatalogs,
|
|
21
23
|
type ProviderLocale,
|
|
22
24
|
validateProviderLocaleCatalogs,
|
|
23
25
|
} from "../src/i18n/index.js";
|
|
24
|
-
import {
|
|
25
|
-
import { safeParseSchemaSync } from "../src/schema.js";
|
|
26
|
+
import type { ProviderDefinition } from "../src/index.js";
|
|
27
|
+
import { APIFUSE_DESCRIPTION_KEY_META_KEY, safeParseSchemaSync } from "../src/schema.js";
|
|
28
|
+
import {
|
|
29
|
+
findStreamCaptureGroup,
|
|
30
|
+
hasStreamEvidenceMarker,
|
|
31
|
+
parseStreamEvidenceRecord,
|
|
32
|
+
} from "../src/stream-evidence.js";
|
|
33
|
+
import { getTypeScript } from "../src/typescript-module.js";
|
|
26
34
|
import { type CheckResult, PROMPT_ASSETS_CHECK_MESSAGE, runChecks } from "./apifuse-check.js";
|
|
35
|
+
import { hasSubstantiveDelimitedTextStructure } from "./submit-check-delimited-text.js";
|
|
27
36
|
import { hasSubstantiveXmlStructure } from "./submit-check-xml.js";
|
|
28
37
|
|
|
38
|
+
const ts: typeof import("typescript") = loadTypeScript();
|
|
39
|
+
|
|
40
|
+
// Shares the lint rules' loader so a workspace whose `typescript` is the 7.x
|
|
41
|
+
// native shell (no compiler API) falls back to `@typescript/typescript6`
|
|
42
|
+
// instead of failing inside the first parse.
|
|
43
|
+
function loadTypeScript(): typeof import("typescript") {
|
|
44
|
+
try {
|
|
45
|
+
return getTypeScript();
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error(
|
|
48
|
+
`apifuse submit-check requires the TypeScript compiler API; install it in the workspace running the CLI (bun add -d typescript). ${error instanceof Error ? error.message : String(error)}`,
|
|
49
|
+
);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
29
54
|
const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
|
|
30
55
|
const TIER_VALUES: ReadonlySet<string> = new Set(TIERS);
|
|
31
56
|
type BountyTier = (typeof TIERS)[number];
|
|
@@ -105,16 +130,38 @@ export type SmokeResult = {
|
|
|
105
130
|
healthOk: boolean;
|
|
106
131
|
bootError?: string;
|
|
107
132
|
operations: SmokeOperationOutcome[];
|
|
133
|
+
/**
|
|
134
|
+
* Whether the provider's declared auth mode needs a gateway-injected
|
|
135
|
+
* credential, and whether this run had one. Recorded so a provider that
|
|
136
|
+
* could not be exercised at all says so, instead of scoring as "ran, but
|
|
137
|
+
* saw no live success" — the two are not the same evidence.
|
|
138
|
+
*/
|
|
139
|
+
credential?: {
|
|
140
|
+
required: boolean;
|
|
141
|
+
supplied: boolean;
|
|
142
|
+
mode: string;
|
|
143
|
+
/**
|
|
144
|
+
* Why the local credential could not be resolved — a malformed
|
|
145
|
+
* `APIFUSE__LOCAL_CONNECTION__SECRETS`, a non-string value, a bad
|
|
146
|
+
* `--credential`. Distinct from "not configured": a misconfiguration
|
|
147
|
+
* reported as an absence sends the author looking for a key they already
|
|
148
|
+
* set.
|
|
149
|
+
*/
|
|
150
|
+
error?: string;
|
|
151
|
+
};
|
|
108
152
|
};
|
|
109
153
|
|
|
110
154
|
type SourceFinding = {
|
|
111
155
|
file: string;
|
|
112
156
|
line: number;
|
|
157
|
+
detail?: string;
|
|
113
158
|
};
|
|
114
159
|
|
|
115
160
|
const SDK_NATIVE_CATEGORY = "sdk-native";
|
|
116
161
|
const VENDOR_SHIM_PROVIDER_ID_PREFIX = "apifuse-provider-";
|
|
117
162
|
const MAX_SOURCE_FINDING_EVIDENCE = 5;
|
|
163
|
+
const MAX_LOAD_ERROR_EVIDENCE = 5;
|
|
164
|
+
const MAX_LOAD_ERROR_MESSAGE_LENGTH = 500;
|
|
118
165
|
|
|
119
166
|
const CATEGORY_MAX_POINTS = {
|
|
120
167
|
definition: 15,
|
|
@@ -133,7 +180,9 @@ const HELP_TEXT = `Usage: apifuse submit-check [path] [--tier bronze|silver|gold
|
|
|
133
180
|
Alias: apifuse bounty-check [path]
|
|
134
181
|
Default: apifuse submit-check .
|
|
135
182
|
|
|
136
|
-
Smoke: --smoke boots the provider dev server, checks /health, and POSTs every operation fixture. APIFUSE__PROVIDER__* env vars enable live upstream calls; without them, structured provider errors can still verify runtime routing. --smoke-note is deprecated and ignored for scoring
|
|
183
|
+
Smoke: --smoke boots the provider dev server, checks /health, and POSTs every operation fixture. APIFUSE__PROVIDER__* env vars enable live upstream calls; without them, structured provider errors can still verify runtime routing. --smoke-note is deprecated and ignored for scoring.
|
|
184
|
+
|
|
185
|
+
A provider whose auth.mode is not "none" reads a credential the gateway injects at invocation time; smoke attaches the same connection from ${LOCAL_CONNECTION_SECRETS_ENV}='{"<key>":"<value>"}', keyed as the provider reads them through ctx.credential.get(...). Without it no operation reaches the upstream, and the smoke check says so rather than scoring it as a missing live success.`;
|
|
137
186
|
|
|
138
187
|
export async function main() {
|
|
139
188
|
try {
|
|
@@ -269,7 +318,20 @@ export async function buildSubmitCheckReport(
|
|
|
269
318
|
): Promise<SubmitCheckReport> {
|
|
270
319
|
const checks: SubmitCheck[] = [];
|
|
271
320
|
const baseChecks = await safeRunChecks(providerRoot);
|
|
272
|
-
const
|
|
321
|
+
const loadResult = await safeLoadProvider(providerRoot);
|
|
322
|
+
const provider = loadResult.ok ? loadResult.provider : undefined;
|
|
323
|
+
let indexParseError: string | undefined;
|
|
324
|
+
if (!provider) {
|
|
325
|
+
const indexPath = resolve(providerRoot, "index.ts");
|
|
326
|
+
if (existsSync(indexPath)) {
|
|
327
|
+
const relPath = toRelativeProviderPath(providerRoot, indexPath);
|
|
328
|
+
try {
|
|
329
|
+
maskCommentsAndStrings(readFileSync(indexPath, "utf8"), relPath);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
indexParseError = error instanceof Error ? error.message : String(error);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
273
335
|
|
|
274
336
|
// Prompt-asset freshness is reported by its own dedicated zero-point
|
|
275
337
|
// blocker below; filter the base-check duplicate so it is not double
|
|
@@ -280,17 +342,20 @@ export async function buildSubmitCheckReport(
|
|
|
280
342
|
),
|
|
281
343
|
);
|
|
282
344
|
checks.push(scorePromptAssetFreshness(providerRoot));
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
345
|
+
if (!indexParseError) {
|
|
346
|
+
checks.push(scoreProviderIdSlug(providerRoot, provider));
|
|
347
|
+
checks.push(scoreNoVendorShim(providerRoot));
|
|
348
|
+
checks.push(scoreNoVendorImport(providerRoot));
|
|
349
|
+
checks.push(scoreDescribeKey(providerRoot));
|
|
350
|
+
checks.push(scoreNoRawFetch(providerRoot));
|
|
351
|
+
checks.push(scoreNoDynamicCode(providerRoot));
|
|
352
|
+
checks.push(scoreNoRedundantRuntimeGuards(providerRoot));
|
|
353
|
+
checks.push(scoreManagedBrowserRuntime(providerRoot));
|
|
354
|
+
checks.push(scoreAsAssertionCount(providerRoot));
|
|
355
|
+
checks.push(scoreUnsafeInputPassthrough(providerRoot));
|
|
356
|
+
checks.push(scoreUnjustifiedLooseSchema(providerRoot));
|
|
357
|
+
checks.push(scoreFlatOperationComposition(providerRoot));
|
|
358
|
+
}
|
|
294
359
|
|
|
295
360
|
if (provider) {
|
|
296
361
|
const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
|
|
@@ -309,15 +374,38 @@ export async function buildSubmitCheckReport(
|
|
|
309
374
|
checks.push(scoreRepositoryDx(providerRoot));
|
|
310
375
|
checks.push(scoreSecrets(providerRoot, provider));
|
|
311
376
|
} else {
|
|
377
|
+
const loadEvidence = indexParseError
|
|
378
|
+
? undefined
|
|
379
|
+
: loadResult.ok
|
|
380
|
+
? undefined
|
|
381
|
+
: formatLoadErrorEvidence(loadResult.error, providerRoot);
|
|
382
|
+
const loadRemediation = indexParseError
|
|
383
|
+
? "Fix the syntax error reported by the provider-load-parse blocker before apifuse check can load the provider."
|
|
384
|
+
: !loadResult.ok
|
|
385
|
+
? "Fix the import/initialization failure shown in evidence so apifuse check can load the provider."
|
|
386
|
+
: "Fix index.ts so it default-exports defineProvider(...).";
|
|
312
387
|
checks.push(
|
|
313
388
|
blocker(
|
|
314
389
|
"provider-load",
|
|
315
390
|
"definition",
|
|
316
391
|
"Provider could not be loaded.",
|
|
317
|
-
|
|
392
|
+
loadRemediation,
|
|
318
393
|
CATEGORY_MAX_POINTS.definition,
|
|
394
|
+
loadEvidence,
|
|
319
395
|
),
|
|
320
396
|
);
|
|
397
|
+
if (indexParseError) {
|
|
398
|
+
checks.push(
|
|
399
|
+
blocker(
|
|
400
|
+
"provider-load-parse",
|
|
401
|
+
"definition",
|
|
402
|
+
"Provider index.ts could not be parsed safely.",
|
|
403
|
+
"Fix the syntax error in index.ts before submitting.",
|
|
404
|
+
0,
|
|
405
|
+
[indexParseError],
|
|
406
|
+
),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
321
409
|
}
|
|
322
410
|
|
|
323
411
|
const total = clamp(Math.round(checks.reduce((sum, check) => sum + check.points, 0)), 0, 100);
|
|
@@ -445,7 +533,7 @@ function scoreDescribeKey(providerRoot: string): SubmitCheck {
|
|
|
445
533
|
}
|
|
446
534
|
|
|
447
535
|
function scoreNoRawFetch(providerRoot: string): SubmitCheck {
|
|
448
|
-
const findings =
|
|
536
|
+
const findings = findGlobalSinkCalls(providerRoot, FETCH_SINKS);
|
|
449
537
|
if (findings.length > 0) {
|
|
450
538
|
const evidence = formatSourceFindings(findings);
|
|
451
539
|
return blocker(
|
|
@@ -461,12 +549,441 @@ function scoreNoRawFetch(providerRoot: string): SubmitCheck {
|
|
|
461
549
|
return pass("no-raw-fetch", SDK_NATIVE_CATEGORY, "Provider source avoids raw fetch().", 0);
|
|
462
550
|
}
|
|
463
551
|
|
|
552
|
+
const DYNAMIC_CODE_SINKS: ReadonlySet<string> = new Set(["eval", "Function"]);
|
|
553
|
+
const FETCH_SINKS: ReadonlySet<string> = new Set(["fetch"]);
|
|
554
|
+
const DYNAMIC_CODE_GLOBAL_OBJECTS: ReadonlySet<string> = new Set([
|
|
555
|
+
"globalThis",
|
|
556
|
+
"window",
|
|
557
|
+
"self",
|
|
558
|
+
"global",
|
|
559
|
+
]);
|
|
560
|
+
|
|
561
|
+
function unwrapLocalExpression(expression: TS.Expression): TS.Expression {
|
|
562
|
+
let current = expression;
|
|
563
|
+
while (
|
|
564
|
+
ts.isParenthesizedExpression(current) ||
|
|
565
|
+
ts.isAsExpression(current) ||
|
|
566
|
+
ts.isSatisfiesExpression(current) ||
|
|
567
|
+
ts.isNonNullExpression(current)
|
|
568
|
+
) {
|
|
569
|
+
current = current.expression;
|
|
570
|
+
}
|
|
571
|
+
if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.CommaToken) {
|
|
572
|
+
return unwrapLocalExpression(current.right);
|
|
573
|
+
}
|
|
574
|
+
return current;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
type LocalBinding = {
|
|
578
|
+
declaration: TS.Identifier;
|
|
579
|
+
initializer?: TS.Expression;
|
|
580
|
+
destructuredProperty?: string;
|
|
581
|
+
mutable: boolean;
|
|
582
|
+
reassigned: boolean;
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
type LocalBindings = {
|
|
586
|
+
scopes: Map<TS.Node, Map<string, LocalBinding[]>>;
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
function isLexicalScope(node: TS.Node): boolean {
|
|
590
|
+
return (
|
|
591
|
+
ts.isSourceFile(node) ||
|
|
592
|
+
ts.isFunctionLike(node) ||
|
|
593
|
+
ts.isBlock(node) ||
|
|
594
|
+
ts.isModuleBlock(node) ||
|
|
595
|
+
ts.isCaseBlock(node) ||
|
|
596
|
+
ts.isCatchClause(node) ||
|
|
597
|
+
ts.isForStatement(node) ||
|
|
598
|
+
ts.isForInStatement(node) ||
|
|
599
|
+
ts.isForOfStatement(node)
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function enclosingScope(node: TS.Node, functionScoped: boolean): TS.Node {
|
|
604
|
+
let current: TS.Node | undefined = node.parent;
|
|
605
|
+
while (current !== undefined) {
|
|
606
|
+
if (
|
|
607
|
+
ts.isSourceFile(current) ||
|
|
608
|
+
(functionScoped ? ts.isFunctionLike(current) : isLexicalScope(current))
|
|
609
|
+
) {
|
|
610
|
+
return current;
|
|
611
|
+
}
|
|
612
|
+
current = current.parent;
|
|
613
|
+
}
|
|
614
|
+
return node.getSourceFile();
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function collectLocalBindings(sourceFile: TS.SourceFile): LocalBindings {
|
|
618
|
+
const bindings: LocalBindings = { scopes: new Map() };
|
|
619
|
+
const addBinding = (
|
|
620
|
+
scope: TS.Node,
|
|
621
|
+
name: TS.Identifier,
|
|
622
|
+
binding: Omit<LocalBinding, "declaration" | "reassigned">,
|
|
623
|
+
): void => {
|
|
624
|
+
let scopeBindings = bindings.scopes.get(scope);
|
|
625
|
+
if (scopeBindings === undefined) {
|
|
626
|
+
scopeBindings = new Map();
|
|
627
|
+
bindings.scopes.set(scope, scopeBindings);
|
|
628
|
+
}
|
|
629
|
+
const existing = scopeBindings.get(name.text);
|
|
630
|
+
const localBinding: LocalBinding = { ...binding, declaration: name, reassigned: false };
|
|
631
|
+
if (existing === undefined) {
|
|
632
|
+
scopeBindings.set(name.text, [localBinding]);
|
|
633
|
+
} else {
|
|
634
|
+
existing.push(localBinding);
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
const addBindingName = (
|
|
638
|
+
bindingName: TS.BindingName,
|
|
639
|
+
scope: TS.Node,
|
|
640
|
+
binding: Omit<LocalBinding, "declaration" | "reassigned">,
|
|
641
|
+
): void => {
|
|
642
|
+
if (ts.isIdentifier(bindingName)) {
|
|
643
|
+
addBinding(scope, bindingName, binding);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
for (const element of bindingName.elements) {
|
|
647
|
+
if (ts.isOmittedExpression(element)) {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (ts.isObjectBindingPattern(bindingName) && !element.dotDotDotToken) {
|
|
651
|
+
const property = element.propertyName ?? element.name;
|
|
652
|
+
const propertyName = propertyNameText(property);
|
|
653
|
+
if (propertyName !== undefined && ts.isIdentifier(element.name)) {
|
|
654
|
+
addBinding(scope, element.name, {
|
|
655
|
+
initializer: binding.initializer,
|
|
656
|
+
destructuredProperty: propertyName,
|
|
657
|
+
mutable: binding.mutable,
|
|
658
|
+
});
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
addBindingName(element.name, scope, { mutable: binding.mutable });
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
const visit = (node: TS.Node): void => {
|
|
666
|
+
if (ts.isVariableDeclaration(node)) {
|
|
667
|
+
const declarationList = ts.isVariableDeclarationList(node.parent) ? node.parent : undefined;
|
|
668
|
+
const isConstBinding =
|
|
669
|
+
declarationList !== undefined && (declarationList.flags & ts.NodeFlags.Const) !== 0;
|
|
670
|
+
const isBlockScoped =
|
|
671
|
+
declarationList !== undefined && (declarationList.flags & ts.NodeFlags.BlockScoped) !== 0;
|
|
672
|
+
addBindingName(node.name, enclosingScope(node, !isBlockScoped), {
|
|
673
|
+
initializer: node.initializer,
|
|
674
|
+
mutable: !isConstBinding,
|
|
675
|
+
});
|
|
676
|
+
} else if (ts.isFunctionDeclaration(node) && node.name !== undefined) {
|
|
677
|
+
addBinding(enclosingScope(node, false), node.name, { mutable: false });
|
|
678
|
+
} else if (ts.isFunctionExpression(node) && node.name !== undefined) {
|
|
679
|
+
addBinding(node, node.name, { mutable: false });
|
|
680
|
+
} else if (ts.isClassDeclaration(node) && node.name !== undefined) {
|
|
681
|
+
addBinding(enclosingScope(node, false), node.name, { mutable: false });
|
|
682
|
+
} else if (ts.isClassExpression(node) && node.name !== undefined) {
|
|
683
|
+
addBinding(node, node.name, { mutable: false });
|
|
684
|
+
} else if (ts.isParameter(node)) {
|
|
685
|
+
addBindingName(node.name, enclosingScope(node, true), { mutable: true });
|
|
686
|
+
} else if (ts.isImportClause(node) && node.name !== undefined) {
|
|
687
|
+
addBinding(sourceFile, node.name, { mutable: false });
|
|
688
|
+
} else if (ts.isNamespaceImport(node) || ts.isImportSpecifier(node)) {
|
|
689
|
+
addBinding(sourceFile, node.name, { mutable: false });
|
|
690
|
+
}
|
|
691
|
+
ts.forEachChild(node, visit);
|
|
692
|
+
};
|
|
693
|
+
visit(sourceFile);
|
|
694
|
+
|
|
695
|
+
const markReassigned = (identifier: TS.Identifier): void => {
|
|
696
|
+
for (const binding of lookupLocalBindings(identifier, bindings) ?? []) {
|
|
697
|
+
binding.reassigned = true;
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
const markAssignmentTarget = (node: TS.Node): void => {
|
|
701
|
+
const target = ts.isExpression(node) ? unwrapLocalExpression(node) : node;
|
|
702
|
+
if (ts.isIdentifier(target)) {
|
|
703
|
+
markReassigned(target);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (ts.isObjectLiteralExpression(target)) {
|
|
707
|
+
for (const element of target.properties) {
|
|
708
|
+
if (ts.isSpreadAssignment(element)) {
|
|
709
|
+
markAssignmentTarget(element.expression);
|
|
710
|
+
} else if (ts.isPropertyAssignment(element)) {
|
|
711
|
+
markAssignmentTarget(element.initializer);
|
|
712
|
+
} else if (ts.isShorthandPropertyAssignment(element)) {
|
|
713
|
+
markAssignmentTarget(element.name);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
} else if (ts.isArrayLiteralExpression(target)) {
|
|
717
|
+
for (const element of target.elements) {
|
|
718
|
+
if (!ts.isOmittedExpression(element)) {
|
|
719
|
+
markAssignmentTarget(ts.isSpreadElement(element) ? element.expression : element);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
const visitAssignments = (node: TS.Node): void => {
|
|
725
|
+
if (
|
|
726
|
+
ts.isBinaryExpression(node) &&
|
|
727
|
+
node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
728
|
+
node.operatorToken.kind <= ts.SyntaxKind.LastAssignment
|
|
729
|
+
) {
|
|
730
|
+
markAssignmentTarget(node.left);
|
|
731
|
+
} else if (
|
|
732
|
+
(ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
|
|
733
|
+
(node.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
734
|
+
node.operator === ts.SyntaxKind.MinusMinusToken)
|
|
735
|
+
) {
|
|
736
|
+
markAssignmentTarget(node.operand);
|
|
737
|
+
} else if (
|
|
738
|
+
(ts.isForInStatement(node) || ts.isForOfStatement(node)) &&
|
|
739
|
+
!ts.isVariableDeclarationList(node.initializer)
|
|
740
|
+
) {
|
|
741
|
+
markAssignmentTarget(node.initializer);
|
|
742
|
+
}
|
|
743
|
+
ts.forEachChild(node, visitAssignments);
|
|
744
|
+
};
|
|
745
|
+
visitAssignments(sourceFile);
|
|
746
|
+
return bindings;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function lookupLocalBindings(
|
|
750
|
+
reference: TS.Identifier,
|
|
751
|
+
bindings: LocalBindings,
|
|
752
|
+
): readonly LocalBinding[] | undefined {
|
|
753
|
+
let current: TS.Node | undefined = reference;
|
|
754
|
+
while (current !== undefined) {
|
|
755
|
+
const localBindings = bindings.scopes.get(current)?.get(reference.text);
|
|
756
|
+
if (localBindings !== undefined && localBindings.length > 0) {
|
|
757
|
+
return localBindings;
|
|
758
|
+
}
|
|
759
|
+
current = current.parent;
|
|
760
|
+
}
|
|
761
|
+
return undefined;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function propertyNameText(name: TS.PropertyName | TS.BindingName): string | undefined {
|
|
765
|
+
if (
|
|
766
|
+
ts.isIdentifier(name) ||
|
|
767
|
+
ts.isStringLiteral(name) ||
|
|
768
|
+
ts.isNumericLiteral(name) ||
|
|
769
|
+
ts.isNoSubstitutionTemplateLiteral(name)
|
|
770
|
+
) {
|
|
771
|
+
return name.text;
|
|
772
|
+
}
|
|
773
|
+
if (ts.isComputedPropertyName(name)) {
|
|
774
|
+
const expression = unwrapLocalExpression(name.expression);
|
|
775
|
+
if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
|
|
776
|
+
return expression.text;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return undefined;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function isUnshadowedGlobalObject(reference: TS.Expression, bindings: LocalBindings): boolean {
|
|
783
|
+
const receiver = unwrapLocalExpression(reference);
|
|
784
|
+
return (
|
|
785
|
+
ts.isIdentifier(receiver) &&
|
|
786
|
+
DYNAMIC_CODE_GLOBAL_OBJECTS.has(receiver.text) &&
|
|
787
|
+
lookupLocalBindings(receiver, bindings) === undefined
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function isGlobalMemberSink(
|
|
792
|
+
expression: TS.Expression,
|
|
793
|
+
sinkNames: ReadonlySet<string>,
|
|
794
|
+
bindings: LocalBindings,
|
|
795
|
+
): boolean {
|
|
796
|
+
const callee = unwrapLocalExpression(expression);
|
|
797
|
+
let sinkName: string | undefined;
|
|
798
|
+
let receiver: TS.Expression | undefined;
|
|
799
|
+
if (ts.isPropertyAccessExpression(callee)) {
|
|
800
|
+
sinkName = callee.name.text;
|
|
801
|
+
receiver = callee.expression;
|
|
802
|
+
} else if (ts.isElementAccessExpression(callee)) {
|
|
803
|
+
const argument = callee.argumentExpression;
|
|
804
|
+
if (
|
|
805
|
+
argument !== undefined &&
|
|
806
|
+
(ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
|
|
807
|
+
) {
|
|
808
|
+
sinkName = argument.text;
|
|
809
|
+
receiver = callee.expression;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (sinkName === undefined || receiver === undefined || !sinkNames.has(sinkName)) {
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
return isUnshadowedGlobalObject(receiver, bindings);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function isGlobalSinkReference(
|
|
819
|
+
expression: TS.Expression,
|
|
820
|
+
sinkNames: ReadonlySet<string>,
|
|
821
|
+
bindings: LocalBindings,
|
|
822
|
+
resolving: ReadonlySet<LocalBinding>,
|
|
823
|
+
): boolean {
|
|
824
|
+
const reference = unwrapLocalExpression(expression);
|
|
825
|
+
if (isGlobalMemberSink(reference, sinkNames, bindings)) {
|
|
826
|
+
return true;
|
|
827
|
+
}
|
|
828
|
+
if (!ts.isIdentifier(reference)) {
|
|
829
|
+
return false;
|
|
830
|
+
}
|
|
831
|
+
return isGlobalSinkIdentifier(reference, sinkNames, bindings, resolving);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function isGlobalSinkIdentifier(
|
|
835
|
+
reference: TS.Identifier,
|
|
836
|
+
sinkNames: ReadonlySet<string>,
|
|
837
|
+
bindings: LocalBindings,
|
|
838
|
+
resolving: ReadonlySet<LocalBinding>,
|
|
839
|
+
): boolean {
|
|
840
|
+
const localBindings = lookupLocalBindings(reference, bindings);
|
|
841
|
+
if (localBindings === undefined || localBindings.length === 0) {
|
|
842
|
+
return sinkNames.has(reference.text);
|
|
843
|
+
}
|
|
844
|
+
return localBindings.every((binding) => {
|
|
845
|
+
if (resolving.has(binding) || binding.initializer === undefined) {
|
|
846
|
+
return false;
|
|
847
|
+
}
|
|
848
|
+
// A mutable alias is safe to resolve only when no assignment targeting
|
|
849
|
+
// this lexical binding exists in its scope (including nested closures).
|
|
850
|
+
// Reassigned let/var aliases remain unresolved to avoid false positives.
|
|
851
|
+
if (binding.mutable && binding.reassigned) {
|
|
852
|
+
return false;
|
|
853
|
+
}
|
|
854
|
+
const nextResolving = new Set(resolving);
|
|
855
|
+
nextResolving.add(binding);
|
|
856
|
+
if (binding.destructuredProperty !== undefined) {
|
|
857
|
+
return (
|
|
858
|
+
sinkNames.has(binding.destructuredProperty) &&
|
|
859
|
+
isUnshadowedGlobalObject(binding.initializer, bindings)
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
return isGlobalSinkReference(binding.initializer, sinkNames, bindings, nextResolving);
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function isGlobalSinkCallee(
|
|
867
|
+
expression: TS.Expression,
|
|
868
|
+
sinkNames: ReadonlySet<string>,
|
|
869
|
+
bindings: LocalBindings,
|
|
870
|
+
): boolean {
|
|
871
|
+
const callee = unwrapLocalExpression(expression);
|
|
872
|
+
if (isGlobalMemberSink(callee, sinkNames, bindings)) {
|
|
873
|
+
return true;
|
|
874
|
+
}
|
|
875
|
+
if (!ts.isIdentifier(callee)) {
|
|
876
|
+
return false;
|
|
877
|
+
}
|
|
878
|
+
return isGlobalSinkIdentifier(callee, sinkNames, bindings, new Set());
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function resolveConstStringIdentifier(
|
|
882
|
+
reference: TS.Identifier,
|
|
883
|
+
bindings: LocalBindings,
|
|
884
|
+
resolving: ReadonlySet<LocalBinding> = new Set(),
|
|
885
|
+
): string | undefined {
|
|
886
|
+
const localBindings = lookupLocalBindings(reference, bindings);
|
|
887
|
+
if (localBindings === undefined || localBindings.length === 0) {
|
|
888
|
+
return undefined;
|
|
889
|
+
}
|
|
890
|
+
let resolved: string | undefined;
|
|
891
|
+
for (const binding of localBindings) {
|
|
892
|
+
if (
|
|
893
|
+
resolving.has(binding) ||
|
|
894
|
+
binding.mutable ||
|
|
895
|
+
binding.initializer === undefined ||
|
|
896
|
+
binding.destructuredProperty !== undefined
|
|
897
|
+
) {
|
|
898
|
+
return undefined;
|
|
899
|
+
}
|
|
900
|
+
const nextResolving = new Set(resolving);
|
|
901
|
+
nextResolving.add(binding);
|
|
902
|
+
const initializer = unwrapLocalExpression(binding.initializer);
|
|
903
|
+
let value: string | undefined;
|
|
904
|
+
if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) {
|
|
905
|
+
value = initializer.text;
|
|
906
|
+
} else if (ts.isIdentifier(initializer)) {
|
|
907
|
+
value = resolveConstStringIdentifier(initializer, bindings, nextResolving);
|
|
908
|
+
}
|
|
909
|
+
if (value === undefined || (resolved !== undefined && resolved !== value)) {
|
|
910
|
+
return undefined;
|
|
911
|
+
}
|
|
912
|
+
resolved = value;
|
|
913
|
+
}
|
|
914
|
+
return resolved;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function findGlobalSinkCalls(
|
|
918
|
+
providerRoot: string,
|
|
919
|
+
sinkNames: ReadonlySet<string>,
|
|
920
|
+
includeConstructors = false,
|
|
921
|
+
): SourceFinding[] {
|
|
922
|
+
const findings: SourceFinding[] = [];
|
|
923
|
+
const seen = new Set<string>();
|
|
924
|
+
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
925
|
+
const source = readFileSync(filePath, "utf8");
|
|
926
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
927
|
+
const sourceFile = ts.createSourceFile(
|
|
928
|
+
relPath,
|
|
929
|
+
source,
|
|
930
|
+
ts.ScriptTarget.Latest,
|
|
931
|
+
true,
|
|
932
|
+
ts.ScriptKind.TS,
|
|
933
|
+
);
|
|
934
|
+
const bindings = collectLocalBindings(sourceFile);
|
|
935
|
+
const visit = (node: TS.Node): void => {
|
|
936
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
if (
|
|
940
|
+
(ts.isCallExpression(node) || (includeConstructors && ts.isNewExpression(node))) &&
|
|
941
|
+
isGlobalSinkCallee(node.expression, sinkNames, bindings)
|
|
942
|
+
) {
|
|
943
|
+
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
944
|
+
const key = `${relPath}:${line}`;
|
|
945
|
+
if (!seen.has(key)) {
|
|
946
|
+
seen.add(key);
|
|
947
|
+
findings.push({ file: relPath, line });
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
ts.forEachChild(node, visit);
|
|
951
|
+
};
|
|
952
|
+
visit(sourceFile);
|
|
953
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return findings;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function scoreNoDynamicCode(providerRoot: string): SubmitCheck {
|
|
961
|
+
const findings = findGlobalSinkCalls(providerRoot, DYNAMIC_CODE_SINKS, true);
|
|
962
|
+
if (findings.length > 0) {
|
|
963
|
+
return blocker(
|
|
964
|
+
"no-dynamic-code",
|
|
965
|
+
SDK_NATIVE_CATEGORY,
|
|
966
|
+
"Dynamic code evaluation is not permitted in provider source.",
|
|
967
|
+
"Remove eval() and Function constructor calls. Provider HTTP access must go through ctx.http.",
|
|
968
|
+
0,
|
|
969
|
+
formatSourceFindings(findings),
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
return pass(
|
|
974
|
+
"no-dynamic-code",
|
|
975
|
+
SDK_NATIVE_CATEGORY,
|
|
976
|
+
"Provider source avoids dynamic code evaluation.",
|
|
977
|
+
0,
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
|
|
464
981
|
const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
|
|
465
|
-
/\bctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|
|
|
982
|
+
/\bctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|handle)\?\./,
|
|
466
983
|
];
|
|
467
984
|
|
|
468
985
|
const SDK_CONTEXT_METHOD_ALIAS_PATTERN =
|
|
469
|
-
/\bconst\s+(\w+)\s*=\s*ctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|
|
|
986
|
+
/\bconst\s+(\w+)\s*=\s*ctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|handle)\.(?:\w+)/;
|
|
470
987
|
|
|
471
988
|
function hasRedundantRuntimeGuard(line: string, remainingLines: readonly string[]): boolean {
|
|
472
989
|
if (REDUNDANT_RUNTIME_GUARD_PATTERNS.some((pattern) => pattern.test(line))) {
|
|
@@ -516,7 +1033,9 @@ function countAsAssertions(providerRoot: string): {
|
|
|
516
1033
|
|
|
517
1034
|
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
518
1035
|
const content = readFileSync(filePath, "utf8");
|
|
519
|
-
const lines = content
|
|
1036
|
+
const lines = maskCommentsAndStrings(content, toRelativeProviderPath(providerRoot, filePath), {
|
|
1037
|
+
blankPropertyKeys: true,
|
|
1038
|
+
}).split(/\r?\n/);
|
|
520
1039
|
for (let index = 0; index < lines.length; index += 1) {
|
|
521
1040
|
const line = lines[index];
|
|
522
1041
|
if (
|
|
@@ -707,18 +1226,44 @@ function offsetToLine(source: string, offset: number): number {
|
|
|
707
1226
|
// between them, so `.passthrough ()` / `.passthrough\n()` are still detected.
|
|
708
1227
|
const PASSTHROUGH_CALL = /\.passthrough\s*\(\s*\)/;
|
|
709
1228
|
|
|
1229
|
+
type ExpressionSlice = {
|
|
1230
|
+
raw: string;
|
|
1231
|
+
masked: string;
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
function trimExpressionSlice(expr: ExpressionSlice): ExpressionSlice {
|
|
1235
|
+
const start = expr.raw.length - expr.raw.trimStart().length;
|
|
1236
|
+
const end = expr.raw.trimEnd().length;
|
|
1237
|
+
return {
|
|
1238
|
+
raw: expr.raw.slice(start, end),
|
|
1239
|
+
masked: expr.masked.slice(start, end),
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function balancedExpressionSlice(
|
|
1244
|
+
source: string,
|
|
1245
|
+
valueStart: number,
|
|
1246
|
+
fileName?: string,
|
|
1247
|
+
): ExpressionSlice {
|
|
1248
|
+
const raw = balancedValueExpression(source, valueStart, fileName);
|
|
1249
|
+
return trimExpressionSlice({
|
|
1250
|
+
raw,
|
|
1251
|
+
masked: maskCommentsAndStrings(source, fileName).slice(valueStart, valueStart + raw.length),
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
|
|
710
1255
|
// Strips redundant wrapping parentheses from an expression so that a value like
|
|
711
1256
|
// `(makeOperations())` or `((x))` classifies the same as `makeOperations()`.
|
|
712
1257
|
// Only unwraps when the leading `(` matches the trailing `)` at depth 0 (i.e.
|
|
713
1258
|
// the whole expression is parenthesized), preserving call expressions such as
|
|
714
1259
|
// `makeOperations()` whose first `(` is not a wrapper.
|
|
715
|
-
function unwrapParens(expr:
|
|
716
|
-
let value = expr
|
|
717
|
-
while (value.startsWith("(")) {
|
|
1260
|
+
function unwrapParens(expr: ExpressionSlice): ExpressionSlice {
|
|
1261
|
+
let value = trimExpressionSlice(expr);
|
|
1262
|
+
while (value.raw.startsWith("(")) {
|
|
718
1263
|
let depth = 0;
|
|
719
1264
|
let matchIndex = -1;
|
|
720
|
-
for (let i = 0; i < value.length; i += 1) {
|
|
721
|
-
const ch = value[i];
|
|
1265
|
+
for (let i = 0; i < value.raw.length; i += 1) {
|
|
1266
|
+
const ch = value.masked[i];
|
|
722
1267
|
if (ch === "(") {
|
|
723
1268
|
depth += 1;
|
|
724
1269
|
} else if (ch === ")") {
|
|
@@ -731,8 +1276,11 @@ function unwrapParens(expr: string): string {
|
|
|
731
1276
|
}
|
|
732
1277
|
// Only a true wrapper spans the entire expression (closing paren is the
|
|
733
1278
|
// last char). Otherwise the leading `(` belongs to a sub-expression.
|
|
734
|
-
if (matchIndex === value.length - 1) {
|
|
735
|
-
value =
|
|
1279
|
+
if (matchIndex === value.raw.length - 1) {
|
|
1280
|
+
value = trimExpressionSlice({
|
|
1281
|
+
raw: value.raw.slice(1, -1),
|
|
1282
|
+
masked: value.masked.slice(1, -1),
|
|
1283
|
+
});
|
|
736
1284
|
} else {
|
|
737
1285
|
break;
|
|
738
1286
|
}
|
|
@@ -744,8 +1292,8 @@ function unwrapParens(expr: string): string {
|
|
|
744
1292
|
// across (){}[] and stopping at the first top-level `,`/`;` or unmatched
|
|
745
1293
|
// closing bracket. This lets a property value be read across newlines, so a
|
|
746
1294
|
// multi-line `input: z.object({...})\n.passthrough()` is captured whole.
|
|
747
|
-
function balancedValueExpression(source: string, valueStart: number): string {
|
|
748
|
-
const masked = maskCommentsAndStrings(source);
|
|
1295
|
+
function balancedValueExpression(source: string, valueStart: number, fileName?: string): string {
|
|
1296
|
+
const masked = maskCommentsAndStrings(source, fileName);
|
|
749
1297
|
let depth = 0;
|
|
750
1298
|
let index = valueStart;
|
|
751
1299
|
for (; index < source.length; index += 1) {
|
|
@@ -769,14 +1317,14 @@ function balancedValueExpression(source: string, valueStart: number): string {
|
|
|
769
1317
|
// nested deeper than the outer object (inside handler bodies, nested objects,
|
|
770
1318
|
// or arrays) are ignored, so only a factory composition of the object itself
|
|
771
1319
|
// is detected. Input is expected to start at the outer `{`.
|
|
772
|
-
function hasTopLevelFactorySpread(expr:
|
|
773
|
-
const open = expr.indexOf("{");
|
|
1320
|
+
function hasTopLevelFactorySpread(expr: ExpressionSlice): boolean {
|
|
1321
|
+
const open = expr.masked.indexOf("{");
|
|
774
1322
|
if (open === -1) {
|
|
775
1323
|
return false;
|
|
776
1324
|
}
|
|
777
1325
|
let depth = 0;
|
|
778
|
-
for (let i = open; i < expr.length; i += 1) {
|
|
779
|
-
const ch = expr[i];
|
|
1326
|
+
for (let i = open; i < expr.raw.length; i += 1) {
|
|
1327
|
+
const ch = expr.masked[i];
|
|
780
1328
|
if (ch === "{" || ch === "(" || ch === "[") {
|
|
781
1329
|
depth += 1;
|
|
782
1330
|
} else if (ch === "}" || ch === ")" || ch === "]") {
|
|
@@ -784,11 +1332,11 @@ function hasTopLevelFactorySpread(expr: string): boolean {
|
|
|
784
1332
|
if (depth === 0) {
|
|
785
1333
|
break;
|
|
786
1334
|
}
|
|
787
|
-
} else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
|
|
1335
|
+
} else if (ch === "." && depth === 1 && expr.masked.startsWith("...", i)) {
|
|
788
1336
|
// A spread at the object's own level. Check whether the spread
|
|
789
1337
|
// argument is a call expression (factory) rather than a plain
|
|
790
1338
|
// identifier/member spread of an already-built object.
|
|
791
|
-
const rest = expr.slice(i + 3);
|
|
1339
|
+
const rest = expr.raw.slice(i + 3);
|
|
792
1340
|
if (/^\s*[A-Za-z_$][\w$.]*\s*\(/.test(rest)) {
|
|
793
1341
|
return true;
|
|
794
1342
|
}
|
|
@@ -803,15 +1351,15 @@ function hasTopLevelFactorySpread(expr: string): boolean {
|
|
|
803
1351
|
// hasTopLevelFactorySpread, so it is excluded here. These identifiers must be
|
|
804
1352
|
// resolved to their declarations: `const hidden = makeOperations()` spread as
|
|
805
1353
|
// `{ ...hidden }` is still a factory-composed map and must block.
|
|
806
|
-
function topLevelSpreadIdentifiers(expr:
|
|
807
|
-
const open = expr.indexOf("{");
|
|
1354
|
+
function topLevelSpreadIdentifiers(expr: ExpressionSlice): string[] {
|
|
1355
|
+
const open = expr.masked.indexOf("{");
|
|
808
1356
|
if (open === -1) {
|
|
809
1357
|
return [];
|
|
810
1358
|
}
|
|
811
1359
|
const names: string[] = [];
|
|
812
1360
|
let depth = 0;
|
|
813
|
-
for (let i = open; i < expr.length; i += 1) {
|
|
814
|
-
const ch = expr[i];
|
|
1361
|
+
for (let i = open; i < expr.raw.length; i += 1) {
|
|
1362
|
+
const ch = expr.masked[i];
|
|
815
1363
|
if (ch === "{" || ch === "(" || ch === "[") {
|
|
816
1364
|
depth += 1;
|
|
817
1365
|
} else if (ch === "}" || ch === ")" || ch === "]") {
|
|
@@ -819,8 +1367,8 @@ function topLevelSpreadIdentifiers(expr: string): string[] {
|
|
|
819
1367
|
if (depth === 0) {
|
|
820
1368
|
break;
|
|
821
1369
|
}
|
|
822
|
-
} else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
|
|
823
|
-
const rest = expr.slice(i + 3);
|
|
1370
|
+
} else if (ch === "." && depth === 1 && expr.masked.startsWith("...", i)) {
|
|
1371
|
+
const rest = expr.raw.slice(i + 3);
|
|
824
1372
|
// Bare identifier spread (no call parens) -> needs declaration
|
|
825
1373
|
// resolution. `...obj.prop` member spreads are treated as already
|
|
826
1374
|
// built and ignored (the leading identifier is captured).
|
|
@@ -853,8 +1401,8 @@ function topLevelSpreadIdentifiers(expr: string): string[] {
|
|
|
853
1401
|
// `makeOperations()` — stays classified as factory composition.
|
|
854
1402
|
const TRANSPARENT_RESHAPE_HEAD = /^Object\s*\.\s*fromEntries\s*\(/;
|
|
855
1403
|
const OBJECT_ENTRIES_HEAD = /^Object\s*\.\s*entries\s*\(/;
|
|
856
|
-
function isTransparentObjectReshape(expr:
|
|
857
|
-
const head = TRANSPARENT_RESHAPE_HEAD.exec(expr);
|
|
1404
|
+
function isTransparentObjectReshape(expr: ExpressionSlice): boolean {
|
|
1405
|
+
const head = TRANSPARENT_RESHAPE_HEAD.exec(expr.masked);
|
|
858
1406
|
if (!head) {
|
|
859
1407
|
return false;
|
|
860
1408
|
}
|
|
@@ -862,7 +1410,7 @@ function isTransparentObjectReshape(expr: string): boolean {
|
|
|
862
1410
|
// transparent only when that argument's root callee is `Object.entries(`
|
|
863
1411
|
// (optionally chained: `Object.entries(obj).filter(...)`), so the source
|
|
864
1412
|
// object is enumerable from source rather than produced by an opaque call.
|
|
865
|
-
const firstArg = expr.slice(head[0].length).trimStart();
|
|
1413
|
+
const firstArg = expr.masked.slice(head[0].length).trimStart();
|
|
866
1414
|
return OBJECT_ENTRIES_HEAD.test(firstArg);
|
|
867
1415
|
}
|
|
868
1416
|
|
|
@@ -996,7 +1544,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
996
1544
|
continue;
|
|
997
1545
|
}
|
|
998
1546
|
const valueStart = match.index + match[0].length;
|
|
999
|
-
const value = balancedValueExpression(source, valueStart);
|
|
1547
|
+
const value = balancedValueExpression(source, valueStart, relPath);
|
|
1000
1548
|
if (PASSTHROUGH_CALL.test(value)) {
|
|
1001
1549
|
const site: ConstSite = {
|
|
1002
1550
|
file: relPath,
|
|
@@ -1025,8 +1573,8 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
1025
1573
|
// that is itself a passthrough expression, or that references a passthrough
|
|
1026
1574
|
// const by name (resolved against the provider-wide map), is a violation.
|
|
1027
1575
|
for (const filePath of files) {
|
|
1028
|
-
const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
|
|
1029
1576
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
1577
|
+
const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
|
|
1030
1578
|
|
|
1031
1579
|
const inputProp = /\binput\s*:\s*/g;
|
|
1032
1580
|
for (let match = inputProp.exec(source); match !== null; match = inputProp.exec(source)) {
|
|
@@ -1037,7 +1585,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
1037
1585
|
continue;
|
|
1038
1586
|
}
|
|
1039
1587
|
const valueStart = match.index + match[0].length;
|
|
1040
|
-
const value = balancedValueExpression(source, valueStart);
|
|
1588
|
+
const value = balancedValueExpression(source, valueStart, relPath);
|
|
1041
1589
|
if (PASSTHROUGH_CALL.test(value)) {
|
|
1042
1590
|
push({ file: relPath, line: offsetToLine(source, valueStart) });
|
|
1043
1591
|
continue;
|
|
@@ -1148,13 +1696,19 @@ function spreadIdentifierResolvesToFactory(
|
|
|
1148
1696
|
if (!existsSync(filePath)) {
|
|
1149
1697
|
continue;
|
|
1150
1698
|
}
|
|
1699
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
1151
1700
|
const fileSource = filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
|
|
1701
|
+
const maskedFileSource = maskCommentsAndStrings(fileSource, relPath, {
|
|
1702
|
+
blankPropertyKeys: true,
|
|
1703
|
+
});
|
|
1152
1704
|
const re = new RegExp(declRe.source, "g");
|
|
1153
|
-
for (let m = re.exec(
|
|
1705
|
+
for (let m = re.exec(maskedFileSource); m !== null; m = re.exec(maskedFileSource)) {
|
|
1154
1706
|
sawDeclaration = true;
|
|
1155
|
-
const expr = unwrapParens(
|
|
1707
|
+
const expr = unwrapParens(
|
|
1708
|
+
balancedExpressionSlice(fileSource, m.index + m[0].length, relPath),
|
|
1709
|
+
);
|
|
1156
1710
|
const isFactory =
|
|
1157
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
|
|
1711
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr.masked) || hasTopLevelFactorySpread(expr)) &&
|
|
1158
1712
|
!isTransparentObjectReshape(expr);
|
|
1159
1713
|
if (isFactory) {
|
|
1160
1714
|
return true;
|
|
@@ -1163,12 +1717,232 @@ function spreadIdentifierResolvesToFactory(
|
|
|
1163
1717
|
}
|
|
1164
1718
|
// No local declaration anywhere but imported into index.ts => constructed
|
|
1165
1719
|
// out of view; treat as factory (conservative, false-negative-safe).
|
|
1166
|
-
if (
|
|
1720
|
+
if (
|
|
1721
|
+
!sawDeclaration &&
|
|
1722
|
+
fileImportsBinding(
|
|
1723
|
+
maskCommentsAndStrings(indexSource, "index.ts", { blankPropertyKeys: true }),
|
|
1724
|
+
name,
|
|
1725
|
+
)
|
|
1726
|
+
) {
|
|
1167
1727
|
return true;
|
|
1168
1728
|
}
|
|
1169
1729
|
return false;
|
|
1170
1730
|
}
|
|
1171
1731
|
|
|
1732
|
+
function isDefineProviderCall(node: TS.CallExpression): boolean {
|
|
1733
|
+
return ts.isIdentifier(node.expression) && node.expression.text === "defineProvider";
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
function resolveDefineProviderImplementationCall(
|
|
1737
|
+
candidate: TS.CallExpression,
|
|
1738
|
+
): TS.CallExpression | undefined {
|
|
1739
|
+
let root = candidate;
|
|
1740
|
+
while (ts.isCallExpression(root.expression)) {
|
|
1741
|
+
root = root.expression;
|
|
1742
|
+
}
|
|
1743
|
+
return isDefineProviderCall(root) ? candidate : undefined;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
function findProviderImplementationCall(sourceFile: TS.SourceFile): TS.CallExpression | undefined {
|
|
1747
|
+
const defaultExports = sourceFile.statements.filter(
|
|
1748
|
+
(statement): statement is TS.ExportAssignment =>
|
|
1749
|
+
ts.isExportAssignment(statement) && !statement.isExportEquals,
|
|
1750
|
+
);
|
|
1751
|
+
|
|
1752
|
+
// Phase-separated providers export a builder invocation directly. Preserve
|
|
1753
|
+
// the previous locator's preference for that call over every defineProvider
|
|
1754
|
+
// declaration that may also appear in the file.
|
|
1755
|
+
for (const assignment of defaultExports) {
|
|
1756
|
+
const expression = unwrapImplementationExpression(assignment.expression);
|
|
1757
|
+
if (!ts.isCallExpression(expression)) {
|
|
1758
|
+
continue;
|
|
1759
|
+
}
|
|
1760
|
+
if (resolveDefineProviderImplementationCall(expression) !== undefined) {
|
|
1761
|
+
continue;
|
|
1762
|
+
}
|
|
1763
|
+
if (ts.isIdentifier(expression.expression)) {
|
|
1764
|
+
return expression;
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// For `defineProvider(metadata)(implementation)`, select the outer call whose
|
|
1769
|
+
// argument is the implementation. A plain `defineProvider(implementation)`
|
|
1770
|
+
// selects the single call itself.
|
|
1771
|
+
for (const assignment of defaultExports) {
|
|
1772
|
+
const defaultExpression = unwrapImplementationExpression(assignment.expression);
|
|
1773
|
+
if (!ts.isCallExpression(defaultExpression)) {
|
|
1774
|
+
continue;
|
|
1775
|
+
}
|
|
1776
|
+
const implementationCall = resolveDefineProviderImplementationCall(defaultExpression);
|
|
1777
|
+
if (implementationCall !== undefined) {
|
|
1778
|
+
return implementationCall;
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// Resolve `export default provider` to a top-level variable initialized by
|
|
1783
|
+
// defineProvider, including its curried implementation call when present.
|
|
1784
|
+
for (const assignment of defaultExports) {
|
|
1785
|
+
if (!ts.isIdentifier(assignment.expression)) {
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1788
|
+
const exportedName = assignment.expression.text;
|
|
1789
|
+
for (const statement of sourceFile.statements) {
|
|
1790
|
+
if (!ts.isVariableStatement(statement)) {
|
|
1791
|
+
continue;
|
|
1792
|
+
}
|
|
1793
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1794
|
+
if (
|
|
1795
|
+
!ts.isIdentifier(declaration.name) ||
|
|
1796
|
+
declaration.name.text !== exportedName ||
|
|
1797
|
+
declaration.initializer === undefined
|
|
1798
|
+
) {
|
|
1799
|
+
continue;
|
|
1800
|
+
}
|
|
1801
|
+
if (!ts.isCallExpression(declaration.initializer)) {
|
|
1802
|
+
continue;
|
|
1803
|
+
}
|
|
1804
|
+
const implementationCall = resolveDefineProviderImplementationCall(declaration.initializer);
|
|
1805
|
+
if (implementationCall !== undefined) {
|
|
1806
|
+
return implementationCall;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
// Structural fixtures without a recognizable default export retain the
|
|
1813
|
+
// previous fallback to the first defineProvider call in source order.
|
|
1814
|
+
let firstCall: TS.CallExpression | undefined;
|
|
1815
|
+
const visit = (node: TS.Node): void => {
|
|
1816
|
+
if (firstCall !== undefined) {
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
if (ts.isCallExpression(node)) {
|
|
1820
|
+
const implementationCall = resolveDefineProviderImplementationCall(node);
|
|
1821
|
+
if (implementationCall !== undefined) {
|
|
1822
|
+
firstCall = implementationCall;
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
ts.forEachChild(node, visit);
|
|
1827
|
+
};
|
|
1828
|
+
visit(sourceFile);
|
|
1829
|
+
return firstCall;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function unwrapImplementationExpression(expression: TS.Expression): TS.Expression {
|
|
1833
|
+
let current = expression;
|
|
1834
|
+
while (
|
|
1835
|
+
ts.isSatisfiesExpression(current) ||
|
|
1836
|
+
ts.isAsExpression(current) ||
|
|
1837
|
+
ts.isParenthesizedExpression(current) ||
|
|
1838
|
+
ts.isNonNullExpression(current)
|
|
1839
|
+
) {
|
|
1840
|
+
current = current.expression;
|
|
1841
|
+
}
|
|
1842
|
+
return current;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
function isOperationsProperty(
|
|
1846
|
+
property: TS.ObjectLiteralElementLike,
|
|
1847
|
+
): property is TS.PropertyAssignment | TS.ShorthandPropertyAssignment {
|
|
1848
|
+
if (ts.isShorthandPropertyAssignment(property)) {
|
|
1849
|
+
return property.name.text === "operations";
|
|
1850
|
+
}
|
|
1851
|
+
if (!ts.isPropertyAssignment(property)) {
|
|
1852
|
+
return false;
|
|
1853
|
+
}
|
|
1854
|
+
return (
|
|
1855
|
+
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
1856
|
+
property.name.text === "operations"
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
function isOperationsAccessorOrMethod(
|
|
1861
|
+
property: TS.ObjectLiteralElementLike,
|
|
1862
|
+
): property is TS.GetAccessorDeclaration | TS.SetAccessorDeclaration | TS.MethodDeclaration {
|
|
1863
|
+
if (
|
|
1864
|
+
!ts.isGetAccessorDeclaration(property) &&
|
|
1865
|
+
!ts.isSetAccessorDeclaration(property) &&
|
|
1866
|
+
!ts.isMethodDeclaration(property)
|
|
1867
|
+
) {
|
|
1868
|
+
return false;
|
|
1869
|
+
}
|
|
1870
|
+
return (
|
|
1871
|
+
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
1872
|
+
property.name.text === "operations"
|
|
1873
|
+
);
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
function isSourceEnumerableStaticObjectLiteral(expression: TS.Expression): boolean {
|
|
1877
|
+
const unwrapped = unwrapImplementationExpression(expression);
|
|
1878
|
+
if (!ts.isObjectLiteralExpression(unwrapped)) {
|
|
1879
|
+
return false;
|
|
1880
|
+
}
|
|
1881
|
+
return unwrapped.properties.every((property) => {
|
|
1882
|
+
if (ts.isSpreadAssignment(property)) {
|
|
1883
|
+
return isSourceEnumerableStaticObjectLiteral(property.expression);
|
|
1884
|
+
}
|
|
1885
|
+
if (property.name === undefined || ts.isComputedPropertyName(property.name)) {
|
|
1886
|
+
return property.name === undefined;
|
|
1887
|
+
}
|
|
1888
|
+
return property.name.text !== "operations" || isOperationsProperty(property);
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
function findComputedImplementationProperty(
|
|
1893
|
+
objectLiteral: TS.ObjectLiteralExpression,
|
|
1894
|
+
): TS.ObjectLiteralElementLike | undefined {
|
|
1895
|
+
for (const property of objectLiteral.properties) {
|
|
1896
|
+
if (property.name !== undefined && ts.isComputedPropertyName(property.name)) {
|
|
1897
|
+
return property;
|
|
1898
|
+
}
|
|
1899
|
+
if (ts.isSpreadAssignment(property)) {
|
|
1900
|
+
const spreadExpression = unwrapImplementationExpression(property.expression);
|
|
1901
|
+
if (ts.isObjectLiteralExpression(spreadExpression)) {
|
|
1902
|
+
const nested = findComputedImplementationProperty(spreadExpression);
|
|
1903
|
+
if (nested !== undefined) {
|
|
1904
|
+
return nested;
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
return undefined;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
function findEffectiveOperationsProperty(
|
|
1913
|
+
objectLiteral: TS.ObjectLiteralExpression,
|
|
1914
|
+
):
|
|
1915
|
+
| TS.PropertyAssignment
|
|
1916
|
+
| TS.ShorthandPropertyAssignment
|
|
1917
|
+
| TS.GetAccessorDeclaration
|
|
1918
|
+
| TS.SetAccessorDeclaration
|
|
1919
|
+
| TS.MethodDeclaration
|
|
1920
|
+
| undefined {
|
|
1921
|
+
let effective:
|
|
1922
|
+
| TS.PropertyAssignment
|
|
1923
|
+
| TS.ShorthandPropertyAssignment
|
|
1924
|
+
| TS.GetAccessorDeclaration
|
|
1925
|
+
| TS.SetAccessorDeclaration
|
|
1926
|
+
| TS.MethodDeclaration
|
|
1927
|
+
| undefined;
|
|
1928
|
+
for (const property of objectLiteral.properties) {
|
|
1929
|
+
if (isOperationsProperty(property) || isOperationsAccessorOrMethod(property)) {
|
|
1930
|
+
effective = property;
|
|
1931
|
+
continue;
|
|
1932
|
+
}
|
|
1933
|
+
if (ts.isSpreadAssignment(property)) {
|
|
1934
|
+
const spreadExpression = unwrapImplementationExpression(property.expression);
|
|
1935
|
+
if (ts.isObjectLiteralExpression(spreadExpression)) {
|
|
1936
|
+
const nested = findEffectiveOperationsProperty(spreadExpression);
|
|
1937
|
+
if (nested !== undefined) {
|
|
1938
|
+
effective = nested;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
return effective;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1172
1946
|
function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
1173
1947
|
const indexPath = resolve(providerRoot, "index.ts");
|
|
1174
1948
|
const ruleId = "flat-operation-composition";
|
|
@@ -1182,10 +1956,23 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1182
1956
|
}
|
|
1183
1957
|
|
|
1184
1958
|
const source = readFileSync(indexPath, "utf8");
|
|
1185
|
-
|
|
1186
|
-
//
|
|
1187
|
-
//
|
|
1188
|
-
|
|
1959
|
+
const indexRelPath = toRelativeProviderPath(providerRoot, indexPath);
|
|
1960
|
+
// The report preflight already rejects unparseable index.ts sources before
|
|
1961
|
+
// this rule runs, so this local AST parse only replaces property location.
|
|
1962
|
+
const sourceFile = ts.createSourceFile(
|
|
1963
|
+
indexRelPath,
|
|
1964
|
+
source,
|
|
1965
|
+
ts.ScriptTarget.Latest,
|
|
1966
|
+
true,
|
|
1967
|
+
ts.ScriptKind.TS,
|
|
1968
|
+
);
|
|
1969
|
+
const implementationCall = findProviderImplementationCall(sourceFile);
|
|
1970
|
+
const rawImplementationArg = implementationCall?.arguments[0];
|
|
1971
|
+
const implementationArg =
|
|
1972
|
+
rawImplementationArg === undefined
|
|
1973
|
+
? undefined
|
|
1974
|
+
: unwrapImplementationExpression(rawImplementationArg);
|
|
1975
|
+
if (implementationArg === undefined || !ts.isObjectLiteralExpression(implementationArg)) {
|
|
1189
1976
|
return pass(
|
|
1190
1977
|
ruleId,
|
|
1191
1978
|
SDK_NATIVE_CATEGORY,
|
|
@@ -1194,69 +1981,73 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1194
1981
|
);
|
|
1195
1982
|
}
|
|
1196
1983
|
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
defineParenIndex = inlineDefault.index + inlineDefault[0].length - 1; // points at `(`
|
|
1208
|
-
} else {
|
|
1209
|
-
const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(source);
|
|
1210
|
-
const exportedName = namedDefault?.[1];
|
|
1211
|
-
if (exportedName !== undefined) {
|
|
1212
|
-
const namedDecl = new RegExp(
|
|
1213
|
-
`(?:^|\\n)[ \t]*(?:export\\s+)?(?:const|let|var)\\s+${exportedName}\\s*(?::[^=\\n]+)?\\s*=\\s*defineProvider\\s*\\(`,
|
|
1214
|
-
).exec(source);
|
|
1215
|
-
if (namedDecl) {
|
|
1216
|
-
defineParenIndex = namedDecl.index + namedDecl[0].length - 1;
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
if (defineParenIndex === -1) {
|
|
1220
|
-
const firstCall = /\bdefineProvider\s*\(/.exec(source);
|
|
1221
|
-
if (firstCall) {
|
|
1222
|
-
defineParenIndex = firstCall.index + firstCall[0].length - 1;
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1984
|
+
// Resolve the top-level operations property from the AST, then feed its raw
|
|
1985
|
+
// initializer offset into the existing expression classifier and alias
|
|
1986
|
+
// resolver unchanged.
|
|
1987
|
+
const opsProp = findEffectiveOperationsProperty(implementationArg);
|
|
1988
|
+
let opsValue: ExpressionSlice | undefined;
|
|
1989
|
+
let opsLine = 1;
|
|
1990
|
+
if (opsProp !== undefined && ts.isPropertyAssignment(opsProp)) {
|
|
1991
|
+
const valueStart = opsProp.initializer.getStart(sourceFile);
|
|
1992
|
+
opsValue = unwrapParens(balancedExpressionSlice(source, valueStart, indexRelPath));
|
|
1993
|
+
opsLine = offsetToLine(source, valueStart);
|
|
1225
1994
|
}
|
|
1226
|
-
|
|
1227
|
-
|
|
1995
|
+
|
|
1996
|
+
// Property shorthand: `buildProvider({ operations })` — resolve the
|
|
1997
|
+
// local `operations` const initializer.
|
|
1998
|
+
let aliasName: string | undefined;
|
|
1999
|
+
if (opsProp !== undefined && ts.isShorthandPropertyAssignment(opsProp)) {
|
|
2000
|
+
aliasName = "operations";
|
|
2001
|
+
} else if (opsValue !== undefined && /^[A-Za-z_$][\w$]*$/.test(opsValue.masked)) {
|
|
2002
|
+
// `operations: ops` — a bare identifier alias to resolve.
|
|
2003
|
+
aliasName = opsValue.raw;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// A computed property might resolve to `operations`, but its composition is
|
|
2007
|
+
// not source-enumerable here. Fail closed, like an unresolved imported alias.
|
|
2008
|
+
const computed = findComputedImplementationProperty(implementationArg);
|
|
2009
|
+
if (computed !== undefined) {
|
|
2010
|
+
const computedLine = offsetToLine(source, computed.getStart(sourceFile));
|
|
2011
|
+
return blocker(
|
|
1228
2012
|
ruleId,
|
|
1229
2013
|
SDK_NATIVE_CATEGORY,
|
|
1230
|
-
"
|
|
2014
|
+
"The defineProvider operations property uses a computed property name that cannot be statically resolved.",
|
|
2015
|
+
"Declare operations with the literal property name `operations` and a static object literal value so the provider-registry AST gate can enumerate it.",
|
|
1231
2016
|
0,
|
|
2017
|
+
[`${indexRelPath}:${computedLine} (computed property name)`],
|
|
1232
2018
|
);
|
|
1233
2019
|
}
|
|
1234
|
-
const argStart = defineParenIndex + 1;
|
|
1235
|
-
const argText = balancedValueExpression(source, argStart);
|
|
1236
2020
|
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
opsLine = offsetToLine(source, valueStart);
|
|
2021
|
+
if (opsProp !== undefined && isOperationsAccessorOrMethod(opsProp)) {
|
|
2022
|
+
const memberLine = offsetToLine(source, opsProp.getStart(sourceFile));
|
|
2023
|
+
return blocker(
|
|
2024
|
+
ruleId,
|
|
2025
|
+
SDK_NATIVE_CATEGORY,
|
|
2026
|
+
"The defineProvider operations member is an accessor/method whose value is not statically enumerable.",
|
|
2027
|
+
"Declare operations as a property assignment with a static object literal value so the provider-registry AST gate can enumerate it.",
|
|
2028
|
+
0,
|
|
2029
|
+
[`${indexRelPath}:${memberLine} (operations accessor/method)`],
|
|
2030
|
+
);
|
|
1248
2031
|
}
|
|
1249
2032
|
|
|
1250
|
-
//
|
|
1251
|
-
//
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
2033
|
+
// A spread can override a preceding literal `operations` property. Only an
|
|
2034
|
+
// inline static object literal (including recursively inline static spreads)
|
|
2035
|
+
// exposes all of its property names to this source-only gate. Identifiers,
|
|
2036
|
+
// calls, and other expressions are opaque and therefore fail closed.
|
|
2037
|
+
const opaqueSpread = implementationArg.properties.find(
|
|
2038
|
+
(property): property is TS.SpreadAssignment =>
|
|
2039
|
+
ts.isSpreadAssignment(property) &&
|
|
2040
|
+
!isSourceEnumerableStaticObjectLiteral(property.expression),
|
|
2041
|
+
);
|
|
2042
|
+
if (opaqueSpread !== undefined) {
|
|
2043
|
+
const spreadLine = offsetToLine(source, opaqueSpread.getStart(sourceFile));
|
|
2044
|
+
return escapeHatchResult(providerRoot, ruleId, [{ file: indexRelPath, line: spreadLine }], {
|
|
2045
|
+
blockerMessage:
|
|
2046
|
+
"The defineProvider implementation uses a non-enumerable spread that could override operations.",
|
|
2047
|
+
remediation:
|
|
2048
|
+
"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.",
|
|
2049
|
+
passMessage: "defineProvider declares operations as a static object literal.",
|
|
2050
|
+
});
|
|
1260
2051
|
}
|
|
1261
2052
|
|
|
1262
2053
|
// Determine the effective initializer expression to classify. The alias may
|
|
@@ -1290,7 +2081,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1290
2081
|
// do not resolve the exact import target path; "any same-named factory
|
|
1291
2082
|
// blocks" is the conservative, false-negative-avoiding choice for a gate.)
|
|
1292
2083
|
type Candidate = {
|
|
1293
|
-
expr:
|
|
2084
|
+
expr: ExpressionSlice;
|
|
1294
2085
|
line: number;
|
|
1295
2086
|
file: string;
|
|
1296
2087
|
isFactory: boolean;
|
|
@@ -1300,15 +2091,18 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1300
2091
|
if (!existsSync(filePath)) {
|
|
1301
2092
|
continue;
|
|
1302
2093
|
}
|
|
1303
|
-
const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
|
|
1304
2094
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
2095
|
+
const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
|
|
2096
|
+
const maskedFileSource = maskCommentsAndStrings(fileSource, relPath, {
|
|
2097
|
+
blankPropertyKeys: true,
|
|
2098
|
+
});
|
|
1305
2099
|
|
|
1306
2100
|
const declRe = new RegExp(aliasDecl.source, "g");
|
|
1307
|
-
for (let m = declRe.exec(
|
|
2101
|
+
for (let m = declRe.exec(maskedFileSource); m !== null; m = declRe.exec(maskedFileSource)) {
|
|
1308
2102
|
const valueStart = m.index + m[0].length;
|
|
1309
|
-
const expr = unwrapParens(
|
|
2103
|
+
const expr = unwrapParens(balancedExpressionSlice(fileSource, valueStart, relPath));
|
|
1310
2104
|
const isFactory =
|
|
1311
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
|
|
2105
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr.masked) || hasTopLevelFactorySpread(expr)) &&
|
|
1312
2106
|
!isTransparentObjectReshape(expr);
|
|
1313
2107
|
candidates.push({
|
|
1314
2108
|
expr,
|
|
@@ -1318,9 +2112,14 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1318
2112
|
});
|
|
1319
2113
|
}
|
|
1320
2114
|
const destructRe = new RegExp(destructured.source, "g");
|
|
1321
|
-
for (
|
|
2115
|
+
for (
|
|
2116
|
+
let m = destructRe.exec(maskedFileSource);
|
|
2117
|
+
m !== null;
|
|
2118
|
+
m = destructRe.exec(maskedFileSource)
|
|
2119
|
+
) {
|
|
2120
|
+
const raw = `${m[1]}(`;
|
|
1322
2121
|
candidates.push({
|
|
1323
|
-
expr:
|
|
2122
|
+
expr: { raw, masked: raw },
|
|
1324
2123
|
line: offsetToLine(fileSource, m.index),
|
|
1325
2124
|
file: relPath,
|
|
1326
2125
|
isFactory: true,
|
|
@@ -1346,11 +2145,15 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1346
2145
|
// the unresolved import as a factory-composed (non-static) shape rather
|
|
1347
2146
|
// than silently passing.
|
|
1348
2147
|
if (!resolved) {
|
|
2148
|
+
const maskedIndexSource = maskCommentsAndStrings(source, indexRelPath, {
|
|
2149
|
+
blankPropertyKeys: true,
|
|
2150
|
+
});
|
|
1349
2151
|
const importMatch = new RegExp(`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`).exec(
|
|
1350
|
-
|
|
2152
|
+
maskedIndexSource,
|
|
1351
2153
|
);
|
|
1352
2154
|
if (importMatch) {
|
|
1353
|
-
|
|
2155
|
+
const raw = `${aliasName}(`;
|
|
2156
|
+
effective = { raw, masked: raw };
|
|
1354
2157
|
effectiveLine = offsetToLine(source, importMatch.index);
|
|
1355
2158
|
effectiveFile = "index.ts";
|
|
1356
2159
|
}
|
|
@@ -1374,14 +2177,16 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1374
2177
|
spreadIdentifierResolvesToFactory(providerRoot, indexPath, source, name),
|
|
1375
2178
|
);
|
|
1376
2179
|
const isStaticLiteral =
|
|
1377
|
-
effective?.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
|
|
2180
|
+
effective?.masked.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
|
|
1378
2181
|
// A call expression `ident(...)` (factory) or a factory-spread literal is
|
|
1379
2182
|
// the rejected, non-static shape — UNLESS it is the stdlib
|
|
1380
2183
|
// `Object.fromEntries(Object.entries(<source-visible obj>)...)` reshape,
|
|
1381
2184
|
// whose op set is still enumerable from source (verified golden pattern).
|
|
1382
2185
|
const isFactoryCall =
|
|
1383
2186
|
effective !== undefined &&
|
|
1384
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) ||
|
|
2187
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(effective.masked) ||
|
|
2188
|
+
hasFactorySpread ||
|
|
2189
|
+
hasFactorySpreadIdentifier) &&
|
|
1385
2190
|
!isTransparentObjectReshape(effective);
|
|
1386
2191
|
|
|
1387
2192
|
if (isFactoryCall && !isStaticLiteral) {
|
|
@@ -1393,7 +2198,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1393
2198
|
blockerMessage:
|
|
1394
2199
|
"defineProvider operations are composed by a factory call instead of a static object literal.",
|
|
1395
2200
|
remediation:
|
|
1396
|
-
"Declare operations as a static literal
|
|
2201
|
+
"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>`.",
|
|
1397
2202
|
passMessage: "defineProvider declares operations as a static object literal.",
|
|
1398
2203
|
});
|
|
1399
2204
|
}
|
|
@@ -1407,7 +2212,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1407
2212
|
}
|
|
1408
2213
|
|
|
1409
2214
|
function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
|
|
1410
|
-
const credentialReferences =
|
|
2215
|
+
const credentialReferences = findCredentialReferences(providerRoot);
|
|
1411
2216
|
const authMode = provider.auth?.mode ?? "none";
|
|
1412
2217
|
const credentialKeys = provider.credential?.keys ?? [];
|
|
1413
2218
|
const storesProviderCredential = authMode !== "none" || credentialKeys.length > 0;
|
|
@@ -1437,6 +2242,107 @@ function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition
|
|
|
1437
2242
|
);
|
|
1438
2243
|
}
|
|
1439
2244
|
|
|
2245
|
+
function isDirectCredentialReference(expression: TS.Expression): boolean {
|
|
2246
|
+
const reference = unwrapLocalExpression(expression);
|
|
2247
|
+
let receiver: TS.Expression | undefined;
|
|
2248
|
+
let memberName: string | undefined;
|
|
2249
|
+
if (ts.isPropertyAccessExpression(reference)) {
|
|
2250
|
+
receiver = reference.expression;
|
|
2251
|
+
memberName = reference.name.text;
|
|
2252
|
+
} else if (ts.isElementAccessExpression(reference)) {
|
|
2253
|
+
receiver = reference.expression;
|
|
2254
|
+
const argument = reference.argumentExpression;
|
|
2255
|
+
if (
|
|
2256
|
+
argument !== undefined &&
|
|
2257
|
+
(ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
|
|
2258
|
+
) {
|
|
2259
|
+
memberName = argument.text;
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
const unwrappedReceiver = receiver === undefined ? undefined : unwrapLocalExpression(receiver);
|
|
2263
|
+
return (
|
|
2264
|
+
memberName === "credential" &&
|
|
2265
|
+
unwrappedReceiver !== undefined &&
|
|
2266
|
+
ts.isIdentifier(unwrappedReceiver) &&
|
|
2267
|
+
unwrappedReceiver.text === "ctx"
|
|
2268
|
+
);
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
function isCredentialReference(
|
|
2272
|
+
expression: TS.Expression,
|
|
2273
|
+
bindings: LocalBindings,
|
|
2274
|
+
resolving: ReadonlySet<LocalBinding> = new Set(),
|
|
2275
|
+
): boolean {
|
|
2276
|
+
const reference = unwrapLocalExpression(expression);
|
|
2277
|
+
if (isDirectCredentialReference(reference)) {
|
|
2278
|
+
return true;
|
|
2279
|
+
}
|
|
2280
|
+
if (!ts.isIdentifier(reference)) {
|
|
2281
|
+
return false;
|
|
2282
|
+
}
|
|
2283
|
+
const localBindings = lookupLocalBindings(reference, bindings);
|
|
2284
|
+
if (localBindings === undefined || localBindings.length === 0) {
|
|
2285
|
+
return false;
|
|
2286
|
+
}
|
|
2287
|
+
return localBindings.every((binding) => {
|
|
2288
|
+
if (resolving.has(binding) || binding.mutable || binding.initializer === undefined) {
|
|
2289
|
+
return false;
|
|
2290
|
+
}
|
|
2291
|
+
const nextResolving = new Set(resolving);
|
|
2292
|
+
nextResolving.add(binding);
|
|
2293
|
+
if (binding.destructuredProperty !== undefined) {
|
|
2294
|
+
const source = unwrapLocalExpression(binding.initializer);
|
|
2295
|
+
return (
|
|
2296
|
+
binding.destructuredProperty === "credential" &&
|
|
2297
|
+
ts.isIdentifier(source) &&
|
|
2298
|
+
source.text === "ctx"
|
|
2299
|
+
);
|
|
2300
|
+
}
|
|
2301
|
+
return isCredentialReference(binding.initializer, bindings, nextResolving);
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
function findCredentialReferences(providerRoot: string): SourceFinding[] {
|
|
2306
|
+
const findings: SourceFinding[] = [];
|
|
2307
|
+
const seen = new Set<string>();
|
|
2308
|
+
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
2309
|
+
const source = readFileSync(filePath, "utf8");
|
|
2310
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
2311
|
+
const sourceFile = ts.createSourceFile(
|
|
2312
|
+
relPath,
|
|
2313
|
+
source,
|
|
2314
|
+
ts.ScriptTarget.Latest,
|
|
2315
|
+
true,
|
|
2316
|
+
ts.ScriptKind.TS,
|
|
2317
|
+
);
|
|
2318
|
+
const bindings = collectLocalBindings(sourceFile);
|
|
2319
|
+
const visit = (node: TS.Node): void => {
|
|
2320
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
2323
|
+
if (
|
|
2324
|
+
ts.isExpression(node) &&
|
|
2325
|
+
(isDirectCredentialReference(node) ||
|
|
2326
|
+
((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) &&
|
|
2327
|
+
isCredentialReference(node.expression, bindings)))
|
|
2328
|
+
) {
|
|
2329
|
+
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
2330
|
+
const key = `${relPath}:${line}`;
|
|
2331
|
+
if (!seen.has(key)) {
|
|
2332
|
+
seen.add(key);
|
|
2333
|
+
findings.push({ file: relPath, line });
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
ts.forEachChild(node, visit);
|
|
2337
|
+
};
|
|
2338
|
+
visit(sourceFile);
|
|
2339
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
2340
|
+
break;
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
return findings;
|
|
2344
|
+
}
|
|
2345
|
+
|
|
1440
2346
|
// ---------------------------------------------------------------------------
|
|
1441
2347
|
// sdk-owned-secret-presence (warn): provider-local double validation of
|
|
1442
2348
|
// declared env secrets.
|
|
@@ -1593,23 +2499,34 @@ function scoreSdkOwnedSecretPresence(
|
|
|
1593
2499
|
function findSourceLineMatches(
|
|
1594
2500
|
providerRoot: string,
|
|
1595
2501
|
pattern: RegExp | ((line: string) => boolean),
|
|
2502
|
+
useMaskedSource = false,
|
|
1596
2503
|
): SourceFinding[] {
|
|
1597
|
-
return findSourceFindings(
|
|
2504
|
+
return findSourceFindings(
|
|
2505
|
+
providerRoot,
|
|
2506
|
+
(line) => matchesLinePattern(line, pattern),
|
|
2507
|
+
useMaskedSource,
|
|
2508
|
+
);
|
|
1598
2509
|
}
|
|
1599
2510
|
|
|
1600
2511
|
function findSourceFindings(
|
|
1601
2512
|
providerRoot: string,
|
|
1602
2513
|
matchesLine: (line: string, remainingLines: readonly string[]) => boolean,
|
|
2514
|
+
useMaskedSource = false,
|
|
1603
2515
|
): SourceFinding[] {
|
|
1604
2516
|
const findings: SourceFinding[] = [];
|
|
1605
2517
|
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
1606
2518
|
const content = readFileSync(filePath, "utf8");
|
|
1607
|
-
const
|
|
2519
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
2520
|
+
const lines = (
|
|
2521
|
+
useMaskedSource
|
|
2522
|
+
? maskCommentsAndStrings(content, relPath, { blankPropertyKeys: true })
|
|
2523
|
+
: content
|
|
2524
|
+
).split(/\r?\n/);
|
|
1608
2525
|
for (let index = 0; index < lines.length; index += 1) {
|
|
1609
2526
|
const line = lines[index];
|
|
1610
2527
|
if (line !== undefined && matchesLine(line, lines.slice(index + 1))) {
|
|
1611
2528
|
findings.push({
|
|
1612
|
-
file:
|
|
2529
|
+
file: relPath,
|
|
1613
2530
|
line: index + 1,
|
|
1614
2531
|
});
|
|
1615
2532
|
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
@@ -1689,12 +2606,25 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
|
|
|
1689
2606
|
);
|
|
1690
2607
|
}
|
|
1691
2608
|
|
|
2609
|
+
const EXCLUDED_SOURCE_DIRECTORY_SEGMENTS = new Set([
|
|
2610
|
+
".git",
|
|
2611
|
+
".worktree",
|
|
2612
|
+
"node_modules",
|
|
2613
|
+
"dist",
|
|
2614
|
+
"build",
|
|
2615
|
+
"coverage",
|
|
2616
|
+
]);
|
|
2617
|
+
|
|
1692
2618
|
// `.agents`/`.apifuse` stay IN scope on purpose: managed content there is
|
|
1693
2619
|
// markdown/JSON (never scannable), while a planted `.ts`/`.sh` under those
|
|
1694
2620
|
// directories must not become a scan-exempt hiding place for secrets, raw
|
|
1695
|
-
// fetch, or vendor imports.
|
|
2621
|
+
// fetch, or vendor imports. Nested dependency/build output directories under
|
|
2622
|
+
// them use the same segment exclusions; `.worktree` is excluded because task
|
|
2623
|
+
// worktrees are duplicate repository checkouts, not provider source.
|
|
1696
2624
|
function shouldScanSourceDirectory(relativePath: string): boolean {
|
|
1697
|
-
return
|
|
2625
|
+
return relativePath
|
|
2626
|
+
.split("/")
|
|
2627
|
+
.every((segment) => !EXCLUDED_SOURCE_DIRECTORY_SEGMENTS.has(segment));
|
|
1698
2628
|
}
|
|
1699
2629
|
|
|
1700
2630
|
function isExcludedTestSource(relativePath: string): boolean {
|
|
@@ -1712,7 +2642,10 @@ function toRelativeProviderPath(providerRoot: string, filePath: string): string
|
|
|
1712
2642
|
}
|
|
1713
2643
|
|
|
1714
2644
|
function formatSourceFindings(findings: readonly SourceFinding[]): string[] {
|
|
1715
|
-
return findings.map(
|
|
2645
|
+
return findings.map(
|
|
2646
|
+
(finding) =>
|
|
2647
|
+
`${finding.file}:${finding.line}${finding.detail === undefined ? "" : `: ${finding.detail}`}`,
|
|
2648
|
+
);
|
|
1716
2649
|
}
|
|
1717
2650
|
|
|
1718
2651
|
function scorePromptAssetFreshness(providerRoot: string): SubmitCheck {
|
|
@@ -1808,7 +2741,7 @@ async function safeRunChecks(providerRoot: string): Promise<CheckResult[]> {
|
|
|
1808
2741
|
{
|
|
1809
2742
|
message: "Base provider checks can run",
|
|
1810
2743
|
passed: false,
|
|
1811
|
-
details: [
|
|
2744
|
+
details: [formatThrownValue(error)],
|
|
1812
2745
|
},
|
|
1813
2746
|
];
|
|
1814
2747
|
}
|
|
@@ -2033,13 +2966,17 @@ function collectProviderRequiredLocaleKeys(provider: ProviderDefinition): string
|
|
|
2033
2966
|
|
|
2034
2967
|
for (const operation of Object.values(provider.operations)) {
|
|
2035
2968
|
addLocaleKeys(keys, [
|
|
2969
|
+
operation.titleKey,
|
|
2036
2970
|
operation.descriptionKey,
|
|
2037
|
-
operation.
|
|
2038
|
-
operation.
|
|
2039
|
-
operation.docs?.summaryKey,
|
|
2040
|
-
operation.docs?.markdownKey,
|
|
2971
|
+
operation.summaryKey,
|
|
2972
|
+
operation.markdownKey,
|
|
2041
2973
|
...(operation.whenToUseKeys ?? []),
|
|
2042
2974
|
...(operation.whenNotToUseKeys ?? []),
|
|
2975
|
+
...(operation.normalizationNotesKeys ?? []),
|
|
2976
|
+
...(operation.examples ?? []).flatMap((example) => [
|
|
2977
|
+
example.scenarioKey,
|
|
2978
|
+
example.rationaleKey,
|
|
2979
|
+
]),
|
|
2043
2980
|
...collectSchemaDescriptionKeys(operation.input),
|
|
2044
2981
|
...collectSchemaDescriptionKeys(operation.output),
|
|
2045
2982
|
]);
|
|
@@ -2106,8 +3043,8 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
|
|
|
2106
3043
|
return true;
|
|
2107
3044
|
})
|
|
2108
3045
|
.map(([operationId]) => operationId);
|
|
2109
|
-
const
|
|
2110
|
-
.filter(([, operation]) => !operation.
|
|
3046
|
+
const missingRiskClass = operations
|
|
3047
|
+
.filter(([, operation]) => !operation.riskClass)
|
|
2111
3048
|
.map(([operationId]) => operationId);
|
|
2112
3049
|
|
|
2113
3050
|
if (weakDescriptions.length > 0) {
|
|
@@ -2119,30 +3056,30 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
|
|
|
2119
3056
|
points: 0,
|
|
2120
3057
|
maxPoints: CATEGORY_MAX_POINTS.operations,
|
|
2121
3058
|
message: "One or more operations have weak descriptions.",
|
|
2122
|
-
remediation: `For ${weakDescriptions.join(", ")}, add an operation \`descriptionKey\` backed by \`locales/en.json\` and \`locales/ko.json
|
|
3059
|
+
remediation: `For ${weakDescriptions.join(", ")}, add an operation \`descriptionKey\` backed by \`locales/en.json\` and \`locales/ko.json\`.`,
|
|
2123
3060
|
evidence: weakDescriptions,
|
|
2124
3061
|
};
|
|
2125
3062
|
}
|
|
2126
3063
|
|
|
2127
|
-
const points =
|
|
3064
|
+
const points = missingRiskClass.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
|
|
2128
3065
|
return {
|
|
2129
3066
|
id: "operation-metadata",
|
|
2130
3067
|
category: "operations",
|
|
2131
|
-
level:
|
|
2132
|
-
status:
|
|
3068
|
+
level: missingRiskClass.length > 0 ? "warn" : "info",
|
|
3069
|
+
status: missingRiskClass.length > 0 ? "warn" : "pass",
|
|
2133
3070
|
points,
|
|
2134
3071
|
maxPoints: CATEGORY_MAX_POINTS.operations,
|
|
2135
3072
|
message:
|
|
2136
|
-
|
|
2137
|
-
? "Operations are described, but some are missing safety
|
|
3073
|
+
missingRiskClass.length > 0
|
|
3074
|
+
? "Operations are described, but some are missing a safety classification."
|
|
2138
3075
|
: "Operation descriptions and metadata are review-ready.",
|
|
2139
3076
|
remediation:
|
|
2140
|
-
|
|
2141
|
-
? `For ${
|
|
3077
|
+
missingRiskClass.length > 0
|
|
3078
|
+
? `For ${missingRiskClass.join(", ")}, declare the operation's required \`riskClass\` as \`read\`, \`write\`, \`destructive\`, or \`external-send\`.`
|
|
2142
3079
|
: undefined,
|
|
2143
3080
|
evidence:
|
|
2144
|
-
|
|
2145
|
-
?
|
|
3081
|
+
missingRiskClass.length > 0
|
|
3082
|
+
? missingRiskClass.map((operationId) => `${operationId}: missing riskClass`)
|
|
2146
3083
|
: operations.map(([operationId]) => operationId),
|
|
2147
3084
|
};
|
|
2148
3085
|
}
|
|
@@ -2174,11 +3111,16 @@ const GENERATED_LOCAL_ONLY_SCAFFOLD_REASON = /generated local-only scaffold/i;
|
|
|
2174
3111
|
function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
|
|
2175
3112
|
const rawPath = resolve(providerRoot, "__fixtures__", "raw.json");
|
|
2176
3113
|
let hasRecordedEvidence = false;
|
|
3114
|
+
let fixtureValidationError: string | undefined;
|
|
2177
3115
|
if (existsSync(rawPath)) {
|
|
2178
3116
|
try {
|
|
2179
|
-
hasRecordedEvidence =
|
|
2180
|
-
|
|
3117
|
+
hasRecordedEvidence = recordedFixtureStats(
|
|
3118
|
+
JSON.parse(readFileSync(rawPath, "utf8")),
|
|
3119
|
+
0,
|
|
3120
|
+
).hasNestedSubstance;
|
|
3121
|
+
} catch (error) {
|
|
2181
3122
|
hasRecordedEvidence = false;
|
|
3123
|
+
fixtureValidationError = error instanceof Error ? error.message : String(error);
|
|
2182
3124
|
}
|
|
2183
3125
|
}
|
|
2184
3126
|
|
|
@@ -2190,6 +3132,16 @@ function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefiniti
|
|
|
2190
3132
|
0,
|
|
2191
3133
|
);
|
|
2192
3134
|
}
|
|
3135
|
+
if (fixtureValidationError) {
|
|
3136
|
+
return blocker(
|
|
3137
|
+
"fixture-provenance",
|
|
3138
|
+
"fixtures",
|
|
3139
|
+
`Malformed recorded fixture evidence in __fixtures__/raw.json: ${fixtureValidationError}`,
|
|
3140
|
+
"Re-run `bun run record` to replace the malformed stream evidence, or repair the named field using the stream evidence contract.",
|
|
3141
|
+
0,
|
|
3142
|
+
["__fixtures__/raw.json"],
|
|
3143
|
+
);
|
|
3144
|
+
}
|
|
2193
3145
|
|
|
2194
3146
|
if (allOperationsAreGeneratedLocalScaffold(provider)) {
|
|
2195
3147
|
return {
|
|
@@ -2217,14 +3169,32 @@ function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefiniti
|
|
|
2217
3169
|
);
|
|
2218
3170
|
}
|
|
2219
3171
|
|
|
2220
|
-
function hasNonEmptyRecordedFixture(value: unknown): boolean {
|
|
2221
|
-
|
|
3172
|
+
export function hasNonEmptyRecordedFixture(value: unknown): boolean {
|
|
3173
|
+
try {
|
|
3174
|
+
return recordedFixtureStats(value, 0).hasNestedSubstance;
|
|
3175
|
+
} catch {
|
|
3176
|
+
return false;
|
|
3177
|
+
}
|
|
2222
3178
|
}
|
|
2223
3179
|
|
|
2224
3180
|
function recordedFixtureStats(
|
|
2225
3181
|
value: unknown,
|
|
2226
3182
|
depth: number,
|
|
2227
3183
|
): { hasNestedSubstance: boolean; leafValues: number } {
|
|
3184
|
+
if (
|
|
3185
|
+
value !== null &&
|
|
3186
|
+
typeof value === "object" &&
|
|
3187
|
+
!Array.isArray(value) &&
|
|
3188
|
+
(value as Record<string, unknown>).__apifuse_capture__ === true
|
|
3189
|
+
) {
|
|
3190
|
+
const group = findStreamCaptureGroup(value);
|
|
3191
|
+
if (!group) throw new Error("Stream capture envelope is invalid.");
|
|
3192
|
+
return { hasNestedSubstance: true, leafValues: group.items.length };
|
|
3193
|
+
}
|
|
3194
|
+
if (hasStreamEvidenceMarker(value)) {
|
|
3195
|
+
parseStreamEvidenceRecord(value);
|
|
3196
|
+
return { hasNestedSubstance: true, leafValues: 1 };
|
|
3197
|
+
}
|
|
2228
3198
|
if (value === null || value === undefined) {
|
|
2229
3199
|
return { hasNestedSubstance: false, leafValues: 0 };
|
|
2230
3200
|
}
|
|
@@ -2259,10 +3229,14 @@ function recordedFixtureStats(
|
|
|
2259
3229
|
if (value.length === 0) {
|
|
2260
3230
|
return { hasNestedSubstance: false, leafValues: 0 };
|
|
2261
3231
|
}
|
|
2262
|
-
// A recorded operation value may be a raw XML
|
|
2263
|
-
// substantive
|
|
2264
|
-
// string as a leaf so existing JSON provenance heuristics are unchanged.
|
|
2265
|
-
return {
|
|
3232
|
+
// A recorded operation value may be a raw XML or delimited-text success
|
|
3233
|
+
// payload. Treat a substantive one as nested evidence while still counting
|
|
3234
|
+
// the string as a leaf so existing JSON provenance heuristics are unchanged.
|
|
3235
|
+
return {
|
|
3236
|
+
hasNestedSubstance:
|
|
3237
|
+
hasSubstantiveXmlStructure(value) || hasSubstantiveDelimitedTextStructure(value),
|
|
3238
|
+
leafValues: 1,
|
|
3239
|
+
};
|
|
2266
3240
|
}
|
|
2267
3241
|
return { hasNestedSubstance: false, leafValues: 1 };
|
|
2268
3242
|
}
|
|
@@ -2327,19 +3301,32 @@ function findVendorKeyLeakFindings(providerRoot: string): SourceFinding[] {
|
|
|
2327
3301
|
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
2328
3302
|
const source = readFileSync(filePath, "utf8");
|
|
2329
3303
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
3304
|
+
maskCommentsAndStrings(source, relPath);
|
|
3305
|
+
const sourceFile = ts.createSourceFile(
|
|
3306
|
+
relPath,
|
|
3307
|
+
source,
|
|
3308
|
+
ts.ScriptTarget.Latest,
|
|
3309
|
+
true,
|
|
3310
|
+
ts.ScriptKind.TS,
|
|
3311
|
+
);
|
|
3312
|
+
const bindings = collectLocalBindings(sourceFile);
|
|
2330
3313
|
const upstreamRanges = findUpstreamMarkedConstRanges(source);
|
|
2331
3314
|
for (const zObject of findZObjectLiterals(source)) {
|
|
2332
3315
|
if (rangeContainsOffset(upstreamRanges, zObject.callStart)) {
|
|
2333
3316
|
continue;
|
|
2334
3317
|
}
|
|
2335
|
-
if (!zObjectAppearsPublicOutput(
|
|
3318
|
+
if (!zObjectAppearsPublicOutput(sourceFile, zObject, relPath)) {
|
|
2336
3319
|
continue;
|
|
2337
3320
|
}
|
|
2338
|
-
for (const keyFinding of vendorKeyFindingsForObject(source, zObject)) {
|
|
3321
|
+
for (const keyFinding of vendorKeyFindingsForObject(source, sourceFile, zObject, bindings)) {
|
|
2339
3322
|
const key = `${relPath}:${keyFinding.line}:${keyFinding.key}`;
|
|
2340
3323
|
if (!seen.has(key)) {
|
|
2341
3324
|
seen.add(key);
|
|
2342
|
-
findings.push({
|
|
3325
|
+
findings.push({
|
|
3326
|
+
file: relPath,
|
|
3327
|
+
line: keyFinding.line,
|
|
3328
|
+
detail: keyFinding.key,
|
|
3329
|
+
});
|
|
2343
3330
|
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
2344
3331
|
return findings;
|
|
2345
3332
|
}
|
|
@@ -2375,20 +3362,260 @@ function findZObjectLiterals(source: string): ZObjectLiteral[] {
|
|
|
2375
3362
|
return literals;
|
|
2376
3363
|
}
|
|
2377
3364
|
|
|
2378
|
-
function zObjectAppearsPublicOutput(
|
|
2379
|
-
|
|
2380
|
-
|
|
3365
|
+
function zObjectAppearsPublicOutput(
|
|
3366
|
+
sourceFile: TS.SourceFile,
|
|
3367
|
+
zObject: ZObjectLiteral,
|
|
3368
|
+
relPath: string,
|
|
3369
|
+
): boolean {
|
|
3370
|
+
let zObjectCall: TS.CallExpression | undefined;
|
|
3371
|
+
const findCall = (node: TS.Node): void => {
|
|
3372
|
+
if (
|
|
3373
|
+
zObjectCall === undefined &&
|
|
3374
|
+
ts.isCallExpression(node) &&
|
|
3375
|
+
node.getStart(sourceFile) === zObject.callStart
|
|
3376
|
+
) {
|
|
3377
|
+
zObjectCall = node;
|
|
3378
|
+
return;
|
|
3379
|
+
}
|
|
3380
|
+
ts.forEachChild(node, findCall);
|
|
3381
|
+
};
|
|
3382
|
+
findCall(sourceFile);
|
|
3383
|
+
if (zObjectCall === undefined) {
|
|
3384
|
+
return true;
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
let expression: TS.Expression = zObjectCall;
|
|
3388
|
+
while (
|
|
3389
|
+
ts.isParenthesizedExpression(expression.parent) ||
|
|
3390
|
+
ts.isAsExpression(expression.parent) ||
|
|
3391
|
+
ts.isSatisfiesExpression(expression.parent) ||
|
|
3392
|
+
ts.isNonNullExpression(expression.parent)
|
|
3393
|
+
) {
|
|
3394
|
+
expression = expression.parent;
|
|
3395
|
+
}
|
|
3396
|
+
if (
|
|
3397
|
+
ts.isPropertyAssignment(expression.parent) &&
|
|
3398
|
+
isPublicOutputPropertyName(expression.parent.name)
|
|
3399
|
+
) {
|
|
3400
|
+
return true;
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3403
|
+
const declaration = findEnclosingVariableDeclaration(expression);
|
|
3404
|
+
if (
|
|
3405
|
+
declaration === undefined ||
|
|
3406
|
+
!ts.isIdentifier(declaration.name) ||
|
|
3407
|
+
declaration.initializer === undefined ||
|
|
3408
|
+
!ts.isVariableDeclarationList(declaration.parent) ||
|
|
3409
|
+
(declaration.parent.flags & ts.NodeFlags.Const) === 0
|
|
3410
|
+
) {
|
|
3411
|
+
return true;
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
const reachability = localSchemaBindingReachability(
|
|
3415
|
+
sourceFile,
|
|
3416
|
+
declaration.name.text,
|
|
3417
|
+
declaration,
|
|
3418
|
+
new Set(),
|
|
3419
|
+
);
|
|
3420
|
+
if (reachability === "public") {
|
|
3421
|
+
return true;
|
|
3422
|
+
}
|
|
3423
|
+
if (reachability === "unknown") {
|
|
2381
3424
|
return true;
|
|
2382
3425
|
}
|
|
2383
|
-
|
|
2384
|
-
|
|
3426
|
+
// Sibling modules are scanned independently and therefore remain
|
|
3427
|
+
// fail-closed. An exported binding in index.ts is likewise consumable by
|
|
3428
|
+
// another module, so local inertness does not prove it is private.
|
|
3429
|
+
if (relPath !== "index.ts") {
|
|
3430
|
+
return true;
|
|
3431
|
+
}
|
|
3432
|
+
return isExportedVariableBinding(sourceFile, declaration);
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
type SchemaReachability = "public" | "internal" | "unknown";
|
|
3436
|
+
|
|
3437
|
+
const PARSE_LIKE_SCHEMA_METHODS: ReadonlySet<string> = new Set([
|
|
3438
|
+
"parse",
|
|
3439
|
+
"safeParse",
|
|
3440
|
+
"parseAsync",
|
|
3441
|
+
"safeParseAsync",
|
|
3442
|
+
]);
|
|
3443
|
+
|
|
3444
|
+
function isExportedVariableBinding(
|
|
3445
|
+
sourceFile: TS.SourceFile,
|
|
3446
|
+
declaration: TS.VariableDeclaration,
|
|
3447
|
+
): boolean {
|
|
3448
|
+
const declarationStatement = declaration.parent.parent;
|
|
3449
|
+
if (
|
|
3450
|
+
ts.isVariableStatement(declarationStatement) &&
|
|
3451
|
+
declarationStatement.modifiers?.some(
|
|
3452
|
+
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
|
|
3453
|
+
) === true
|
|
3454
|
+
) {
|
|
3455
|
+
return true;
|
|
3456
|
+
}
|
|
3457
|
+
if (!ts.isIdentifier(declaration.name)) {
|
|
3458
|
+
return false;
|
|
3459
|
+
}
|
|
3460
|
+
const name = declaration.name.text;
|
|
3461
|
+
let exported = false;
|
|
3462
|
+
const visit = (node: TS.Node): void => {
|
|
3463
|
+
if (exported) {
|
|
3464
|
+
return;
|
|
3465
|
+
}
|
|
3466
|
+
if (ts.isExportSpecifier(node)) {
|
|
3467
|
+
const localName = node.propertyName ?? node.name;
|
|
3468
|
+
if (ts.isIdentifier(localName) && localName.text === name) {
|
|
3469
|
+
exported = true;
|
|
3470
|
+
return;
|
|
3471
|
+
}
|
|
3472
|
+
}
|
|
3473
|
+
if (ts.isExportAssignment(node)) {
|
|
3474
|
+
const expression = unwrapLocalExpression(node.expression);
|
|
3475
|
+
if (ts.isIdentifier(expression) && expression.text === name) {
|
|
3476
|
+
exported = true;
|
|
3477
|
+
return;
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
ts.forEachChild(node, visit);
|
|
3481
|
+
};
|
|
3482
|
+
for (const statement of sourceFile.statements) {
|
|
3483
|
+
visit(statement);
|
|
3484
|
+
if (exported) {
|
|
3485
|
+
break;
|
|
3486
|
+
}
|
|
3487
|
+
}
|
|
3488
|
+
return exported;
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
function isPublicOutputPropertyName(name: TS.PropertyName): boolean {
|
|
3492
|
+
const text = propertyNameText(name);
|
|
3493
|
+
return text === "output" || text === "response";
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
function findEnclosingVariableDeclaration(node: TS.Node): TS.VariableDeclaration | undefined {
|
|
3497
|
+
let current: TS.Node | undefined = node;
|
|
3498
|
+
while (current !== undefined && !ts.isSourceFile(current)) {
|
|
3499
|
+
if (ts.isVariableDeclaration(current)) {
|
|
3500
|
+
return current;
|
|
3501
|
+
}
|
|
3502
|
+
current = current.parent;
|
|
3503
|
+
}
|
|
3504
|
+
return undefined;
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
function localSchemaBindingReachability(
|
|
3508
|
+
sourceFile: TS.SourceFile,
|
|
3509
|
+
name: string,
|
|
3510
|
+
declaration: TS.VariableDeclaration,
|
|
3511
|
+
resolving: ReadonlySet<string>,
|
|
3512
|
+
): SchemaReachability {
|
|
3513
|
+
if (resolving.has(name)) {
|
|
3514
|
+
return "unknown";
|
|
3515
|
+
}
|
|
3516
|
+
const nextResolving = new Set(resolving);
|
|
3517
|
+
nextResolving.add(name);
|
|
3518
|
+
let result: SchemaReachability = "internal";
|
|
3519
|
+
const visit = (node: TS.Node): void => {
|
|
3520
|
+
if (result === "public") {
|
|
3521
|
+
return;
|
|
3522
|
+
}
|
|
3523
|
+
if (ts.isIdentifier(node) && node.text === name && node !== declaration.name) {
|
|
3524
|
+
const parent = node.parent;
|
|
3525
|
+
if (isParseLikeSchemaReceiver(node)) {
|
|
3526
|
+
return;
|
|
3527
|
+
}
|
|
3528
|
+
if (ts.isExportSpecifier(parent)) {
|
|
3529
|
+
const localName = parent.propertyName ?? parent.name;
|
|
3530
|
+
if (localName === node) {
|
|
3531
|
+
result = "public";
|
|
3532
|
+
}
|
|
3533
|
+
return;
|
|
3534
|
+
}
|
|
3535
|
+
if (
|
|
3536
|
+
ts.isPropertyAssignment(parent) &&
|
|
3537
|
+
parent.initializer === node &&
|
|
3538
|
+
isPublicOutputPropertyName(parent.name)
|
|
3539
|
+
) {
|
|
3540
|
+
result = "public";
|
|
3541
|
+
return;
|
|
3542
|
+
}
|
|
3543
|
+
if (ts.isShorthandPropertyAssignment(parent)) {
|
|
3544
|
+
result = isPublicOutputPropertyName(parent.name) ? "public" : "unknown";
|
|
3545
|
+
return;
|
|
3546
|
+
}
|
|
3547
|
+
if (
|
|
3548
|
+
ts.isVariableDeclaration(parent) &&
|
|
3549
|
+
parent.initializer === node &&
|
|
3550
|
+
ts.isIdentifier(parent.name) &&
|
|
3551
|
+
ts.isVariableDeclarationList(parent.parent) &&
|
|
3552
|
+
(parent.parent.flags & ts.NodeFlags.Const) !== 0
|
|
3553
|
+
) {
|
|
3554
|
+
const aliasReachability = localSchemaBindingReachability(
|
|
3555
|
+
sourceFile,
|
|
3556
|
+
parent.name.text,
|
|
3557
|
+
parent,
|
|
3558
|
+
nextResolving,
|
|
3559
|
+
);
|
|
3560
|
+
if (aliasReachability === "public" || result === "internal") {
|
|
3561
|
+
result = aliasReachability;
|
|
3562
|
+
}
|
|
3563
|
+
return;
|
|
3564
|
+
}
|
|
3565
|
+
if (ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) {
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
if (
|
|
3569
|
+
(ts.isPropertyAccessExpression(parent) && parent.name === node) ||
|
|
3570
|
+
(ts.isPropertyAssignment(parent) && parent.name === node) ||
|
|
3571
|
+
ts.isBindingElement(parent) ||
|
|
3572
|
+
ts.isImportSpecifier(parent)
|
|
3573
|
+
) {
|
|
3574
|
+
return;
|
|
3575
|
+
}
|
|
3576
|
+
result = "unknown";
|
|
3577
|
+
}
|
|
3578
|
+
ts.forEachChild(node, visit);
|
|
3579
|
+
};
|
|
3580
|
+
visit(sourceFile);
|
|
3581
|
+
return result;
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3584
|
+
function isParseLikeSchemaReceiver(identifier: TS.Identifier): boolean {
|
|
3585
|
+
const access = identifier.parent;
|
|
3586
|
+
let methodName: string | undefined;
|
|
3587
|
+
if (ts.isPropertyAccessExpression(access) && access.expression === identifier) {
|
|
3588
|
+
methodName = access.name.text;
|
|
3589
|
+
} else if (ts.isElementAccessExpression(access) && access.expression === identifier) {
|
|
3590
|
+
const argument = access.argumentExpression;
|
|
3591
|
+
if (
|
|
3592
|
+
argument !== undefined &&
|
|
3593
|
+
(ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
|
|
3594
|
+
) {
|
|
3595
|
+
methodName = argument.text;
|
|
3596
|
+
}
|
|
3597
|
+
}
|
|
3598
|
+
return (
|
|
3599
|
+
methodName !== undefined &&
|
|
3600
|
+
PARSE_LIKE_SCHEMA_METHODS.has(methodName) &&
|
|
3601
|
+
ts.isCallExpression(access.parent) &&
|
|
3602
|
+
access.parent.expression === access
|
|
3603
|
+
);
|
|
2385
3604
|
}
|
|
2386
3605
|
|
|
2387
3606
|
function vendorKeyFindingsForObject(
|
|
2388
3607
|
source: string,
|
|
3608
|
+
sourceFile: TS.SourceFile,
|
|
2389
3609
|
zObject: ZObjectLiteral,
|
|
3610
|
+
bindings: LocalBindings,
|
|
2390
3611
|
): Array<{ key: string; line: number }> {
|
|
2391
|
-
const keys = collectTopLevelObjectKeys(
|
|
3612
|
+
const keys = collectTopLevelObjectKeys(
|
|
3613
|
+
source,
|
|
3614
|
+
sourceFile,
|
|
3615
|
+
zObject.objectStart,
|
|
3616
|
+
zObject.objectEnd,
|
|
3617
|
+
bindings,
|
|
3618
|
+
);
|
|
2392
3619
|
const digitFamilies = new Map<string, Set<string>>();
|
|
2393
3620
|
for (const key of keys) {
|
|
2394
3621
|
const member = numberedFamilyMember(key.name);
|
|
@@ -2444,8 +3671,10 @@ function isAllowedPublicOutputKeyName(name: string): boolean {
|
|
|
2444
3671
|
|
|
2445
3672
|
function collectTopLevelObjectKeys(
|
|
2446
3673
|
source: string,
|
|
3674
|
+
sourceFile: TS.SourceFile,
|
|
2447
3675
|
objectStart: number,
|
|
2448
3676
|
objectEnd: number,
|
|
3677
|
+
bindings: LocalBindings,
|
|
2449
3678
|
): Array<{ name: string; offset: number }> {
|
|
2450
3679
|
const keys: Array<{ name: string; offset: number }> = [];
|
|
2451
3680
|
const masked = maskCommentsAndStrings(source);
|
|
@@ -2467,17 +3696,16 @@ function collectTopLevelObjectKeys(
|
|
|
2467
3696
|
index = endQuote + 1;
|
|
2468
3697
|
} else if (masked[index] === "[") {
|
|
2469
3698
|
const computedEnd = findMatchingBracket(masked, index);
|
|
2470
|
-
|
|
2471
|
-
if (computedEnd === -1 || literalStart === -1) {
|
|
3699
|
+
if (computedEnd === -1) {
|
|
2472
3700
|
break;
|
|
2473
3701
|
}
|
|
2474
|
-
const
|
|
2475
|
-
if (
|
|
2476
|
-
const
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
if (
|
|
2480
|
-
key =
|
|
3702
|
+
const expression = computedPropertyExpressionAtOffset(sourceFile, index);
|
|
3703
|
+
if (expression !== undefined) {
|
|
3704
|
+
const unwrapped = unwrapLocalExpression(expression);
|
|
3705
|
+
if (ts.isStringLiteral(unwrapped) || ts.isNoSubstitutionTemplateLiteral(unwrapped)) {
|
|
3706
|
+
key = unwrapped.text;
|
|
3707
|
+
} else if (ts.isIdentifier(unwrapped)) {
|
|
3708
|
+
key = resolveConstStringIdentifier(unwrapped, bindings);
|
|
2481
3709
|
}
|
|
2482
3710
|
}
|
|
2483
3711
|
index = computedEnd + 1;
|
|
@@ -2500,8 +3728,27 @@ function collectTopLevelObjectKeys(
|
|
|
2500
3728
|
if (masked[index] === ",") {
|
|
2501
3729
|
index += 1;
|
|
2502
3730
|
}
|
|
2503
|
-
}
|
|
2504
|
-
return keys;
|
|
3731
|
+
}
|
|
3732
|
+
return keys;
|
|
3733
|
+
}
|
|
3734
|
+
|
|
3735
|
+
function computedPropertyExpressionAtOffset(
|
|
3736
|
+
sourceFile: TS.SourceFile,
|
|
3737
|
+
offset: number,
|
|
3738
|
+
): TS.Expression | undefined {
|
|
3739
|
+
let expression: TS.Expression | undefined;
|
|
3740
|
+
const visit = (node: TS.Node): void => {
|
|
3741
|
+
if (expression !== undefined || offset < node.getFullStart() || offset >= node.getEnd()) {
|
|
3742
|
+
return;
|
|
3743
|
+
}
|
|
3744
|
+
if (ts.isComputedPropertyName(node) && node.getStart(sourceFile) === offset) {
|
|
3745
|
+
expression = node.expression;
|
|
3746
|
+
return;
|
|
3747
|
+
}
|
|
3748
|
+
ts.forEachChild(node, visit);
|
|
3749
|
+
};
|
|
3750
|
+
visit(sourceFile);
|
|
3751
|
+
return expression;
|
|
2505
3752
|
}
|
|
2506
3753
|
|
|
2507
3754
|
function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[] {
|
|
@@ -2511,6 +3758,15 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
|
|
|
2511
3758
|
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
2512
3759
|
const source = readFileSync(filePath, "utf8");
|
|
2513
3760
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
3761
|
+
maskCommentsAndStrings(source, relPath);
|
|
3762
|
+
const sourceFile = ts.createSourceFile(
|
|
3763
|
+
relPath,
|
|
3764
|
+
source,
|
|
3765
|
+
ts.ScriptTarget.Latest,
|
|
3766
|
+
true,
|
|
3767
|
+
ts.ScriptKind.TS,
|
|
3768
|
+
);
|
|
3769
|
+
const bindings = collectLocalBindings(sourceFile);
|
|
2514
3770
|
const zObjectRanges = findZObjectLiterals(source).map((zObject) => ({
|
|
2515
3771
|
start: zObject.callStart,
|
|
2516
3772
|
end: zObject.objectEnd,
|
|
@@ -2523,7 +3779,7 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
|
|
|
2523
3779
|
].filter((range) => rangeContainedInRanges(fixtureRanges, range));
|
|
2524
3780
|
|
|
2525
3781
|
for (const range of fixtureResponseRanges) {
|
|
2526
|
-
for (const literal of findStringLiteralsInRange(source, range)) {
|
|
3782
|
+
for (const literal of findStringLiteralsInRange(source, range, relPath)) {
|
|
2527
3783
|
if (
|
|
2528
3784
|
rangeContainsOffset(zObjectRanges, literal.offset) ||
|
|
2529
3785
|
rangeContainsOffset(upstreamRanges, literal.offset) ||
|
|
@@ -2544,12 +3800,86 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
|
|
|
2544
3800
|
}
|
|
2545
3801
|
}
|
|
2546
3802
|
}
|
|
3803
|
+
|
|
3804
|
+
const visitAliases = (node: TS.Node): void => {
|
|
3805
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3808
|
+
const offset = node.getStart(sourceFile);
|
|
3809
|
+
if (
|
|
3810
|
+
ts.isIdentifier(node) &&
|
|
3811
|
+
isIdentifierValueUse(node) &&
|
|
3812
|
+
offset >= range.start &&
|
|
3813
|
+
offset <= range.end &&
|
|
3814
|
+
!rangeContainsOffset(zObjectRanges, offset) &&
|
|
3815
|
+
!rangeContainsOffset(upstreamRanges, offset)
|
|
3816
|
+
) {
|
|
3817
|
+
const value = resolveConstStringIdentifier(node, bindings);
|
|
3818
|
+
if (
|
|
3819
|
+
value !== undefined &&
|
|
3820
|
+
isVendorTimestampCandidate(value, propertyNameForValueExpression(node))
|
|
3821
|
+
) {
|
|
3822
|
+
const line = offsetToLine(source, offset);
|
|
3823
|
+
const key = `${relPath}:${line}:${value}`;
|
|
3824
|
+
if (!seen.has(key)) {
|
|
3825
|
+
seen.add(key);
|
|
3826
|
+
findings.push({ file: relPath, line });
|
|
3827
|
+
}
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
ts.forEachChild(node, visitAliases);
|
|
3831
|
+
};
|
|
3832
|
+
visitAliases(sourceFile);
|
|
3833
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
3834
|
+
return findings;
|
|
3835
|
+
}
|
|
2547
3836
|
}
|
|
2548
3837
|
}
|
|
2549
3838
|
|
|
2550
3839
|
return findings;
|
|
2551
3840
|
}
|
|
2552
3841
|
|
|
3842
|
+
function isIdentifierValueUse(identifier: TS.Identifier): boolean {
|
|
3843
|
+
const parent = identifier.parent;
|
|
3844
|
+
return !(
|
|
3845
|
+
(ts.isPropertyAssignment(parent) && parent.name === identifier) ||
|
|
3846
|
+
(ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
|
|
3847
|
+
ts.isBindingElement(parent) ||
|
|
3848
|
+
ts.isVariableDeclaration(parent) ||
|
|
3849
|
+
ts.isParameter(parent) ||
|
|
3850
|
+
ts.isImportClause(parent) ||
|
|
3851
|
+
ts.isImportSpecifier(parent) ||
|
|
3852
|
+
ts.isNamespaceImport(parent) ||
|
|
3853
|
+
ts.isFunctionDeclaration(parent) ||
|
|
3854
|
+
ts.isFunctionExpression(parent) ||
|
|
3855
|
+
ts.isClassDeclaration(parent) ||
|
|
3856
|
+
ts.isClassExpression(parent) ||
|
|
3857
|
+
ts.isPropertyDeclaration(parent) ||
|
|
3858
|
+
ts.isPropertySignature(parent) ||
|
|
3859
|
+
ts.isMethodDeclaration(parent) ||
|
|
3860
|
+
ts.isMethodSignature(parent)
|
|
3861
|
+
);
|
|
3862
|
+
}
|
|
3863
|
+
|
|
3864
|
+
function propertyNameForValueExpression(expression: TS.Expression): string | undefined {
|
|
3865
|
+
let current: TS.Expression = expression;
|
|
3866
|
+
while (
|
|
3867
|
+
ts.isParenthesizedExpression(current.parent) ||
|
|
3868
|
+
ts.isAsExpression(current.parent) ||
|
|
3869
|
+
ts.isSatisfiesExpression(current.parent) ||
|
|
3870
|
+
ts.isNonNullExpression(current.parent)
|
|
3871
|
+
) {
|
|
3872
|
+
current = current.parent;
|
|
3873
|
+
}
|
|
3874
|
+
if (ts.isPropertyAssignment(current.parent) && current.parent.initializer === current) {
|
|
3875
|
+
return propertyNameText(current.parent.name);
|
|
3876
|
+
}
|
|
3877
|
+
if (ts.isShorthandPropertyAssignment(current.parent)) {
|
|
3878
|
+
return current.parent.name.text;
|
|
3879
|
+
}
|
|
3880
|
+
return undefined;
|
|
3881
|
+
}
|
|
3882
|
+
|
|
2553
3883
|
function findPropertyObjectRanges(source: string, propertyName: string): ObjectRange[] {
|
|
2554
3884
|
const ranges: ObjectRange[] = [];
|
|
2555
3885
|
const masked = maskCommentsAndStrings(source);
|
|
@@ -2593,23 +3923,16 @@ function findNamedConstValueRanges(source: string): NamedObjectRange[] {
|
|
|
2593
3923
|
return ranges;
|
|
2594
3924
|
}
|
|
2595
3925
|
|
|
2596
|
-
function findConstValueRangeContaining(
|
|
2597
|
-
source: string,
|
|
2598
|
-
offset: number,
|
|
2599
|
-
): NamedObjectRange | undefined {
|
|
2600
|
-
return findNamedConstValueRanges(source).find(
|
|
2601
|
-
(range) => offset >= range.start && offset <= range.end,
|
|
2602
|
-
);
|
|
2603
|
-
}
|
|
2604
|
-
|
|
2605
3926
|
function findStringLiteralsInRange(
|
|
2606
3927
|
source: string,
|
|
2607
3928
|
range: ObjectRange,
|
|
3929
|
+
fileName: string,
|
|
2608
3930
|
): Array<{ value: string; offset: number }> {
|
|
2609
3931
|
const literals: Array<{ value: string; offset: number }> = [];
|
|
3932
|
+
const masked = maskCommentsAndStrings(source, fileName);
|
|
2610
3933
|
let index = range.start;
|
|
2611
3934
|
while (index <= range.end) {
|
|
2612
|
-
const quote =
|
|
3935
|
+
const quote = masked[index];
|
|
2613
3936
|
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
|
2614
3937
|
index += 1;
|
|
2615
3938
|
continue;
|
|
@@ -2719,57 +4042,233 @@ function findStringEnd(source: string, start: number): number {
|
|
|
2719
4042
|
return -1;
|
|
2720
4043
|
}
|
|
2721
4044
|
|
|
2722
|
-
|
|
4045
|
+
// Masking now parses with TypeScript, which is orders of magnitude more
|
|
4046
|
+
// expensive than the character walk it replaced, and six scanners call this
|
|
4047
|
+
// helper once per candidate match — the same file is masked thousands of
|
|
4048
|
+
// times in one run (measured: 1,872 calls for a 3.4k-line provider). Without
|
|
4049
|
+
// memoization that run regresses from ~8s to minutes and can exceed the CI
|
|
4050
|
+
// validation timeout. Successful results are cached by exact source text and
|
|
4051
|
+
// property-key mode; the cache stays small because a run only ever reads a
|
|
4052
|
+
// handful of files.
|
|
4053
|
+
// Parse failures are NOT cached: they throw and abort the check (fail-closed).
|
|
4054
|
+
const MASK_CACHE_LIMIT = 64;
|
|
4055
|
+
const maskCache = new Map<string, string>();
|
|
4056
|
+
|
|
4057
|
+
// Neutralize { } ( ) [ ], backtick, quotes, slash, backslash, comma, and semicolon
|
|
4058
|
+
// because scanners treat these as depth, string/regex delimiters, or top-level stops.
|
|
4059
|
+
const PRESERVED_PROPERTY_KEY_STRUCTURAL_CHARS = new Set([
|
|
4060
|
+
"{",
|
|
4061
|
+
"}",
|
|
4062
|
+
"(",
|
|
4063
|
+
")",
|
|
4064
|
+
"[",
|
|
4065
|
+
"]",
|
|
4066
|
+
"`",
|
|
4067
|
+
'"',
|
|
4068
|
+
"'",
|
|
4069
|
+
"/",
|
|
4070
|
+
"\\",
|
|
4071
|
+
",",
|
|
4072
|
+
";",
|
|
4073
|
+
]);
|
|
4074
|
+
|
|
4075
|
+
type MaskCommentsAndStringsOptions = {
|
|
4076
|
+
blankPropertyKeys?: boolean;
|
|
4077
|
+
};
|
|
4078
|
+
|
|
4079
|
+
// Which literal bodies are blanked alongside comment bodies. "all" blanks
|
|
4080
|
+
// every string/template/regex body; "values" does the same but keeps quoted
|
|
4081
|
+
// property-key text (only its structural characters are blanked); "none"
|
|
4082
|
+
// leaves every literal and all code byte-identical, so only comments go.
|
|
4083
|
+
type LiteralMaskMode = "all" | "values" | "none";
|
|
4084
|
+
|
|
4085
|
+
export function maskCommentsAndStrings(
|
|
4086
|
+
source: string,
|
|
4087
|
+
fileName = "provider.ts",
|
|
4088
|
+
options: MaskCommentsAndStringsOptions = {},
|
|
4089
|
+
): string {
|
|
4090
|
+
return maskSource(source, fileName, options.blankPropertyKeys === true ? "all" : "values");
|
|
4091
|
+
}
|
|
4092
|
+
|
|
4093
|
+
// Blanks comment bodies only, from the same TypeScript-lexer comment ranges
|
|
4094
|
+
// maskCommentsAndStrings uses: a `//` inside a string, template, or regex is
|
|
4095
|
+
// not a comment, and a block comment spanning lines is blanked whole. Line
|
|
4096
|
+
// terminators survive, so line N of the result is line N of the source.
|
|
4097
|
+
// Throws like maskCommentsAndStrings when the source cannot be parsed.
|
|
4098
|
+
export function maskComments(source: string, fileName = "provider.ts"): string {
|
|
4099
|
+
return maskSource(source, fileName, "none");
|
|
4100
|
+
}
|
|
4101
|
+
|
|
4102
|
+
function maskSource(source: string, fileName: string, literals: LiteralMaskMode): string {
|
|
4103
|
+
const { scriptKind } = maskScriptKind(fileName);
|
|
4104
|
+
const cacheKey = `${literals}\0${scriptKind}\0${source}`;
|
|
4105
|
+
const cached = maskCache.get(cacheKey);
|
|
4106
|
+
if (cached !== undefined) {
|
|
4107
|
+
return cached;
|
|
4108
|
+
}
|
|
4109
|
+
const masked = computeMaskedSource(source, fileName, literals);
|
|
4110
|
+
if (maskCache.size >= MASK_CACHE_LIMIT) {
|
|
4111
|
+
const oldest = maskCache.keys().next();
|
|
4112
|
+
if (!oldest.done) {
|
|
4113
|
+
maskCache.delete(oldest.value);
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
maskCache.set(cacheKey, masked);
|
|
4117
|
+
return masked;
|
|
4118
|
+
}
|
|
4119
|
+
|
|
4120
|
+
// The script kind a source is lexed as, from its file name, and the synthetic
|
|
4121
|
+
// implementation file name transpileModule parses it under. Declaration files
|
|
4122
|
+
// trigger an internal TypeScript Debug Failure when passed to transpileModule,
|
|
4123
|
+
// so `.d.ts` (like every other non-JS/JSX/TSX name) parses as `provider.ts`.
|
|
4124
|
+
function maskScriptKind(fileName: string): {
|
|
4125
|
+
scriptKind: TS.ScriptKind;
|
|
4126
|
+
syntheticFileName: string;
|
|
4127
|
+
} {
|
|
4128
|
+
if (fileName.endsWith(".tsx")) {
|
|
4129
|
+
return { scriptKind: ts.ScriptKind.TSX, syntheticFileName: "provider.tsx" };
|
|
4130
|
+
}
|
|
4131
|
+
if (fileName.endsWith(".jsx")) {
|
|
4132
|
+
return { scriptKind: ts.ScriptKind.JSX, syntheticFileName: "provider.jsx" };
|
|
4133
|
+
}
|
|
4134
|
+
if (/\.[cm]?js$/.test(fileName)) {
|
|
4135
|
+
return { scriptKind: ts.ScriptKind.JS, syntheticFileName: "provider.js" };
|
|
4136
|
+
}
|
|
4137
|
+
return { scriptKind: ts.ScriptKind.TS, syntheticFileName: "provider.ts" };
|
|
4138
|
+
}
|
|
4139
|
+
|
|
4140
|
+
function computeMaskedSource(source: string, fileName: string, literals: LiteralMaskMode): string {
|
|
4141
|
+
const blankPropertyKeys = literals === "all";
|
|
4142
|
+
const { scriptKind, syntheticFileName } = maskScriptKind(fileName);
|
|
4143
|
+
const transpiled = ts.transpileModule(source, {
|
|
4144
|
+
// Parsing is all we need here: parse under the synthetic implementation
|
|
4145
|
+
// filename while retaining the real filename for source mapping and
|
|
4146
|
+
// sanitized diagnostics below.
|
|
4147
|
+
fileName: syntheticFileName,
|
|
4148
|
+
reportDiagnostics: true,
|
|
4149
|
+
compilerOptions: { target: ts.ScriptTarget.Latest },
|
|
4150
|
+
});
|
|
4151
|
+
const sourceFile = ts.createSourceFile(
|
|
4152
|
+
fileName,
|
|
4153
|
+
source,
|
|
4154
|
+
ts.ScriptTarget.Latest,
|
|
4155
|
+
true,
|
|
4156
|
+
scriptKind,
|
|
4157
|
+
);
|
|
4158
|
+
const parseDiagnostic = transpiled.diagnostics?.[0];
|
|
4159
|
+
if (parseDiagnostic) {
|
|
4160
|
+
const position = parseDiagnostic.start ?? 0;
|
|
4161
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(position);
|
|
4162
|
+
throw new Error(
|
|
4163
|
+
`Cannot safely scan TypeScript source ${sanitizeDiagnosticFileName(fileName)} at ${line + 1}:${character + 1}: ${ts.flattenDiagnosticMessageText(parseDiagnostic.messageText, "\n")}`,
|
|
4164
|
+
);
|
|
4165
|
+
}
|
|
4166
|
+
|
|
2723
4167
|
const chars = source.split("");
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
index = end;
|
|
2733
|
-
continue;
|
|
2734
|
-
}
|
|
2735
|
-
if (source.startsWith("/*", index)) {
|
|
2736
|
-
const bodyStart = index + 2;
|
|
2737
|
-
const close = source.indexOf("*/", bodyStart);
|
|
2738
|
-
const end = close === -1 ? source.length : close;
|
|
2739
|
-
for (let bodyIndex = bodyStart; bodyIndex < end; bodyIndex += 1) {
|
|
2740
|
-
if (chars[bodyIndex] !== "\n") {
|
|
2741
|
-
chars[bodyIndex] = " ";
|
|
2742
|
-
}
|
|
4168
|
+
const maskRange = (start: number, end: number): void => {
|
|
4169
|
+
for (let index = start; index < end; index += 1) {
|
|
4170
|
+
// Preserve the backslash of a string line continuation (\ followed
|
|
4171
|
+
// by LF, CRLF, or lone CR) and every line terminator, so masked
|
|
4172
|
+
// string bodies remain syntactically valid and the mask output can
|
|
4173
|
+
// be re-parsed (mask(mask(x)) === mask(x)).
|
|
4174
|
+
if (source[index] === "\\" && (source[index + 1] === "\n" || source[index + 1] === "\r")) {
|
|
4175
|
+
continue;
|
|
2743
4176
|
}
|
|
2744
|
-
index
|
|
2745
|
-
continue;
|
|
2746
|
-
}
|
|
2747
|
-
const quote = source[index];
|
|
2748
|
-
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
|
2749
|
-
continue;
|
|
2750
|
-
}
|
|
2751
|
-
const end = findStringEnd(source, index);
|
|
2752
|
-
if (end === -1) {
|
|
2753
|
-
break;
|
|
4177
|
+
if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " ";
|
|
2754
4178
|
}
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
4179
|
+
};
|
|
4180
|
+
const commentRanges = new Map<string, TS.CommentRange>();
|
|
4181
|
+
const addCommentRanges = (ranges: readonly TS.CommentRange[] | undefined): void => {
|
|
4182
|
+
for (const range of ranges ?? []) {
|
|
4183
|
+
commentRanges.set(`${range.pos}:${range.end}`, range);
|
|
2760
4184
|
}
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
4185
|
+
};
|
|
4186
|
+
|
|
4187
|
+
const maskLiteralBody = (node: TS.Node): void => {
|
|
4188
|
+
const start = node.getStart(sourceFile);
|
|
4189
|
+
if (ts.isStringLiteral(node)) {
|
|
4190
|
+
// Preserve quoted property keys ("response": ...) by default so range/key
|
|
4191
|
+
// scanners can still match them. Line scanners opt into blanking key bodies
|
|
4192
|
+
// as well, preventing key text from looking like executable source.
|
|
4193
|
+
const isQuotedPropertyKey = ts.isPropertyAssignment(node.parent) && node.parent.name === node;
|
|
4194
|
+
if (blankPropertyKeys || !isQuotedPropertyKey) {
|
|
4195
|
+
maskRange(start + 1, node.end - 1);
|
|
4196
|
+
} else {
|
|
4197
|
+
for (let index = start + 1; index < node.end - 1; index += 1) {
|
|
4198
|
+
if (
|
|
4199
|
+
source[index] === "\\" &&
|
|
4200
|
+
(source[index + 1] === "\n" || source[index + 1] === "\r")
|
|
4201
|
+
) {
|
|
4202
|
+
continue;
|
|
4203
|
+
}
|
|
4204
|
+
if (PRESERVED_PROPERTY_KEY_STRUCTURAL_CHARS.has(source[index] ?? "")) {
|
|
4205
|
+
chars[index] = " ";
|
|
4206
|
+
}
|
|
2765
4207
|
}
|
|
2766
4208
|
}
|
|
4209
|
+
} else if (ts.isRegularExpressionLiteral(node)) {
|
|
4210
|
+
let closeDelimiter = node.end - 1;
|
|
4211
|
+
while (closeDelimiter > start && /[A-Za-z]/.test(source[closeDelimiter] ?? "")) {
|
|
4212
|
+
closeDelimiter -= 1;
|
|
4213
|
+
}
|
|
4214
|
+
maskRange(start + 1, closeDelimiter);
|
|
4215
|
+
} else if (ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
4216
|
+
maskRange(start + 1, node.end - 1);
|
|
4217
|
+
} else if (node.kind === ts.SyntaxKind.TemplateHead) {
|
|
4218
|
+
maskRange(start + 1, node.end - 2);
|
|
4219
|
+
} else if (node.kind === ts.SyntaxKind.TemplateMiddle) {
|
|
4220
|
+
maskRange(start + 1, node.end - 2);
|
|
4221
|
+
} else if (node.kind === ts.SyntaxKind.TemplateTail) {
|
|
4222
|
+
maskRange(start + 1, node.end - 1);
|
|
2767
4223
|
}
|
|
2768
|
-
|
|
4224
|
+
};
|
|
4225
|
+
|
|
4226
|
+
const visit = (node: TS.Node): void => {
|
|
4227
|
+
addCommentRanges(ts.getLeadingCommentRanges(source, node.getFullStart()));
|
|
4228
|
+
addCommentRanges(ts.getTrailingCommentRanges(source, node.getEnd()));
|
|
4229
|
+
if (literals !== "none") maskLiteralBody(node);
|
|
4230
|
+
for (const child of node.getChildren(sourceFile)) visit(child);
|
|
4231
|
+
};
|
|
4232
|
+
|
|
4233
|
+
visit(sourceFile);
|
|
4234
|
+
for (const comment of commentRanges.values()) {
|
|
4235
|
+
const markerLength = 2;
|
|
4236
|
+
const bodyEnd =
|
|
4237
|
+
comment.kind === ts.SyntaxKind.MultiLineCommentTrivia
|
|
4238
|
+
? comment.end - markerLength
|
|
4239
|
+
: comment.end;
|
|
4240
|
+
maskRange(comment.pos + markerLength, bodyEnd);
|
|
2769
4241
|
}
|
|
2770
4242
|
return chars.join("");
|
|
2771
4243
|
}
|
|
2772
4244
|
|
|
4245
|
+
// Escapes every non-printable or formatting character before a submitter-
|
|
4246
|
+
// controlled filename is interpolated into a diagnostic that the CLI prints.
|
|
4247
|
+
// Category-based on purpose: enumerated code-point lists kept missing members
|
|
4248
|
+
// of the same class (C1 controls, U+061C, U+2028/U+2029 were each found
|
|
4249
|
+
// individually in review), so this escapes the whole Unicode categories —
|
|
4250
|
+
// Cc (controls), Cf (formatting, includes all Bidi_Control), Zl/Zp
|
|
4251
|
+
// (line/paragraph separators). Ordinary letters, digits, spaces, and CJK
|
|
4252
|
+
// filenames pass through unchanged.
|
|
4253
|
+
const DIAGNOSTIC_UNSAFE_CHARACTER = /\p{Cc}|\p{Cf}|\p{Zl}|\p{Zp}/u;
|
|
4254
|
+
|
|
4255
|
+
function sanitizeDiagnosticFileName(fileName: string): string {
|
|
4256
|
+
let sanitized = "";
|
|
4257
|
+
for (const character of fileName) {
|
|
4258
|
+
const codePoint = character.codePointAt(0);
|
|
4259
|
+
if (codePoint === undefined) continue;
|
|
4260
|
+
if (DIAGNOSTIC_UNSAFE_CHARACTER.test(character)) {
|
|
4261
|
+
sanitized +=
|
|
4262
|
+
codePoint <= 0xff
|
|
4263
|
+
? `\\x${codePoint.toString(16).padStart(2, "0")}`
|
|
4264
|
+
: `\\u{${codePoint.toString(16)}}`;
|
|
4265
|
+
} else {
|
|
4266
|
+
sanitized += character;
|
|
4267
|
+
}
|
|
4268
|
+
}
|
|
4269
|
+
return sanitized;
|
|
4270
|
+
}
|
|
4271
|
+
|
|
2773
4272
|
function skipWhitespaceAndComments(source: string, start: number, end: number): number {
|
|
2774
4273
|
let index = start;
|
|
2775
4274
|
while (index < end) {
|
|
@@ -2865,6 +4364,7 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2865
4364
|
const operations = Object.entries(provider.operations);
|
|
2866
4365
|
const missing: string[] = [];
|
|
2867
4366
|
const vacuous: string[] = [];
|
|
4367
|
+
const evidenceFreeScenario: string[] = [];
|
|
2868
4368
|
const placeholder: string[] = [];
|
|
2869
4369
|
const unsupported: string[] = [];
|
|
2870
4370
|
const generatedStarter: string[] = [];
|
|
@@ -2876,8 +4376,13 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2876
4376
|
missing.push(operationId);
|
|
2877
4377
|
continue;
|
|
2878
4378
|
}
|
|
2879
|
-
if (hasCheck && !hasUnsupported
|
|
2880
|
-
|
|
4379
|
+
if (hasCheck && !hasUnsupported) {
|
|
4380
|
+
const vacuousClass = vacuousHealthCaseClass(operation.healthCheck);
|
|
4381
|
+
if (vacuousClass === "empty-assertions") {
|
|
4382
|
+
vacuous.push(operationId);
|
|
4383
|
+
} else if (vacuousClass === "evidence-free-scenario") {
|
|
4384
|
+
evidenceFreeScenario.push(operationId);
|
|
4385
|
+
}
|
|
2881
4386
|
}
|
|
2882
4387
|
if (hasUnsupported) {
|
|
2883
4388
|
const reason = operation.healthCheckUnsupported?.reason ?? "";
|
|
@@ -2904,14 +4409,36 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2904
4409
|
);
|
|
2905
4410
|
}
|
|
2906
4411
|
|
|
2907
|
-
if (vacuous.length > 0) {
|
|
4412
|
+
if (vacuous.length > 0 || evidenceFreeScenario.length > 0) {
|
|
4413
|
+
const remediationParts: string[] = [];
|
|
4414
|
+
if (vacuous.length > 0) {
|
|
4415
|
+
remediationParts.push(
|
|
4416
|
+
`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.`,
|
|
4417
|
+
);
|
|
4418
|
+
}
|
|
4419
|
+
if (evidenceFreeScenario.length > 0) {
|
|
4420
|
+
remediationParts.push(
|
|
4421
|
+
`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.`,
|
|
4422
|
+
);
|
|
4423
|
+
}
|
|
4424
|
+
const message =
|
|
4425
|
+
evidenceFreeScenario.length === 0
|
|
4426
|
+
? "One or more operations have healthCheck cases with empty assertions."
|
|
4427
|
+
: vacuous.length === 0
|
|
4428
|
+
? "One or more operations have healthCheck scenarios without response evidence."
|
|
4429
|
+
: "One or more operations have healthCheck cases with empty assertions or evidence-free scenarios.";
|
|
2908
4430
|
return blocker(
|
|
2909
4431
|
"health-coverage",
|
|
2910
4432
|
"health",
|
|
2911
|
-
|
|
2912
|
-
|
|
4433
|
+
message,
|
|
4434
|
+
remediationParts.join(" "),
|
|
2913
4435
|
CATEGORY_MAX_POINTS.health,
|
|
2914
|
-
|
|
4436
|
+
[
|
|
4437
|
+
...vacuous.map((operationId) => `${operationId}: empty healthCheck.assertions`),
|
|
4438
|
+
...evidenceFreeScenario.map(
|
|
4439
|
+
(operationId) => `${operationId}: healthCheck scenario asserts nothing beyond transport`,
|
|
4440
|
+
),
|
|
4441
|
+
],
|
|
2915
4442
|
);
|
|
2916
4443
|
}
|
|
2917
4444
|
|
|
@@ -2966,14 +4493,216 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2966
4493
|
);
|
|
2967
4494
|
}
|
|
2968
4495
|
|
|
2969
|
-
|
|
4496
|
+
type VacuousHealthCaseClass = "empty-assertions" | "evidence-free-scenario";
|
|
4497
|
+
|
|
4498
|
+
/**
|
|
4499
|
+
* Classify an operation whose every health case is vacuous. Returns undefined
|
|
4500
|
+
* when at least one case carries real coverage: an imperative case with a
|
|
4501
|
+
* non-vacuous assertions function, or a declarative case whose scenario reads
|
|
4502
|
+
* the probe response. When all cases are vacuous, the class picks the blocker
|
|
4503
|
+
* wording — "empty-assertions" whenever any vacuous case is imperative (the
|
|
4504
|
+
* pre-scenario behavior, kept byte-identical), "evidence-free-scenario" when
|
|
4505
|
+
* the suite is scenario-only.
|
|
4506
|
+
*/
|
|
4507
|
+
function vacuousHealthCaseClass(
|
|
2970
4508
|
healthCheck: ProviderDefinition["operations"][string]["healthCheck"],
|
|
2971
|
-
):
|
|
4509
|
+
): VacuousHealthCaseClass | undefined {
|
|
2972
4510
|
const cases = healthCheck?.cases;
|
|
2973
4511
|
if (!Array.isArray(cases) || cases.length === 0) {
|
|
4512
|
+
return "empty-assertions";
|
|
4513
|
+
}
|
|
4514
|
+
let sawImperativeCase = false;
|
|
4515
|
+
for (const healthCase of cases) {
|
|
4516
|
+
if (healthCase?.scenario !== undefined) {
|
|
4517
|
+
if (scenarioCarriesResponseEvidence(healthCase.scenario)) {
|
|
4518
|
+
return undefined;
|
|
4519
|
+
}
|
|
4520
|
+
continue;
|
|
4521
|
+
}
|
|
4522
|
+
sawImperativeCase = true;
|
|
4523
|
+
if (!isVacuousAssertionFunction(healthCase?.assertions)) {
|
|
4524
|
+
return undefined;
|
|
4525
|
+
}
|
|
4526
|
+
}
|
|
4527
|
+
return sawImperativeCase ? "empty-assertions" : "evidence-free-scenario";
|
|
4528
|
+
}
|
|
4529
|
+
|
|
4530
|
+
/**
|
|
4531
|
+
* A declarative case (`scenario`) carries real coverage when at least one
|
|
4532
|
+
* `assert` step reads the probe response beyond the transport envelope. The
|
|
4533
|
+
* SDK's own definitions drive this judgement:
|
|
4534
|
+
*
|
|
4535
|
+
* - The assert step is the scenario's only verdict-bearing construct
|
|
4536
|
+
* (`AssertResult.passed`). A guard can only attribute
|
|
4537
|
+
* degraded/expected_absence and stop the run (an escape hatch, not a health
|
|
4538
|
+
* verdict), and an operation step does not intrinsically fail on a non-2xx
|
|
4539
|
+
* response — that is what the explicit `status_2xx` predicate exists for. So
|
|
4540
|
+
* a scenario without a semantic assert step proves nothing beyond the
|
|
4541
|
+
* operation merely executing, which is the same emptiness the imperative
|
|
4542
|
+
* vacuity check flags.
|
|
4543
|
+
* - Leaves that cannot observe upstream content carry no evidence: predicates
|
|
4544
|
+
* whose operands read nothing produced by a step (pure literals,
|
|
4545
|
+
* attempt/credential metadata, and the `status_code`/`request_id`/`kind`
|
|
4546
|
+
* envelope fields of a step result — this is what makes the legacy transport
|
|
4547
|
+
* pin `status_2xx` on `["status_code"]` non-evidence), predicates comparing
|
|
4548
|
+
* a reference against an identical copy of itself (`equals(x, x)` holds for
|
|
4549
|
+
* any response), and positive-polarity presence/type pins (`exists`,
|
|
4550
|
+
* `type_is`, `not(not_exists(...))`, zero-bound `array_length_gte`) on the
|
|
4551
|
+
* bare `data` root — the declarative spellings of the legacy transport-only
|
|
4552
|
+
* assertion (`status === 200` + `typeof data === "object"`), satisfied by
|
|
4553
|
+
* any well-formed response body. Polarity matters: `not(exists(data))`
|
|
4554
|
+
* ("this probe must return no body") is falsified by ordinary responses, so
|
|
4555
|
+
* the carve-out only applies where the pin appears un-negated.
|
|
4556
|
+
* - Everything else counts: paths under `data`, extract-step results,
|
|
4557
|
+
* `duration_ms` (parity with imperative `ctx.durationMs` reads),
|
|
4558
|
+
* `status_2xx` applied to a body path (envelope-style upstreams answer HTTP
|
|
4559
|
+
* 200 and carry the real code in the body), and quantifiers, which iterate
|
|
4560
|
+
* response rows by construction.
|
|
4561
|
+
*
|
|
4562
|
+
* Fail-open contract (same as isVacuousAssertionFunction): the provider module
|
|
4563
|
+
* already validated this scenario against HealthScenarioSchema in its own SDK
|
|
4564
|
+
* at import time, so any shape this walk does not recognize (unknown step
|
|
4565
|
+
* kind, expression kind, operator, or reference namespace) indicates a newer
|
|
4566
|
+
* grammar, not an empty probe — treat it as evidence rather than flag it.
|
|
4567
|
+
* The inverse direction is accepted and documented: a determined author can
|
|
4568
|
+
* still fabricate a read that never fails (a decorative deep path inside a
|
|
4569
|
+
* disjunction, a degenerate bound). That effort is comparable to writing the
|
|
4570
|
+
* real one-line assertion — the same accepted limitation as the imperative
|
|
4571
|
+
* check's decorative-ctx-read — and the live `--smoke` probe remains the
|
|
4572
|
+
* runtime defense.
|
|
4573
|
+
*/
|
|
4574
|
+
export function scenarioCarriesResponseEvidence(scenario: unknown): boolean {
|
|
4575
|
+
if (!isRecord(scenario) || !Array.isArray(scenario.steps)) {
|
|
4576
|
+
return true;
|
|
4577
|
+
}
|
|
4578
|
+
let sawUnknownStepKind = false;
|
|
4579
|
+
for (const step of scenario.steps) {
|
|
4580
|
+
if (!isRecord(step)) {
|
|
4581
|
+
sawUnknownStepKind = true;
|
|
4582
|
+
continue;
|
|
4583
|
+
}
|
|
4584
|
+
if (step.kind === "assert") {
|
|
4585
|
+
if (expressionReadsResponse(step.expression)) {
|
|
4586
|
+
return true;
|
|
4587
|
+
}
|
|
4588
|
+
continue;
|
|
4589
|
+
}
|
|
4590
|
+
if (step.kind !== "operation" && step.kind !== "extract" && step.kind !== "guard") {
|
|
4591
|
+
sawUnknownStepKind = true;
|
|
4592
|
+
}
|
|
4593
|
+
}
|
|
4594
|
+
return sawUnknownStepKind;
|
|
4595
|
+
}
|
|
4596
|
+
|
|
4597
|
+
/**
|
|
4598
|
+
* Predicate operators of the current scenario grammar. An operator outside
|
|
4599
|
+
* this set indicates a newer grammar and fails open (counts as evidence),
|
|
4600
|
+
* because a future operator may source the response through fields this walk
|
|
4601
|
+
* does not know about.
|
|
4602
|
+
*/
|
|
4603
|
+
const KNOWN_SCENARIO_PREDICATE_OPERATORS: ReadonlySet<string> = new Set([
|
|
4604
|
+
"exists",
|
|
4605
|
+
"not_exists",
|
|
4606
|
+
"non_empty",
|
|
4607
|
+
"is_true",
|
|
4608
|
+
"equals",
|
|
4609
|
+
"not_equals",
|
|
4610
|
+
"contains",
|
|
4611
|
+
"matches",
|
|
4612
|
+
"number_gt",
|
|
4613
|
+
"number_gte",
|
|
4614
|
+
"number_lt",
|
|
4615
|
+
"number_lte",
|
|
4616
|
+
"array_length_eq",
|
|
4617
|
+
"array_length_gte",
|
|
4618
|
+
"array_length_lte",
|
|
4619
|
+
"status_2xx",
|
|
4620
|
+
"type_is",
|
|
4621
|
+
]);
|
|
4622
|
+
|
|
4623
|
+
function expressionReadsResponse(expression: unknown, negationDepth = 0): boolean {
|
|
4624
|
+
if (!isRecord(expression)) {
|
|
4625
|
+
return true;
|
|
4626
|
+
}
|
|
4627
|
+
if (expression.kind === "all" || expression.kind === "any") {
|
|
4628
|
+
return Array.isArray(expression.clauses)
|
|
4629
|
+
? expression.clauses.some((clause) => expressionReadsResponse(clause, negationDepth))
|
|
4630
|
+
: true;
|
|
4631
|
+
}
|
|
4632
|
+
if (expression.kind === "not") {
|
|
4633
|
+
return expressionReadsResponse(expression.clause, negationDepth + 1);
|
|
4634
|
+
}
|
|
4635
|
+
if (expression.kind === "quantifier") {
|
|
4636
|
+
return true;
|
|
4637
|
+
}
|
|
4638
|
+
if (expression.kind !== "predicate" || typeof expression.operator !== "string") {
|
|
4639
|
+
return true;
|
|
4640
|
+
}
|
|
4641
|
+
const operator = expression.operator;
|
|
4642
|
+
if (!KNOWN_SCENARIO_PREDICATE_OPERATORS.has(operator)) {
|
|
4643
|
+
return true;
|
|
4644
|
+
}
|
|
4645
|
+
const actualReads = operandReadsStepOutput(expression.actual);
|
|
4646
|
+
const expectedReads = operandReadsStepOutput(expression.expected);
|
|
4647
|
+
if (!actualReads && !expectedReads) {
|
|
4648
|
+
return false;
|
|
4649
|
+
}
|
|
4650
|
+
// A predicate comparing a reference against an identical copy of itself
|
|
4651
|
+
// (equals(x, x) and friends) holds or fails identically for every response,
|
|
4652
|
+
// so the reads discriminate nothing.
|
|
4653
|
+
if (
|
|
4654
|
+
actualReads &&
|
|
4655
|
+
expectedReads &&
|
|
4656
|
+
JSON.stringify(expression.actual) === JSON.stringify(expression.expected)
|
|
4657
|
+
) {
|
|
4658
|
+
return false;
|
|
4659
|
+
}
|
|
4660
|
+
// Presence/type pins on the whole `data` root are satisfied by any
|
|
4661
|
+
// well-formed JSON body — but only in positive polarity. Under an odd
|
|
4662
|
+
// number of `not` wrappers the same pin means "this probe must return no
|
|
4663
|
+
// body / a different shape", which ordinary responses falsify, so it is a
|
|
4664
|
+
// real (if unusual) constraint and stays evidence.
|
|
4665
|
+
const positivePolarity = negationDepth % 2 === 0;
|
|
4666
|
+
const bareEnvelopePin = positivePolarity
|
|
4667
|
+
? operator === "exists" ||
|
|
4668
|
+
operator === "type_is" ||
|
|
4669
|
+
(operator === "array_length_gte" && expression.expected === 0)
|
|
4670
|
+
: operator === "not_exists";
|
|
4671
|
+
if (bareEnvelopePin && !expectedReads && isBareResponseEnvelope(expression.actual)) {
|
|
4672
|
+
return false;
|
|
4673
|
+
}
|
|
4674
|
+
return true;
|
|
4675
|
+
}
|
|
4676
|
+
|
|
4677
|
+
function operandReadsStepOutput(operand: unknown): boolean {
|
|
4678
|
+
if (!isRecord(operand) || !isRecord(operand.ref)) {
|
|
4679
|
+
return false;
|
|
4680
|
+
}
|
|
4681
|
+
const reference = operand.ref;
|
|
4682
|
+
if (reference.namespace === "attempt" || reference.namespace === "credentials") {
|
|
4683
|
+
return false;
|
|
4684
|
+
}
|
|
4685
|
+
if (reference.namespace !== "steps") {
|
|
4686
|
+
return true;
|
|
4687
|
+
}
|
|
4688
|
+
if (!Array.isArray(reference.path) || reference.path.length === 0) {
|
|
2974
4689
|
return true;
|
|
2975
4690
|
}
|
|
2976
|
-
|
|
4691
|
+
const head = reference.path[0];
|
|
4692
|
+
return head !== "status_code" && head !== "request_id" && head !== "kind";
|
|
4693
|
+
}
|
|
4694
|
+
|
|
4695
|
+
function isBareResponseEnvelope(operand: unknown): boolean {
|
|
4696
|
+
if (!isRecord(operand) || !isRecord(operand.ref)) {
|
|
4697
|
+
return false;
|
|
4698
|
+
}
|
|
4699
|
+
const reference = operand.ref;
|
|
4700
|
+
return (
|
|
4701
|
+
reference.namespace === "steps" &&
|
|
4702
|
+
Array.isArray(reference.path) &&
|
|
4703
|
+
reference.path.length === 1 &&
|
|
4704
|
+
reference.path[0] === "data"
|
|
4705
|
+
);
|
|
2977
4706
|
}
|
|
2978
4707
|
|
|
2979
4708
|
function isVacuousAssertionFunction(assertions: unknown): boolean {
|
|
@@ -3341,7 +5070,7 @@ function isAstNode(value: unknown): value is acorn.AnyNode {
|
|
|
3341
5070
|
);
|
|
3342
5071
|
}
|
|
3343
5072
|
|
|
3344
|
-
function scoreSmoke(
|
|
5073
|
+
export function scoreSmoke(
|
|
3345
5074
|
smokeResult: SmokeResult | undefined,
|
|
3346
5075
|
smokeNote: string | undefined,
|
|
3347
5076
|
): SubmitCheck {
|
|
@@ -3363,8 +5092,22 @@ function scoreSmoke(
|
|
|
3363
5092
|
};
|
|
3364
5093
|
}
|
|
3365
5094
|
|
|
5095
|
+
const credential = smokeResult.credential;
|
|
5096
|
+
// An invalid configuration is reported even for a provider that needs no
|
|
5097
|
+
// credential: the operator set something, and silence would read as accepted.
|
|
5098
|
+
const credentialEvidence =
|
|
5099
|
+
credential?.required === true || credential?.error !== undefined
|
|
5100
|
+
? [
|
|
5101
|
+
credential.supplied
|
|
5102
|
+
? `credential: supplied for auth.mode "${credential.mode}"`
|
|
5103
|
+
: credential.error
|
|
5104
|
+
? `credential: CONFIGURED BUT INVALID — ${credential.error}`
|
|
5105
|
+
: `credential: NOT supplied; auth.mode "${credential.mode}" needs one the gateway injects at invocation time, so every credentialed operation failed before reaching the upstream`,
|
|
5106
|
+
]
|
|
5107
|
+
: [];
|
|
3366
5108
|
const evidence = [
|
|
3367
5109
|
`/health: ${smokeResult.healthOk ? "ok" : "failed"}`,
|
|
5110
|
+
...credentialEvidence,
|
|
3368
5111
|
...smokeResult.operations.map(
|
|
3369
5112
|
(outcome) =>
|
|
3370
5113
|
`${outcome.operationId}: ${outcome.status}${outcome.httpStatus ? ` HTTP ${outcome.httpStatus}` : ""} - ${outcome.message}`,
|
|
@@ -3388,6 +5131,31 @@ function scoreSmoke(
|
|
|
3388
5131
|
};
|
|
3389
5132
|
}
|
|
3390
5133
|
|
|
5134
|
+
// Checked BEFORE the success branch. A provider that needs a
|
|
5135
|
+
// gateway-injected credential and did not get one never exercised its
|
|
5136
|
+
// credentialed path, so a schema-valid success from some other operation --
|
|
5137
|
+
// an unauthenticated one, or a fixture-shaped response -- must not buy full
|
|
5138
|
+
// smoke points. Saying "verified" would credit the run with evidence it does
|
|
5139
|
+
// not have.
|
|
5140
|
+
if (credential?.required === true && !credential.supplied) {
|
|
5141
|
+
return {
|
|
5142
|
+
id: "local-smoke",
|
|
5143
|
+
category: "smoke",
|
|
5144
|
+
level: "warn",
|
|
5145
|
+
status: "warn",
|
|
5146
|
+
points: 7,
|
|
5147
|
+
maxPoints: CATEGORY_MAX_POINTS.smoke,
|
|
5148
|
+
message: credential.error
|
|
5149
|
+
? `Runtime path was verified, but the configured local credential could not be used, so the provider's credentialed operations never reached the upstream.`
|
|
5150
|
+
: `Runtime path was verified, but auth.mode "${credential.mode}" needs a credential this run did not have, so the provider's credentialed operations never reached the upstream.`,
|
|
5151
|
+
remediation: credential.error
|
|
5152
|
+
? `Fix the local credential configuration and rerun \`bun run submit-check -- --smoke\`: ${credential.error}`
|
|
5153
|
+
: `Supply the credential the gateway injects and rerun \`bun run submit-check -- --smoke\`: ${LOCAL_CONNECTION_SECRETS_ENV}='{"<key>":"<value>"}', where the keys are the ones the provider passes to ctx.credential.get(...). For platform-managed providers that value is the platform-owned key an operator holds; it is not in the checkout.`,
|
|
5154
|
+
evidence,
|
|
5155
|
+
details: smokeResult,
|
|
5156
|
+
};
|
|
5157
|
+
}
|
|
5158
|
+
|
|
3391
5159
|
const successes = smokeResult.operations.filter((outcome) => outcome.status === "success");
|
|
3392
5160
|
if (successes.length > 0) {
|
|
3393
5161
|
return {
|
|
@@ -3458,6 +5226,12 @@ export async function runSubmitCheckSmoke(
|
|
|
3458
5226
|
operations: [],
|
|
3459
5227
|
};
|
|
3460
5228
|
}
|
|
5229
|
+
// The gateway attaches a resolved credential to every invocation of a
|
|
5230
|
+
// provider whose auth.mode is not "none". Smoke POSTed an envelope with
|
|
5231
|
+
// no `connection` at all, so every platform-managed provider answered
|
|
5232
|
+
// MISSING_SECRET and scored as "runtime verified, no live success" —
|
|
5233
|
+
// a capability gap wearing the costume of an ordinary upstream miss.
|
|
5234
|
+
const connection = resolveLocalConnection(loadedProvider);
|
|
3461
5235
|
const operations: SmokeOperationOutcome[] = [];
|
|
3462
5236
|
for (const [operationId, operation] of Object.entries(loadedProvider.operations)) {
|
|
3463
5237
|
operations.push(
|
|
@@ -3465,10 +5239,21 @@ export async function runSubmitCheckSmoke(
|
|
|
3465
5239
|
requestId: `req_submit_check_smoke_${operationId}`,
|
|
3466
5240
|
input: operation.fixtures?.request ?? {},
|
|
3467
5241
|
headers: {},
|
|
5242
|
+
...(connection.ok && connection.connection ? { connection: connection.connection } : {}),
|
|
3468
5243
|
}),
|
|
3469
5244
|
);
|
|
3470
5245
|
}
|
|
3471
|
-
return {
|
|
5246
|
+
return {
|
|
5247
|
+
measured: true,
|
|
5248
|
+
healthOk: true,
|
|
5249
|
+
operations,
|
|
5250
|
+
credential: {
|
|
5251
|
+
required: providerRequiresConnection(loadedProvider),
|
|
5252
|
+
supplied: connection.ok && connection.connection !== undefined,
|
|
5253
|
+
mode: loadedProvider.auth?.mode ?? "none",
|
|
5254
|
+
...(connection.ok ? {} : { error: connection.reason }),
|
|
5255
|
+
},
|
|
5256
|
+
};
|
|
3472
5257
|
} finally {
|
|
3473
5258
|
await stopSmokeServer(server);
|
|
3474
5259
|
}
|
|
@@ -3655,25 +5440,6 @@ function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
|
|
|
3655
5440
|
};
|
|
3656
5441
|
}
|
|
3657
5442
|
|
|
3658
|
-
if (authMode === "none") {
|
|
3659
|
-
const securedOperations = Object.entries(provider.operations).filter(
|
|
3660
|
-
([, operation]) => operation.annotations?.openWorld === false,
|
|
3661
|
-
);
|
|
3662
|
-
if (securedOperations.length > 0) {
|
|
3663
|
-
return {
|
|
3664
|
-
id: "auth-safety",
|
|
3665
|
-
category: "auth",
|
|
3666
|
-
level: "warn",
|
|
3667
|
-
status: "warn",
|
|
3668
|
-
points: 7,
|
|
3669
|
-
maxPoints: CATEGORY_MAX_POINTS.auth,
|
|
3670
|
-
message: "Provider is no-auth but at least one operation is not marked openWorld.",
|
|
3671
|
-
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(", ")}.`,
|
|
3672
|
-
evidence: securedOperations.map(([operationId]) => operationId),
|
|
3673
|
-
};
|
|
3674
|
-
}
|
|
3675
|
-
}
|
|
3676
|
-
|
|
3677
5443
|
return pass(
|
|
3678
5444
|
"auth-safety",
|
|
3679
5445
|
"auth",
|
|
@@ -3875,18 +5641,18 @@ function findEntropySecretFindings(providerRoot: string, providerId: string): Se
|
|
|
3875
5641
|
if (isEntropySecretExcludedPath(relativePath)) continue;
|
|
3876
5642
|
const content = readFileSync(filePath, "utf8");
|
|
3877
5643
|
const lines = content.split(/\r?\n/);
|
|
3878
|
-
for (
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
5644
|
+
for (const candidate of extractStringLiteralCandidates(content, relativePath)) {
|
|
5645
|
+
// Context is the whole source line, comment tail included: a
|
|
5646
|
+
// comment that names the value is evidence about it, even though
|
|
5647
|
+
// comment text itself is never a candidate.
|
|
5648
|
+
const finding = classifyEntropyCandidate({
|
|
5649
|
+
value: candidate.value,
|
|
5650
|
+
line: lines[candidate.lineIndex] ?? "",
|
|
5651
|
+
file: relativePath,
|
|
5652
|
+
lineNumber: candidate.lineIndex + 1,
|
|
5653
|
+
providerId,
|
|
5654
|
+
});
|
|
5655
|
+
if (finding) findings.push(finding);
|
|
3890
5656
|
}
|
|
3891
5657
|
}
|
|
3892
5658
|
return findings;
|
|
@@ -3902,7 +5668,62 @@ function isEntropySecretExcludedPath(relativePath: string): boolean {
|
|
|
3902
5668
|
);
|
|
3903
5669
|
}
|
|
3904
5670
|
|
|
3905
|
-
export
|
|
5671
|
+
export type StringLiteralCandidate = {
|
|
5672
|
+
value: string;
|
|
5673
|
+
// 0-based index of the source line the literal sits on.
|
|
5674
|
+
lineIndex: number;
|
|
5675
|
+
};
|
|
5676
|
+
|
|
5677
|
+
// Every string-literal candidate (a quoted body of at least
|
|
5678
|
+
// ENTROPY_CANDIDATE_MIN_LENGTH characters) in one provider source file.
|
|
5679
|
+
// Candidates come from code only. Comments are prose, not values, so each
|
|
5680
|
+
// file's own comment syntax is removed before a quote is read:
|
|
5681
|
+
// - JS/TS sources: comment trivia is blanked with the TypeScript lexer
|
|
5682
|
+
// (maskComments, the comment ranges the other source lints skip), so `//`
|
|
5683
|
+
// inside a string or regex is not a comment and a `/* … */` block spanning
|
|
5684
|
+
// lines is skipped whole. A backtick in code opens a template literal — a
|
|
5685
|
+
// string value — so it stays a quote form.
|
|
5686
|
+
// - Shell sources (.sh/.bash, Dockerfile, entrypoint): `#` starts a comment
|
|
5687
|
+
// when it begins a word outside quotes, and a backtick is command
|
|
5688
|
+
// substitution (code), not a string delimiter.
|
|
5689
|
+
// Identifiers are therefore never candidates by construction: a bare name is
|
|
5690
|
+
// not a string literal, and a backticked name in a comment is comment text.
|
|
5691
|
+
// A JS/TS source the lexer cannot parse keeps the unmasked scan (every quote
|
|
5692
|
+
// pair is a candidate): when comments cannot be located the scan may
|
|
5693
|
+
// over-report, never under-report.
|
|
5694
|
+
export function extractStringLiteralCandidates(
|
|
5695
|
+
content: string,
|
|
5696
|
+
relativePath: string,
|
|
5697
|
+
): StringLiteralCandidate[] {
|
|
5698
|
+
const shell = !isJavaScriptFamilySource(relativePath);
|
|
5699
|
+
const codeLines = (shell ? content : maskCommentsForSecretScan(content, relativePath)).split(
|
|
5700
|
+
/\r?\n/,
|
|
5701
|
+
);
|
|
5702
|
+
const candidates: StringLiteralCandidate[] = [];
|
|
5703
|
+
for (let lineIndex = 0; lineIndex < codeLines.length; lineIndex += 1) {
|
|
5704
|
+
const line = codeLines[lineIndex] ?? "";
|
|
5705
|
+
const values = shell ? extractShellLineStrings(line) : extractQuotedLineStrings(line);
|
|
5706
|
+
for (const value of values) candidates.push({ value, lineIndex });
|
|
5707
|
+
}
|
|
5708
|
+
return candidates;
|
|
5709
|
+
}
|
|
5710
|
+
|
|
5711
|
+
function isJavaScriptFamilySource(relativePath: string): boolean {
|
|
5712
|
+
return /\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(relativePath);
|
|
5713
|
+
}
|
|
5714
|
+
|
|
5715
|
+
function maskCommentsForSecretScan(content: string, relativePath: string): string {
|
|
5716
|
+
try {
|
|
5717
|
+
return maskComments(content, relativePath);
|
|
5718
|
+
} catch {
|
|
5719
|
+
// Unparseable: scan the source unmasked rather than skip it.
|
|
5720
|
+
return content;
|
|
5721
|
+
}
|
|
5722
|
+
}
|
|
5723
|
+
|
|
5724
|
+
// Quoted bodies on one comment-free JS/TS line (`"`, `'`, and template
|
|
5725
|
+
// backticks), with backslash escapes.
|
|
5726
|
+
function extractQuotedLineStrings(line: string): string[] {
|
|
3906
5727
|
const candidates: string[] = [];
|
|
3907
5728
|
for (let index = 0; index < line.length; index += 1) {
|
|
3908
5729
|
const quote = line[index];
|
|
@@ -3929,6 +5750,51 @@ export function extractStringLiteralCandidates(line: string): string[] {
|
|
|
3929
5750
|
return candidates;
|
|
3930
5751
|
}
|
|
3931
5752
|
|
|
5753
|
+
// Characters after which a `#` begins a new shell word (blanks and the
|
|
5754
|
+
// metacharacters `;&|()<>`).
|
|
5755
|
+
const SHELL_WORD_BOUNDARY = /[\s;&|()<>]/;
|
|
5756
|
+
|
|
5757
|
+
// Quoted bodies on one shell line. Single quotes take no escapes; double
|
|
5758
|
+
// quotes and unquoted text honor backslash escapes. A `#` that begins a word
|
|
5759
|
+
// outside quotes ends the line's code (the rest is a comment). A word begins
|
|
5760
|
+
// at the line start or after an UNESCAPED boundary character, so `${#var}`,
|
|
5761
|
+
// `$#`, `"a"#b`, and `word\ #` (escaped blank) keep `#` inside the word.
|
|
5762
|
+
// Backticks are command substitution, so their text is lexed as code. A
|
|
5763
|
+
// quote left open on the line is read as a literal character, as the
|
|
5764
|
+
// line-based scan always has.
|
|
5765
|
+
function extractShellLineStrings(line: string): string[] {
|
|
5766
|
+
const candidates: string[] = [];
|
|
5767
|
+
let index = 0;
|
|
5768
|
+
let atWordStart = true;
|
|
5769
|
+
while (index < line.length) {
|
|
5770
|
+
const char = line[index];
|
|
5771
|
+
if (char === "\\") {
|
|
5772
|
+
index += 2;
|
|
5773
|
+
atWordStart = false;
|
|
5774
|
+
continue;
|
|
5775
|
+
}
|
|
5776
|
+
if (char === "#" && atWordStart) break;
|
|
5777
|
+
if (char === '"' || char === "'") {
|
|
5778
|
+
const contentStart = index + 1;
|
|
5779
|
+
let cursor = contentStart;
|
|
5780
|
+
while (cursor < line.length && line[cursor] !== char) {
|
|
5781
|
+
cursor += char === '"' && line[cursor] === "\\" ? 2 : 1;
|
|
5782
|
+
}
|
|
5783
|
+
if (cursor < line.length) {
|
|
5784
|
+
if (cursor - contentStart >= ENTROPY_CANDIDATE_MIN_LENGTH) {
|
|
5785
|
+
candidates.push(line.slice(contentStart, cursor));
|
|
5786
|
+
}
|
|
5787
|
+
index = cursor + 1;
|
|
5788
|
+
atWordStart = false;
|
|
5789
|
+
continue;
|
|
5790
|
+
}
|
|
5791
|
+
}
|
|
5792
|
+
atWordStart = SHELL_WORD_BOUNDARY.test(char ?? "");
|
|
5793
|
+
index += 1;
|
|
5794
|
+
}
|
|
5795
|
+
return candidates;
|
|
5796
|
+
}
|
|
5797
|
+
|
|
3932
5798
|
function classifyEntropyCandidate(input: {
|
|
3933
5799
|
value: string;
|
|
3934
5800
|
line: string;
|
|
@@ -4023,9 +5889,7 @@ type LineStringLiteral = {
|
|
|
4023
5889
|
// Stable identity for the container a literal sits in ("top" when the
|
|
4024
5890
|
// literal is not inside any bracket on the line).
|
|
4025
5891
|
function literalContainerKey(literal: LineStringLiteral): string {
|
|
4026
|
-
return literal.container
|
|
4027
|
-
? `${literal.container.bracket}${literal.container.index}`
|
|
4028
|
-
: "top";
|
|
5892
|
+
return literal.container ? `${literal.container.bracket}${literal.container.index}` : "top";
|
|
4029
5893
|
}
|
|
4030
5894
|
|
|
4031
5895
|
// Single-pass line tokenizer: extracts every string literal with its span and
|
|
@@ -4035,7 +5899,7 @@ function literalContainerKey(literal: LineStringLiteral): string {
|
|
|
4035
5899
|
// other literal is a VALUE: ternary arms (preceded by "?" or ":"), array
|
|
4036
5900
|
// elements, call arguments, and assignment right-hand sides, even when a
|
|
4037
5901
|
// ternary's ":" happens to follow them. Uses the same quote/escape walking as
|
|
4038
|
-
//
|
|
5902
|
+
// extractQuotedLineStrings.
|
|
4039
5903
|
function tokenizeLineStringLiterals(line: string): LineStringLiteral[] {
|
|
4040
5904
|
const literals: LineStringLiteral[] = [];
|
|
4041
5905
|
const bracketStack: Array<{ bracket: "[" | "(" | "{"; index: number }> = [];
|
|
@@ -4166,8 +6030,9 @@ function shannonEntropy(value: string): number {
|
|
|
4166
6030
|
|
|
4167
6031
|
function guessSecretName(line: string): string {
|
|
4168
6032
|
const match =
|
|
4169
|
-
/\b(
|
|
4170
|
-
/["']?([A-Za-z_$][\w$-]*)["']?\s
|
|
6033
|
+
/\b[A-Za-z_$][\w$]*(?:\s*\.\s*([A-Za-z_$][\w$]*))+\s*=/.exec(line) ??
|
|
6034
|
+
/["']?([A-Za-z_$][\w$-]*)["']?\s*:\s*["'`]/.exec(line) ??
|
|
6035
|
+
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line);
|
|
4171
6036
|
const raw = match?.[1] ?? "SECRET";
|
|
4172
6037
|
return raw
|
|
4173
6038
|
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
@@ -4182,6 +6047,35 @@ const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/
|
|
|
4182
6047
|
// the three stay coherent.
|
|
4183
6048
|
const ENTROPY_CANDIDATE_MIN_LENGTH = 20;
|
|
4184
6049
|
|
|
6050
|
+
// A secret-ish name is only supporting evidence. Shorter values must also look
|
|
6051
|
+
// credential-like, and known config tokens/numeric values are never candidates.
|
|
6052
|
+
const SECRET_NAMED_ENV_VALUE_MIN_LENGTH = 4;
|
|
6053
|
+
const SECRET_NAMED_ENV_UNSTRUCTURED_MIN_LENGTH = 8;
|
|
6054
|
+
const NON_SECRET_ENV_VALUE_TOKENS = new Set([
|
|
6055
|
+
"true",
|
|
6056
|
+
"false",
|
|
6057
|
+
"yes",
|
|
6058
|
+
"no",
|
|
6059
|
+
"on",
|
|
6060
|
+
"off",
|
|
6061
|
+
"none",
|
|
6062
|
+
"null",
|
|
6063
|
+
"nil",
|
|
6064
|
+
"auto",
|
|
6065
|
+
"default",
|
|
6066
|
+
"local",
|
|
6067
|
+
"debug",
|
|
6068
|
+
"info",
|
|
6069
|
+
"warn",
|
|
6070
|
+
"error",
|
|
6071
|
+
"always",
|
|
6072
|
+
"never",
|
|
6073
|
+
"enabled",
|
|
6074
|
+
"disabled",
|
|
6075
|
+
"0",
|
|
6076
|
+
"1",
|
|
6077
|
+
]);
|
|
6078
|
+
|
|
4185
6079
|
const SECRET_PATTERNS: Array<[string, RegExp]> = [
|
|
4186
6080
|
["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
|
|
4187
6081
|
["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
|
|
@@ -4193,12 +6087,150 @@ const SECRET_PATTERNS: Array<[string, RegExp]> = [
|
|
|
4193
6087
|
],
|
|
4194
6088
|
];
|
|
4195
6089
|
|
|
4196
|
-
|
|
6090
|
+
type SafeLoadProviderResult =
|
|
6091
|
+
| { ok: true; provider: ProviderDefinition | undefined }
|
|
6092
|
+
| { ok: false; error: unknown };
|
|
6093
|
+
|
|
6094
|
+
async function safeLoadProvider(providerRoot: string): Promise<SafeLoadProviderResult> {
|
|
6095
|
+
try {
|
|
6096
|
+
return { ok: true, provider: await loadProvider(providerRoot) };
|
|
6097
|
+
} catch (error) {
|
|
6098
|
+
return { ok: false, error };
|
|
6099
|
+
}
|
|
6100
|
+
}
|
|
6101
|
+
|
|
6102
|
+
const UNRENDERABLE_LOAD_ERROR = "<thrown value could not be rendered>";
|
|
6103
|
+
|
|
6104
|
+
function formatLoadErrorEvidence(error: unknown, providerRoot: string): string[] {
|
|
6105
|
+
const evidence: string[] = [];
|
|
6106
|
+
const seen = new Set<object>();
|
|
6107
|
+
let current: unknown = error;
|
|
6108
|
+
let frame = 0;
|
|
6109
|
+
while (frame < MAX_LOAD_ERROR_EVIDENCE) {
|
|
6110
|
+
if (typeof current === "object" || typeof current === "function") {
|
|
6111
|
+
if (current !== null && seen.has(current)) {
|
|
6112
|
+
break;
|
|
6113
|
+
}
|
|
6114
|
+
if (current !== null) seen.add(current);
|
|
6115
|
+
}
|
|
6116
|
+
evidence.push(
|
|
6117
|
+
`${frame === 0 ? "Load error" : "Cause"}: ${sanitizeLoadErrorText(
|
|
6118
|
+
formatThrownValue(current),
|
|
6119
|
+
providerRoot,
|
|
6120
|
+
)}`,
|
|
6121
|
+
);
|
|
6122
|
+
frame += 1;
|
|
6123
|
+
const cause = readThrownValueCause(current);
|
|
6124
|
+
if (!cause.found || cause.value === undefined || cause.value === null) {
|
|
6125
|
+
break;
|
|
6126
|
+
}
|
|
6127
|
+
current = cause.value;
|
|
6128
|
+
}
|
|
6129
|
+
return evidence;
|
|
6130
|
+
}
|
|
6131
|
+
|
|
6132
|
+
function formatThrownValue(value: unknown): string {
|
|
4197
6133
|
try {
|
|
4198
|
-
|
|
6134
|
+
if (value instanceof Error) {
|
|
6135
|
+
return String(value.message);
|
|
6136
|
+
}
|
|
6137
|
+
if (
|
|
6138
|
+
value !== null &&
|
|
6139
|
+
(typeof value === "object" || typeof value === "function") &&
|
|
6140
|
+
"message" in value
|
|
6141
|
+
) {
|
|
6142
|
+
return String((value as { message?: unknown }).message);
|
|
6143
|
+
}
|
|
6144
|
+
return `<non-Error value thrown: ${String(value)}>`;
|
|
4199
6145
|
} catch {
|
|
4200
|
-
return
|
|
6146
|
+
return UNRENDERABLE_LOAD_ERROR;
|
|
6147
|
+
}
|
|
6148
|
+
}
|
|
6149
|
+
|
|
6150
|
+
function readThrownValueCause(value: unknown): { found: boolean; value?: unknown } {
|
|
6151
|
+
try {
|
|
6152
|
+
if (
|
|
6153
|
+
value === null ||
|
|
6154
|
+
(typeof value !== "object" && typeof value !== "function") ||
|
|
6155
|
+
!("cause" in value)
|
|
6156
|
+
) {
|
|
6157
|
+
return { found: false };
|
|
6158
|
+
}
|
|
6159
|
+
return { found: true, value: (value as { cause?: unknown }).cause };
|
|
6160
|
+
} catch {
|
|
6161
|
+
return { found: false };
|
|
6162
|
+
}
|
|
6163
|
+
}
|
|
6164
|
+
|
|
6165
|
+
function isPlausibleSecretNamedEnvValue(value: string): boolean {
|
|
6166
|
+
const normalized = value.trim();
|
|
6167
|
+
if (normalized.length < SECRET_NAMED_ENV_VALUE_MIN_LENGTH) return false;
|
|
6168
|
+
if (NON_SECRET_ENV_VALUE_TOKENS.has(normalized.toLowerCase())) return false;
|
|
6169
|
+
if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) return false;
|
|
6170
|
+
if (normalized.length >= SECRET_NAMED_ENV_UNSTRUCTURED_MIN_LENGTH) return true;
|
|
6171
|
+
|
|
6172
|
+
const hasLetter = /[A-Za-z]/.test(normalized);
|
|
6173
|
+
const hasDigitOrSymbol = /[^A-Za-z\s]/.test(normalized);
|
|
6174
|
+
return hasLetter && hasDigitOrSymbol;
|
|
6175
|
+
}
|
|
6176
|
+
|
|
6177
|
+
function replaceEnvValue(
|
|
6178
|
+
input: string,
|
|
6179
|
+
value: string,
|
|
6180
|
+
requireIdentifierBoundaries: boolean,
|
|
6181
|
+
): string {
|
|
6182
|
+
if (!requireIdentifierBoundaries) return input.replaceAll(value, "[REDACTED]");
|
|
6183
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
6184
|
+
return input.replace(
|
|
6185
|
+
new RegExp(`(?<![A-Za-z0-9_])${escaped}(?![A-Za-z0-9_])`, "g"),
|
|
6186
|
+
"[REDACTED]",
|
|
6187
|
+
);
|
|
6188
|
+
}
|
|
6189
|
+
|
|
6190
|
+
function sanitizeLoadErrorText(value: string, providerRoot: string): string {
|
|
6191
|
+
let output = value.replace(
|
|
6192
|
+
/(^|[^A-Za-z0-9_.])((?:\/[^\s"'`]+)+)/g,
|
|
6193
|
+
(_match, prefix: string, rawPath: string) => {
|
|
6194
|
+
const pathCandidate = rawPath.replace(/[),.;:!?]+$/u, "");
|
|
6195
|
+
const relativePath = relative(providerRoot, resolve(pathCandidate));
|
|
6196
|
+
const insideProvider =
|
|
6197
|
+
relativePath === "" || (relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
|
|
6198
|
+
return `${prefix}${insideProvider ? relativePath || "." : "[REDACTED_PATH]"}`;
|
|
6199
|
+
},
|
|
6200
|
+
);
|
|
6201
|
+
output = output.replace(/[A-Za-z]:[\\/][^\s"'`]+/g, "[REDACTED_PATH]");
|
|
6202
|
+
output = output.replace(
|
|
6203
|
+
/((?:password|token|secret|api[_-]?key|credential|authorization)\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|["'`]?[^\s,;"'`]+)/gi,
|
|
6204
|
+
"$1[REDACTED]",
|
|
6205
|
+
);
|
|
6206
|
+
const envValues: Array<{ value: string; requireIdentifierBoundaries: boolean }> = [];
|
|
6207
|
+
for (const [name, item] of Object.entries(process.env)) {
|
|
6208
|
+
if (typeof item !== "string") continue;
|
|
6209
|
+
const secretNamed = SECRETISH_IDENTIFIER_PATTERN.test(name);
|
|
6210
|
+
let shouldRedact = secretNamed;
|
|
6211
|
+
if (secretNamed) {
|
|
6212
|
+
shouldRedact = isPlausibleSecretNamedEnvValue(item);
|
|
6213
|
+
} else if (item.length >= ENTROPY_CANDIDATE_MIN_LENGTH && shouldConsiderEntropyValue(item)) {
|
|
6214
|
+
const charset = classifyEntropyCharset(item);
|
|
6215
|
+
const threshold = charset === "hex" ? 3.0 : 4.5;
|
|
6216
|
+
shouldRedact = charset !== undefined && shannonEntropy(item) >= threshold;
|
|
6217
|
+
}
|
|
6218
|
+
if (shouldRedact) {
|
|
6219
|
+
envValues.push({
|
|
6220
|
+
value: item,
|
|
6221
|
+
requireIdentifierBoundaries:
|
|
6222
|
+
secretNamed && item.trim().length < ENTROPY_CANDIDATE_MIN_LENGTH,
|
|
6223
|
+
});
|
|
6224
|
+
}
|
|
6225
|
+
}
|
|
6226
|
+
envValues.sort((a, b) => b.value.length - a.value.length);
|
|
6227
|
+
for (const envValue of envValues) {
|
|
6228
|
+
output = replaceEnvValue(output, envValue.value, envValue.requireIdentifierBoundaries);
|
|
4201
6229
|
}
|
|
6230
|
+
output = redact(output).replace(/\s+/g, " ");
|
|
6231
|
+
return output.length > MAX_LOAD_ERROR_MESSAGE_LENGTH
|
|
6232
|
+
? `${output.slice(0, MAX_LOAD_ERROR_MESSAGE_LENGTH - 1)}…`
|
|
6233
|
+
: output;
|
|
4202
6234
|
}
|
|
4203
6235
|
|
|
4204
6236
|
async function loadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
|