import fs from 'node:fs' import path from 'node:path' import { parseSync, Visitor, type CallExpression, type Expression, type NewExpression, type ParamPattern, type Program, } from 'oxc-parser' export type OutboundCategory = | 'runtime-delivery' | 'bounded-diagnostics' | 'explicit-user-action' | 'local-development' | 'same-origin-runtime' | 'guest-app-network' | 'release-delivery' export interface OutboundEndpointDeclaration { category: OutboundCategory destination: string preConsent: boolean payload: string } export const OUTBOUND_ENDPOINTS: Record = { runtime_delivery: { category: 'runtime-delivery', destination: 'https://contrast.dev/runtimes/manifest.json and selected runtime tarball', preConsent: true, payload: 'HTTP runtime version request; no project or app data', }, bounded_diagnostics: { category: 'bounded-diagnostics', destination: 'configured PostHog ingestion host', preConsent: false, payload: 'package name/version/unsupported API or error class only', }, contrast_user_action: { category: 'explicit-user-action', destination: 'configured Contrast origin', preConsent: false, payload: 'explicit auth, billing, preview, issue report, test, or generated-flow request; a stored login may refresh auth/subscription state', }, cli_delivery: { category: 'explicit-user-action', destination: 'https://r2.rnxsim.com/rnx-cli/channels/stable and the selected executable/checksum', preConsent: false, payload: 'requested CLI version, platform, and architecture only', }, cli_release_publish: { category: 'release-delivery', destination: 'configured R2 origin and https://r2.rnxsim.com/rnx-cli', preConsent: false, payload: 'release executables, checksums, version, and source identity', }, release_notes: { category: 'explicit-user-action', destination: 'https://contrast.dev/api/changelog/sootsim', preConsent: false, payload: 'requested runtime version only', }, electron_download: { category: 'explicit-user-action', destination: 'https://contrast.dev/api/electron-release/', preConsent: false, payload: 'platform, architecture, and requested desktop release', }, local_development: { category: 'local-development', destination: 'loopback development server, bridge, browser debugging, or app asset URL', preConsent: true, payload: 'local protocol traffic needed to discover, load, and drive the selected app', }, same_origin_runtime: { category: 'same-origin-runtime', destination: 'the origin already serving the rnx runtime', preConsent: true, payload: 'runtime assets and shared local settings', }, guest_app_network: { category: 'guest-app-network', destination: 'URLs requested by the user app or supplied explicitly by the user', preConsent: true, payload: 'the app request itself; rnx does not add telemetry fields', }, remote_display_server: { category: 'explicit-user-action', destination: 'display-server WebSocket URL configured by the embedding host', preConsent: true, payload: 'display protocol identity, local input events, scroll lifecycle, resync requests, and applied/painted acknowledgements', }, presigned_upload: { category: 'explicit-user-action', destination: 'server-issued presigned object upload URL', preConsent: false, payload: 'preview objects selected by the user for upload', }, // its own entry, deliberately not folded into contrast_user_action. this is // the only egress that sends the CONTENTS of the user's project somewhere, // and to an operator-chosen host rather than a Contrast one, so it must not // be readable as "the usual Contrast call". rnx_cloud_box: { category: 'explicit-user-action', destination: 'the RNX cloud box service named by --endpoint or RNX_BOX_ENDPOINT; no default, so this call cannot happen unless the user names a host', preConsent: false, payload: 'every file git reports for the current checkout, then shell commands and file reads and writes for as long as the box shell is open', }, swift_compile: { category: 'explicit-user-action', destination: 'the origin already serving the app, or the absolute Contrast origin a node caller passes', preConsent: false, payload: 'the project .swift sources, packed bundle resources and images, plus the signed preview token or ccdn_ api key that authorizes the compile', }, rnx_cloud_sim: { category: 'explicit-user-action', destination: 'https://rnxsim.com by default, or the test or disposable origin named by RNX_CLOUD_ORIGIN', preConsent: false, payload: 'the Metro JavaScript bundle selected by rnx ios --remote, then structured simulator commands and lifecycle requests', }, tunnel_tool_download: { category: 'explicit-user-action', destination: 'https://github.com/cloudflare/cloudflared/releases/latest/download/', preConsent: false, payload: 'platform and architecture only, carried in the release asset name; fires only for rnx test --cloud with a live dev stack when no cloudflared binary is installed', }, } export interface OutboundCallDeclaration { category: keyof typeof OUTBOUND_ENDPOINTS count: number } // generated from the AST-level check below, then reviewed and classified. // a new fetch, websocket, node HTTP request, or TCP connection has no entry and // fails the package test until its destination and consent behavior are declared. export const DECLARED_OUTBOUND_CALLS: Record = { 'packages/sootsim/cli/cloud-client.ts :: fetch :: `${apiOrigin}/v1/boxes`': { category: 'rnx_cloud_sim', count: 1, }, 'packages/sootsim/cli/cloud-client.ts :: fetch :: `${session.apiOrigin}/v1/sims/${encodeURIComponent()}/claim`': { category: 'rnx_cloud_sim', count: 1 }, // two call sites delete a box by id: closing a nano-box simulator, and // closing a box from a session that is not itself the box. one destination, // so one declaration. 'packages/sootsim/cli/cloud-client.ts :: fetch :: `${session.apiOrigin}/v1/boxes/${encodeURIComponent()}`': { category: 'rnx_cloud_sim', count: 2 }, // closing an instance simulator deletes it by its own id with its own token. 'packages/sootsim/cli/cloud-client.ts :: fetch :: `${session.apiOrigin}/v1/sims/${encodeURIComponent()}`': { category: 'rnx_cloud_sim', count: 1 }, // the box api mirrors the sim api for a micro box: the same destination and // the same box token, reached through the session rather than a loose id. // driving a box sends each command to its own box. 'packages/sootsim/cli/cloud-client.ts :: fetch :: `${this.session.apiOrigin}/v1/boxes/${encodeURIComponent()}/commands`': { category: 'rnx_cloud_sim', count: 1 }, // stopping a box leaves it allocated, unlike the delete above. 'packages/sootsim/cli/cloud-client.ts :: fetch :: `${session.apiOrigin}/v1/boxes/${encodeURIComponent()}/stop`': { category: 'rnx_cloud_sim', count: 1 }, // the display grant is polled until the box hands one back; the body is // empty, so the request carries no project or app data. 'packages/sootsim/cli/cloud-client.ts :: fetch :: `${options.apiOrigin}/v1/boxes/${encodeURIComponent()}/display-grant`': { category: 'rnx_cloud_sim', count: 1 }, // `rnx remote` creates through the instance api and lists what it made. // the create carries labels and wait=true in the query; the list filters // by the remote label. 'packages/sootsim/cli/cloud-client.ts :: fetch :: `${apiOrigin}/v1/sims?${query}`': { category: 'rnx_cloud_sim', count: 1, }, 'packages/sootsim/cli/cloud-client.ts :: fetch :: `${apiOrigin}/v1/sims?${selector}`': { category: 'rnx_cloud_sim', count: 1, }, // rereading one simulator and deleting one by id share the route: the // reattach probe reads with either credential and `rnx remote stop` // deletes with the account credential. 'packages/sootsim/cli/cloud-client.ts :: fetch :: `${apiOrigin}/v1/sims/${encodeURIComponent()}`': { category: 'rnx_cloud_sim', count: 2 }, 'packages/sootsim/cli/cloud-client.ts :: fetch :: `${this.session.apiOrigin}/v1/sims/${encodeURIComponent()}/commands`': { category: 'rnx_cloud_sim', count: 1 }, 'packages/sootsim/cli/cloud-client.ts :: fetch :: bundleUrl': { category: 'guest_app_network', count: 1, }, // submitting a cloud test job (POST) and cancelling one (DELETE) share // the jobs route: one destination, two call sites. 'packages/sootsim/cli/commands/test.ts :: fetch :: endpoint': { category: 'contrast_user_action', count: 3, }, 'packages/sootsim/cli/commands/test.ts :: fetch :: pollUrl': { category: 'contrast_user_action', count: 1, }, // the live-dev-stack publisher fetches a cloudflared binary only when none // is installed; the token proxy below it forwards to the user's own // loopback dev server and sidecars, never anywhere remote. 'packages/sootsim/cli/cloud-test-tunnel.ts :: fetch :: `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`': { category: 'tunnel_tool_download', count: 1 }, "packages/sootsim/cli/cloud-test-tunnel.ts :: node-request :: { host: '127.0.0.1', port: selectedPort, path: req.url, method: req.method, headers, }": { category: 'local_development', count: 1 }, // compiling a project's .swift sources needs a swift toolchain, so the // bundler posts them to the app's own /api/swift-compile. it only fires with // a compile credential and only for a project that has .swift sources. 'packages/sootsim/src/swift-submit.ts :: fetch :: url': { category: 'swift_compile', count: 1, }, 'packages/sootsim/src/cloud.ts :: fetch :: url': { category: 'rnx_cloud_sim', count: 1, }, // maestro's flow `http` API. the url is whatever the flow author wrote, and // it is fetched from a child process because that API is synchronous. 'packages/sootsim/cli/internal-child.ts :: fetch :: request.url': { category: 'guest_app_network', count: 1, }, // `rnx box create` uploads the checkout to the box service and then talks to // it. the destination is whatever the user named; there is no default. 'packages/rnx-cloud-box/src/client.ts :: fetch :: endpointOf(base, name, verb, search)': { category: 'rnx_cloud_box', count: 1, }, 'packages/rnx-cloud-box/src/client.ts :: websocket :: url': { category: 'rnx_cloud_box', count: 1, }, // a micro box's own Browser Run session, over the worker's BROWSER binding. // the binding routes on path alone and ignores the host, so `browser.invalid` // is a placeholder that exists only so URL can parse the search parameters. // nothing leaves Cloudflare and no project content is sent. 'packages/rnx-cloud-box/src/browser-run.ts :: fetch :: sessionEndpoint(undefined, { keepAliveMs, includeTargets: true })': { category: 'rnx_cloud_box', count: 1 }, 'packages/rnx-cloud-box/src/browser-run.ts :: fetch :: `https://browser.invalid/v1/devtools/browser/${sessionId}/json/list`': { category: 'rnx_cloud_box', count: 1 }, // twice: closing the session, and the CDP websocket upgrade that navigates a // target. both address the same session over the same binding. 'packages/rnx-cloud-box/src/browser-run.ts :: fetch :: `https://browser.invalid/v1/devtools/browser/${sessionId}`': { category: 'rnx_cloud_box', count: 2 }, // the account's own session meter: starting, listing and heartbeating the // session a cloud box is billed against. no project content. "packages/rnx-cloud-box/src/client.ts :: fetch :: `${options.apiOrigin.replace(/\\/+$/, '')}/api/v1/rnx/sessions${ options.accountId ? `?accountId=${encodeURIComponent()}` : '' }`": { category: 'contrast_user_action', count: 1 }, 'packages/rnx-cloud-box/src/client.ts :: fetch :: url': { category: 'contrast_user_action', count: 1, }, // `rnx box connect` asks the account service which box that ID or name is // and for the box's own token. the account is the only thing that knows, so // this is the one call that turns a name into an authorized Box reference. 'packages/rnx-cloud-box/src/client.ts :: fetch :: connectUrl': { category: 'contrast_user_action', count: 1, }, "packages/rnx-cloud-box/src/box-do.ts :: fetch :: `${origin}/api/v1/rnx/sessions${serviceAuthorized ? '/internal' : ''}`": { category: 'contrast_user_action', count: 1 }, "packages/rnx-cloud-box/src/box-do.ts :: fetch :: `${origin}/api/v1/rnx/sessions/${ serviceAuthority ? 'internal/' : '' }${encodeURIComponent()}${ accountId ? `?accountId=${encodeURIComponent()}` : '' }`": { category: 'contrast_user_action', count: 1 }, 'packages/rnx-cloud-box/src/box-do.ts :: fetch :: `${origin}/api/v1/rnx/sessions/internal/${encodeURIComponent()}`': { category: 'contrast_user_action', count: 1 }, // stop and delete authorize the caller against the account before any // teardown. the account service owns the verdict on a key, its scope and the // account it names; no project content is sent. 'packages/rnx-cloud-box/src/box-do.ts :: fetch :: `${origin}/api/v1/rnx/sessions?accountId=${encodeURIComponent()}&kind=box&status=running`': { category: 'contrast_user_action', count: 1 }, // the Cloudflare Worker forwards the already-authenticated request to the // selected Durable Object in the same service. 'packages/rnx-cloud-box/src/worker.ts :: fetch :: request': { category: 'same_origin_runtime', count: 1, }, // a LOCAL box authorizing its own checkout with the loopback bridge daemon. // this one refuses to guess a port, so it cannot reach anything but a bridge // the user's own session is running; see the note on ws-bridge.ts below // about splitting these if bridgeWsUrl ever returns a remote endpoint. 'packages/sootsim/cli/commands/box/plane-share.ts :: websocket :: bridgeWsUrl(port)': { category: 'local_development', count: 2, }, 'packages/contrast-runtime-delivery/src/index.ts :: fetch :: url': { category: 'runtime_delivery', count: 2, }, "packages/sootsim/scripts/demo-app-registry.ts :: fetch :: 'http://localhost:1986/'": { category: 'local_development', count: 1, }, 'packages/sootsim/scripts/dev-server-scanner.ts :: http.request :: { hostname, port, path, method, headers }': { category: 'local_development', count: 1, }, 'packages/sootsim/scripts/dev-server-scanner.ts :: sock.connect :: port': { category: 'local_development', count: 1, }, 'packages/sootsim/scripts/smoke-cli-binary.ts :: fetch :: `http://127.0.0.1:${port}/healthz`': { category: 'local_development', count: 1 }, // the binary smoke's replacement-module probe against its own loopback server 'packages/sootsim/scripts/smoke-cli-binary.ts :: fetch :: url': { category: 'local_development', count: 1, }, 'packages/sootsim-engine/src/auth/openLogin.ts :: fetch :: `${CONTRAST_ORIGIN}/api/dev-login`': { category: 'contrast_user_action', count: 1 }, 'packages/sootsim-engine/src/auth/shared-session.ts :: fetch :: `${CONTRAST_ORIGIN}/api/auth/me`': { category: 'contrast_user_action', count: 1 }, 'packages/sootsim-engine/src/auth/shared-session.ts :: fetch :: `${CONTRAST_ORIGIN}/api/auth/sign-out`': { category: 'contrast_user_action', count: 1 }, "packages/sootsim-engine/src/billing/client.ts :: fetch :: apiUrl('/api/sootsim/billing/checkout')": { category: 'contrast_user_action', count: 1 }, 'packages/sootsim-engine/src/billing/client.ts :: fetch :: apiUrl(path)': { category: 'contrast_user_action', count: 3, }, 'packages/sootsim-engine/src/demo/demo-share-register.ts :: fetch :: shareUrl': { category: 'guest_app_network', count: 1, }, 'packages/sootsim-engine/src/engine/android-emoji-font.ts :: fetch :: url': { category: 'same_origin_runtime', count: 1, }, 'packages/sootsim-engine/src/engine/app-font-loader.ts :: fetch :: manifestFetchUrl(origin)': { category: 'local_development', count: 1 }, 'packages/sootsim-engine/src/engine/app-font-registry.ts :: fetch :: resolvedUrl': { category: 'guest_app_network', count: 1, }, 'packages/sootsim-engine/src/engine/config-plugin-splash-loader.ts :: fetch :: manifestFetchUrl(origin)': { category: 'local_development', count: 1 }, 'packages/sootsim-engine/src/engine/emoji-metrics-font-loader.ts :: fetch :: attemptedBundledUrl': { category: 'same_origin_runtime', count: 1 }, 'packages/sootsim-engine/src/engine/expo-app-config.ts :: fetch :: manifestFetchUrl(origin)': { category: 'local_development', count: 1 }, 'packages/sootsim-engine/src/engine/image-loader.ts :: fetch :: resolveImageFetchUri(uri)': { category: 'guest_app_network', count: 1 }, 'packages/sootsim-engine/src/engine/language-font-loader.ts :: fetch :: url': { category: 'same_origin_runtime', count: 1, }, 'packages/sootsim-engine/src/engine/lottie-runtime.ts :: fetch :: descriptor.value': { category: 'guest_app_network', count: 2, }, 'packages/sootsim-engine/src/ios/sf-symbol-catalog.ts :: fetch :: `${catalogBaseUrl}${asset}.json`': { category: 'same_origin_runtime', count: 1, }, 'packages/sootsim-engine/src/engine/system-font-extras.ts :: fetch :: url': { category: 'same_origin_runtime', count: 1, }, 'packages/sootsim-engine/src/ExternalApp.tsx :: fetch :: getBundleProxyUrl(devServerUrl)': { category: 'guest_app_network', count: 1 }, 'packages/sootsim-engine/src/ExternalApp.tsx :: fetch :: requestUrl': { category: 'guest_app_network', count: 1, }, 'packages/sootsim-engine/src-electron/tray.ts :: client.get :: parsed': { category: 'guest_app_network', count: 1, }, 'packages/sootsim-engine/src/mcp/ws-bridge.ts :: fetch :: bridgeHealthUrl': { category: 'local_development', count: 1, }, 'packages/sootsim-engine/src/mcp/ws-bridge.ts :: websocket :: resolveSootsimBridgeWebSocketUrl({ bridgePort, href: window.location.href, })': { category: 'local_development', count: 1 }, 'packages/sootsim-engine/src/remote-stream/browser-tenant-proxy.ts :: websocket :: this.config.url': { category: 'remote_display_server', count: 1 }, 'packages/sootsim-engine/src/menu/menuActions.ts :: fetch :: screenshot': { category: 'guest_app_network', count: 1, }, 'packages/sootsim-engine/src/preview/fetch-recorder.ts :: fetch :: absolute': { category: 'guest_app_network', count: 2, }, // the path is a parameter so one uploader serves both callers: recordings use // /api/preview/upload/{init,finalize} (the default) and `rnx preview` passes // /api/v1/previews{,/finalize}. both are first-party contrast endpoints. 'packages/sootsim-engine/src/preview/presigned-upload.ts :: fetch :: `${origin}${endpoints.finalize}`': { category: 'contrast_user_action', count: 1 }, 'packages/sootsim-engine/src/preview/presigned-upload.ts :: fetch :: `${origin}${endpoints.init}`': { category: 'contrast_user_action', count: 1 }, 'packages/sootsim-engine/src/preview/presigned-upload.ts :: fetch :: job.url': { category: 'presigned_upload', count: 1, }, 'packages/sootsim-engine/src/preview/warm-bundle-cache.ts :: fetch :: url': { category: 'guest_app_network', count: 1, }, "packages/sootsim-engine/src/projects/projects-persistence.ts :: fetch :: '/__sootsim/shared-config'": { category: 'same_origin_runtime', count: 1 }, 'packages/sootsim-engine/src/react-native/index.ts :: fetch :: resource': { category: 'guest_app_network', count: 1, }, 'packages/sootsim-engine/src/react-native/Keyboard.ts :: fetch :: url': { category: 'guest_app_network', count: 1, }, 'packages/sootsim-engine/src/render-worker/canvaskit-shared.ts :: fetch :: fontUrl': { category: 'same_origin_runtime', count: 1, }, 'packages/sootsim-engine/src/render-worker/canvaskit-shared.ts :: fetch :: pipelineCacheUrl': { category: 'same_origin_runtime', count: 1 }, 'packages/sootsim-engine/src/render-worker/canvaskit-shared.ts :: fetch :: wasmUrl': { category: 'same_origin_runtime', count: 1, }, // the cpu simulator iframe document's dedicated worker loads the headless // canvaskit wasm and the font and asset manifests from the page's own // `/deps-native/engine-assets/` directory. 'packages/sootsim-engine/src/headless/browser-tenant-worker.ts :: fetch :: new URL(name, assetBase).href': { category: 'same_origin_runtime', count: 1 }, // hashes its own delivered script to identify the engine to display clients. 'packages/sootsim-engine/src/headless/browser-tenant-worker.ts :: fetch :: workerSourceUrl': { category: 'same_origin_runtime', count: 1 }, // the tenant build's built-in app chunk prefetch list and the chunk bodies // it names, resolved against the worker chunk's own url. 'packages/sootsim-engine/src/render-worker/app-in-worker.tsx :: fetch :: listUrl': { category: 'same_origin_runtime', count: 1, }, 'packages/sootsim-engine/src/render-worker/app-in-worker.tsx :: fetch :: new URL(file, listUrl)': { category: 'same_origin_runtime', count: 1 }, 'packages/sootsim-engine/src/render-worker/host.ts :: fetch :: msg.fallbackUrl': { category: 'guest_app_network', count: 1, }, 'packages/sootsim-engine/src/render-worker/video-host.ts :: fetch :: uri': { category: 'guest_app_network', count: 1, }, 'packages/sootsim-engine/src/render-worker/worker-font-loader.ts :: fetch :: url': { category: 'same_origin_runtime', count: 1, }, 'packages/sootsim-engine/src/render-worker/worker-handlers/lifecycle.ts :: fetch :: url': { category: 'guest_app_network', count: 1 }, 'packages/sootsim-engine/src/screenshots/capture-store.ts :: fetch :: cap.dataUrl': { category: 'same_origin_runtime', count: 1, }, 'packages/sootsim-engine/src/menu/menuActions.ts :: fetch :: `http://127.0.0.1:${port}/__send-to-box`': { category: 'local_development', count: 1 }, "packages/sootsim-engine/src/screenshots/capture-store.ts :: fetch :: captureApiUrl(ref, 'grid')": { category: 'contrast_user_action', count: 1 }, 'packages/sootsim-engine/src/screenshots/capture-store.ts :: fetch :: captureApiUrl(ref, variant)': { category: 'contrast_user_action', count: 1 }, 'packages/sootsim-engine/src/screenshots/capture-store.ts :: fetch :: captureApiUrl(ref)': { category: 'contrast_user_action', count: 1 }, "packages/sootsim-engine/src/server-discovery.ts :: fetch :: '/__server-scan'": { category: 'local_development', count: 1, }, 'packages/sootsim-engine/src/server-discovery.ts :: fetch :: `${gatewayOrigin}/__scan`': { category: 'local_development', count: 1 }, "packages/sootsim-engine/src/settings/persistence.ts :: fetch :: '/__sootsim/shared-config'": { category: 'same_origin_runtime', count: 2 }, 'packages/sootsim/cli/app-fonts.ts :: fetch :: `${origin}/`': { category: 'local_development', count: 1, }, 'packages/sootsim/cli/app-fonts.ts :: fetch :: url': { category: 'local_development', count: 1, }, 'packages/sootsim/cli/app-splash.ts :: fetch :: url': { category: 'local_development', count: 1, }, 'packages/sootsim/cli/commands/control.ts :: fetch :: imageUrl': { category: 'local_development', count: 1, }, 'packages/sootsim/cli/commands/control.ts :: fetch :: spec.url': { category: 'local_development', count: 1, }, 'packages/sootsim/cli/commands/control.ts :: websocket :: `ws://127.0.0.1:${port}`': { category: 'local_development', count: 1, }, 'packages/sootsim/cli/commands/cpu-profile.ts :: fetch :: `http://localhost:${cdpPort}/json/list`': { category: 'local_development', count: 1 }, 'packages/sootsim/cli/commands/cpu-profile.ts :: websocket :: wsUrl': { category: 'local_development', count: 1, }, 'packages/sootsim/cli/commands/install-desktop.ts :: fetch :: url': { category: 'electron_download', count: 1, }, "packages/sootsim/cli/commands/login.ts :: fetch :: `${origin.replace(/\\/$/, '')}/api/dev-login`": { category: 'local_development', count: 1 }, "packages/sootsim/cli/commands/logout.ts :: fetch :: `${current.origin.replace(/\\/$/, '')}/api/auth/sign-out`": { category: 'contrast_user_action', count: 1 }, "packages/sootsim/cli/commands/maestro-generate.ts :: fetch :: `${args.origin.replace(/\\/$/, '')}/api/llm`": { category: 'contrast_user_action', count: 1 }, 'packages/sootsim/cli/commands/report-issue.ts :: fetch :: url': { category: 'contrast_user_action', count: 1, }, 'packages/sootsim/cli/commands/runtime.ts :: websocket :: `ws://127.0.0.1:${port}`': { category: 'local_development', count: 1, }, 'packages/sootsim/cli/commands/screenshots-capture.ts :: fetch :: `${apiOrigin}/api/sootsim/screenshot-capture?ref=${encodeURIComponent()}&org=${encodeURIComponent()}`': { category: 'contrast_user_action', count: 1 }, 'packages/sootsim/cli/commands/screenshots-capture.ts :: fetch :: `${apiOrigin}/api/sootsim/screenshot-decks?org=${encodeURIComponent()}&repo=${encodeURIComponent()}`': { category: 'contrast_user_action', count: 1 }, 'packages/sootsim/cli/cli-update.ts :: fetch :: url': { category: 'cli_delivery', count: 1, }, 'packages/sootsim/scripts/publish-cli-binaries.ts :: fetch :: `${url}?sha256=${digest}`': { category: 'cli_release_publish', count: 1, }, 'packages/sootsim/scripts/publish-cli-binaries.ts :: fetch :: `${publicOrigin}/${channelKey}?sourceSha=${sourceSha}`': { category: 'cli_release_publish', count: 1, }, 'packages/sootsim/cli/commands/upload.ts :: fetch :: url': { category: 'presigned_upload', count: 1, }, 'packages/sootsim/cli/recording-access.ts :: fetch :: `${billingOrigin}/api/sootsim/billing/subscription`': { category: 'contrast_user_action', count: 1 }, "packages/sootsim/cli/run-registry.ts :: fetch :: `${args.origin.replace(/\\/$/, '')}/api/sootsim/test-runs`": { category: 'contrast_user_action', count: 1 }, 'packages/sootsim/cli/runtime-notes.ts :: fetch-alias :: url': { category: 'release_notes', count: 1, }, 'packages/sootsim/cli/telemetry.ts :: fetch :: `${config.host}/batch/`': { category: 'bounded_diagnostics', count: 1, }, // the inventory records the call expression, not the URL, so this entry can // no longer show its own destination. bridgeWsUrl() is loopback-only today; // when a cloud box makes it return a remote wss:// endpoint, that egress // would land under this same local_development entry and go unnoticed. split // the declaration then, rather than letting one line cover both. 'packages/sootsim/cli/ws-bridge.ts :: websocket :: bridgeWsUrl(wsPort)': { category: 'local_development', count: 1, }, 'packages/sootsim/src/agent-daemon-client.ts :: socket.connect :: port': { category: 'local_development', count: 1, }, 'packages/sootsim/src/agent-daemon-client.ts :: websocket :: `ws://127.0.0.1:${this.port}`': { category: 'local_development', count: 1 }, "packages/sootsim/src/auth/shared-session.ts :: fetch :: `${origin.replace(/\\/$/, '')}/api/auth/me`": { category: 'contrast_user_action', count: 1 }, "packages/sootsim/src/dev-bundle-resolution.ts :: fetch :: '/__server-scan'": { category: 'local_development', count: 1, }, 'packages/sootsim/src/dev-bundle-resolution.ts :: fetch :: `/__fetch-proxy?url=${encodeURIComponent()}`': { category: 'guest_app_network', count: 1 }, 'packages/sootsim/src/dev-bundle-resolution.ts :: fetch :: targetUrl': { category: 'guest_app_network', count: 1, }, 'packages/sootsim/src/dev-server-open.ts :: http.get :: `http://127.0.0.1:${port}/__server-scan`': { category: 'local_development', count: 1 }, "packages/sootsim/src/host/fetch-proxy-handler.ts :: transport.request :: { hostname: normalizeHostname(targetUrl.hostname), port: targetUrl.port || (targetUrl.protocol === 'https:' ? 443 : 80), path: targetUrl.pathname + targetUrl.search, method, headers: targetHeaders, ...loopbackTlsOptions(targetUrl), }": { category: 'guest_app_network', count: 1 }, "packages/sootsim/src/host/fetch-proxy-handler.ts :: transport.request :: { hostname: normalizeHostname(targetUrl.hostname), port: targetUrl.port || (targetUrl.protocol === 'https:' ? 443 : 80), path: targetUrl.pathname + targetUrl.search, method: req.method, headers: fwdHeaders, ...loopbackTlsOptions(targetUrl), }": { category: 'guest_app_network', count: 1 }, 'packages/sootsim/src/host/websocket-proxy.ts :: node-request :: upstreamRequestUrl': { category: 'guest_app_network', count: 1, }, 'packages/sootsim/src/screenshots/org-deck.ts :: fetch :: url': { category: 'contrast_user_action', count: 1, }, } interface DetectedOutboundCall { key: string count: number } function sourceFilesUnder(root: string): string[] { const files: string[] = [] const pending = [root] while (pending.length > 0) { const current = pending.pop() if (!current) continue for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const fullPath = path.join(current, entry.name) if (entry.isDirectory()) { if (entry.name !== 'vendor' && entry.name !== 'test-fixtures') pending.push(fullPath) continue } if (/\.(?:ts|tsx)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) { files.push(fullPath) } } } return files.sort() } interface NetworkNames { fetch: Set httpClient: Set netClient: Set socket: Set nodeRequest: Set nodeGet: Set } function networkNames(source: Program): NetworkNames { const names: NetworkNames = { fetch: new Set(['fetch']), httpClient: new Set(['http', 'https']), netClient: new Set(['net']), socket: new Set(), nodeRequest: new Set(), nodeGet: new Set(), } const isClientExpression = (node: Expression, clients: Set): boolean => { if (node.type === 'Identifier') return clients.has(node.name) if (node.type === 'ParenthesizedExpression') return isClientExpression(node.expression, clients) return ( node.type === 'ConditionalExpression' && isClientExpression(node.consequent, clients) && isClientExpression(node.alternate, clients) ) } const collectFetchParameters = (parameters: ParamPattern[]): void => { for (const parameter of parameters) { if ( parameter.type === 'AssignmentPattern' && parameter.left.type === 'Identifier' && parameter.right.type === 'Identifier' && names.fetch.has(parameter.right.name) ) { names.fetch.add(parameter.left.name) } } } new Visitor({ ArrowFunctionExpression(node) { collectFetchParameters(node.params) }, FunctionDeclaration(node) { collectFetchParameters(node.params) }, FunctionExpression(node) { collectFetchParameters(node.params) }, ImportDeclaration(node) { const moduleName = node.source.value.replace(/^node:/, '') if (moduleName === 'http' || moduleName === 'https') { for (const specifier of node.specifiers) { if (specifier.type === 'ImportSpecifier') { const imported = specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value if (imported === 'request') names.nodeRequest.add(specifier.local.name) if (imported === 'get') names.nodeGet.add(specifier.local.name) } if ( specifier.type === 'ImportDefaultSpecifier' || specifier.type === 'ImportNamespaceSpecifier' ) { names.httpClient.add(specifier.local.name) } } } if (moduleName === 'net') { for (const specifier of node.specifiers) { if ( specifier.type === 'ImportDefaultSpecifier' || specifier.type === 'ImportNamespaceSpecifier' ) { names.netClient.add(specifier.local.name) } } } }, VariableDeclarator(node) { if (node.id.type !== 'Identifier' || !node.init) return if (node.init.type === 'Identifier' && names.fetch.has(node.init.name)) { names.fetch.add(node.id.name) } if (isClientExpression(node.init, names.httpClient)) { names.httpClient.add(node.id.name) } if (isClientExpression(node.init, names.netClient)) { names.netClient.add(node.id.name) } if (isClientExpression(node.init, names.nodeRequest)) { names.nodeRequest.add(node.id.name) } if ( node.init.type === 'NewExpression' && node.init.callee.type === 'MemberExpression' && !node.init.callee.computed && node.init.callee.property.type === 'Identifier' && node.init.callee.property.name === 'Socket' && isClientExpression(node.init.callee.object, names.netClient) ) { names.socket.add(node.id.name) } }, }).visit(source) return names } function callKind( node: CallExpression | NewExpression, names: NetworkNames, source: string, ): string | null { if (node.type === 'NewExpression' && node.callee.type === 'Identifier') { if (node.callee.name === 'WebSocket') return 'websocket' if (node.callee.name === 'EventSource') return 'event-source' if (node.callee.name === 'XMLHttpRequest') return 'xml-http-request' return null } if (node.type !== 'CallExpression') return null const expression = node.callee if (expression.type === 'Identifier') { if (names.fetch.has(expression.name)) { return expression.name === 'fetch' ? 'fetch' : 'fetch-alias' } if (names.nodeRequest.has(expression.name)) return 'node-request' if (names.nodeGet.has(expression.name)) return 'node-get' return null } if ( expression.type !== 'MemberExpression' || expression.computed || expression.property.type !== 'Identifier' ) { return null } const method = expression.property.name if (method === 'fetch') return 'fetch' if (method === 'sendBeacon') return 'send-beacon' const ownerExpression = expression.object const owner = source .slice(ownerExpression.start, ownerExpression.end) .replace(/\s+/g, ' ') const knownHttpClient = ownerExpression.type === 'Identifier' && names.httpClient.has(ownerExpression.name) const knownNetClient = ownerExpression.type === 'Identifier' && names.netClient.has(ownerExpression.name) const knownSocket = ownerExpression.type === 'Identifier' && names.socket.has(ownerExpression.name) if (method === 'request' && (knownHttpClient || owner === 'transport')) { return `${owner}.request` } if (method === 'get' && knownHttpClient) return `${owner}.get` if ( (method === 'connect' || method === 'createConnection') && (knownNetClient || knownSocket) ) { return `${owner}.${method}` } return null } function firstArgument(node: CallExpression | NewExpression, source: string): string { const first = node.arguments[0] if (!first) return '' const text = source.slice(first.start, first.end).replace(/\s+/g, ' ').trim() // the key is source text, so renaming a local used as a path id used to change // it and red main on a commit that moved no destination. what justifies erasing // the argument to encodeURIComponent is that it is a local NAME rather than a // destination, not that it is a path segment: at dev-bundle-resolution.ts and // backend-origin.ts the argument is a whole target url behind a proxy path. a // name carries no destination either way, so erasing it loses nothing, while // the host expression and the path itself still have to be re-declared. return text.replace(/encodeURIComponent\([^()]*\)/g, 'encodeURIComponent()') } export function detectOutboundCalls(repositoryRoot: string): DetectedOutboundCall[] { const roots = [ path.join(repositoryRoot, 'packages/sootsim/cli'), path.join(repositoryRoot, 'packages/sootsim/scripts'), path.join(repositoryRoot, 'packages/sootsim/src'), path.join(repositoryRoot, 'packages/sootsim-engine/src'), path.join(repositoryRoot, 'packages/sootsim-engine/src-electron'), path.join(repositoryRoot, 'packages/contrast-runtime-delivery/src'), path.join(repositoryRoot, 'packages/rnx-cloud-box/src'), ] const counts = new Map() for (const file of roots.flatMap(sourceFilesUnder)) { if (file.endsWith('/outbound-endpoints.ts')) continue const source = fs.readFileSync(file, 'utf8') const program = parseSync(file, source, { lang: file.endsWith('.tsx') ? 'tsx' : 'ts', }).program const names = networkNames(program) const relative = path.relative(repositoryRoot, file).split(path.sep).join('/') const recordCall = (node: CallExpression | NewExpression): void => { const kind = callKind(node, names, source) if (kind) { const key = `${relative} :: ${kind} :: ${firstArgument(node, source)}` counts.set(key, (counts.get(key) ?? 0) + 1) } } new Visitor({ CallExpression: recordCall, NewExpression: recordCall, }).visit(program) } return [...counts.entries()] .map(([key, count]) => ({ key, count })) .sort((a, b) => a.key.localeCompare(b.key)) } export function undeclaredOutboundCalls(repositoryRoot: string): string[] { const detected = detectOutboundCalls(repositoryRoot) const failures: string[] = [] const seen = new Set() for (const call of detected) { seen.add(call.key) const declaration = DECLARED_OUTBOUND_CALLS[call.key] if (!declaration) { failures.push(`undeclared: ${call.key} (count ${call.count})`) continue } if (declaration.count !== call.count) { failures.push( `count changed: ${call.key} (declared ${declaration.count}, found ${call.count})`, ) } } for (const key of Object.keys(DECLARED_OUTBOUND_CALLS)) { if (!seen.has(key)) failures.push(`stale declaration: ${key}`) } return failures }