mirror of
https://github.com/morluto/rea
synced 2026-10-30 07:39:58 -04: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:
@@ -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;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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