diff --git a/src/application/BrowserEvidence.test.ts b/src/application/BrowserEvidence.test.ts new file mode 100644 index 0000000000..a48d36a062 --- /dev/null +++ b/src/application/BrowserEvidence.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { createBrowserEvidence } from "./BrowserEvidence.js"; +import { + analyzeWebBundleInputSchema, + webBundleAnalysisSchema, +} from "../domain/webBundleAnalysis.js"; + +const provider = { id: "fixture", name: "Fixture", version: "1" }; + +const result = webBundleAnalysisSchema.parse({ + capture: { + target_url: "https://app.example.test/dashboard?token=redacted", + target_origin: "https://app.example.test", + scripts_observed: 0, + scripts_analyzed: 0, + source_artifacts: [], + }, + observations: { + chunks: { nodes: [], edges: [] }, + routes: [], + endpoints: [], + webmcp_declarations: [], + source_maps: { + status: "not_requested", + requested: 0, + processed: 0, + items: [], + }, + }, + inferences: [], + unknowns: [], + completeness: { + status: "complete", + parsed_scripts: 0, + parse_failures: 0, + visited_ast_nodes: 0, + }, + limitations: [], +}); + +const input = (allowedOrigins: string[]) => + analyzeWebBundleInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "page-1", + observation_ms: 0, + allowed_origins: allowedOrigins, + }); + +describe("web bundle Evidence origin scope", () => { + it("records the captured target origin when the request uses the default scope", () => { + const evidence = createBrowserEvidence( + "analyze_web_bundle", + input([]), + result, + provider, + ); + + expect(evidence.parameters.allowed_origins).toEqual([ + "https://app.example.test", + ]); + }); + + it("preserves an explicit caller origin scope", () => { + const evidence = createBrowserEvidence( + "analyze_web_bundle", + input(["https://explicit.example.test"]), + result, + provider, + ); + + expect(evidence.parameters.allowed_origins).toEqual([ + "https://explicit.example.test", + ]); + }); + + it("does not infer an origin from a URL without one", () => { + const evidence = createBrowserEvidence( + "analyze_web_bundle", + input([]), + webBundleAnalysisSchema.parse({ + ...result, + capture: { + ...result.capture, + target_url: "file:///tmp/app.html", + target_origin: null, + }, + }), + provider, + ); + + expect(evidence.parameters.allowed_origins).toEqual([]); + }); + + it("does not infer a non-HTTP origin from a bundle URL", () => { + const evidence = createBrowserEvidence( + "analyze_web_bundle", + input([]), + webBundleAnalysisSchema.parse({ + ...result, + capture: { + ...result.capture, + target_url: "ftp://host/app", + target_origin: null, + }, + }), + provider, + ); + + expect(evidence.parameters.allowed_origins).toEqual([]); + }); +}); diff --git a/src/application/BrowserEvidence.ts b/src/application/BrowserEvidence.ts index b38c745b41..311d8ac549 100644 --- a/src/application/BrowserEvidence.ts +++ b/src/application/BrowserEvidence.ts @@ -198,6 +198,8 @@ const browserResultTargetOrigin = ( typeof result.inspection.target.origin === "string" ) return result.inspection.target.origin; + if ("capture" in result && "target_origin" in result.capture) + return result.capture.target_origin ?? undefined; if (!("target" in result)) return undefined; if ("origin" in result.target) return result.target.origin; if ("initial_url" in result.target) diff --git a/src/domain/browserObservation.test.ts b/src/domain/browserObservation.test.ts index 2de2a7cd28..ddf03ddad6 100644 --- a/src/domain/browserObservation.test.ts +++ b/src/domain/browserObservation.test.ts @@ -4,6 +4,7 @@ import { browserEndpointSchema, browserObservationEndpointSchema, browserOriginSchema, + recordedBrowserOriginSchema, recordedBrowserObservationEndpointSchema, sanitizeEndpointCandidate, sanitizeBrowserUrl, @@ -29,6 +30,20 @@ describe("browser observation contracts", () => { expect(browserOriginSchema.safeParse(value).success, value).toBe(false); }); + it("accepts only canonical recorded HTTP origins", () => { + expect(recordedBrowserOriginSchema.parse("https://app.example.test")).toBe( + "https://app.example.test", + ); + for (const value of [ + "https://%2A.example.com", + "https://*.example.com", + "https://Example.COM:443", + ]) + expect(recordedBrowserOriginSchema.safeParse(value).success, value).toBe( + false, + ); + }); + it("accepts only literal loopback HTTP CDP endpoints", () => { expect(browserEndpointSchema.parse("http://127.0.0.1:9222")).toBe( "http://127.0.0.1:9222", diff --git a/src/domain/browserObservation.ts b/src/domain/browserObservation.ts index 2cd5c3b63b..691b68c70a 100644 --- a/src/domain/browserObservation.ts +++ b/src/domain/browserObservation.ts @@ -44,6 +44,18 @@ export const browserOriginSchema = z return origin; }); +/** Already-normalized HTTP(S) origin retained in provider output. */ +export const recordedBrowserOriginSchema = z + .string() + .superRefine((value, context) => { + const parsed = browserOriginSchema.safeParse(value); + if (parsed.success && parsed.data === value) return; + context.addIssue({ + code: "custom", + message: "Expected one canonical HTTP(S) origin", + }); + }); + /** Recognize the URL API's bracketed IPv6 form and normalized bare literals. */ export const isLiteralLoopbackHostname = (hostname: string): boolean => hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1"; diff --git a/src/domain/webBundleAnalysis.ts b/src/domain/webBundleAnalysis.ts index 1cfa2355ff..a48a3bad62 100644 --- a/src/domain/webBundleAnalysis.ts +++ b/src/domain/webBundleAnalysis.ts @@ -9,7 +9,10 @@ import { optionalBrowserDeadlineMs, } from "./browserDeadline.js"; import { emptyArraySchema } from "./emptyArraySchema.js"; -import { inspectWebPageWithSourceInputSchema } from "./browserObservation.js"; +import { + inspectWebPageWithSourceInputSchema, + recordedBrowserOriginSchema, +} from "./browserObservation.js"; import { webTextArtifactMetadataSchema, webTextArtifactSchema, @@ -366,6 +369,9 @@ export const webBundleAnalysisSchema = z.object({ route_inventory: javaScriptRouteInventorySchema.exactOptional(), capture: z.object({ target_url: z.string(), + // Older saved observations omit this field; current analyses retain the + // validated HTTP(S) target origin or null when no browser origin exists. + target_origin: recordedBrowserOriginSchema.nullable().optional(), scripts_observed: z.int().min(0), scripts_analyzed: z.int().min(0), source_artifacts: z.array(webTextArtifactSummarySchema), diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index 8f4104d080..cbd1cf6424 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -1100,6 +1100,17 @@ describe("web bundle configuration and navigation parity", () => { }); describe("web bundle artifact metadata", () => { + it("retains only a validated HTTP target origin", () => { + expect( + analyzeCapturedWebBundle(inspection("export {};")).capture.target_origin, + ).toBe(origin); + + const nonHttp = inspection("export {};"); + nonHttp.target.url = "ftp://host/app"; + nonHttp.target.origin = "ftp://host"; + expect(analyzeCapturedWebBundle(nonHttp).capture.target_origin).toBeNull(); + }); + it("preserves long provider media types in artifact summaries", () => { const result = analyzeCapturedWebBundle(inspection("export {};")); const mediaType = `application/${"x".repeat(300)}`; diff --git a/src/domain/webBundleAnalyzerResult.ts b/src/domain/webBundleAnalyzerResult.ts index 2091073cd2..9d7c403c7c 100644 --- a/src/domain/webBundleAnalyzerResult.ts +++ b/src/domain/webBundleAnalyzerResult.ts @@ -2,6 +2,7 @@ import { buildJavaScriptRouteInventorySteps } from "./javascript/javascriptRoute import { completeSemanticSteps } from "./javascript/javascriptSemanticTraversal.js"; import { RETURNED_REFERENCE_WIDENING_LIMITATION } from "./javascript/javascriptSemanticCoverage.js"; import type { WebPageInspection } from "./browserObservationSchemas.js"; +import { browserOriginSchema } from "./browserObservation.js"; import { webBundleAnalysisSchema, type WebBundleAnalysis, @@ -231,15 +232,19 @@ const buildCaptureObservation = ( inspection: WebPageInspection, sourceScripts: IncludedScript[], attemptedScripts: number, -) => ({ - target_url: inspection.target.url, - scripts_observed: inspection.scripts.total, - // This preserves the historical meaning: source scripts submitted for - // analysis, including worker/deadline failures; explicitly unstarted suffixes - // are excluded. Successfully parsed scripts are counted separately below. - scripts_analyzed: attemptedScripts, - source_artifacts: sourceScripts.map(sourceArtifact), -}); +) => { + const targetOrigin = browserOriginSchema.safeParse(inspection.target.origin); + return { + target_url: inspection.target.url, + target_origin: targetOrigin.success ? targetOrigin.data : null, + scripts_observed: inspection.scripts.total, + // This preserves the historical meaning: source scripts submitted for + // analysis, including worker/deadline failures; explicitly unstarted suffixes + // are excluded. Successfully parsed scripts are counted separately below. + scripts_analyzed: attemptedScripts, + source_artifacts: sourceScripts.map(sourceArtifact), + }; +}; const sourceArtifact = (script: IncludedScript) => { if (!script.source.included) throw new TypeError("Filtered source changed");