mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 18:05:45 +00:00
fix(environment): state scenario redaction and body budgets as they behave
A response body that redaction emptied, because a declared secret could not be cut out, was replayed as an empty reply; it is now treated as not retained. Bodies that kept redaction markers are counted in a manifest limitation, since their tools replay the redacted bytes. The no-body limitation counted transactions no tool can cite and said their tools replay status and content type, which a failed request does not have; it now counts records with a usable request line and says either value may be absent. The tool contract and capture docs now say a scenario parent relies on capture-time redaction instead of the sensitive-values refusal, the MCP contract page lists scenario parents for environment-manifest, and a scenario parent asked for another view is told the one it supports. The browser scenario example raised max_network_body_bytes alone, but the allocation budgets drop a body far below 256 KiB; it now raises them and says how body and event budgets differ. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d2416ca542
commit
655e623341
9 files changed
+144
-16
No files matched your search
@@ -36,8 +36,18 @@ member of `view.json` is the whole of `capture.json`.
|
||||
The capture can also come from REA itself: a
|
||||
[browser scenario](browser-scenario-contract.md) records a scripted session
|
||||
in a browser REA launches, and its Evidence is a manifest parent like a HAR
|
||||
inspection. Select the `network` event family and response bodies, and raise
|
||||
the body budget to the 256 KiB a replayed reply can carry:
|
||||
inspection. Select the `network` event family and response bodies. A replayed
|
||||
reply carries up to 256 KiB of a recorded body, but the scenario keeps at most
|
||||
`max_network_body_bytes` of each body (64 KiB by default), and only within its
|
||||
allocation budgets: base64 is charged about 11 bytes per body byte, and
|
||||
redaction more the shorter a declared secret is (about 35 bytes per body byte
|
||||
for a 12-character `admin_password`). A body over
|
||||
`max_network_body_allocation_bytes` (1 MiB by default) is kept as `unavailable`
|
||||
with that reason; one over `max_event_allocation_bytes` (4 MiB) or
|
||||
`max_total_event_allocation_bytes` (16 MiB) loses its whole content event,
|
||||
headers included, which the capture lists among its omissions. Either way its
|
||||
tool replays no body. The example raises all four so that 256 KiB bodies are
|
||||
kept when the declared secret has at least 12 characters:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -79,7 +89,12 @@ the body budget to the 256 KiB a replayed reply can carry:
|
||||
"response_body": true,
|
||||
"header_values": false
|
||||
},
|
||||
"limits": { "max_network_body_bytes": 262144 }
|
||||
"limits": {
|
||||
"max_network_body_bytes": 262144,
|
||||
"max_network_body_allocation_bytes": 12582912,
|
||||
"max_event_allocation_bytes": 12582912,
|
||||
"max_total_event_allocation_bytes": 67108864
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -98,7 +113,9 @@ unrepresented too. Tools are chosen by response media type, so a scenario that
|
||||
selected neither response bodies nor header values is refused. The browser
|
||||
scenario redacts credential headers and the secrets it declares; values the
|
||||
application returns in response bodies, such as session tokens, are kept, and
|
||||
the replayed body is the bytes the browser exposes for each response.
|
||||
the replayed body is the bytes the browser exposes for each response, with any
|
||||
declared secret replaced by its redaction marker (a body that redaction had to
|
||||
empty replays no body).
|
||||
|
||||
## What a manifest is
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ no group was templated.
|
||||
See [derived API surfaces](web-network-captures.md#derived-api-surface).
|
||||
|
||||
`inspect_analysis_view` also accepts `environment-manifest` for retained HAR capture
|
||||
Evidence, projecting the primary origin's request lines into named tools with path
|
||||
Evidence and `capture_browser_scenario` Evidence, projecting the primary origin's request lines into named tools with path
|
||||
templates, parameters, observed query names, value-free body shapes and record
|
||||
pointers, plus excluded groups and three fixed unknowns. Templates, parameters and
|
||||
names are inferences; the Evidence is inferred whenever any tool or excluded group
|
||||
|
||||
@@ -39,8 +39,13 @@ change.
|
||||
bytes and their integrity checks are unchanged.
|
||||
- The manifest result names `parent_operation: "capture_browser_scenario"`,
|
||||
carries an empty `artifact` (`path: ""`, `sha256: null`) and states, as
|
||||
limitations, the ordinal rule, any dropped capture events and how many
|
||||
transactions retained no response body.
|
||||
limitations, the ordinal rule, any dropped capture events, how many
|
||||
transactions with a usable request line retained no response body (tools
|
||||
citing them replay only the recorded status and content type, either of
|
||||
which may be absent) and how many retained response bodies carry redaction
|
||||
markers. A body that redaction emptied, because a declared secret could not
|
||||
be cut out, is treated as not retained rather than replayed as an empty
|
||||
reply.
|
||||
- `environment-serve` and `environment-export` authenticate either parent
|
||||
kind through the same loader, project scenario transactions through the same
|
||||
adapter, and keep the cross-check that the manifest's parent is the supplied
|
||||
|
||||
@@ -407,7 +407,10 @@ whenever it is nonzero the Evidence is inferred, otherwise derived. The view
|
||||
performs no network activity and replays nothing. Native mitmproxy, SAZ and
|
||||
qlog captures, and parents inspected with `sensitive_values`, return the same
|
||||
typed unsupported errors as the api-surface view, including the canonical
|
||||
token `sensitive_values_unretained`.
|
||||
token `sensitive_values_unretained`. A `capture_browser_scenario` parent is not
|
||||
refused for declaring secrets: the capture redacted them as it recorded, a
|
||||
transaction whose URL was redacted is left unrepresented, and a response body
|
||||
keeps the capture's redaction markers, which a manifest limitation counts.
|
||||
|
||||
The manifest accumulator shares the api-surface admission model: the capture
|
||||
clone, the inline envelope, record pointers, groups, every route part, observed
|
||||
|
||||
@@ -69,6 +69,23 @@ describe("environment manifests from capture_browser_scenario parents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("names the one view a scenario capture supports when another is requested", () => {
|
||||
const refused = inspectAnalysisView({
|
||||
source: {
|
||||
kind: "inline",
|
||||
evidence: scenarioCaptureEvidence(scenarioCaptureFixture()),
|
||||
},
|
||||
view: { kind: "summary" },
|
||||
});
|
||||
if (refused.ok) throw new Error("Expected the unsupported view refusal");
|
||||
expect(projectAnalysisError(refused.error)).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
message: expect.stringContaining(
|
||||
"supports only the environment-manifest view",
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it("serves and exports a scenario-built manifest from the same capture", () => {
|
||||
const source = loadEnvironmentSource(fixture.manifest, fixture.capture);
|
||||
if (!source.ok) throw source.error;
|
||||
|
||||
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_analysis_view"),
|
||||
kind: "application",
|
||||
description:
|
||||
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; a transaction whose URL was redacted is left unrepresented. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishesLine truncated
|
||||
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recordeLine truncated
|
||||
inputSchema: inspectAnalysisViewInputSchema,
|
||||
outputSchema: evidenceResultOf(analysisViewResultSchema),
|
||||
examples: [
|
||||
|
||||
@@ -815,7 +815,9 @@ const unsupportedParent = (
|
||||
new AnalysisUnsupportedTargetError(
|
||||
INSPECT_ANALYSIS_VIEW_OPERATION,
|
||||
parentArtifactPath(parent),
|
||||
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact or member, Android package, Dart AOT, or web network capture Evidence.`,
|
||||
parent.operation === "capture_browser_scenario"
|
||||
? `Parent Evidence operation "capture_browser_scenario" supports only the environment-manifest view.`
|
||||
: `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact or member, Android package, Dart AOT, or web network capture Evidence.`,
|
||||
);
|
||||
|
||||
const parentArtifactPath = (parent: AnalysisViewParent): string => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -56,7 +58,7 @@ describe("scenarioCaptureRecords", () => {
|
||||
request: { method: "GET", url: "http://127.0.0.1:3101/api/slow" },
|
||||
});
|
||||
expect(projected.limitations).toContain(
|
||||
"1 of 8 scenario transactions retained no response body; their tools replay status and content type only.",
|
||||
"1 of 8 scenario transactions with a usable request line retained no response body; tools citing them replay only the recorded status and content type, either of which may be absent.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -73,9 +75,65 @@ describe("scenarioCaptureRecords", () => {
|
||||
items: [...capture.events.items, { ...response, sequence: 99 }],
|
||||
},
|
||||
};
|
||||
const projected = scenarioCaptureRecords(duplicated, "manifest");
|
||||
expect(projected.records[0]?.reported).toEqual({});
|
||||
// No manifest tool can cite it, so it is not counted among bodiless tools.
|
||||
expect(
|
||||
scenarioCaptureRecords(duplicated, "manifest").records[0]?.reported,
|
||||
).toEqual({});
|
||||
projected.limitations.some((limitation) =>
|
||||
limitation.includes("retained no response body"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("replays a redacted body's markers, counts it, and treats a body redaction emptied as not retained", () => {
|
||||
const capture = scenarioCaptureFixture(SCENARIO_EXCHANGES.slice(1, 3));
|
||||
const redacted =
|
||||
(transaction: string, bytes: Buffer) =>
|
||||
(event: (typeof capture.events.items)[number]) =>
|
||||
event.kind === "network-content" &&
|
||||
event.phase === "response" &&
|
||||
event.transaction_id === transaction &&
|
||||
event.body.state === "captured"
|
||||
? {
|
||||
...event,
|
||||
body: {
|
||||
...event.body,
|
||||
content: bytes.toString("base64"),
|
||||
bytes: bytes.length,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
redacted: true,
|
||||
},
|
||||
}
|
||||
: event;
|
||||
const marker = Buffer.from('{"token":"[REDACTED:owner_token]"}');
|
||||
const projected = scenarioCaptureRecords(
|
||||
{
|
||||
...capture,
|
||||
events: {
|
||||
...capture.events,
|
||||
items: capture.events.items
|
||||
.map(redacted("request-1", marker))
|
||||
.map(redacted("request-2", Buffer.alloc(0))),
|
||||
},
|
||||
},
|
||||
"manifest",
|
||||
);
|
||||
expect(projected.records[0]?.reported).toMatchObject({
|
||||
response: { content: { text: marker.toString("base64") } },
|
||||
});
|
||||
expect(projected.records[1]?.reported).toMatchObject({
|
||||
response: { status: 200, content: { mimeType: "application/json" } },
|
||||
});
|
||||
expect(projected.records[1]?.reported).not.toHaveProperty(
|
||||
"response.content.text",
|
||||
);
|
||||
expect(projected.records[1]?.binary_fields).toEqual([]);
|
||||
expect(projected.limitations).toEqual(
|
||||
expect.arrayContaining([
|
||||
"1 of 2 scenario transactions with a usable request line retained no response body; tools citing them replay only the recorded status and content type, either of which may be absent.",
|
||||
"1 of 2 scenario transactions retained a response body with declared secret values replaced by redaction markers; tools citing them replay the redacted bytes.",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -96,6 +96,14 @@ const captured = (
|
||||
): CapturedBody | undefined =>
|
||||
content?.body.state === "captured" ? content.body : undefined;
|
||||
|
||||
/** A captured response body, unless redaction emptied it because a secret could not be cut out. */
|
||||
const retainedBody = (
|
||||
content: ContentEvent | undefined,
|
||||
): CapturedBody | undefined => {
|
||||
const body = captured(content);
|
||||
return body?.redacted === true && body.bytes === 0 ? undefined : body;
|
||||
};
|
||||
|
||||
/** Strictly decoded text, keeping a byte-order mark; undefined for bytes that are not UTF-8. */
|
||||
const utf8Text = (body: CapturedBody): string | undefined => {
|
||||
try {
|
||||
@@ -134,7 +142,13 @@ const record = (
|
||||
observed: TransactionIndex["requests"][number],
|
||||
index: TransactionIndex,
|
||||
purpose: "manifest" | "replay",
|
||||
): { readonly record: WebNetworkCaptureRecord; readonly body: boolean } => {
|
||||
): {
|
||||
readonly record: WebNetworkCaptureRecord;
|
||||
/** The record keeps a usable request line, so the manifest may cite it. */
|
||||
readonly represented: boolean;
|
||||
readonly body: boolean;
|
||||
readonly redacted: boolean;
|
||||
} => {
|
||||
const { item, event: request } = observed;
|
||||
const location = {
|
||||
kind: "json-pointer" as const,
|
||||
@@ -159,11 +173,13 @@ const record = (
|
||||
)
|
||||
return {
|
||||
record: { ordinal, location, reported: {}, ...empty },
|
||||
represented: false,
|
||||
body: false,
|
||||
redacted: false,
|
||||
};
|
||||
const requestContent = onlyContent(index, request.sequence);
|
||||
const responseContent = onlyContent(index, response?.sequence);
|
||||
const body = captured(responseContent);
|
||||
const body = retainedBody(responseContent);
|
||||
const mimeType = mediaType(responseContent) ?? "";
|
||||
return {
|
||||
record: {
|
||||
@@ -198,7 +214,9 @@ const record = (
|
||||
},
|
||||
],
|
||||
},
|
||||
represented: !request.url.redacted,
|
||||
body: body !== undefined,
|
||||
redacted: body?.redacted === true,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -210,10 +228,13 @@ export const scenarioCaptureRecords = (
|
||||
const index = indexTransactions(capture.events.items);
|
||||
const records: WebNetworkCaptureRecord[] = [];
|
||||
let withoutBody = 0;
|
||||
let redactedBodies = 0;
|
||||
for (const [ordinal, observed] of index.requests.entries()) {
|
||||
const projected = record(ordinal, observed, index, purpose);
|
||||
records.push(projected.record);
|
||||
if (!projected.represented) continue;
|
||||
if (!projected.body) withoutBody += 1;
|
||||
if (projected.redacted) redactedBodies += 1;
|
||||
}
|
||||
return {
|
||||
records,
|
||||
@@ -227,7 +248,12 @@ export const scenarioCaptureRecords = (
|
||||
: []),
|
||||
...(withoutBody > 0
|
||||
? [
|
||||
`${String(withoutBody)} of ${String(records.length)} scenario transactions retained no response body; their tools replay status and content type only.`,
|
||||
`${String(withoutBody)} of ${String(records.length)} scenario transactions with a usable request line retained no response body; tools citing them replay only the recorded status and content type, either of which may be absent.`,
|
||||
]
|
||||
: []),
|
||||
...(redactedBodies > 0
|
||||
? [
|
||||
`${String(redactedBodies)} of ${String(records.length)} scenario transactions retained a response body with declared secret values replaced by redaction markers; tools citing them replay the redacted bytes.`,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user