feat(analysis-view): report observed query parameter names (#2982)
This commit is contained in:
parent
87a8c8c923
commit
dd36b4a8a0
|
|
@ -65,3 +65,7 @@ Use `inspect_analysis_view` with `view: {kind: "api-surface"}` on retained
|
|||
method, origin and path template. Format matches are inferences, not handler
|
||||
identity or schemas. Group pages cite parent record ordinals; consult record
|
||||
coverage and exclusions before claiming completeness. No requests are replayed.
|
||||
When the connected schema advertises `include_query_parameter_names`, select
|
||||
it in the view for distinct observed names with original record ordinals. Query
|
||||
values are omitted; the names establish neither required parameters nor a
|
||||
complete server schema. Keep the original parent Evidence for exact URLs.
|
||||
|
|
|
|||
|
|
@ -224,6 +224,22 @@ are 0 and 50. The view groups request lines by method, WHATWG URL origin and
|
|||
path template, sorted by origin, template and method. Query strings, fragments,
|
||||
credentials, headers and bodies are not copied into groups.
|
||||
|
||||
Select `include_query_parameter_names: true` in the view to include distinct
|
||||
observed query names per group. The default and explicit `false` omit the
|
||||
`query_parameter_names` field. Selected groups include an array of
|
||||
`{name, record_ordinals}` entries, sorted by Unicode code point; each name's
|
||||
ordinals follow the parent's selected record order and occur once per record.
|
||||
For `/products?category=books&page=1` and
|
||||
`/products?category=games&sort=price`, names are `category`, `page` and `sort`.
|
||||
Query values and fragments are not copied into this inventory.
|
||||
|
||||
Names use WHATWG `URLSearchParams` form-query decoding: literal `+` becomes
|
||||
space, `%2B` becomes `+`, percent escapes decode as UTF-8, and empty names are
|
||||
retained. Empty query pairs are ignored. Original URLs stay in parent Evidence;
|
||||
name decoding does not normalize the recorded paths used for grouping.
|
||||
The inventory describes observed names, without inferring required parameters,
|
||||
value types or a complete API schema. Sensitive-parent refusals still apply.
|
||||
|
||||
| Rule | Exact segment format | Placeholder |
|
||||
| ---------- | ---------------------------------------------------- | ------------ |
|
||||
| `numeric` | ASCII digits only | `{numeric}` |
|
||||
|
|
@ -269,13 +285,16 @@ and validation scratch by walking the parent without cloning it. Inline requests
|
|||
borrow the Evidence value through CLI/MCP envelope validation; the application
|
||||
admits the entire portable envelope before authenticated parsing or hashing.
|
||||
The envelope clone, hash scratch and capture clone stay charged during grouping.
|
||||
Temporary URL/path/MIME work and retained state must
|
||||
Temporary URL/path/MIME/query work and retained state must
|
||||
fit together within a 96 MiB projection representation budget. A whole group or
|
||||
merge is admitted before allocating its retained sets, map entries or array
|
||||
values. Fixed group storage, distinct text, exclusions and limitations count as
|
||||
well as matched segments. Per-record reservations are released after each
|
||||
record; repeated values do not accumulate another retained charge. Only the
|
||||
requested page is materialized as output groups.
|
||||
Opt-in query projection reserves parser tuples and the per-record name set
|
||||
before decoding, then admits distinct retained names and source ordinals before
|
||||
changing a group. Repeated names within a request retain that ordinal once.
|
||||
|
||||
The representation budget uses conservative storage and worst-case JSON-escaping
|
||||
allowances; it is not an enforced V8 heap or process-memory limit. It is separate
|
||||
|
|
|
|||
|
|
@ -49,7 +49,10 @@ export const inspectAnalysisViewValidated = (
|
|||
): Result<Evidence, AnalysisError> => {
|
||||
const accumulator =
|
||||
input.view.kind === "api-surface"
|
||||
? new WebNetworkApiSurfaceAccumulator()
|
||||
? new WebNetworkApiSurfaceAccumulator(
|
||||
undefined,
|
||||
input.view.include_query_parameter_names === true,
|
||||
)
|
||||
: undefined;
|
||||
if (input.source.kind === "inline" && accumulator !== undefined) {
|
||||
const admitted = accumulator.admitInlineEvidence(input.source.evidence);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,36 @@ const add = (
|
|||
if (!result.ok) throw result.error;
|
||||
};
|
||||
|
||||
it("bounds query parser expansion before publishing or merging observed names", () => {
|
||||
const accumulator = new WebNetworkApiSurfaceAccumulator(64 * 1024, true);
|
||||
add(accumulator, record(0, "/products?kept=1"));
|
||||
const before = accumulator.page(0, 1);
|
||||
expect(
|
||||
accumulator.add(record(1, `/products?${"x=1&".repeat(200)}`)),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisResourceConstraintError" },
|
||||
});
|
||||
expect(accumulator.page(0, 1)).toEqual(before);
|
||||
add(accumulator, record(2, "/products?after=2"));
|
||||
expect(accumulator.page(0, 1).groups[0]?.query_parameter_names).toEqual([
|
||||
{ name: "after", record_ordinals: [2] },
|
||||
{ name: "kept", record_ordinals: [0] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("charges each retained name once and each name's ordinal once per request", () => {
|
||||
const accumulator = new WebNetworkApiSurfaceAccumulator(1024 * 1024, true);
|
||||
for (let ordinal = 0; ordinal < 1000; ordinal += 1)
|
||||
add(accumulator, record(ordinal, "/products?category=one&category=two"));
|
||||
expect(accumulator.page(0, 1).groups[0]?.query_parameter_names).toEqual([
|
||||
{
|
||||
name: "category",
|
||||
record_ordinals: Array.from({ length: 1000 }, (_, index) => index),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("admits the capture schema clone and grouping against the same budget", () => {
|
||||
const sample = record(0, `/${"a".repeat(1200)}`);
|
||||
const isolated = new WebNetworkApiSurfaceAccumulator(64 * 1024);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import {
|
|||
} from "../analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js";
|
||||
import { parseRecordedTransportUrl } from "../recordedTransportUrl.js";
|
||||
import {
|
||||
parseRecordedTransportUrl,
|
||||
parseUrlQueryParameterNames,
|
||||
} from "../recordedTransportUrl.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { MAX_JSON_DEPTH } from "../jsonValue.js";
|
||||
import {
|
||||
|
|
@ -41,6 +44,7 @@ interface GroupState {
|
|||
readonly ordinals: number[];
|
||||
readonly statuses: Set<number>;
|
||||
readonly contentTypes: Set<string>;
|
||||
readonly queryNames?: Map<string, number[]>;
|
||||
}
|
||||
type Buckets = Map<string, Map<string, Map<string, Map<string, GroupState>>>>;
|
||||
|
||||
|
|
@ -120,7 +124,10 @@ export class WebNetworkApiSurfaceAccumulator {
|
|||
#retainedBytes = 0;
|
||||
#temporaryBytes = 0;
|
||||
|
||||
constructor(readonly maximumBytes = WEB_NETWORK_CAPTURE_LIMITS.outputBytes) {}
|
||||
constructor(
|
||||
readonly maximumBytes = WEB_NETWORK_CAPTURE_LIMITS.outputBytes,
|
||||
readonly includeQueryParameterNames = false,
|
||||
) {}
|
||||
|
||||
#retain(bytes: number): boolean {
|
||||
if (this.#retainedBytes + this.#temporaryBytes + bytes > this.maximumBytes)
|
||||
|
|
@ -305,7 +312,9 @@ export class WebNetworkApiSurfaceAccumulator {
|
|||
if (!this.#temporary(RECORD_SCRATCH_BYTES + textBytes(url.length)))
|
||||
return this.#overBudget();
|
||||
try {
|
||||
const parsed = parseRecordedTransportUrl(url);
|
||||
const parsed = parseRecordedTransportUrl(url, {
|
||||
includeQuery: this.includeQueryParameterNames,
|
||||
});
|
||||
if (parsed.origin === null)
|
||||
return this.#exclude(record.ordinal, "url-unparseable");
|
||||
if (typeof method !== "string" || method.length === 0)
|
||||
|
|
@ -325,6 +334,8 @@ export class WebNetworkApiSurfaceAccumulator {
|
|||
return this.#overBudget();
|
||||
const route = this.#route(parsed.path);
|
||||
if (!route.ok) return route;
|
||||
const queryNames = this.#queryNames(parsed.query);
|
||||
if (!queryNames.ok) return queryNames;
|
||||
return this.#append(
|
||||
record.ordinal,
|
||||
method,
|
||||
|
|
@ -332,6 +343,7 @@ export class WebNetworkApiSurfaceAccumulator {
|
|||
route.value,
|
||||
typeof status === "number" && status !== 0 ? status : null,
|
||||
mediaTypeEssence(rawMime),
|
||||
queryNames.value,
|
||||
);
|
||||
} finally {
|
||||
// Per-record strings and descriptors never become cumulative scratch.
|
||||
|
|
@ -339,6 +351,26 @@ export class WebNetworkApiSurfaceAccumulator {
|
|||
}
|
||||
}
|
||||
|
||||
#queryNames(
|
||||
query: string | undefined,
|
||||
): Result<ReadonlySet<string> | undefined, AnalysisError> {
|
||||
if (query === undefined) return ok(undefined);
|
||||
// Reserve form-parser tuples, decoded strings, the iterator and the name
|
||||
// set before URLSearchParams materializes them. Delimiter count is an upper
|
||||
// bound: empty query pairs are ignored by the WHATWG parser.
|
||||
let pairs = query.length > 1 ? 1 : 0;
|
||||
for (const character of query) if (character === "&") pairs += 1;
|
||||
if (
|
||||
!this.#temporary(
|
||||
RECORD_SCRATCH_BYTES +
|
||||
textBytes(query.length) +
|
||||
pairs * ENTRY_BYTES * 4,
|
||||
)
|
||||
)
|
||||
return this.#overBudget();
|
||||
return ok(new Set(parseUrlQueryParameterNames(query)));
|
||||
}
|
||||
|
||||
#append(
|
||||
ordinal: number,
|
||||
method: string,
|
||||
|
|
@ -346,6 +378,7 @@ export class WebNetworkApiSurfaceAccumulator {
|
|||
route: Route,
|
||||
status: number | null,
|
||||
mime: string | null,
|
||||
queryNames: ReadonlySet<string> | undefined,
|
||||
): Result<void, AnalysisError> {
|
||||
let group = this.#buckets
|
||||
.get(method)
|
||||
|
|
@ -371,6 +404,14 @@ export class WebNetworkApiSurfaceAccumulator {
|
|||
if (status !== null && !group?.statuses.has(status)) bytes += ENTRY_BYTES;
|
||||
if (mime !== null && !group?.contentTypes.has(mime))
|
||||
bytes += ENTRY_BYTES + textBytes(mime.length);
|
||||
if (queryNames !== undefined) {
|
||||
if (group === undefined) bytes += CAPTURE_CONTAINER_BYTES;
|
||||
for (const name of queryNames) {
|
||||
bytes += ENTRY_BYTES; // This name's source ordinal, once per record.
|
||||
if (!group?.queryNames?.has(name))
|
||||
bytes += ENTRY_BYTES + textBytes(name.length);
|
||||
}
|
||||
}
|
||||
// Reserve the whole mutation while route scratch is still live. No sets,
|
||||
// map entries, arrays or new observed values are retained before admission.
|
||||
if (!this.#retain(bytes)) return this.#overBudget();
|
||||
|
|
@ -387,6 +428,7 @@ export class WebNetworkApiSurfaceAccumulator {
|
|||
ordinals: [],
|
||||
statuses: new Set(),
|
||||
contentTypes: new Set(),
|
||||
...(queryNames === undefined ? {} : { queryNames: new Map() }),
|
||||
};
|
||||
const byOrigin = this.#buckets.get(method) ?? new Map();
|
||||
this.#buckets.set(method, byOrigin);
|
||||
|
|
@ -404,6 +446,13 @@ export class WebNetworkApiSurfaceAccumulator {
|
|||
if (status === null || mime === null) this.#unavailableResponse = true;
|
||||
if (status !== null) group.statuses.add(status);
|
||||
if (mime !== null) group.contentTypes.add(mime);
|
||||
if (queryNames !== undefined && group.queryNames !== undefined) {
|
||||
for (const name of queryNames) {
|
||||
const ordinals = group.queryNames.get(name);
|
||||
if (ordinals === undefined) group.queryNames.set(name, [ordinal]);
|
||||
else ordinals.push(ordinal);
|
||||
}
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
|
|
@ -440,6 +489,16 @@ export class WebNetworkApiSurfaceAccumulator {
|
|||
record_ordinals: [...group.ordinals],
|
||||
statuses: [...group.statuses].sort((a, b) => a - b),
|
||||
content_types: [...group.contentTypes].sort(compare),
|
||||
...(group.queryNames === undefined
|
||||
? {}
|
||||
: {
|
||||
query_parameter_names: [...group.queryNames]
|
||||
.sort(([a], [b]) => compare(a, b))
|
||||
.map(([name, record_ordinals]) => ({
|
||||
name,
|
||||
record_ordinals: [...record_ordinals],
|
||||
})),
|
||||
}),
|
||||
flags: [
|
||||
...(group.ordinals.length === 1 ? ["single-sample" as const] : []),
|
||||
...(group.statuses.size > 1 ? ["mixed-statuses" as const] : []),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const project = (
|
|||
offset = 0,
|
||||
limit = 50,
|
||||
parameters: unknown = { sensitive_value_count: 0 },
|
||||
includeQueryParameterNames?: boolean,
|
||||
) => {
|
||||
const parent = analysisViewCaptureEvidence(capture);
|
||||
return projectAnalysisView(
|
||||
|
|
@ -20,9 +21,111 @@ const project = (
|
|||
limitations: [],
|
||||
parameters,
|
||||
},
|
||||
{ kind: "api-surface", offset, limit },
|
||||
{
|
||||
kind: "api-surface",
|
||||
offset,
|
||||
limit,
|
||||
...(includeQueryParameterNames === undefined
|
||||
? {}
|
||||
: {
|
||||
include_query_parameter_names: includeQueryParameterNames,
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
it("selects query names without values, retains source ordinals and leaves default groups unchanged", () => {
|
||||
const capture = analysisViewCaptureFixture();
|
||||
capture.records = capture.records.slice(0, 3);
|
||||
capture.total_records = 3;
|
||||
const urls = [
|
||||
"https://api.test/products?category=books&page=1&category=repeat#fragment-only",
|
||||
"https://api.test/products?category=games&sort=price",
|
||||
"https://api.test/health",
|
||||
];
|
||||
for (const [index, record] of capture.records.entries())
|
||||
record.reported = {
|
||||
request: { method: "GET", url: urls[index]! },
|
||||
response: { status: 200, content: { mimeType: "application/json" } },
|
||||
};
|
||||
const selected = project(capture, 0, 50, { sensitive_value_count: 0 }, true);
|
||||
const omitted = project(capture);
|
||||
const disabled = project(capture, 0, 50, { sensitive_value_count: 0 }, false);
|
||||
if (
|
||||
!selected.ok ||
|
||||
selected.value.kind !== "api-surface" ||
|
||||
!omitted.ok ||
|
||||
omitted.value.kind !== "api-surface" ||
|
||||
!disabled.ok ||
|
||||
disabled.value.kind !== "api-surface"
|
||||
)
|
||||
throw new Error("Expected surfaces");
|
||||
expect(disabled.value.groups).toEqual(omitted.value.groups);
|
||||
expect(selected.value.groups).toEqual([
|
||||
{ ...omitted.value.groups[0], query_parameter_names: [] },
|
||||
{
|
||||
...omitted.value.groups[1],
|
||||
query_parameter_names: [
|
||||
{ name: "category", record_ordinals: [0, 1] },
|
||||
{ name: "page", record_ordinals: [0] },
|
||||
{ name: "sort", record_ordinals: [1] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
for (const literal of ["books", "games", "repeat", "price", "fragment-only"])
|
||||
expect(JSON.stringify(selected.value)).not.toContain(literal);
|
||||
expect(selected.value.parent_evidence_id).toBe(
|
||||
omitted.value.parent_evidence_id,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves decoded empty and Unicode query names, plus distinctions and caller ordinal order", () => {
|
||||
const capture = analysisViewCaptureFixture();
|
||||
capture.records = [capture.records[3]!, capture.records[1]!];
|
||||
const urls = [
|
||||
"https://api.test/a/%2e%2e/products?b=1&a+b=2&a%2Bb=3&=4&%E9%9B%AA=5&b=6&&%FF=7&__proto__=8#ignored=9",
|
||||
"https://api.test/a/%2e%2e/products?b=10",
|
||||
];
|
||||
capture.records.forEach((record, index) => {
|
||||
record.reported = { request: { method: "GET", url: urls[index]! } };
|
||||
});
|
||||
const result = project(capture, 0, 1, { sensitive_value_count: 0 }, true);
|
||||
if (!result.ok || result.value.kind !== "api-surface")
|
||||
throw new Error("Expected surface");
|
||||
expect(result.value.groups[0]).toMatchObject({
|
||||
path_template: "/a/%2e%2e/products",
|
||||
record_ordinals: [3, 1],
|
||||
query_parameter_names: [
|
||||
{ name: "", record_ordinals: [3] },
|
||||
{ name: "__proto__", record_ordinals: [3] },
|
||||
{ name: "a b", record_ordinals: [3] },
|
||||
{ name: "a+b", record_ordinals: [3] },
|
||||
{ name: "b", record_ordinals: [3, 1] },
|
||||
{ name: "雪", record_ordinals: [3] },
|
||||
{ name: "<22>", record_ordinals: [3] },
|
||||
],
|
||||
});
|
||||
expect(result.value.records.total).toBe(15);
|
||||
expect(result.value.limitations.join(" ")).toContain("ordinal subset");
|
||||
});
|
||||
|
||||
it("preserves sensitive-parent refusal when query names are selected", () => {
|
||||
expect(
|
||||
project(
|
||||
analysisViewCaptureFixture(),
|
||||
0,
|
||||
50,
|
||||
{ sensitive_value_count: 1 },
|
||||
true,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisUnsupportedTargetError",
|
||||
reason: "sensitive_values_unretained",
|
||||
},
|
||||
});
|
||||
});
|
||||
it("groups only exact formats, preserves literal routes, excludes URLs and produces deterministic digests", () => {
|
||||
const result = project();
|
||||
if (!result.ok || result.value.kind !== "api-surface")
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ export const apiSurfaceViewSchema = z.strictObject({
|
|||
kind: z.literal("api-surface"),
|
||||
offset: z.number().int().nonnegative().default(0),
|
||||
limit: z.number().int().positive().default(50),
|
||||
include_query_parameter_names: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Include distinct observed query parameter names and their original record ordinals; names do not establish required parameters or a complete API schema.",
|
||||
),
|
||||
});
|
||||
const ruleSchema = z.enum(["numeric", "uuid", "hex", "iso-date"]);
|
||||
const groupSchema = z.strictObject({
|
||||
|
|
@ -32,6 +38,14 @@ const groupSchema = z.strictObject({
|
|||
record_ordinals: z.array(z.number().int().nonnegative()),
|
||||
statuses: z.array(z.number()),
|
||||
content_types: z.array(z.string()),
|
||||
query_parameter_names: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
name: z.string(),
|
||||
record_ordinals: z.array(z.number().int().nonnegative()),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
flags: z.array(
|
||||
z.enum(["single-sample", "mixed-statuses", "mixed-content-types"]),
|
||||
),
|
||||
|
|
@ -66,7 +80,10 @@ export const apiSurfaceFields = {
|
|||
export const projectWebNetworkApiSurfaceView = (
|
||||
parent: AnalysisViewParent,
|
||||
view: AnalysisViewRequest,
|
||||
accumulator = new WebNetworkApiSurfaceAccumulator(),
|
||||
accumulator = new WebNetworkApiSurfaceAccumulator(
|
||||
undefined,
|
||||
view.kind === "api-surface" && view.include_query_parameter_names === true,
|
||||
),
|
||||
): Result<UnsignedAnalysisView, AnalysisError> => {
|
||||
const unsupported = (reason: string) =>
|
||||
err(
|
||||
|
|
@ -111,6 +128,11 @@ export const projectWebNetworkApiSurfaceView = (
|
|||
...parent.limitations,
|
||||
...capture.limitations,
|
||||
"Grouping does not establish handler identity, URL-space schemas or causality.",
|
||||
...(view.include_query_parameter_names === true
|
||||
? [
|
||||
"Observed query parameter names do not establish required parameters, value types or a complete API schema.",
|
||||
]
|
||||
: []),
|
||||
...(capture.records.length < capture.total_records
|
||||
? [
|
||||
"Parent contains an ordinal subset; the surface covers only retained records.",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { urlDeclaresQueryOrFragment } from "./urlComponents.js";
|
||||
import { parseUrlQueryParameterNames } from "./recordedTransportUrl.js";
|
||||
|
||||
const parseExactOrigin = (value: string): string | undefined => {
|
||||
let url: URL;
|
||||
|
|
@ -254,7 +255,7 @@ export const sanitizeBrowserUrl = (value: string): SanitizedBrowserUrl => {
|
|||
};
|
||||
}
|
||||
const hadCredentials = parsed.username !== "" || parsed.password !== "";
|
||||
const names = [...parsed.searchParams.keys()];
|
||||
const names = [...parseUrlQueryParameterNames(parsed.search)];
|
||||
const sanitizedUrl = hadCredentials
|
||||
? removeUrlUserInfo(value, parsed)
|
||||
: value;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
export interface RecordedTransportUrl {
|
||||
readonly origin: string | null;
|
||||
readonly path: string | null;
|
||||
/** WHATWG query spelling, present only when explicitly selected for projection. */
|
||||
readonly query?: string;
|
||||
readonly credentials:
|
||||
| { readonly start: number; readonly end: number }
|
||||
| "ambiguous"
|
||||
|
|
@ -15,6 +17,7 @@ export interface RecordedTransportUrl {
|
|||
*/
|
||||
export const parseRecordedTransportUrl = (
|
||||
value: string,
|
||||
options: { readonly includeQuery?: boolean } = {},
|
||||
): RecordedTransportUrl => {
|
||||
const prefix = /^[\x00-\x20]*(?:([A-Za-z][A-Za-z0-9+.-]*):)?\/\//u.exec(
|
||||
value,
|
||||
|
|
@ -67,9 +70,22 @@ export const parseRecordedTransportUrl = (
|
|||
(special && value[authorityEnd] === "\\")
|
||||
)
|
||||
return { origin, path: null, credentials };
|
||||
if (value[authorityEnd] !== "/") return { origin, path: "/", credentials };
|
||||
const query =
|
||||
options.includeQuery === true ? { query: absolute!.search } : {};
|
||||
if (value[authorityEnd] !== "/")
|
||||
return { origin, path: "/", credentials, ...query };
|
||||
let pathEnd = authorityEnd;
|
||||
while (pathEnd < value.length && !"?#".includes(value[pathEnd]!))
|
||||
pathEnd += 1;
|
||||
return { origin, path: value.slice(authorityEnd, pathEnd), credentials };
|
||||
return {
|
||||
origin,
|
||||
path: value.slice(authorityEnd, pathEnd),
|
||||
credentials,
|
||||
...query,
|
||||
};
|
||||
};
|
||||
|
||||
/** WHATWG form-query name decoding; preserve order, repeats and empty names. */
|
||||
export const parseUrlQueryParameterNames = (
|
||||
query: string,
|
||||
): IterableIterator<string> => new URLSearchParams(query).keys();
|
||||
|
|
|
|||
|
|
@ -20,6 +20,56 @@ import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.
|
|||
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import { inspectEvidenceBundle } from "../../../src/application/investigation/InspectEvidenceBundle.js";
|
||||
|
||||
cliTest(
|
||||
"projects opted-in query names through compiled CLI without query values",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-query-names-cli-");
|
||||
const path = join(root, "view.json");
|
||||
const capture = analysisViewCaptureFixture();
|
||||
capture.records = capture.records.slice(0, 2);
|
||||
capture.total_records = 2;
|
||||
capture.records.forEach((record, index) => {
|
||||
record.reported = {
|
||||
request: {
|
||||
method: "GET",
|
||||
url:
|
||||
index === 0
|
||||
? "https://api.test/products?category=books&page=1&category=repeat"
|
||||
: "https://api.test/products?category=games&sort=price#ignored",
|
||||
},
|
||||
};
|
||||
});
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
source: {
|
||||
kind: "inline",
|
||||
evidence: analysisViewCaptureEvidence(capture),
|
||||
},
|
||||
view: { kind: "api-surface", include_query_parameter_names: true },
|
||||
}),
|
||||
);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-analysis-view", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const evidence = parseEvidence(result.json);
|
||||
expect(evidence.normalized_result).toMatchObject({
|
||||
groups: [
|
||||
{
|
||||
query_parameter_names: [
|
||||
{ name: "category", record_ordinals: [0, 1] },
|
||||
{ name: "page", record_ordinals: [0] },
|
||||
{ name: "sort", record_ordinals: [1] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
for (const value of ["books", "games", "repeat", "price", "ignored"])
|
||||
expect(result.stdout).not.toContain(value);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"projects portable artifact inventory after the original ZIP is removed",
|
||||
async ({ cli }) => {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,57 @@ const connect = async (
|
|||
return { client, session };
|
||||
};
|
||||
|
||||
it("projects opted-in query names identically from retained and portable MCP Evidence", async () => {
|
||||
const { client, session } = await connect();
|
||||
const capture = analysisViewCaptureFixture();
|
||||
capture.records = capture.records.slice(0, 2);
|
||||
capture.total_records = 2;
|
||||
capture.records.forEach((record, index) => {
|
||||
record.reported = {
|
||||
request: {
|
||||
method: "GET",
|
||||
url:
|
||||
index === 0
|
||||
? "https://api.test/products?a+b=one&a%2Bb=two&=three&a+b=four"
|
||||
: "https://api.test/products?a+b=five&snow=six#ignored",
|
||||
},
|
||||
};
|
||||
});
|
||||
const parent = analysisViewCaptureEvidence(capture);
|
||||
const retained = session.recordEvidence(parent);
|
||||
if (!retained.ok) throw retained.error;
|
||||
const outputs = [];
|
||||
for (const source of [
|
||||
{ kind: "inline", evidence: parent },
|
||||
{ kind: "retained-evidence", evidence_id: parent.evidence_id },
|
||||
]) {
|
||||
const response = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: {
|
||||
source,
|
||||
view: { kind: "api-surface", include_query_parameter_names: true },
|
||||
},
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
const evidence = parseEvidence(response.structuredContent);
|
||||
expect(evidence.normalized_result).toMatchObject({
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
groups: [
|
||||
{
|
||||
query_parameter_names: [
|
||||
{ name: "", record_ordinals: [0] },
|
||||
{ name: "a b", record_ordinals: [0, 1] },
|
||||
{ name: "a+b", record_ordinals: [0] },
|
||||
{ name: "snow", record_ordinals: [1] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
outputs.push(evidence.normalized_result);
|
||||
}
|
||||
expect(outputs[0]).toEqual(outputs[1]);
|
||||
});
|
||||
|
||||
it("discovers an oversized native dossier by exact metadata and reads its retained view", async () => {
|
||||
const { client, session } = await connect();
|
||||
const advertised = (await client.listTools()).tools.find(
|
||||
|
|
|
|||
Loading…
Reference in New Issue