feat(analysis-view): report observed query parameter names (#2982)

This commit is contained in:
Nianze Wu 2026-10-14 04:00:21 +08:00 committed by GitHub
parent 87a8c8c923
commit dd36b4a8a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 368 additions and 10 deletions

View File

@ -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.

View File

@ -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

View File

@ -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);

View File

@ -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);

View File

@ -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] : []),

View File

@ -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")

View File

@ -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.",

View File

@ -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;

View File

@ -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();

View File

@ -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 }) => {

View File

@ -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(