feat(javascript): compare static endpoints with HAR captures
This commit is contained in:
1 parent
fc4c381dd7
commit
a7af4a1506
27 files changed
+2226
-18
No files matched your search
@@ -817,6 +817,7 @@ arrays and nested sources declared by the tool's input schema:
|
||||
| `match_native_functions` | `left`, `right`, `left_observations[]`, `right_observations[]` |
|
||||
| `trace_application_feature`, `trace_javascript_semantics`, `compare_source_to_bundle` | `application`; feature tracing also accepts `native_observations[]` |
|
||||
| `compare_application_versions`, `compare_javascript_export_shapes` | `left`, `right`; version comparison also accepts both native-observation arrays |
|
||||
| `compare_static_and_observed_endpoints` | `application`, `capture`; summary retains the complete comparison for `endpoint-comparison` pages |
|
||||
| `reconcile_javascript_runtime` | `static_layers[].analysis`, `runtime_observations[]` |
|
||||
| `compare_managed_members`, `verify_managed_native_boundaries`, `import_managed_reconstruction` | Comparison `left`/`right`; verification `managed_boundaries`/`native_observations[]`; reconstruction `static_members` |
|
||||
| `project_managed_application_graph` | `managed_artifact`, `managed_members`, `managed_native_boundaries` |
|
||||
|
||||
@@ -475,6 +475,109 @@ parent envelope, capture and examined records are deduplicated onto the view.
|
||||
Portable captures must retain unique ordinals within their declared total;
|
||||
caller-selected subsets can preserve any requested ordinal order.
|
||||
|
||||
## Compare static JavaScript endpoints with HAR
|
||||
|
||||
`compare_static_and_observed_endpoints` answers which supplied static network
|
||||
references correspond to captured request groups. Supply `application` from
|
||||
`analyze_javascript_application` and `capture` from `inspect_web_network_capture`
|
||||
with HAR format. Both accept inline Evidence or exact retained references.
|
||||
For normal MCP investigations, request producer summaries, then pass their
|
||||
`parent_evidence_id` values; large inline graphs can exceed transport admission.
|
||||
Existing Evidence bundles can instead be loaded with `import_evidence_bundle`.
|
||||
|
||||
```json
|
||||
{
|
||||
"application": {
|
||||
"kind": "retained-evidence",
|
||||
"evidence_id": "ev_<application-digest>"
|
||||
},
|
||||
"capture": {
|
||||
"kind": "retained-evidence",
|
||||
"evidence_id": "ev_<capture-digest>"
|
||||
},
|
||||
"origin": "https://api.example.test"
|
||||
}
|
||||
```
|
||||
|
||||
MCP defaults to a summary with source identities, counts, source coverage and
|
||||
the complete comparison's `parent_evidence_id`. Select `detail: "complete"` for
|
||||
all facts. An optional `view` can return a selected `endpoint-comparison` page
|
||||
in this initial call and takes precedence over `detail`; it uses the same
|
||||
projector as later retained queries. This answers a selected question without
|
||||
an extra call or requiring the client's generic view-output schema preparation.
|
||||
To read another page, call `inspect_analysis_view` on the retained parent:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"kind": "retained-evidence",
|
||||
"evidence_id": "ev_<comparison-digest>"
|
||||
},
|
||||
"view": {
|
||||
"kind": "endpoint-comparison",
|
||||
"collection": "static-endpoints",
|
||||
"statuses": ["unknown"],
|
||||
"fields": ["path", "status", "reason"],
|
||||
"offset": 0,
|
||||
"limit": 32
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Collections are `static-endpoints`, `observed-groups` and `matches`. Exact
|
||||
`endpoint_ids` or `group_ids` select batches where applicable. Fields are
|
||||
collection-specific; row identities always remain. Empty filters select none.
|
||||
Pages classify the complete source pair before filtering, and page exhaustion
|
||||
never upgrades source coverage. Observed-group pages omit potentially large
|
||||
`record_ordinals` by default; explicitly select that field when needed.
|
||||
|
||||
Each static network **observation** is matched, not observed in this capture,
|
||||
or unknown. Each unmatched observed group is reported separately. Match rows
|
||||
link stable endpoint/group identities, identify `exact` or inferred
|
||||
`template-compatible` basis, and name one `supporting_record_ordinal`. An exact
|
||||
witness identifies a real whole path within the group; other group members can
|
||||
have different literal paths. Segment-value combinations are never evidence
|
||||
that a whole path occurred. Group templates use the existing API-surface owner.
|
||||
|
||||
`origin` is a caller assertion for root-relative targets, never a captured base
|
||||
URL. Without it those targets remain unknown. Candidate values, unconfirmed
|
||||
static observations, unresolved
|
||||
expressions, non-URL GraphQL/tRPC operation names, non-HTTP targets and spellings
|
||||
requiring URL normalization remain unknown. Known method spellings and the
|
||||
EventSource/sendBeacon API definitions constrain matching; unavailable methods
|
||||
stay null and multiple possible methods remain ambiguous. Fetch options and
|
||||
XHR method arguments are not recovered by this comparison.
|
||||
If multiple observed methods prevent pairing, those groups also stay unknown
|
||||
unless another static observation establishes a match; they do not count as
|
||||
observed-without-static-match.
|
||||
|
||||
Not-observed requires an exercised origin, every parent record retained, and
|
||||
no excluded request lines. It never means hidden, unused or inaccessible.
|
||||
Source-specific limitations remain accessible on the linked source Evidence;
|
||||
the comparison reports their counts without copying unrelated source text.
|
||||
Query strings, fragments, headers and bodies are not projected. Sensitive-parent
|
||||
refusals are the same as API-surface. Sources, grouping, whole-path witnesses
|
||||
and derived rows share its 96 MiB representation allowance. No URL is contacted,
|
||||
and this workflow does not reopen targets or rerun static analysis.
|
||||
|
||||
CLI uses the same comparison and standard Evidence publication. Put
|
||||
`application`, `capture` and optional `origin` in the input JSON; delivery uses
|
||||
flags. Complete detail is the CLI default. A summary requires a bundle so all
|
||||
facts and their source closure survive process exit:
|
||||
|
||||
```bash
|
||||
rea compare-static-and-observed-endpoints /evidence/compare-input.json \
|
||||
--detail summary --evidence_output /evidence/comparison.json --json
|
||||
```
|
||||
|
||||
Add `--view '{"kind":"endpoint-comparison","collection":"static-endpoints","statuses":["not-observed-in-capture"],"fields":["path","status","reason"]}'`
|
||||
to return that selection immediately. CLI views require summary detail and a
|
||||
bundle, preserving complete Evidence after exit.
|
||||
|
||||
CLI input can use retained IDs with `--evidence-bundle`. Later
|
||||
`inspect-analysis-view` calls can use an `evidence-file` source pointing at the
|
||||
saved bundle and the summary's complete parent ID.
|
||||
|
||||
## Environment manifest
|
||||
|
||||
Pass retained HAR Evidence to `inspect_analysis_view` with
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { compareStaticAndObservedEndpointsEvidence } from "./StaticObservedEndpointComparisonService.js";
|
||||
import { STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE } from "../../contracts/javascript/staticObservedEndpointExample.js";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
|
||||
it("supports cancellation at a cooperative boundary before publishing a result", async () => {
|
||||
const controller = new AbortController();
|
||||
const pending = compareStaticAndObservedEndpointsEvidence(
|
||||
STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE,
|
||||
undefined,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
controller.abort("stop comparison");
|
||||
expect(await pending).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
});
|
||||
|
||||
it("admits the combined representation before source authentication can clone it", async () => {
|
||||
const result = await compareStaticAndObservedEndpointsEvidence({
|
||||
...STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE,
|
||||
application: {
|
||||
...STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE.application,
|
||||
raw_result: "x".repeat(8 * 1024 * 1024),
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
operation: "compare_static_and_observed_endpoints",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("locates malformed retained capture facts at the named consumer input", async () => {
|
||||
const capture = createEvidence(
|
||||
undefined,
|
||||
STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE.capture.provider,
|
||||
{
|
||||
operation: "inspect_web_network_capture",
|
||||
parameters: { sensitive_value_count: 0 },
|
||||
result: {},
|
||||
confidence: "observed",
|
||||
},
|
||||
);
|
||||
const result = await compareStaticAndObservedEndpointsEvidence({
|
||||
...STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE,
|
||||
capture,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisInputError",
|
||||
operation: "compare_static_and_observed_endpoints",
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: ["capture", "normalized_result", "format"],
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { WebNetworkCapture } from "../../domain/webNetworkCapture.js";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { compareStaticObservedEndpointsRequestSchema } from "../../contracts/javascript/applicationWorkflowInputContracts.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import { readImmutableEvidence } from "../../domain/evidenceValidation.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { EvidenceWorkflowExecution } from "../investigation/EvidenceWorkflowExecution.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { digestCanonicalValue } from "../../domain/canonicalDigest.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "../../domain/investigationProviders.js";
|
||||
import { WebNetworkApiSurfaceAccumulator } from "../../domain/analysisView/WebNetworkApiSurfaceAccumulator.js";
|
||||
import { admitHarCaptureParent } from "../../domain/analysisView/WebNetworkProjectionAccumulator.js";
|
||||
import { compareStaticObservedEndpoints } from "../../domain/javascript/staticObservedEndpoints.js";
|
||||
import type { StaticObservedEndpointComparison } from "../../domain/javascript/staticObservedEndpointSchemas.js";
|
||||
import {
|
||||
resolveEvidenceRequest,
|
||||
type EvidenceLookup,
|
||||
} from "../EvidenceInputResolver.js";
|
||||
import {
|
||||
applicationGraphEvidenceInputError,
|
||||
parseApplicationGraphEvidence,
|
||||
type ApplicationGraphEvidenceSource,
|
||||
} from "./JavaScriptApplicationEvidenceGraph.js";
|
||||
|
||||
const OPERATION = "compare_static_and_observed_endpoints";
|
||||
interface EndpointComparisonExecution extends EvidenceWorkflowExecution {
|
||||
readonly result: StaticObservedEndpointComparison;
|
||||
}
|
||||
|
||||
interface ComparisonSources {
|
||||
readonly application: ApplicationGraphEvidenceSource;
|
||||
readonly capture: WebNetworkCapture;
|
||||
readonly captureEvidence: Evidence;
|
||||
readonly origin: string | undefined;
|
||||
}
|
||||
type ComparisonOptions = { readonly signal?: AbortSignal };
|
||||
type ComparisonFacts = Pick<
|
||||
StaticObservedEndpointComparison,
|
||||
"static_endpoints" | "observed_groups" | "matches" | "summary"
|
||||
>;
|
||||
|
||||
const readSources = (
|
||||
rawInput: unknown,
|
||||
lookup: EvidenceLookup | undefined,
|
||||
accumulator: WebNetworkApiSurfaceAccumulator,
|
||||
options: ComparisonOptions,
|
||||
): Result<ComparisonSources, AnalysisError> => {
|
||||
const request = resolveEvidenceRequest(
|
||||
OPERATION,
|
||||
rawInput,
|
||||
compareStaticObservedEndpointsRequestSchema,
|
||||
lookup,
|
||||
{
|
||||
...options,
|
||||
readEvidence: (value) => {
|
||||
const admitted = accumulator.admitInlineEvidence(value);
|
||||
return admitted.ok ? readImmutableEvidence(value) : admitted;
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!request.ok) return request;
|
||||
const input = request.value.input;
|
||||
const source = parseApplicationGraphEvidence(input.application, [
|
||||
"application",
|
||||
]);
|
||||
if (!source.ok)
|
||||
return err(applicationGraphEvidenceInputError(OPERATION, source.error));
|
||||
if (source.value.kind !== "static-application")
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["application"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Use analyze_javascript_application Evidence; reconciled, managed and web-bundle graphs are outside this comparison's static scope.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const captureEvidence = input.capture;
|
||||
const admitted = admitHarCaptureParent(
|
||||
{
|
||||
evidenceId: captureEvidence.evidence_id,
|
||||
operation: captureEvidence.operation,
|
||||
predicateType: captureEvidence.predicate_type,
|
||||
normalizedResult: captureEvidence.normalized_result,
|
||||
subject: captureEvidence.subject,
|
||||
parameters: captureEvidence.parameters,
|
||||
limitations: captureEvidence.limitations,
|
||||
},
|
||||
"api-surface",
|
||||
accumulator,
|
||||
{ operation: OPERATION, sourceField: "capture" },
|
||||
);
|
||||
if (!admitted.ok) return admitted;
|
||||
return ok({
|
||||
application: source.value,
|
||||
capture: admitted.value,
|
||||
captureEvidence,
|
||||
origin: input.origin,
|
||||
});
|
||||
};
|
||||
|
||||
const sourceCoverage = (
|
||||
{ application, capture, captureEvidence }: ComparisonSources,
|
||||
excluded: number,
|
||||
): StaticObservedEndpointComparison["source_coverage"] => ({
|
||||
application: {
|
||||
status: application.graph.coverage.status,
|
||||
truncated: application.graph.coverage.truncated,
|
||||
omitted_count: application.graph.coverage.omitted_count,
|
||||
limit_count: application.graph.coverage.limits.length,
|
||||
limitation_count: new Set([
|
||||
...application.evidence.limitations,
|
||||
...application.graph.limitations,
|
||||
]).size,
|
||||
},
|
||||
capture_limitation_count: new Set([
|
||||
...captureEvidence.limitations,
|
||||
...capture.limitations,
|
||||
]).size,
|
||||
observed_records_examined: capture.records.length,
|
||||
observed_records_total: capture.total_records,
|
||||
observed_records_excluded: excluded,
|
||||
});
|
||||
|
||||
const createComparison = (
|
||||
sources: ComparisonSources,
|
||||
comparison: ComparisonFacts,
|
||||
accumulator: WebNetworkApiSurfaceAccumulator,
|
||||
): Result<EndpointComparisonExecution, AnalysisError> => {
|
||||
const { application, capture, captureEvidence, origin } = sources;
|
||||
const evidenceLinks = [
|
||||
application.evidence.evidence_id,
|
||||
captureEvidence.evidence_id,
|
||||
].sort();
|
||||
const limitations = [
|
||||
"Source-specific limitation details remain on the two source Evidence records; source_coverage reports their counts without copying unrelated capture or graph text.",
|
||||
"Not-observed-in-capture refers only to this capture; it does not establish unused, hidden, inaccessible or absent server behavior.",
|
||||
"Template-compatible pairs are inferred format matches, not handler identity or runtime execution proof. Unknown static methods are never filled from observed methods.",
|
||||
"Observed-without-static-match means no confirmed correspondence in the supplied static observations; incomplete static discovery can miss code references.",
|
||||
...(origin === undefined
|
||||
? []
|
||||
: [
|
||||
"The origin for root-relative static targets is a caller assertion, not an observed application base URL.",
|
||||
]),
|
||||
].sort();
|
||||
const admittedLimitations = accumulator.addLimitations(limitations);
|
||||
if (!admittedLimitations.ok) return admittedLimitations;
|
||||
const parameters = {
|
||||
application_evidence_id: application.evidence.evidence_id,
|
||||
capture_evidence_id: captureEvidence.evidence_id,
|
||||
origin: origin ?? null,
|
||||
};
|
||||
const result: StaticObservedEndpointComparison = {
|
||||
comparison_id: `endpoint_comparison_${digestCanonicalValue(parameters)}`,
|
||||
application: {
|
||||
evidence_id: application.evidence.evidence_id,
|
||||
graph_id: application.graph.graph_id,
|
||||
},
|
||||
capture: {
|
||||
evidence_id: captureEvidence.evidence_id,
|
||||
artifact: {
|
||||
path: capture.artifact.path,
|
||||
sha256: capture.artifact.sha256,
|
||||
},
|
||||
},
|
||||
declared_origin: origin ?? null,
|
||||
...comparison,
|
||||
source_coverage: sourceCoverage(
|
||||
sources,
|
||||
accumulator.comparisonCoverage().excluded_records,
|
||||
),
|
||||
evidence_links: evidenceLinks,
|
||||
limitations,
|
||||
};
|
||||
const evidence: Evidence = createEvidence(
|
||||
undefined,
|
||||
JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER,
|
||||
{
|
||||
predicateType: "rea.static-observed-endpoint-comparison",
|
||||
operation: OPERATION,
|
||||
parameters,
|
||||
result,
|
||||
rawResult: null,
|
||||
confidence: "inferred",
|
||||
authority: "analyst-inference",
|
||||
environment: null,
|
||||
limitations,
|
||||
evidenceLinks,
|
||||
},
|
||||
);
|
||||
return ok({
|
||||
evidence,
|
||||
result,
|
||||
sources: [application.evidence, captureEvidence],
|
||||
});
|
||||
};
|
||||
|
||||
/** Authenticate sources once, group cooperatively, then publish the completed comparison. */
|
||||
export const compareStaticAndObservedEndpointsEvidence = async (
|
||||
rawInput: unknown,
|
||||
lookup?: EvidenceLookup,
|
||||
options: ComparisonOptions = {},
|
||||
): Promise<Result<EndpointComparisonExecution, AnalysisError>> => {
|
||||
const accumulator = new WebNetworkApiSurfaceAccumulator(undefined, false, {
|
||||
operation: OPERATION,
|
||||
});
|
||||
const admitted = readSources(rawInput, lookup, accumulator, options);
|
||||
if (!admitted.ok) return admitted;
|
||||
const sources = admitted.value;
|
||||
const { capture, captureEvidence } = sources;
|
||||
for (const [index, record] of capture.records.entries()) {
|
||||
if (index % 256 === 0) {
|
||||
await setImmediate();
|
||||
if (options.signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(OPERATION, {
|
||||
cause: options.signal.reason,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const added = accumulator.add(record);
|
||||
if (!added.ok) return added;
|
||||
}
|
||||
const compared = compareStaticObservedEndpoints({
|
||||
applicationEvidenceId: sources.application.evidence.evidence_id,
|
||||
captureEvidenceId: captureEvidence.evidence_id,
|
||||
graph: sources.application.graph,
|
||||
accumulator,
|
||||
...(sources.origin === undefined ? {} : { origin: sources.origin }),
|
||||
captureComplete: capture.records.length === capture.total_records,
|
||||
});
|
||||
if (!compared.ok) return compared;
|
||||
await setImmediate();
|
||||
if (options.signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
|
||||
);
|
||||
return createComparison(sources, compared.value, accumulator);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Logger } from "pino";
|
||||
import { registerSourceToBundleCommand } from "./sourceToBundleCommand.js";
|
||||
import { registerStaticObservedEndpointsCommand } from "./staticObservedEndpointsCommand.js";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
@@ -72,6 +73,7 @@ export const registerApplicationCommands = (
|
||||
},
|
||||
});
|
||||
registerSourceToBundleCommand(cli, logger);
|
||||
registerStaticObservedEndpointsCommand(cli, logger);
|
||||
registerEvidenceJsonCommand({
|
||||
cli,
|
||||
logger,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { z } from "incur";
|
||||
import type { Logger } from "pino";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { parseCliJsonInput } from "../cliJsonInput.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { compareStaticObservedEndpointsFactsSchema } from "../contracts/javascript/applicationWorkflowInputContracts.js";
|
||||
import { getEvidenceInputContract } from "../domain/evidenceInputContract.js";
|
||||
import { endpointComparisonViewRequestSchema } from "../domain/analysisView/endpointComparisonView.js";
|
||||
import { parseAnalysisInput } from "../application/AnalysisInput.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "../domain/investigationProviders.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
import {
|
||||
evidenceBundleOption,
|
||||
runEvidenceInputCommand,
|
||||
} from "./evidenceInputCommand.js";
|
||||
import {
|
||||
analysisEvidenceDeliveryOptions,
|
||||
parseCliAnalysisEvidenceDelivery,
|
||||
publishCliAnalysisEvidence,
|
||||
} from "./analysisEvidenceDelivery.js";
|
||||
|
||||
const operation = "compare_static_and_observed_endpoints";
|
||||
const deliverySchema = z
|
||||
.object({
|
||||
detail: analysisEvidenceDeliveryOptions.detail,
|
||||
view: endpointComparisonViewRequestSchema.optional(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.view !== undefined && value.detail !== "summary")
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["view"],
|
||||
message:
|
||||
"A selected page requires --detail summary and --evidence_output.",
|
||||
});
|
||||
});
|
||||
const commandOptions = z.object({
|
||||
evidenceBundle: evidenceBundleOption,
|
||||
...analysisEvidenceDeliveryOptions,
|
||||
view: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"JSON endpoint-comparison page; requires --detail summary and --evidence_output",
|
||||
),
|
||||
});
|
||||
|
||||
const runComparison = async (
|
||||
inputJson: string,
|
||||
options: z.output<typeof commandOptions>,
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
const selected = parseCliAnalysisEvidenceDelivery(
|
||||
operation,
|
||||
options,
|
||||
deliverySchema,
|
||||
);
|
||||
if (!selected.ok) return projectAnalysisError(selected.error);
|
||||
const delivery = {
|
||||
providerId: JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER.id,
|
||||
operation,
|
||||
signal,
|
||||
detail: selected.value.detail,
|
||||
evidenceOutput: selected.value.evidenceOutput,
|
||||
...(selected.value.view === undefined ? {} : { view: selected.value.view }),
|
||||
};
|
||||
const raw = await parseCliJsonInput(inputJson, operation, signal);
|
||||
if (!raw.ok) return raw.error;
|
||||
// The frame validates transport fields without cloning either Evidence before budget admission.
|
||||
const input = parseAnalysisInput(
|
||||
operation,
|
||||
raw.value,
|
||||
getEvidenceInputContract(compareStaticObservedEndpointsFactsSchema)
|
||||
.frameSchema,
|
||||
signal,
|
||||
);
|
||||
if (!input.ok) return projectAnalysisError(input.error);
|
||||
let sources: readonly Evidence[] = [];
|
||||
const result = await runEvidenceInputCommand(
|
||||
operation,
|
||||
options.evidenceBundle,
|
||||
signal,
|
||||
async (lookup) => {
|
||||
const { compareStaticAndObservedEndpointsEvidence } =
|
||||
await import("../application/javascript/StaticObservedEndpointComparisonService.js");
|
||||
const compared = await compareStaticAndObservedEndpointsEvidence(
|
||||
{ ...input.value, detail: options.detail },
|
||||
lookup,
|
||||
{ signal },
|
||||
);
|
||||
if (!compared.ok) return compared;
|
||||
sources = compared.value.sources;
|
||||
return { ok: true, value: compared.value.evidence };
|
||||
},
|
||||
);
|
||||
return publishCliAnalysisEvidence(result, {
|
||||
...delivery,
|
||||
sourceEvidence: sources,
|
||||
});
|
||||
};
|
||||
|
||||
/** Reuse standard CLI publication so summaries keep their complete source closure on disk. */
|
||||
export const registerStaticObservedEndpointsCommand = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
const name = CLI_COMMANDS.compareStaticAndObservedEndpoints;
|
||||
cli.command(name, {
|
||||
description:
|
||||
"Compare static JavaScript endpoints and HAR Evidence JSON offline; --detail summary with --evidence_output saves full facts for selected inspect-analysis-view pages",
|
||||
args: z.object({
|
||||
inputJson: z
|
||||
.string()
|
||||
.describe(
|
||||
"Inline JSON or file containing application, capture and optional origin",
|
||||
),
|
||||
}),
|
||||
options: commandOptions,
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, name, () =>
|
||||
runComparison(args.inputJson, options, signal),
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -179,6 +179,7 @@ export const CLI_COMMANDS = Object.freeze({
|
||||
traceApplicationFeature: "trace-application-feature",
|
||||
traceJavaScriptSemantics: "trace-javascript-semantics",
|
||||
compareApplicationVersions: "compare-application-versions",
|
||||
compareStaticAndObservedEndpoints: "compare-static-and-observed-endpoints",
|
||||
compareSourceToBundle: "compare-source-to-bundle",
|
||||
compareJavaScriptExportShapes: "compare-javascript-export-shapes",
|
||||
buildReconstructionObligationLedger: "build-reconstruction-obligation-ledger",
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
featureTraceViewResultSchema,
|
||||
sourceToBundleSummaryViewResultSchema,
|
||||
sourceToBundlePageViewResultSchema,
|
||||
endpointComparisonSummaryViewResultSchema,
|
||||
endpointComparisonPageViewResultSchema,
|
||||
} from "../domain/analysisView/analysisView.js";
|
||||
import { staticObservedEndpointComparisonSchema } from "../domain/javascript/staticObservedEndpointSchemas.js";
|
||||
import { STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE } from "./javascript/staticObservedEndpointExample.js";
|
||||
import { applicationVersionComparisonResultSchema } from "../domain/javascript/javascriptApplicationVersionComparisonSchemas.js";
|
||||
import { applicationFeatureTraceResultSchema } from "../domain/javascript/javascriptFeatureTraceSchemas.js";
|
||||
import { javaScriptSemanticTraceResultSchema } from "../domain/javascript/javascriptSemanticTraceSchemas.js";
|
||||
@@ -13,6 +17,7 @@ import { javaScriptExportShapeComparisonResultSchema } from "../domain/javascrip
|
||||
import { sourceToBundleComparisonResultSchema } from "../domain/javascript/sourceToBundleComparisonSchemas.js";
|
||||
import {
|
||||
compareApplicationVersionsRequestSchema,
|
||||
compareStaticObservedEndpointsRequestSchema,
|
||||
compareJavaScriptExportShapesRequestSchema,
|
||||
compareSourceToBundleRequestSchema,
|
||||
traceApplicationFeatureToolRequestSchema,
|
||||
@@ -207,6 +212,27 @@ export const APPLICATION_TOOL_CONTRACTS = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "compare_static_and_observed_endpoints",
|
||||
...toolContractMetadata("compare_static_and_observed_endpoints"),
|
||||
description:
|
||||
"Compare authenticated analyze_javascript_application and HAR Evidence, inline or by exact retained reference. No files, targets or URLs are acquired. Default summary retains the full comparison; optional view returns an endpoint-comparison page in this call (takes precedence over detail). inspect_analysis_view kind endpoint-comparison pages static-endpoints, observed-groups or matches with filters and fields. detail complete returns all facts. Pairings identify exact or inferred template-compatible basis; candidate/partial targets and ambiguous methods remain unknown. Root-relative targets need a caller-declared origin. Not-observed requires an exercised origin, every parent record retained and no excluded records; it never means hidden, unused or inaccessible. Copies no query strings, fragments, headers or bodies. Sensitive-declaration parents are refused; the shared 96 MiB representation budget includes source admission and derived state.",
|
||||
kind: "application",
|
||||
inputSchema: compareStaticObservedEndpointsRequestSchema,
|
||||
outputSchema: evidenceResultOf(
|
||||
z.union([
|
||||
staticObservedEndpointComparisonSchema,
|
||||
endpointComparisonSummaryViewResultSchema,
|
||||
endpointComparisonPageViewResultSchema,
|
||||
]),
|
||||
),
|
||||
examples: [
|
||||
{
|
||||
title: "Compare retained static endpoints with an offline HAR",
|
||||
input: STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "compare_source_to_bundle",
|
||||
...toolContractMetadata("compare_source_to_bundle"),
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
import { sourceToBundleViewRequestSchema } from "../../domain/analysisView/sourceToBundleView.js";
|
||||
import { featureTraceViewRequestSchema } from "../../domain/analysisView/featureTraceView.js";
|
||||
import { z } from "zod";
|
||||
import { recordedBrowserOriginSchema } from "../../domain/browserObservation.js";
|
||||
import { endpointComparisonViewRequestSchema } from "../../domain/analysisView/endpointComparisonView.js";
|
||||
|
||||
import { evidenceInputSchema } from "../../domain/evidenceReference.js";
|
||||
import { compareJavaScriptExportShapesInputSchema } from "../../domain/javascript/javascriptExportShapeComparisonSchemas.js";
|
||||
@@ -51,6 +53,31 @@ const compareApplicationVersionsFacts = {
|
||||
right_native_observations: z.array(evidenceInputSchema).default([]),
|
||||
} as const;
|
||||
|
||||
/** Compare existing static Application Graph and HAR Evidence without acquiring targets. */
|
||||
export const compareStaticObservedEndpointsFactsSchema = z.strictObject({
|
||||
application: evidenceInputSchema,
|
||||
capture: evidenceInputSchema,
|
||||
origin: recordedBrowserOriginSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Caller-declared canonical HTTP(S) origin for root-relative static targets; omit to keep their origin unknown. This never contacts the origin.",
|
||||
),
|
||||
});
|
||||
export const compareStaticObservedEndpointsRequestSchema =
|
||||
compareStaticObservedEndpointsFactsSchema.extend({
|
||||
view: endpointComparisonViewRequestSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Return this selected page in the initial call, retaining the complete comparison. An explicit view takes precedence over detail.",
|
||||
),
|
||||
detail: z
|
||||
.enum(["summary", "complete"])
|
||||
.default("summary")
|
||||
.describe(
|
||||
"Summary retains the complete comparison and returns counts and source coverage. Use inspect_analysis_view kind endpoint-comparison for selected rows; complete returns all facts inline.",
|
||||
),
|
||||
});
|
||||
|
||||
/** Application comparison request accepting inline or same-session application Evidence. */
|
||||
export const compareApplicationVersionsRequestSchema = z.strictObject({
|
||||
...compareApplicationVersionsFacts,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "./javascriptRuntimeReconciliationExample.js";
|
||||
|
||||
/** Portable source records demonstrate admission, without claiming a real capture. */
|
||||
export const STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE = {
|
||||
application: JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE,
|
||||
capture: createEvidence(
|
||||
undefined,
|
||||
{ id: "rea-example-har", name: "Example HAR", version: "1" },
|
||||
{
|
||||
operation: "inspect_web_network_capture",
|
||||
parameters: { sensitive_value_count: 0 },
|
||||
result: {
|
||||
artifact: { path: "/capture.har", sha256: null, bytes: 0 },
|
||||
format: "har",
|
||||
decoder: { id: "rea-example-har", name: "Example HAR", version: "1" },
|
||||
container: {
|
||||
reported: {},
|
||||
numeric_literals: [],
|
||||
redactions: [],
|
||||
records_pointer: "/log/entries",
|
||||
},
|
||||
total_records: 0,
|
||||
records: [],
|
||||
runtime_attribution: "unknown",
|
||||
limitations: ["Empty illustrative capture, not a runtime measurement."],
|
||||
},
|
||||
confidence: "observed",
|
||||
},
|
||||
),
|
||||
origin: "https://example.test",
|
||||
};
|
||||
@@ -769,6 +769,7 @@ const TOOL_EFFECT_AUDIT: Readonly<Record<string, EffectAudit>> = {
|
||||
trace_application_feature: evidence,
|
||||
trace_javascript_semantics: evidence,
|
||||
compare_application_versions: evidence,
|
||||
compare_static_and_observed_endpoints: evidence,
|
||||
search_guidance: effects(),
|
||||
read_guidance_section: effects(),
|
||||
guided_workflows: effects(),
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
textBytes,
|
||||
type RouteRule,
|
||||
type RouteTemplate,
|
||||
type RecordedRequestLine,
|
||||
} from "./webNetworkRouteTemplates.js";
|
||||
|
||||
type ApiSurfaceFields = {
|
||||
@@ -35,6 +36,8 @@ interface GroupState {
|
||||
readonly method: string;
|
||||
readonly origin: string;
|
||||
readonly path_template: string;
|
||||
readonly rules: string;
|
||||
readonly paths?: Map<string, number>;
|
||||
readonly segments: readonly {
|
||||
readonly segment_index: number;
|
||||
readonly rule: RouteRule;
|
||||
@@ -47,6 +50,20 @@ interface GroupState {
|
||||
}
|
||||
type Buckets = Map<string, Map<string, Map<string, Map<string, GroupState>>>>;
|
||||
|
||||
/** Borrowed group facts; exact membership retains whole paths, never segment products. */
|
||||
export interface ApiSurfaceComparisonGroup {
|
||||
readonly method: string;
|
||||
readonly origin: string;
|
||||
readonly path_template: string;
|
||||
readonly rules: string;
|
||||
readonly templated_segments: readonly {
|
||||
readonly segment_index: number;
|
||||
readonly rule: RouteRule;
|
||||
}[];
|
||||
readonly record_ordinals: readonly number[];
|
||||
readonly exactPathOrdinal: (path: string) => number | undefined;
|
||||
}
|
||||
|
||||
/** Own group identity, deduplication and admission for the entire projection. */
|
||||
export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumulator {
|
||||
readonly #buckets: Buckets = new Map();
|
||||
@@ -57,8 +74,11 @@ export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumul
|
||||
constructor(
|
||||
maximumBytes = WEB_NETWORK_CAPTURE_LIMITS.outputBytes,
|
||||
readonly includeQueryParameterNames = false,
|
||||
private readonly comparison?: {
|
||||
readonly operation: string;
|
||||
},
|
||||
) {
|
||||
super(maximumBytes, "API-surface");
|
||||
super(maximumBytes, "API-surface", comparison?.operation);
|
||||
}
|
||||
|
||||
#exclude(
|
||||
@@ -87,15 +107,7 @@ export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumul
|
||||
if (route === null) return this.overBudget();
|
||||
const queryNames = readQueryParameterNames(line.query, this.scratch);
|
||||
if (queryNames === null) return this.overBudget();
|
||||
return this.#append(
|
||||
record.ordinal,
|
||||
line.method,
|
||||
line.origin,
|
||||
route,
|
||||
line.status,
|
||||
mediaTypeEssence(line.rawMime),
|
||||
queryNames,
|
||||
);
|
||||
return this.#append(record.ordinal, line, route, queryNames);
|
||||
} finally {
|
||||
this.releaseTemporary();
|
||||
}
|
||||
@@ -103,19 +115,20 @@ export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumul
|
||||
|
||||
#append(
|
||||
ordinal: number,
|
||||
method: string,
|
||||
origin: string,
|
||||
line: Extract<RecordedRequestLine, { readonly kind: "line" }>,
|
||||
route: RouteTemplate,
|
||||
status: number | null,
|
||||
mime: string | null,
|
||||
queryNames: ReadonlySet<string> | undefined,
|
||||
): Result<void, AnalysisError> {
|
||||
const { method, origin, path, status } = line;
|
||||
const mime = mediaTypeEssence(line.rawMime);
|
||||
let group = this.#buckets
|
||||
.get(method)
|
||||
?.get(origin)
|
||||
?.get(route.template)
|
||||
?.get(route.rules);
|
||||
let bytes = ENTRY_BYTES; // One retained record ordinal, including page output.
|
||||
if (this.comparison !== undefined && !group?.paths?.has(path))
|
||||
bytes += ENTRY_BYTES + textBytes(path.length);
|
||||
if (group === undefined) {
|
||||
bytes +=
|
||||
GROUP_BYTES +
|
||||
@@ -150,6 +163,10 @@ export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumul
|
||||
method,
|
||||
origin,
|
||||
path_template: route.template,
|
||||
rules: route.rules,
|
||||
...(this.comparison === undefined
|
||||
? {}
|
||||
: { paths: new Map<string, number>() }),
|
||||
segments: route.segments.map((segment) => ({
|
||||
segment_index: segment.segment_index,
|
||||
rule: segment.rule,
|
||||
@@ -173,6 +190,8 @@ export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumul
|
||||
group.segments[index]!.values.add(segment.value);
|
||||
}
|
||||
group.ordinals.push(ordinal);
|
||||
if (group.paths !== undefined && !group.paths.has(path))
|
||||
group.paths.set(path, ordinal);
|
||||
if (status === null || mime === null) this.#unavailableResponse = true;
|
||||
if (status !== null) group.statuses.add(status);
|
||||
if (mime !== null) group.contentTypes.add(mime);
|
||||
@@ -186,6 +205,35 @@ export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumul
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** Comparison-only inventory, borrowing admitted ordinals without page serialization. */
|
||||
*comparisonGroups(): IterableIterator<ApiSurfaceComparisonGroup> {
|
||||
if (this.comparison === undefined)
|
||||
throw new Error(
|
||||
"Exact paths must be retained when admitting comparison records",
|
||||
);
|
||||
for (const group of this.#groups)
|
||||
yield {
|
||||
method: group.method,
|
||||
origin: group.origin,
|
||||
path_template: group.path_template,
|
||||
rules: group.rules,
|
||||
templated_segments: group.segments.map(({ segment_index, rule }) => ({
|
||||
segment_index,
|
||||
rule,
|
||||
})),
|
||||
record_ordinals: group.ordinals,
|
||||
exactPathOrdinal: (path) => group.paths!.get(path),
|
||||
};
|
||||
}
|
||||
|
||||
/** Counts do not allocate the existing view's complete page or exclusion list. */
|
||||
comparisonCoverage() {
|
||||
return {
|
||||
groups: this.#groups.length,
|
||||
excluded_records: this.#excluded.length,
|
||||
};
|
||||
}
|
||||
|
||||
page(offset: number, limit: number) {
|
||||
this.#groups.sort(
|
||||
(a, b) =>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "../webNetworkCapture.js";
|
||||
import { admitAnalysisViewData } from "./analysisViewDataAdmission.js";
|
||||
import { analysisViewInputError } from "./analysisViewErrors.js";
|
||||
import { analysisViewResultInputError } from "./analysisViewParent.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import type { AnalysisViewParent } from "./analysisViewParent.js";
|
||||
import {
|
||||
ENTRY_BYTES,
|
||||
@@ -32,6 +32,7 @@ export abstract class WebNetworkProjectionAccumulator {
|
||||
protected constructor(
|
||||
readonly maximumBytes: number,
|
||||
private readonly budgetSubject: string,
|
||||
private readonly operation = "inspect_analysis_view",
|
||||
) {}
|
||||
|
||||
protected retain(bytes: number): boolean {
|
||||
@@ -72,7 +73,7 @@ export abstract class WebNetworkProjectionAccumulator {
|
||||
protected overBudget(): Result<never, AnalysisError> {
|
||||
return err(
|
||||
new AnalysisResourceConstraintError(
|
||||
"inspect_analysis_view",
|
||||
this.operation,
|
||||
"memory",
|
||||
`${this.budgetSubject} retained state and temporary work exceed the projection representation budget.`,
|
||||
{ output_bytes: this.maximumBytes },
|
||||
@@ -123,11 +124,14 @@ export const admitHarCaptureParent = (
|
||||
parent: AnalysisViewParent,
|
||||
viewKind: "api-surface" | "environment-manifest" | "environment-state",
|
||||
accumulator: WebNetworkProjectionAccumulator,
|
||||
context: { readonly operation?: string; readonly sourceField?: string } = {},
|
||||
): Result<WebNetworkCapture, AnalysisError> => {
|
||||
const operation = context.operation ?? "inspect_analysis_view";
|
||||
const sourceField = context.sourceField ?? "source";
|
||||
const unsupported = (reason: string, remediationAction?: string) =>
|
||||
err(
|
||||
new AnalysisUnsupportedTargetError(
|
||||
"inspect_analysis_view",
|
||||
operation,
|
||||
parent.evidenceId,
|
||||
reason,
|
||||
remediationAction === undefined ? undefined : { remediationAction },
|
||||
@@ -142,7 +146,15 @@ export const admitHarCaptureParent = (
|
||||
const parsed = safeParseOwnedWebNetworkCapture(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisViewResultInputError(parent.normalizedResult, parsed.error),
|
||||
analysisInputErrorFromIssues(
|
||||
operation,
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: [sourceField, "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ [sourceField]: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
if (parsed.data.format !== "har")
|
||||
return unsupported(
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { projectJavaScriptRoutesView } from "./javascriptRoutesView.js";
|
||||
import { projectWebBundleView } from "./webBundleView.js";
|
||||
import { projectSourceToBundleView } from "./sourceToBundleView.js";
|
||||
import { projectEndpointComparisonView } from "./endpointComparisonView.js";
|
||||
import { projectFeatureTraceView } from "./featureTraceView.js";
|
||||
import { projectNativeFeatureTraceView } from "./nativeFeatureTraceView.js";
|
||||
import { projectBatchDecompileView } from "./batchDecompileView.js";
|
||||
@@ -155,6 +156,8 @@ import {
|
||||
nativeFunctionSummaryViewResultSchema,
|
||||
sourceToBundleSummaryViewResultSchema,
|
||||
sourceToBundlePageViewResultSchema,
|
||||
endpointComparisonSummaryViewResultSchema,
|
||||
endpointComparisonPageViewResultSchema,
|
||||
featureTraceSummaryViewResultSchema,
|
||||
batchDecompileSummaryViewResultSchema,
|
||||
featureTraceViewResultSchema,
|
||||
@@ -224,6 +227,8 @@ export {
|
||||
nativeFunctionSummaryViewResultSchema,
|
||||
sourceToBundleSummaryViewResultSchema,
|
||||
sourceToBundlePageViewResultSchema,
|
||||
endpointComparisonSummaryViewResultSchema,
|
||||
endpointComparisonPageViewResultSchema,
|
||||
featureTraceSummaryViewResultSchema,
|
||||
batchDecompileSummaryViewResultSchema,
|
||||
featureTraceViewResultSchema,
|
||||
@@ -351,6 +356,10 @@ const retainedViewProjectors = new Map<string, RetainedViewProjector>([
|
||||
retain("inspect_gpu_code_objects", projectGpuCodeObjectsView),
|
||||
retain("match_native_functions", projectNativeFunctionMatchingView),
|
||||
retain("compare_source_to_bundle", projectSourceToBundleView),
|
||||
retain(
|
||||
"compare_static_and_observed_endpoints",
|
||||
projectEndpointComparisonView,
|
||||
),
|
||||
retain("trace_application_feature", projectFeatureTraceView),
|
||||
retain("batch_decompile", projectBatchDecompileView),
|
||||
retain("inspect_artifact", projectArtifactInventoryView),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { nativeExportsViewRequestSchema } from "./nativeExportsView.js";
|
||||
import { javaScriptRoutesViewRequestSchema } from "./javascriptRoutesView.js";
|
||||
import { webBundleViewRequestSchema } from "./webBundleView.js";
|
||||
import { sourceToBundleViewRequestSchema } from "./sourceToBundleView.js";
|
||||
import { endpointComparisonViewRequestSchema } from "./endpointComparisonView.js";
|
||||
import { featureTraceViewRequestSchema } from "./featureTraceView.js";
|
||||
import { nativeFeatureTraceViewRequestSchema } from "./nativeFeatureTraceView.js";
|
||||
import { batchDecompileViewRequestSchema } from "./batchDecompileView.js";
|
||||
@@ -72,6 +73,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
|
||||
webBundleViewRequestSchema,
|
||||
nativeFunctionMatchingViewRequestSchema,
|
||||
sourceToBundleViewRequestSchema,
|
||||
endpointComparisonViewRequestSchema,
|
||||
featureTraceViewRequestSchema,
|
||||
nativeFeatureTraceViewRequestSchema,
|
||||
batchDecompileViewRequestSchema,
|
||||
|
||||
@@ -73,6 +73,11 @@ import {
|
||||
sourceToBundleViewItemsSchema,
|
||||
sourceToBundleViewContextSchema,
|
||||
} from "./sourceToBundleView.js";
|
||||
import {
|
||||
endpointComparisonViewContextSchema,
|
||||
endpointComparisonViewItemsSchema,
|
||||
endpointComparisonViewRequestSchema,
|
||||
} from "./endpointComparisonView.js";
|
||||
import {
|
||||
featureTraceSummarySchema,
|
||||
featureTraceViewCollections,
|
||||
@@ -1017,6 +1022,21 @@ export const sourceToBundleSummaryViewResultSchema = z.strictObject({
|
||||
parent_operation: z.literal("compare_source_to_bundle"),
|
||||
summary: sourceToBundleViewContextSchema,
|
||||
});
|
||||
export const endpointComparisonSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
artifact: z.null(),
|
||||
parent_operation: z.literal("compare_static_and_observed_endpoints"),
|
||||
summary: endpointComparisonViewContextSchema,
|
||||
});
|
||||
export const endpointComparisonPageViewResultSchema = z.strictObject({
|
||||
...viewResultBase,
|
||||
...endpointComparisonViewContextSchema.shape,
|
||||
kind: z.literal("endpoint-comparison"),
|
||||
artifact: z.null(),
|
||||
parent_operation: z.literal("compare_static_and_observed_endpoints"),
|
||||
view: endpointComparisonViewRequestSchema,
|
||||
items: endpointComparisonViewItemsSchema,
|
||||
});
|
||||
export const sourceToBundlePageViewResultSchema = z.strictObject({
|
||||
...viewResultBase,
|
||||
...sourceToBundleViewContextSchema.shape,
|
||||
@@ -1146,6 +1166,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
javaScriptRoutesViewResultSchema,
|
||||
webBundlePageViewResultSchema,
|
||||
sourceToBundlePageViewResultSchema,
|
||||
endpointComparisonPageViewResultSchema,
|
||||
featureTraceViewResultSchema,
|
||||
batchDecompileViewResultSchema,
|
||||
z.strictObject({
|
||||
@@ -1241,6 +1262,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
||||
nativeFunctionMatchingSummaryViewResultSchema,
|
||||
nativeFunctionSummaryViewResultSchema,
|
||||
sourceToBundleSummaryViewResultSchema,
|
||||
endpointComparisonSummaryViewResultSchema,
|
||||
featureTraceSummaryViewResultSchema,
|
||||
batchDecompileSummaryViewResultSchema,
|
||||
webBundleSummaryViewResultSchema,
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { z } from "zod";
|
||||
import type { StaticObservedEndpointComparison } from "../javascript/staticObservedEndpointSchemas.js";
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import {
|
||||
comparedStaticEndpointSchema,
|
||||
comparedObservedGroupSchema,
|
||||
endpointMatchSchema,
|
||||
staticObservedEndpointComparisonSchema,
|
||||
} from "../javascript/staticObservedEndpointSchemas.js";
|
||||
import {
|
||||
createAnalysisViewResultReader,
|
||||
type AnalysisViewParent,
|
||||
} from "./analysisViewParent.js";
|
||||
import {
|
||||
ownedView,
|
||||
type AdmittedView,
|
||||
type SummaryAnalysisView,
|
||||
} from "./analysisViewShape.js";
|
||||
import {
|
||||
boundedViewMessages,
|
||||
completeWithinViewCoverage,
|
||||
pageViewCoverage,
|
||||
} from "./analysisViewCoverage.js";
|
||||
import { selectViewRows } from "./selectViewRows.js";
|
||||
import { analysisViewInputError } from "./analysisViewErrors.js";
|
||||
import { err, ok } from "../result.js";
|
||||
|
||||
const page = {
|
||||
kind: z.literal("endpoint-comparison"),
|
||||
offset: z.int().nonnegative().default(0),
|
||||
limit: z.int().positive().default(64),
|
||||
};
|
||||
export const endpointComparisonViewRequestSchema = z.discriminatedUnion(
|
||||
"collection",
|
||||
[
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.literal("static-endpoints"),
|
||||
endpoint_ids: z
|
||||
.array(comparedStaticEndpointSchema.shape.endpoint_id)
|
||||
.optional(),
|
||||
statuses: z.array(comparedStaticEndpointSchema.shape.status).optional(),
|
||||
fields: z.array(comparedStaticEndpointSchema.keyof()).optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.literal("observed-groups"),
|
||||
group_ids: z.array(comparedObservedGroupSchema.shape.group_id).optional(),
|
||||
statuses: z.array(comparedObservedGroupSchema.shape.status).optional(),
|
||||
fields: z.array(comparedObservedGroupSchema.keyof()).optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.literal("matches"),
|
||||
endpoint_ids: z.array(endpointMatchSchema.shape.endpoint_id).optional(),
|
||||
group_ids: z.array(endpointMatchSchema.shape.group_id).optional(),
|
||||
fields: z.array(endpointMatchSchema.keyof()).optional(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
export const endpointComparisonViewItemsSchema = z.union([
|
||||
z.array(
|
||||
comparedStaticEndpointSchema
|
||||
.partial()
|
||||
.required({ endpoint_id: true, node_id: true, observation_id: true }),
|
||||
),
|
||||
z.array(comparedObservedGroupSchema.partial().required({ group_id: true })),
|
||||
z.array(
|
||||
endpointMatchSchema
|
||||
.partial()
|
||||
.required({ match_id: true, endpoint_id: true, group_id: true }),
|
||||
),
|
||||
]);
|
||||
export const endpointComparisonViewContextSchema =
|
||||
staticObservedEndpointComparisonSchema.omit({
|
||||
static_endpoints: true,
|
||||
observed_groups: true,
|
||||
matches: true,
|
||||
limitations: true,
|
||||
});
|
||||
const readReport = createAnalysisViewResultReader(
|
||||
staticObservedEndpointComparisonSchema,
|
||||
);
|
||||
const set = (values: readonly string[] | undefined) =>
|
||||
values === undefined ? undefined : new Set(values);
|
||||
const fieldsOf = (row: object, fields: readonly string[]) => {
|
||||
const source = row as Record<string, JsonValue>;
|
||||
return Object.fromEntries(fields.map((field) => [field, source[field]!]));
|
||||
};
|
||||
|
||||
const selectComparisonRows = (
|
||||
report: StaticObservedEndpointComparison,
|
||||
view: z.output<typeof endpointComparisonViewRequestSchema>,
|
||||
) => {
|
||||
const { static_endpoints, observed_groups, matches } = report;
|
||||
let selected: {
|
||||
rows: z.output<typeof endpointComparisonViewItemsSchema>;
|
||||
total: number;
|
||||
};
|
||||
if (view.collection === "static-endpoints") {
|
||||
const ids = set(view.endpoint_ids),
|
||||
statuses = set(view.statuses);
|
||||
selected = selectViewRows(
|
||||
static_endpoints,
|
||||
view,
|
||||
(row) =>
|
||||
(ids === undefined || ids.has(row.endpoint_id)) &&
|
||||
(statuses === undefined || statuses.has(row.status)),
|
||||
(row) => ({
|
||||
...fieldsOf(
|
||||
row,
|
||||
view.fields ?? [
|
||||
"method",
|
||||
"method_basis",
|
||||
"origin",
|
||||
"origin_basis",
|
||||
"path",
|
||||
"status",
|
||||
"reason",
|
||||
],
|
||||
),
|
||||
endpoint_id: row.endpoint_id,
|
||||
node_id: row.node_id,
|
||||
observation_id: row.observation_id,
|
||||
}),
|
||||
);
|
||||
} else if (view.collection === "observed-groups") {
|
||||
const ids = set(view.group_ids),
|
||||
statuses = set(view.statuses);
|
||||
selected = selectViewRows(
|
||||
observed_groups,
|
||||
view,
|
||||
(row) =>
|
||||
(ids === undefined || ids.has(row.group_id)) &&
|
||||
(statuses === undefined || statuses.has(row.status)),
|
||||
(row) => ({
|
||||
...fieldsOf(
|
||||
row,
|
||||
view.fields ?? [
|
||||
"method",
|
||||
"origin",
|
||||
"path_template",
|
||||
"status",
|
||||
"reason",
|
||||
],
|
||||
),
|
||||
group_id: row.group_id,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const endpoints = set(view.endpoint_ids),
|
||||
groups = set(view.group_ids);
|
||||
selected = selectViewRows(
|
||||
matches,
|
||||
view,
|
||||
(row) =>
|
||||
(endpoints === undefined || endpoints.has(row.endpoint_id)) &&
|
||||
(groups === undefined || groups.has(row.group_id)),
|
||||
(row) => ({
|
||||
...fieldsOf(row, view.fields ?? ["basis", "supporting_record_ordinal"]),
|
||||
match_id: row.match_id,
|
||||
endpoint_id: row.endpoint_id,
|
||||
group_id: row.group_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return selected;
|
||||
};
|
||||
|
||||
/** Page completed classifications; filters never reclassify a subset or upgrade source coverage. */
|
||||
export const projectEndpointComparisonView = (
|
||||
parent: AnalysisViewParent,
|
||||
admitted: AdmittedView,
|
||||
) => {
|
||||
const view = ownedView<
|
||||
SummaryAnalysisView | z.output<typeof endpointComparisonViewRequestSchema>
|
||||
>(admitted, "summary", "endpoint-comparison");
|
||||
if (view === undefined)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Endpoint comparisons support summary and endpoint-comparison pages",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = readReport(parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const {
|
||||
static_endpoints: _static,
|
||||
observed_groups: _observed,
|
||||
matches: _matches,
|
||||
limitations,
|
||||
...context
|
||||
} = parsed.value;
|
||||
const shared = {
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_operation: "compare_static_and_observed_endpoints" as const,
|
||||
parent_digest: parent.evidenceId.slice(3),
|
||||
artifact: null,
|
||||
limitations: boundedViewMessages(
|
||||
[...parent.limitations, ...limitations],
|
||||
parent.evidenceId,
|
||||
"limitations",
|
||||
),
|
||||
unknowns:
|
||||
parsed.value.summary.unknown > 0
|
||||
? [
|
||||
"Some static endpoint observations cannot be classified; page static-endpoints with status unknown for their reasons.",
|
||||
]
|
||||
: [],
|
||||
};
|
||||
if (view.kind === "summary")
|
||||
return ok({
|
||||
...shared,
|
||||
kind: "summary" as const,
|
||||
view,
|
||||
summary: context,
|
||||
coverage: completeWithinViewCoverage(1, 1),
|
||||
});
|
||||
const selected = selectComparisonRows(parsed.value, view);
|
||||
return ok({
|
||||
...shared,
|
||||
...context,
|
||||
kind: "endpoint-comparison" as const,
|
||||
view,
|
||||
items: selected.rows,
|
||||
coverage: pageViewCoverage(
|
||||
view.offset,
|
||||
selected.rows.length,
|
||||
selected.total,
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { z } from "zod";
|
||||
import { digestSchema, prefixedDigestSchema } from "../digests.js";
|
||||
import { ROUTE_RULES } from "../analysisView/webNetworkRouteTemplates.js";
|
||||
|
||||
export const comparedStaticEndpointSchema = z.strictObject({
|
||||
endpoint_id: prefixedDigestSchema("static_endpoint"),
|
||||
node_id: prefixedDigestSchema("jag_node"),
|
||||
observation_id: prefixedDigestSchema("jag_observation"),
|
||||
method: z.string().nullable(),
|
||||
method_basis: z.enum(["api-definition", "callee-spelling", "unknown"]),
|
||||
origin: z.string().nullable(),
|
||||
origin_basis: z.enum(["literal-url", "caller-origin", "unknown"]),
|
||||
path: z.string().nullable(),
|
||||
status: z.enum(["matched", "not-observed-in-capture", "unknown"]),
|
||||
reason: z
|
||||
.enum([
|
||||
"candidate-endpoint",
|
||||
"unconfirmed-static-observation",
|
||||
"partial-target",
|
||||
"non-http-target",
|
||||
"unresolved-target",
|
||||
"unparseable-target",
|
||||
"normalization-required",
|
||||
"origin-required",
|
||||
"ambiguous-method",
|
||||
"capture-incomplete",
|
||||
"origin-not-exercised",
|
||||
"no-observed-match",
|
||||
])
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export const comparedObservedGroupSchema = z.strictObject({
|
||||
group_id: prefixedDigestSchema("observed_endpoint"),
|
||||
method: z.string(),
|
||||
origin: z.string(),
|
||||
path_template: z.string(),
|
||||
templated_segments: z.array(
|
||||
z.strictObject({
|
||||
segment_index: z.int().nonnegative(),
|
||||
rule: z.enum(ROUTE_RULES),
|
||||
}),
|
||||
),
|
||||
record_ordinals: z.array(z.int().nonnegative()),
|
||||
status: z.enum(["matched", "unknown", "observed-without-static-match"]),
|
||||
reason: z.literal("ambiguous-static-method").nullable(),
|
||||
});
|
||||
|
||||
export const endpointMatchSchema = z.strictObject({
|
||||
match_id: prefixedDigestSchema("endpoint_match"),
|
||||
endpoint_id: comparedStaticEndpointSchema.shape.endpoint_id,
|
||||
group_id: comparedObservedGroupSchema.shape.group_id,
|
||||
basis: z.enum(["exact", "template-compatible"]),
|
||||
supporting_record_ordinal: z.int().nonnegative(),
|
||||
});
|
||||
|
||||
export const staticObservedEndpointComparisonSchema = z.strictObject({
|
||||
comparison_id: prefixedDigestSchema("endpoint_comparison"),
|
||||
application: z.strictObject({
|
||||
evidence_id: prefixedDigestSchema("ev"),
|
||||
graph_id: prefixedDigestSchema("jag"),
|
||||
}),
|
||||
capture: z.strictObject({
|
||||
evidence_id: prefixedDigestSchema("ev"),
|
||||
artifact: z.strictObject({
|
||||
path: z.string(),
|
||||
sha256: digestSchema.nullable(),
|
||||
}),
|
||||
}),
|
||||
declared_origin: z.string().nullable(),
|
||||
summary: z.strictObject({
|
||||
static_network_endpoint_observations: z.int().nonnegative(),
|
||||
matched: z.int().nonnegative(),
|
||||
not_observed_in_capture: z.int().nonnegative(),
|
||||
unknown: z.int().nonnegative(),
|
||||
observed_groups: z.int().nonnegative(),
|
||||
observed_without_static_match: z.int().nonnegative(),
|
||||
observed_unknown: z.int().nonnegative(),
|
||||
matches: z.int().nonnegative(),
|
||||
}),
|
||||
source_coverage: z.strictObject({
|
||||
application: z.strictObject({
|
||||
status: z.enum(["complete", "partial", "unknown", "unavailable"]),
|
||||
truncated: z.boolean(),
|
||||
omitted_count: z.int().nonnegative().nullable(),
|
||||
limit_count: z.int().nonnegative(),
|
||||
limitation_count: z.int().nonnegative(),
|
||||
}),
|
||||
capture_limitation_count: z.int().nonnegative(),
|
||||
observed_records_examined: z.int().nonnegative(),
|
||||
observed_records_total: z.int().nonnegative(),
|
||||
observed_records_excluded: z.int().nonnegative(),
|
||||
}),
|
||||
static_endpoints: z.array(comparedStaticEndpointSchema),
|
||||
observed_groups: z.array(comparedObservedGroupSchema),
|
||||
matches: z.array(endpointMatchSchema),
|
||||
evidence_links: z.array(prefixedDigestSchema("ev")),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type StaticObservedEndpointComparison = z.output<
|
||||
typeof staticObservedEndpointComparisonSchema
|
||||
>;
|
||||
@@ -0,0 +1,296 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
createJavaScriptApplicationGraph,
|
||||
createJavaScriptApplicationNode,
|
||||
} from "./javascriptApplicationGraph.js";
|
||||
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "./javascriptApplicationAnalysis.js";
|
||||
import { analysisViewCaptureFixture } from "../../../tests/fixtures/analysisView.js";
|
||||
import { WebNetworkApiSurfaceAccumulator } from "../analysisView/WebNetworkApiSurfaceAccumulator.js";
|
||||
import { compareStaticObservedEndpoints } from "./staticObservedEndpoints.js";
|
||||
import { staticObservedEndpointComparisonSchema } from "./staticObservedEndpointSchemas.js";
|
||||
|
||||
const original = javascriptApplicationAnalysisResultSchema.parse(
|
||||
JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.normalized_result,
|
||||
).graph;
|
||||
const evidence = original.nodes[0]!.observations[0]!.evidence;
|
||||
if (!evidence.artifact.available)
|
||||
throw new Error("Static fixture must identify its source bytes");
|
||||
const artifactSha256 = evidence.artifact.sha256;
|
||||
const graphFor = (
|
||||
targets: readonly {
|
||||
value: string;
|
||||
mechanism?: string;
|
||||
interpretation?: string;
|
||||
resolution?: string;
|
||||
}[],
|
||||
sourceState: "observed" | "unknown" = "observed",
|
||||
) =>
|
||||
createJavaScriptApplicationGraph({
|
||||
schema: original.schema,
|
||||
root_node_ids: original.root_node_ids,
|
||||
coverage: original.coverage,
|
||||
limitations: original.limitations,
|
||||
nodes: [
|
||||
...original.nodes,
|
||||
...targets.map((target, index) =>
|
||||
createJavaScriptApplicationNode({
|
||||
kind: "endpoint",
|
||||
identity: {
|
||||
strategy: "artifact-local-key",
|
||||
stability: "artifact-version",
|
||||
artifact_sha256: artifactSha256,
|
||||
namespace: "network-endpoint",
|
||||
key: String(index),
|
||||
},
|
||||
observations: [
|
||||
{
|
||||
label: null,
|
||||
properties: {
|
||||
endpoint_kind: "network",
|
||||
mechanism: "call:fetch",
|
||||
...target,
|
||||
},
|
||||
evidence:
|
||||
sourceState === "observed"
|
||||
? evidence
|
||||
: {
|
||||
...evidence,
|
||||
state: sourceState,
|
||||
confidence: "unknown",
|
||||
limitations: [
|
||||
"Fixture explicitly leaves network interpretation unknown.",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
const run = (
|
||||
targets: Parameters<typeof graphFor>[0],
|
||||
requests: readonly { path: string; method?: string; origin?: string }[],
|
||||
options: {
|
||||
complete?: boolean;
|
||||
origin?: string | null;
|
||||
budget?: number;
|
||||
sourceState?: "observed" | "unknown";
|
||||
} = {},
|
||||
) => {
|
||||
const accumulator = new WebNetworkApiSurfaceAccumulator(
|
||||
options.budget,
|
||||
false,
|
||||
{ operation: "compare_static_and_observed_endpoints" },
|
||||
);
|
||||
for (const [ordinal, request] of requests.entries()) {
|
||||
const added = accumulator.add({
|
||||
...analysisViewCaptureFixture().records[0]!,
|
||||
ordinal,
|
||||
limitations: [],
|
||||
reported: {
|
||||
request: {
|
||||
method: request.method ?? "GET",
|
||||
url: `${request.origin ?? "https://api.test"}${request.path}`,
|
||||
},
|
||||
response: { status: 200, content: { mimeType: "application/json" } },
|
||||
},
|
||||
});
|
||||
if (!added.ok) return added;
|
||||
}
|
||||
return compareStaticObservedEndpoints({
|
||||
applicationEvidenceId: `ev_${"a".repeat(64)}`,
|
||||
captureEvidenceId: `ev_${"b".repeat(64)}`,
|
||||
graph: graphFor(targets, options.sourceState),
|
||||
accumulator,
|
||||
captureComplete: options.complete ?? true,
|
||||
...(options.origin === null
|
||||
? {}
|
||||
: { origin: options.origin ?? "https://api.test" }),
|
||||
});
|
||||
};
|
||||
|
||||
it("reports all four outcomes, exact/template basis and unknown method without copying URL details", () => {
|
||||
const result = run(
|
||||
[
|
||||
{ value: "/matched?source-query=ordinary#fragment" },
|
||||
{ value: "/users/789" },
|
||||
{ value: "/missing" },
|
||||
{ value: "KEY", interpretation: "candidate" },
|
||||
],
|
||||
[
|
||||
{ path: "/matched?capture-query=ordinary" },
|
||||
{ path: "/users/123" },
|
||||
{ path: "/observed-only" },
|
||||
],
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.summary).toMatchObject({
|
||||
matched: 2,
|
||||
not_observed_in_capture: 1,
|
||||
unknown: 1,
|
||||
observed_without_static_match: 1,
|
||||
});
|
||||
expect(result.value.matches.map(({ basis }) => basis).sort()).toEqual([
|
||||
"exact",
|
||||
"template-compatible",
|
||||
]);
|
||||
expect(
|
||||
result.value.static_endpoints.find(({ path }) => path === "/matched"),
|
||||
).toMatchObject({
|
||||
method: null,
|
||||
method_basis: "unknown",
|
||||
origin_basis: "caller-origin",
|
||||
});
|
||||
expect(JSON.stringify(result.value)).not.toMatch(
|
||||
/source-query|capture-query|fragment|ordinary/,
|
||||
);
|
||||
expect(
|
||||
staticObservedEndpointComparisonSchema.shape.static_endpoints.safeParse(
|
||||
result.value.static_endpoints,
|
||||
).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not promote an explicitly unknown static observation to a match", () => {
|
||||
const result = run([{ value: "/matched" }], [{ path: "/matched" }], {
|
||||
sourceState: "unknown",
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.static_endpoints[0]).toMatchObject({
|
||||
status: "unknown",
|
||||
reason: "unconfirmed-static-observation",
|
||||
});
|
||||
expect(result.value.matches).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not manufacture an exact whole path from independent templated segment values", () => {
|
||||
const result = run(
|
||||
[{ value: "/a/1/b/2" }],
|
||||
[{ path: "/a/1/b/3" }, { path: "/a/4/b/2" }],
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.matches[0]?.basis).toBe("template-compatible");
|
||||
expect(result.value.observed_groups[0]?.record_ordinals).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("names a concrete exact witness within a group containing different literal paths", () => {
|
||||
const result = run(
|
||||
[{ value: "/users/123" }],
|
||||
[{ path: "/users/456" }, { path: "/users/123" }],
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.matches[0]).toMatchObject({
|
||||
basis: "exact",
|
||||
supporting_record_ordinal: 1,
|
||||
});
|
||||
expect(result.value.observed_groups[0]?.record_ordinals).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it.each([{ complete: false }, { origin: null }])(
|
||||
"keeps missing endpoints unknown with incomplete capture or undeclared origin: %j",
|
||||
(options) => {
|
||||
const result = run([{ value: "/missing" }], [{ path: "/other" }], options);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.static_endpoints[0]?.status).toBe("unknown");
|
||||
},
|
||||
);
|
||||
|
||||
it("requires an exercised origin, even for an exact absolute URL", () => {
|
||||
const result = run(
|
||||
[{ value: "https://unseen.test/missing" }],
|
||||
[{ path: "/other" }],
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.static_endpoints[0]).toMatchObject({
|
||||
status: "unknown",
|
||||
reason: "origin-not-exercised",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps normalization, operation names and partial expressions unknown", () => {
|
||||
const result = run(
|
||||
[
|
||||
{ value: "/a/%2e%2e/admin" },
|
||||
{ value: "GetViewer" },
|
||||
{ value: "trpc.user.byId" },
|
||||
{ value: "fetch(prefix + path)", resolution: "partial" },
|
||||
],
|
||||
[{ path: "/admin" }],
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.summary.unknown).toBe(4);
|
||||
expect(result.value.matches).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"keeps ambiguity on both sides and preserves confirmed method matches (known peer: %s)",
|
||||
(knownPeer) => {
|
||||
const result = run(
|
||||
[
|
||||
{ value: "/ambiguous" },
|
||||
...(knownPeer
|
||||
? [{ value: "/ambiguous", mechanism: "new:EventSource" }]
|
||||
: []),
|
||||
{ value: "/events", mechanism: "new:EventSource" },
|
||||
],
|
||||
[
|
||||
{ path: "/ambiguous", method: "GET" },
|
||||
{ path: "/ambiguous", method: "POST" },
|
||||
{ path: "/events", method: "POST" },
|
||||
],
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(
|
||||
result.value.static_endpoints.find(
|
||||
({ path, method }) => path === "/ambiguous" && method === null,
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "unknown",
|
||||
reason: "ambiguous-method",
|
||||
method: null,
|
||||
});
|
||||
expect(
|
||||
result.value.static_endpoints.find(({ path }) => path === "/events"),
|
||||
).toMatchObject({ status: "not-observed-in-capture", method: "GET" });
|
||||
expect(result.value.summary.observed_unknown).toBe(knownPeer ? 1 : 2);
|
||||
expect(
|
||||
result.value.observed_groups.find(
|
||||
({ path_template, method }) =>
|
||||
path_template === "/ambiguous" && method === "POST",
|
||||
),
|
||||
).toMatchObject({ status: "unknown", reason: "ambiguous-static-method" });
|
||||
expect(
|
||||
result.value.observed_groups.find(
|
||||
({ path_template, method }) =>
|
||||
path_template === "/ambiguous" && method === "GET",
|
||||
),
|
||||
).toMatchObject({
|
||||
status: knownPeer ? "matched" : "unknown",
|
||||
reason: knownPeer ? null : "ambiguous-static-method",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not upgrade absence when even one recorded request is excluded", () => {
|
||||
const result = run(
|
||||
[{ value: "/missing" }],
|
||||
[{ path: "/known" }, { path: "/omitted", method: "" }],
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.static_endpoints[0]).toMatchObject({
|
||||
status: "unknown",
|
||||
reason: "capture-incomplete",
|
||||
});
|
||||
});
|
||||
|
||||
it("charges comparison state against the inherited representation allowance", () => {
|
||||
const result = run([{ value: "/large".repeat(500) }], [{ path: "/known" }], {
|
||||
budget: 4096,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisResourceConstraintError" },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
import { digestCanonicalValue } from "../canonicalDigest.js";
|
||||
import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js";
|
||||
import { parseRecordedTransportUrl } from "../recordedTransportUrl.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js";
|
||||
import {
|
||||
WebNetworkApiSurfaceAccumulator,
|
||||
type ApiSurfaceComparisonGroup,
|
||||
} from "../analysisView/WebNetworkApiSurfaceAccumulator.js";
|
||||
import {
|
||||
ENTRY_BYTES,
|
||||
GROUP_BYTES,
|
||||
scanRouteTemplate,
|
||||
textBytes,
|
||||
} from "../analysisView/webNetworkRouteTemplates.js";
|
||||
import type { StaticObservedEndpointComparison } from "./staticObservedEndpointSchemas.js";
|
||||
|
||||
type Endpoint = StaticObservedEndpointComparison["static_endpoints"][number];
|
||||
type Group = StaticObservedEndpointComparison["observed_groups"][number];
|
||||
type Match = StaticObservedEndpointComparison["matches"][number];
|
||||
const identity = (prefix: string, facts: unknown) =>
|
||||
`${prefix}_${digestCanonicalValue(facts)}`;
|
||||
const routeKey = (origin: string, path: string, rules: string) =>
|
||||
JSON.stringify([origin, path, rules]);
|
||||
|
||||
/** Interpret only method-bearing API spellings; fetch options and XHR methods stay unknown. */
|
||||
const methodFor = (
|
||||
mechanism: unknown,
|
||||
): Pick<Endpoint, "method" | "method_basis"> => {
|
||||
if (typeof mechanism !== "string")
|
||||
return { method: null, method_basis: "unknown" };
|
||||
if (/(?:^|[.:])EventSource$/u.test(mechanism))
|
||||
return { method: "GET", method_basis: "api-definition" };
|
||||
if (/(?:^|[.:])sendBeacon$/u.test(mechanism))
|
||||
return { method: "POST", method_basis: "api-definition" };
|
||||
const method = /(?:^|[.:])(get|post|put|patch|delete|head|options)$/u.exec(
|
||||
mechanism,
|
||||
)?.[1];
|
||||
return method === undefined
|
||||
? { method: null, method_basis: "unknown" }
|
||||
: { method: method.toUpperCase(), method_basis: "callee-spelling" };
|
||||
};
|
||||
|
||||
/** Keep recorded spelling; targets requiring URL normalization stay unknown instead of acquiring a different identity. */
|
||||
const targetFor = (
|
||||
value: unknown,
|
||||
origin: string | undefined,
|
||||
): Pick<Endpoint, "origin" | "origin_basis" | "path" | "reason"> => {
|
||||
const unknown = (reason: Endpoint["reason"]) => ({
|
||||
origin: null,
|
||||
origin_basis: "unknown" as const,
|
||||
path: null,
|
||||
reason,
|
||||
});
|
||||
if (typeof value !== "string") return unknown("unparseable-target");
|
||||
if (value.startsWith("/") && !value.startsWith("//")) {
|
||||
const path = value.split(/[?#]/u, 1)[0]!;
|
||||
if (origin === undefined) return { ...unknown("origin-required"), path };
|
||||
// Use the same recorded URL owner as HAR, rather than URL.pathname's normalization.
|
||||
const parsed = parseRecordedTransportUrl(`${origin}${path}`);
|
||||
if (
|
||||
parsed.path !== path ||
|
||||
parsed.origin !== origin ||
|
||||
parsed.credentials !== null
|
||||
)
|
||||
return unknown("unparseable-target");
|
||||
if (URL.parse(`${origin}${path}`)?.pathname !== path)
|
||||
return unknown("normalization-required");
|
||||
return { origin, origin_basis: "caller-origin", path, reason: null };
|
||||
}
|
||||
if (!/^https?:\/\//iu.test(value))
|
||||
return unknown(
|
||||
/^[A-Za-z][A-Za-z0-9+.-]*:/u.test(value)
|
||||
? "non-http-target"
|
||||
: "unresolved-target",
|
||||
);
|
||||
const parsed = parseRecordedTransportUrl(value);
|
||||
if (
|
||||
parsed.path === null ||
|
||||
parsed.origin === null ||
|
||||
parsed.credentials !== null
|
||||
)
|
||||
return unknown("unparseable-target");
|
||||
if (URL.parse(value)?.pathname !== parsed.path)
|
||||
return unknown("normalization-required");
|
||||
return {
|
||||
origin: parsed.origin,
|
||||
origin_basis: "literal-url",
|
||||
path: parsed.path,
|
||||
reason: null,
|
||||
};
|
||||
};
|
||||
|
||||
interface ComparisonInput {
|
||||
readonly applicationEvidenceId: string;
|
||||
readonly captureEvidenceId: string;
|
||||
readonly graph: JavaScriptApplicationGraph;
|
||||
readonly accumulator: WebNetworkApiSurfaceAccumulator;
|
||||
readonly origin?: string;
|
||||
readonly captureComplete: boolean;
|
||||
}
|
||||
interface IndexedGroup {
|
||||
readonly source: ApiSurfaceComparisonGroup;
|
||||
readonly row: Group;
|
||||
}
|
||||
interface ObservedIndex {
|
||||
readonly rows: Group[];
|
||||
readonly routes: Map<
|
||||
string,
|
||||
{ methods: Map<string, IndexedGroup>; ambiguous: boolean }
|
||||
>;
|
||||
readonly origins: Set<string>;
|
||||
readonly complete: boolean;
|
||||
}
|
||||
type Node = JavaScriptApplicationGraph["nodes"][number];
|
||||
|
||||
/** Each route owns its methods and ambiguity; a group is never guessed from display text. */
|
||||
const indexObservedGroups = (
|
||||
input: ComparisonInput,
|
||||
): Result<ObservedIndex, AnalysisError> => {
|
||||
const { accumulator } = input;
|
||||
const index: ObservedIndex = {
|
||||
rows: [],
|
||||
routes: new Map(),
|
||||
origins: new Set(),
|
||||
complete:
|
||||
input.captureComplete &&
|
||||
accumulator.comparisonCoverage().excluded_records === 0,
|
||||
};
|
||||
for (const source of accumulator.comparisonGroups()) {
|
||||
const admitted = accumulator.admitDerivedBytes(
|
||||
GROUP_BYTES + ENTRY_BYTES * source.record_ordinals.length,
|
||||
);
|
||||
if (!admitted.ok) return admitted;
|
||||
const row: Group = {
|
||||
group_id: identity("observed_endpoint", [
|
||||
input.captureEvidenceId,
|
||||
source.method,
|
||||
source.origin,
|
||||
source.path_template,
|
||||
source.templated_segments,
|
||||
]),
|
||||
method: source.method,
|
||||
origin: source.origin,
|
||||
path_template: source.path_template,
|
||||
templated_segments: [...source.templated_segments],
|
||||
record_ordinals: [...source.record_ordinals],
|
||||
status: "observed-without-static-match",
|
||||
reason: null,
|
||||
};
|
||||
index.rows.push(row);
|
||||
index.origins.add(source.origin);
|
||||
const key = routeKey(source.origin, source.path_template, source.rules);
|
||||
const bucket = index.routes.get(key);
|
||||
if (bucket === undefined)
|
||||
index.routes.set(key, {
|
||||
methods: new Map([[source.method, { source, row }]]),
|
||||
ambiguous: false,
|
||||
});
|
||||
else bucket.methods.set(source.method, { source, row });
|
||||
}
|
||||
return ok(index);
|
||||
};
|
||||
|
||||
const readStaticEndpoint = (
|
||||
input: ComparisonInput,
|
||||
node: Node,
|
||||
observation: Node["observations"][number],
|
||||
): Result<Endpoint | undefined, AnalysisError> => {
|
||||
const properties = observation.properties;
|
||||
if (properties.endpoint_kind !== "network") return ok(undefined);
|
||||
const admitted = input.accumulator.admitDerivedBytes(
|
||||
GROUP_BYTES +
|
||||
(typeof properties.value === "string"
|
||||
? textBytes(properties.value.length)
|
||||
: 0),
|
||||
);
|
||||
if (!admitted.ok) return admitted;
|
||||
const candidate = properties.interpretation === "candidate";
|
||||
const partial = properties.resolution === "partial";
|
||||
let reason: Endpoint["reason"] = null;
|
||||
if (candidate) reason = "candidate-endpoint";
|
||||
else if (observation.evidence.state !== "observed")
|
||||
reason = "unconfirmed-static-observation";
|
||||
else if (partial) reason = "partial-target";
|
||||
return ok({
|
||||
endpoint_id: identity("static_endpoint", [
|
||||
input.applicationEvidenceId,
|
||||
node.node_id,
|
||||
observation.observation_id,
|
||||
]),
|
||||
node_id: node.node_id,
|
||||
observation_id: observation.observation_id,
|
||||
...methodFor(reason === null ? properties.mechanism : undefined),
|
||||
...(reason !== null
|
||||
? {
|
||||
origin: null,
|
||||
origin_basis: "unknown" as const,
|
||||
path: null,
|
||||
reason,
|
||||
}
|
||||
: targetFor(properties.value, input.origin)),
|
||||
status: "unknown",
|
||||
});
|
||||
};
|
||||
|
||||
const matchEndpoint = (
|
||||
endpoint: Endpoint,
|
||||
index: ObservedIndex,
|
||||
budget: WebNetworkApiSurfaceAccumulator,
|
||||
): Result<Match | undefined, AnalysisError> => {
|
||||
if (
|
||||
endpoint.reason !== null ||
|
||||
endpoint.path === null ||
|
||||
endpoint.origin === null
|
||||
)
|
||||
return ok(undefined);
|
||||
let failure: AnalysisError | undefined;
|
||||
const route = scanRouteTemplate(endpoint.path, (bytes) => {
|
||||
const admitted = budget.admitDerivedBytes(bytes);
|
||||
if (!admitted.ok) failure = admitted.error;
|
||||
return admitted.ok;
|
||||
});
|
||||
if (route === null) {
|
||||
if (failure === undefined)
|
||||
throw new Error("Route reservation failed without its owned error");
|
||||
return err(failure);
|
||||
}
|
||||
const bucket = index.routes.get(
|
||||
routeKey(endpoint.origin, route.template, route.rules),
|
||||
);
|
||||
if (
|
||||
endpoint.method === null &&
|
||||
bucket !== undefined &&
|
||||
bucket.methods.size > 1
|
||||
) {
|
||||
endpoint.reason = "ambiguous-method";
|
||||
bucket.ambiguous = true;
|
||||
return ok(undefined);
|
||||
}
|
||||
const selected =
|
||||
endpoint.method === null
|
||||
? bucket?.methods.values().next().value
|
||||
: bucket?.methods.get(endpoint.method);
|
||||
if (selected !== undefined) {
|
||||
const admitted = budget.admitDerivedBytes(GROUP_BYTES);
|
||||
if (!admitted.ok) return admitted;
|
||||
endpoint.status = "matched";
|
||||
selected.row.status = "matched";
|
||||
const exactOrdinal = selected.source.exactPathOrdinal(endpoint.path);
|
||||
const basis =
|
||||
exactOrdinal !== undefined
|
||||
? ("exact" as const)
|
||||
: ("template-compatible" as const);
|
||||
return ok({
|
||||
match_id: identity("endpoint_match", [
|
||||
endpoint.endpoint_id,
|
||||
selected.row.group_id,
|
||||
basis,
|
||||
]),
|
||||
endpoint_id: endpoint.endpoint_id,
|
||||
group_id: selected.row.group_id,
|
||||
basis,
|
||||
supporting_record_ordinal:
|
||||
exactOrdinal ?? selected.source.record_ordinals[0]!,
|
||||
});
|
||||
}
|
||||
if (!index.complete) endpoint.reason = "capture-incomplete";
|
||||
else if (!index.origins.has(endpoint.origin))
|
||||
endpoint.reason = "origin-not-exercised";
|
||||
else {
|
||||
endpoint.status = "not-observed-in-capture";
|
||||
endpoint.reason = "no-observed-match";
|
||||
}
|
||||
return ok(undefined);
|
||||
};
|
||||
|
||||
const finalizeObservedGroups = (index: ObservedIndex): void => {
|
||||
for (const bucket of index.routes.values()) {
|
||||
if (!bucket.ambiguous) continue;
|
||||
for (const { row } of bucket.methods.values()) {
|
||||
if (row.status === "matched") continue;
|
||||
row.status = "unknown";
|
||||
row.reason = "ambiguous-static-method";
|
||||
}
|
||||
}
|
||||
index.rows.sort((a, b) => compare(a.group_id, b.group_id));
|
||||
};
|
||||
|
||||
const summarize = (
|
||||
endpoints: readonly Endpoint[],
|
||||
groups: readonly Group[],
|
||||
matchCount: number,
|
||||
): StaticObservedEndpointComparison["summary"] => {
|
||||
const summary = {
|
||||
static_network_endpoint_observations: endpoints.length,
|
||||
matched: 0,
|
||||
not_observed_in_capture: 0,
|
||||
unknown: 0,
|
||||
observed_groups: groups.length,
|
||||
observed_without_static_match: 0,
|
||||
observed_unknown: 0,
|
||||
matches: matchCount,
|
||||
};
|
||||
for (const endpoint of endpoints) {
|
||||
if (endpoint.status === "matched") summary.matched++;
|
||||
else if (endpoint.status === "unknown") summary.unknown++;
|
||||
else summary.not_observed_in_capture++;
|
||||
}
|
||||
for (const group of groups) {
|
||||
if (group.status === "observed-without-static-match")
|
||||
summary.observed_without_static_match++;
|
||||
else if (group.status === "unknown") summary.observed_unknown++;
|
||||
}
|
||||
return summary;
|
||||
};
|
||||
|
||||
/** Join admitted observations in indexed work; retain one concrete witness per pairing. */
|
||||
export const compareStaticObservedEndpoints = (
|
||||
input: ComparisonInput,
|
||||
): Result<
|
||||
Pick<
|
||||
StaticObservedEndpointComparison,
|
||||
"static_endpoints" | "observed_groups" | "matches" | "summary"
|
||||
>,
|
||||
AnalysisError
|
||||
> => {
|
||||
const indexed = indexObservedGroups(input);
|
||||
if (!indexed.ok) return indexed;
|
||||
const index = indexed.value;
|
||||
const endpoints: Endpoint[] = [];
|
||||
const matches: Match[] = [];
|
||||
for (const node of input.graph.nodes) {
|
||||
if (node.kind !== "endpoint") continue;
|
||||
for (const observation of node.observations) {
|
||||
const endpoint = readStaticEndpoint(input, node, observation);
|
||||
if (!endpoint.ok) return endpoint;
|
||||
if (endpoint.value === undefined) continue;
|
||||
endpoints.push(endpoint.value);
|
||||
const match = matchEndpoint(endpoint.value, index, input.accumulator);
|
||||
if (!match.ok) return match;
|
||||
if (match.value !== undefined) matches.push(match.value);
|
||||
}
|
||||
}
|
||||
finalizeObservedGroups(index);
|
||||
endpoints.sort((a, b) => compare(a.endpoint_id, b.endpoint_id));
|
||||
matches.sort((a, b) => compare(a.match_id, b.match_id));
|
||||
return ok({
|
||||
static_endpoints: endpoints,
|
||||
observed_groups: index.rows,
|
||||
matches,
|
||||
summary: summarize(endpoints, index.rows, matches.length),
|
||||
});
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import { registerTraceFeatureTool } from "./registerApplicationTools/traceFeature.js";
|
||||
import { registerTraceJavaScriptSemanticsTool } from "./registerApplicationTools/traceSemantics.js";
|
||||
import { registerCompareApplicationVersionsTool } from "./registerApplicationTools/compareVersions.js";
|
||||
import { registerCompareEndpointsTool } from "./registerApplicationTools/compareEndpoints.js";
|
||||
import { registerCompareSourceToBundleTool } from "./registerApplicationTools/compareSourceToBundle.js";
|
||||
import { registerCompareJavaScriptExportShapesTool } from "./registerApplicationTools/compareExportShapes.js";
|
||||
import { registerCoverageTools } from "./registerApplicationTools/coverage.js";
|
||||
@@ -23,6 +24,7 @@ export const registerApplicationTools = (
|
||||
registerTraceFeatureTool(server, options);
|
||||
registerTraceJavaScriptSemanticsTool(server, options);
|
||||
registerCompareApplicationVersionsTool(server, options);
|
||||
registerCompareEndpointsTool(server, options);
|
||||
registerCompareSourceToBundleTool(server, options);
|
||||
registerCompareJavaScriptExportShapesTool(server, options);
|
||||
registerReconstructionObligationLedgerTool(server, options);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { EvidenceMcpServer } from "../EvidenceMcpServer.js";
|
||||
import { runAdmittedToolOperation } from "../admittedToolOperation.js";
|
||||
import { requireAnalysisViewRetention } from "../../application/analysisView/AnalysisViewService.js";
|
||||
import { compareStaticAndObservedEndpointsEvidence } from "../../application/javascript/StaticObservedEndpointComparisonService.js";
|
||||
import { toolContract } from "../../contracts/toolContracts.js";
|
||||
import { toolRegistrationOptions } from "../toolRegistrationOptions.js";
|
||||
import { logToolExecution } from "../toolLogging.js";
|
||||
import { deliverApplicationEvidence } from "./helpers.js";
|
||||
import type { ApplicationToolRegistration } from "./types.js";
|
||||
|
||||
const contract = toolContract("compare_static_and_observed_endpoints");
|
||||
|
||||
/** Both adapters execute the same authenticated, provider-free comparison. */
|
||||
export const registerCompareEndpointsTool = (
|
||||
server: EvidenceMcpServer,
|
||||
options: ApplicationToolRegistration,
|
||||
): void => {
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
toolRegistrationOptions(contract),
|
||||
async (input, context) =>
|
||||
runAdmittedToolOperation(
|
||||
server,
|
||||
options.withAdmittedAnalysis,
|
||||
contract.name,
|
||||
context.mcpReq.signal,
|
||||
async () => {
|
||||
if (input.detail === "summary" || input.view !== undefined) {
|
||||
const retention = requireAnalysisViewRetention(
|
||||
contract.name,
|
||||
options,
|
||||
);
|
||||
if (!retention.ok)
|
||||
return server.delivery.toCallToolResult(retention, contract);
|
||||
}
|
||||
const result = await logToolExecution(
|
||||
options.logger,
|
||||
contract.name,
|
||||
() =>
|
||||
compareStaticAndObservedEndpointsEvidence(
|
||||
input,
|
||||
options.evidenceById,
|
||||
{ signal: context.mcpReq.signal },
|
||||
),
|
||||
);
|
||||
if (!result.ok)
|
||||
return server.delivery.toCallToolResult(result, contract);
|
||||
return deliverApplicationEvidence(
|
||||
server,
|
||||
options,
|
||||
contract,
|
||||
result.value,
|
||||
input.view ??
|
||||
(input.detail === "summary" ? { kind: "summary" } : undefined),
|
||||
context.mcpReq.signal,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,406 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { expect } from "vitest";
|
||||
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import {
|
||||
createEvidence,
|
||||
parseEvidence,
|
||||
type Evidence,
|
||||
} from "../../../src/domain/evidence.js";
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
evidenceBundleSchema,
|
||||
} from "../../../src/domain/evidenceBundle.js";
|
||||
import {
|
||||
staticObservedEndpointComparisonSchema,
|
||||
type StaticObservedEndpointComparison,
|
||||
} from "../../../src/domain/javascript/staticObservedEndpointSchemas.js";
|
||||
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
|
||||
import { webNetworkCaptureSchema } from "../../../src/domain/webNetworkCapture.js";
|
||||
|
||||
const prepareInputs = async (cli: TestCli) => {
|
||||
const root = await createTestTempDirectory("rea-static-observed-endpoints-");
|
||||
const appPath = join(root, "app");
|
||||
await mkdir(appPath);
|
||||
const fixture = new URL(
|
||||
"../../fixtures/javascript-endpoint-capture/",
|
||||
import.meta.url,
|
||||
);
|
||||
const sourcePath = join(appPath, "app.js");
|
||||
const source = await readFile(new URL("app.js.txt", fixture), "utf8");
|
||||
await writeFile(sourcePath, source);
|
||||
await writeFile(
|
||||
join(appPath, "index.html"),
|
||||
await readFile(new URL("index.html.txt", fixture)),
|
||||
);
|
||||
const harPath = join(root, "capture.har");
|
||||
const harBytes = await readFile(new URL("recording.har", fixture), "utf8");
|
||||
expect(createHash("sha256").update(harBytes).digest("hex")).toBe(
|
||||
"f0e7d2c869c811d4272a7b2916c6531c8c6cc7938336a35575e2a6b7b6346714",
|
||||
);
|
||||
await writeFile(harPath, harBytes);
|
||||
const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli);
|
||||
const analyzed = await run({
|
||||
arguments: [
|
||||
"analyze-javascript-application",
|
||||
appPath,
|
||||
"--artifact-format",
|
||||
"directory",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(analyzed.exitCode, analyzed.stderr).toBe(0);
|
||||
const application = parseEvidence(analyzed.json);
|
||||
const inspected = await run({
|
||||
arguments: ["inspect-web-network-capture", harPath, "har", "--json"],
|
||||
});
|
||||
expect(inspected.exitCode, inspected.stderr).toBe(0);
|
||||
const capture = parseEvidence(inspected.json);
|
||||
const input = { application, capture, origin: "http://127.0.0.1:42845" };
|
||||
const inputPath = join(root, "comparison-input.json");
|
||||
await writeFile(inputPath, JSON.stringify(input));
|
||||
|
||||
return {
|
||||
root,
|
||||
sourcePath,
|
||||
source,
|
||||
harPath,
|
||||
harBytes,
|
||||
run,
|
||||
application,
|
||||
capture,
|
||||
input,
|
||||
inputPath,
|
||||
};
|
||||
};
|
||||
|
||||
type McpCall = Awaited<ReturnType<typeof connectLocalToolsMcp>>["call"];
|
||||
const assertPagedComparison = async (
|
||||
call: McpCall,
|
||||
report: StaticObservedEndpointComparison,
|
||||
complete: Evidence,
|
||||
input: Parameters<McpCall>[1],
|
||||
) => {
|
||||
const pageRequest = {
|
||||
source: { kind: "retained-evidence", evidence_id: complete.evidence_id },
|
||||
view: {
|
||||
kind: "endpoint-comparison",
|
||||
collection: "static-endpoints",
|
||||
fields: ["path", "status", "reason"],
|
||||
offset: 0,
|
||||
limit: 2,
|
||||
},
|
||||
};
|
||||
const first = await call("inspect_analysis_view", pageRequest);
|
||||
const firstView = analysisViewResultSchema.parse(
|
||||
parseEvidence(first.structuredContent).normalized_result,
|
||||
);
|
||||
expect(firstView).toMatchObject({
|
||||
source_coverage: report.source_coverage,
|
||||
summary: report.summary,
|
||||
coverage: { examined: 2, total: 7, exhausted: false, next_offset: 2 },
|
||||
});
|
||||
if (firstView.kind !== "endpoint-comparison")
|
||||
throw new Error("Expected endpoint page");
|
||||
const inline = await call("compare_static_and_observed_endpoints", {
|
||||
...input,
|
||||
detail: "complete",
|
||||
view: pageRequest.view,
|
||||
});
|
||||
expect(inline.isError, JSON.stringify(inline.content)).not.toBe(true);
|
||||
expect(parseEvidence(inline.structuredContent).normalized_result).toEqual(
|
||||
firstView,
|
||||
);
|
||||
expect(firstView.items).toEqual(
|
||||
report.static_endpoints
|
||||
.slice(0, 2)
|
||||
.map(
|
||||
({ endpoint_id, node_id, observation_id, path, status, reason }) => ({
|
||||
endpoint_id,
|
||||
node_id,
|
||||
observation_id,
|
||||
path,
|
||||
status,
|
||||
reason,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const empty = await call("inspect_analysis_view", {
|
||||
...pageRequest,
|
||||
view: { ...pageRequest.view, statuses: [], fields: [] },
|
||||
});
|
||||
expect(
|
||||
parseEvidence(empty.structuredContent).normalized_result,
|
||||
).toMatchObject({
|
||||
items: [],
|
||||
coverage: { total: 0, exhausted: true },
|
||||
summary: report.summary,
|
||||
});
|
||||
const matchPage = await call("inspect_analysis_view", {
|
||||
source: pageRequest.source,
|
||||
view: { kind: "endpoint-comparison", collection: "matches", fields: [] },
|
||||
});
|
||||
expect(
|
||||
parseEvidence(matchPage.structuredContent).normalized_result,
|
||||
).toMatchObject({
|
||||
items: report.matches.map(({ match_id, endpoint_id, group_id }) => ({
|
||||
match_id,
|
||||
endpoint_id,
|
||||
group_id,
|
||||
})),
|
||||
});
|
||||
|
||||
return { firstView, pageRequest };
|
||||
};
|
||||
|
||||
const assertCliPublication = async (
|
||||
context: Awaited<ReturnType<typeof prepareInputs>>,
|
||||
comparison: {
|
||||
readonly complete: Evidence;
|
||||
readonly summary: Awaited<ReturnType<McpCall>>;
|
||||
readonly views: Awaited<ReturnType<typeof assertPagedComparison>>;
|
||||
},
|
||||
) => {
|
||||
const { root, run, inputPath, application, capture } = context;
|
||||
const {
|
||||
complete,
|
||||
summary,
|
||||
views: { firstView, pageRequest },
|
||||
} = comparison;
|
||||
const bundlePath = join(root, "comparison.bundle.json");
|
||||
const cliSummary = await run({
|
||||
arguments: [
|
||||
"compare-static-and-observed-endpoints",
|
||||
inputPath,
|
||||
"--detail",
|
||||
"summary",
|
||||
"--evidence_output",
|
||||
bundlePath,
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(cliSummary.exitCode, cliSummary.stderr).toBe(0);
|
||||
expect(cliSummary.json).toMatchObject({
|
||||
summary: summary.structuredContent,
|
||||
});
|
||||
const bundle = evidenceBundleSchema.parse(
|
||||
JSON.parse(await readFile(bundlePath, "utf8")),
|
||||
);
|
||||
expect(new Set(bundle.records.map(({ evidence_id }) => evidence_id))).toEqual(
|
||||
new Set([
|
||||
application.evidence_id,
|
||||
capture.evidence_id,
|
||||
complete.evidence_id,
|
||||
]),
|
||||
);
|
||||
const fileViewPath = join(root, "page-input.json");
|
||||
await writeFile(
|
||||
fileViewPath,
|
||||
JSON.stringify({
|
||||
...pageRequest,
|
||||
source: {
|
||||
kind: "evidence-file",
|
||||
path: bundlePath,
|
||||
evidence_id: complete.evidence_id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const cliPage = await run({
|
||||
arguments: ["inspect-analysis-view", fileViewPath, "--json"],
|
||||
});
|
||||
expect(cliPage.exitCode, cliPage.stderr).toBe(0);
|
||||
expect(parseEvidence(cliPage.json).normalized_result).toEqual(firstView);
|
||||
const inlineCli = await run({
|
||||
arguments: [
|
||||
"compare-static-and-observed-endpoints",
|
||||
inputPath,
|
||||
"--detail",
|
||||
"summary",
|
||||
"--view",
|
||||
JSON.stringify(pageRequest.view),
|
||||
"--evidence_output",
|
||||
join(root, "selected.bundle.json"),
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(inlineCli.exitCode, inlineCli.stderr).toBe(0);
|
||||
expect(inlineCli.json).toMatchObject({
|
||||
summary: { normalized_result: firstView },
|
||||
});
|
||||
const invalidView = await run({
|
||||
arguments: [
|
||||
"compare-static-and-observed-endpoints",
|
||||
inputPath,
|
||||
"--view",
|
||||
JSON.stringify(pageRequest.view),
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(invalidView.exitCode).not.toBe(0);
|
||||
expect(invalidView.json).toMatchObject({ code: "invalid_request" });
|
||||
const missingSink = await run({
|
||||
arguments: [
|
||||
"compare-static-and-observed-endpoints",
|
||||
inputPath,
|
||||
"--detail",
|
||||
"summary",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(missingSink.exitCode).not.toBe(0);
|
||||
expect(missingSink.json).toMatchObject({ code: "invalid_request" });
|
||||
};
|
||||
|
||||
cliTest(
|
||||
"compares real source and decoded HAR through CLI/MCP, retains complete facts and pages stable identities",
|
||||
async ({ cli }) => {
|
||||
const context = await prepareInputs(cli);
|
||||
const {
|
||||
root,
|
||||
sourcePath,
|
||||
source,
|
||||
harPath,
|
||||
harBytes,
|
||||
run,
|
||||
application,
|
||||
capture,
|
||||
input,
|
||||
inputPath,
|
||||
} = context;
|
||||
const compared = await run({
|
||||
arguments: ["compare-static-and-observed-endpoints", inputPath, "--json"],
|
||||
});
|
||||
expect(compared.exitCode, compared.stderr).toBe(0);
|
||||
const complete = parseEvidence(compared.json);
|
||||
const report = staticObservedEndpointComparisonSchema.parse(
|
||||
complete.normalized_result,
|
||||
);
|
||||
expect(report.summary).toMatchObject({
|
||||
static_network_endpoint_observations: 7,
|
||||
matched: 3,
|
||||
not_observed_in_capture: 1,
|
||||
unknown: 3,
|
||||
observed_without_static_match: 3,
|
||||
});
|
||||
expect(new Set(report.matches.map(({ basis }) => basis))).toEqual(
|
||||
new Set(["exact", "template-compatible"]),
|
||||
);
|
||||
expect(complete.evidence_links).toEqual(
|
||||
[application.evidence_id, capture.evidence_id].sort(),
|
||||
);
|
||||
expect(JSON.stringify(report)).not.toMatch(/kept-source-only|fragment|KEY/);
|
||||
const { client, call } = await connectLocalToolsMcp({
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
|
||||
REA_MCP_TOOL_FAMILIES: "application,session",
|
||||
});
|
||||
const schemas = await advertisedMcpToolSchemas(
|
||||
client,
|
||||
"compare_static_and_observed_endpoints",
|
||||
);
|
||||
expect(schemas.ajv.validate(schemas.inputSchema, input)).toBe(true);
|
||||
expect(
|
||||
schemas.ajv.validate(schemas.inputSchema, { ...input, detail: "wrong" }),
|
||||
).toBe(false);
|
||||
const retained = {
|
||||
application: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: application.evidence_id,
|
||||
},
|
||||
capture: { kind: "retained-evidence", evidence_id: capture.evidence_id },
|
||||
origin: input.origin,
|
||||
};
|
||||
let bundleNumber = 0;
|
||||
const importRecords = async (
|
||||
records: Parameters<typeof createEvidenceBundle>[0],
|
||||
) => {
|
||||
const path = join(root, `sources-${bundleNumber++}.bundle.json`);
|
||||
await writeFile(path, JSON.stringify(createEvidenceBundle(records)));
|
||||
const response = await call("import_evidence_bundle", { path });
|
||||
expect(response.isError, JSON.stringify(response.content)).not.toBe(true);
|
||||
};
|
||||
await importRecords([application, capture]);
|
||||
const fullMcp = await call("compare_static_and_observed_endpoints", {
|
||||
...retained,
|
||||
detail: "complete",
|
||||
});
|
||||
expect(fullMcp.isError, JSON.stringify(fullMcp.content)).not.toBe(true);
|
||||
expect(parseEvidence(fullMcp.structuredContent)).toEqual(complete);
|
||||
const summary = await call(
|
||||
"compare_static_and_observed_endpoints",
|
||||
retained,
|
||||
);
|
||||
expect(summary.isError).not.toBe(true);
|
||||
const summaryView = analysisViewResultSchema.parse(
|
||||
parseEvidence(summary.structuredContent).normalized_result,
|
||||
);
|
||||
expect(summaryView).toMatchObject({
|
||||
kind: "summary",
|
||||
parent_evidence_id: complete.evidence_id,
|
||||
summary: {
|
||||
summary: report.summary,
|
||||
source_coverage: report.source_coverage,
|
||||
},
|
||||
});
|
||||
expect(summaryView).not.toHaveProperty("static_endpoints");
|
||||
|
||||
const views = await assertPagedComparison(call, report, complete, retained);
|
||||
await assertCliPublication(context, { complete, summary, views });
|
||||
const tampered = await call("compare_static_and_observed_endpoints", {
|
||||
...retained,
|
||||
capture: { ...capture, normalized_result: {} },
|
||||
});
|
||||
expect(parseMcpToolError(tampered).error).toMatchObject({
|
||||
code: "evidence_integrity_mismatch",
|
||||
});
|
||||
const sourceCapture = webNetworkCaptureSchema.parse(
|
||||
capture.normalized_result,
|
||||
);
|
||||
const replacement = (parameters: object, result: unknown) =>
|
||||
createEvidence(undefined, capture.provider, {
|
||||
operation: capture.operation,
|
||||
parameters,
|
||||
result,
|
||||
confidence: "observed",
|
||||
});
|
||||
const declared = replacement(
|
||||
{ ...capture.parameters, sensitive_value_count: 1 },
|
||||
sourceCapture,
|
||||
);
|
||||
await importRecords([declared]);
|
||||
const refused = await call("compare_static_and_observed_endpoints", {
|
||||
...retained,
|
||||
capture: { kind: "retained-evidence", evidence_id: declared.evidence_id },
|
||||
});
|
||||
expect(parseMcpToolError(refused).error).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
});
|
||||
expect(JSON.stringify(refused)).toContain("sensitive_values_unretained");
|
||||
const subset = replacement(capture.parameters, {
|
||||
...sourceCapture,
|
||||
records: sourceCapture.records.slice(0, 1),
|
||||
});
|
||||
await importRecords([subset]);
|
||||
const limited = await call("compare_static_and_observed_endpoints", {
|
||||
...retained,
|
||||
capture: { kind: "retained-evidence", evidence_id: subset.evidence_id },
|
||||
detail: "complete",
|
||||
});
|
||||
expect(limited.isError).not.toBe(true);
|
||||
expect(
|
||||
staticObservedEndpointComparisonSchema
|
||||
.parse(parseEvidence(limited.structuredContent).normalized_result)
|
||||
.static_endpoints.find(({ path }) => path === "/api/admin/export"),
|
||||
).toMatchObject({ status: "unknown", reason: "capture-incomplete" });
|
||||
expect(await readFile(sourcePath, "utf8")).toBe(source);
|
||||
expect(
|
||||
createHash("sha256")
|
||||
.update(await readFile(harPath))
|
||||
.digest("hex"),
|
||||
).toBe(createHash("sha256").update(harBytes).digest("hex"));
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
# Static endpoint and capture fixture
|
||||
|
||||
This source-owned loopback application was recorded on Linux with Playwright
|
||||
1.64.1 and Chromium headless shell 153.0.8010.12. `recording.har` is the original
|
||||
HAR 1.2 from `browser.newContext({recordHar: {mode: "full", content: "embed"}})`;
|
||||
its SHA-256 is `f0e7d2c869c811d4272a7b2916c6531c8c6cc7938336a35575e2a6b7b6346714`.
|
||||
The recorded origin is `http://127.0.0.1:42845`. Do not reserialize the HAR.
|
||||
|
||||
The server returned the two adjacent text files as `/app.js` and `/`, and JSON
|
||||
for the API paths. It streamed each response in two chunks 40 ms apart. The
|
||||
browser awaited all three fetch response bodies before context close. The five
|
||||
recorded requests are `/`, `/app.js`, `/api/users?ordinary=kept-source-only`,
|
||||
`/users/123` and `/observed-only`. The script's unused function bodies were
|
||||
never called. Transport authentication headers and cookies are absent.
|
||||
|
||||
The public acceptance test copies these sources into a temporary application
|
||||
and runs the production static analyzer, HAR decoder, comparison and retained
|
||||
views through CLI/MCP. It replays this recording offline and needs no browser
|
||||
or network. The recording proves only this small application's selected run.
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
function unusedAdmin() { return fetch('/api/admin/export'); }
|
||||
function templateCandidate() { return fetch('/users/789'); }
|
||||
function neverContacted() { return fetch('https://never-contact.invalid/route'); }
|
||||
function translatedValue() { return translate.get('KEY'); }
|
||||
Promise.all([fetch('/api/users?ordinary=kept-source-only#fragment'), fetch('/users/123'), fetch(window.dynamicTarget)].map(request => request.then(response => response.text()))).then(() => window.done = true);
|
||||
@@ -0,0 +1 @@
|
||||
<script>window.dynamicTarget="/observed-only"</script><script src="/app.js"></script>
|
||||
@@ -0,0 +1 @@
|
||||
{"log":{"version":"1.2","creator":{"name":"Playwright","version":"1.64.1"},"browser":{"name":"chromium","version":"153.0.8010.12"},"pages":[{"startedDateTime":"2026-11-06T03:22:56.796Z","id":"page@07d055c0281abfb821900dbff3f19b1f","title":"","pageTimings":{"onContentLoad":98,"onLoad":98}}],"entries":[{"pageref":"page@07d055c0281abfb821900dbff3f19b1f","startedDateTime":"2026-11-06T03:22:56.799Z","time":48.571000000000005,"request":{"method":"GET","url":"http://127.0.0.1:42845/","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Accept","value":"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"},{"name":"Accept-Encoding","value":"gzip, deflate, br, zstd"},{"name":"Connection","value":"keep-alive"},{"name":"Host","value":"127.0.0.1:42845"},{"name":"Sec-Fetch-Dest","value":"document"},{"name":"Sec-Fetch-Mode","value":"navigate"},{"name":"Sec-Fetch-Site","value":"none"},{"name":"Sec-Fetch-User","value":"?1"},{"name":"Upgrade-Insecure-Requests","value":"1"},{"name":"User-Agent","value":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36"},{"name":"sec-ch-ua","value":"\"HeadlessChrome\";v=\"153\", \"Not_A Brand\";v=\"8\", \"Chromium\";v=\"153\""},{"name":"sec-ch-ua-mobile","value":"?0"},{"name":"sec-ch-ua-platform","value":"\"Linux\""}],"queryString":[],"headersSize":630,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Connection","value":"keep-alive"},{"name":"Content-Type","value":"text/html"},{"name":"Date","value":"Fri, 06 Nov 2026 03:22:56 GMT"},{"name":"Keep-Alive","value":"timeout=5"},{"name":"Transfer-Encoding","value":"chunked"}],"content":{"size":85,"mimeType":"text/html","compression":0,"text":"<script>window.dynamicTarget=\"/observed-only\"</script><script src=\"/app.js\"></script>"},"headersSize":156,"bodySize":102,"redirectURL":"","_transferSize":258},"cache":{},"timings":{"dns":0.063,"connect":0.148,"ssl":1.43,"send":0,"wait":6.175,"receive":40.755},"_frameref":"frame@2eea7869e2d6761b00a3030963155215","_monotonicTime":589.594,"_resourceType":"document","serverIPAddress":"127.0.0.1","_serverPort":42845,"_securityDetails":{}},{"pageref":"page@07d055c0281abfb821900dbff3f19b1f","startedDateTime":"2026-11-06T03:22:56.848Z","time":44.629999999999995,"request":{"method":"GET","url":"http://127.0.0.1:42845/app.js","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Accept","value":"*/*"},{"name":"Accept-Encoding","value":"gzip, deflate, br, zstd"},{"name":"Connection","value":"keep-alive"},{"name":"Host","value":"127.0.0.1:42845"},{"name":"Referer","value":"http://127.0.0.1:42845/"},{"name":"Sec-Fetch-Dest","value":"script"},{"name":"Sec-Fetch-Mode","value":"no-cors"},{"name":"Sec-Fetch-Site","value":"same-origin"},{"name":"User-Agent","value":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36"},{"name":"sec-ch-ua","value":"\"HeadlessChrome\";v=\"153\", \"Not_A Brand\";v=\"8\", \"Chromium\";v=\"153\""},{"name":"sec-ch-ua-mobile","value":"?0"},{"name":"sec-ch-ua-platform","value":"\"Linux\""}],"queryString":[],"headersSize":492,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Connection","value":"keep-alive"},{"name":"Content-Type","value":"text/javascript"},{"name":"Date","value":"Fri, 06 Nov 2026 03:22:56 GMT"},{"name":"Keep-Alive","value":"timeout=5"},{"name":"Transfer-Encoding","value":"chunked"}],"content":{"size":477,"mimeType":"text/javascript","compression":0,"text":"\nfunction unusedAdmin() { return fetch('/api/admin/export'); }\nfunction templateCandidate() { return fetch('/users/789'); }\nfunction neverContacted() { return fetch('https://never-contact.invalid/route'); }\nfunction translatedValue() { return translate.get('KEY'); }\nPromise.all([fetch('/api/users?ordinary=kept-source-only#fragment'), fetch('/users/123'), fetch(window.dynamicTarget)].map(request => request.then(response => response.text()))).then(() => window.done = true);\n"},"headersSize":162,"bodySize":494,"redirectURL":"","_transferSize":656},"cache":{},"timings":{"dns":-1,"connect":-1,"ssl":-1,"send":0,"wait":0.937,"receive":43.693},"_frameref":"frame@2eea7869e2d6761b00a3030963155215","_monotonicTime":638.947,"_resourceType":"script","serverIPAddress":"127.0.0.1","_serverPort":42845,"_securityDetails":{}},{"pageref":"page@07d055c0281abfb821900dbff3f19b1f","startedDateTime":"2026-11-06T03:22:56.894Z","time":44.669000000000004,"request":{"method":"GET","url":"http://127.0.0.1:42845/api/users?ordinary=kept-source-only","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Accept","value":"*/*"},{"name":"Accept-Encoding","value":"gzip, deflate, br, zstd"},{"name":"Connection","value":"keep-alive"},{"name":"Host","value":"127.0.0.1:42845"},{"name":"Referer","value":"http://127Line truncated
|
||||
Reference in new issue
Block a user