feat: add web and Electron reverse-engineering workflows
This commit is contained in:
+214
-68
@@ -2,28 +2,34 @@
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
import { CdpBrowserProvider } from "../dist/browser/CdpBrowserProvider.js";
|
||||
import {
|
||||
inspectWebPageInputSchema,
|
||||
listBrowserTargetsInputSchema,
|
||||
} from "../dist/domain/browserObservation.js";
|
||||
import { analyzeWebBundleInputSchema } from "../dist/domain/webBundleAnalysis.js";
|
||||
import { observeWebSessionInputSchema } from "../dist/domain/browserSession.js";
|
||||
import { compareWebCapturesInputSchema } from "../dist/domain/webCaptureDiff.js";
|
||||
import {
|
||||
captureWebScreenshotInputSchema,
|
||||
compareWebScreenshotsInputSchema,
|
||||
} from "../dist/domain/webScreenshot.js";
|
||||
import { startBrowserVerifierSite } from "./fixtures/browser-verifier-site.mjs";
|
||||
|
||||
const SECRET_VALUES = [
|
||||
"network-secret-value",
|
||||
"console-secret-value",
|
||||
"storage-secret-value",
|
||||
"websocket-secret-value",
|
||||
"ax-private-label-value",
|
||||
];
|
||||
|
||||
const executable = await browserExecutable();
|
||||
const profile = await mkdtemp(join(tmpdir(), "rea-real-browser-"));
|
||||
const site = await startSite();
|
||||
const site = await startBrowserVerifierSite();
|
||||
let browser;
|
||||
try {
|
||||
browser = spawn(
|
||||
@@ -77,19 +83,113 @@ try {
|
||||
approved: true,
|
||||
target_id: target,
|
||||
observation_ms: 200,
|
||||
include_accessibility_text: true,
|
||||
include_script_sources: true,
|
||||
include_console_text: true,
|
||||
console_text_approved: true,
|
||||
include_json_body_shapes: true,
|
||||
json_body_schema_approved: true,
|
||||
include_websocket_shapes: true,
|
||||
websocket_shape_approved: true,
|
||||
}),
|
||||
);
|
||||
if (!withSource.ok) throw withSource.error;
|
||||
const source = withSource.value.scripts.items.find(
|
||||
(script) =>
|
||||
script.source.included &&
|
||||
script.source.content.includes("reaSourceMarker"),
|
||||
script.source.artifact.text.includes("reaSourceMarker"),
|
||||
);
|
||||
if (source === undefined)
|
||||
throw new Error(
|
||||
"Real Chrome did not return explicitly approved script source",
|
||||
);
|
||||
if (
|
||||
!withSource.value.accessibility.nodes.some((node) =>
|
||||
node.name?.includes("ax-private-label-value"),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Real Chrome did not return independently approved accessibility text",
|
||||
);
|
||||
assertSensitiveShapes(withSource.value);
|
||||
|
||||
const bundle = await provider.analyzeBundle(
|
||||
analyzeWebBundleInputSchema.parse({
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [site.origin],
|
||||
approved: true,
|
||||
target_id: target,
|
||||
observation_ms: 200,
|
||||
source_capture_approved: true,
|
||||
fetch_source_maps: true,
|
||||
source_map_fetch_approved: true,
|
||||
}),
|
||||
);
|
||||
if (!bundle.ok) throw bundle.error;
|
||||
assertBundleAnalysis(bundle.value);
|
||||
|
||||
const captureDiff = await provider.compareCaptures(
|
||||
compareWebCapturesInputSchema.parse({
|
||||
before: { inspection: observed.value },
|
||||
after: { inspection: withSource.value },
|
||||
}),
|
||||
);
|
||||
if (!captureDiff.ok) throw captureDiff.error;
|
||||
if (
|
||||
captureDiff.value.dimensions.dom_structure.status !== "unchanged" ||
|
||||
captureDiff.value.dimensions.scripts.status !== "unchanged"
|
||||
)
|
||||
throw new Error("Real Chrome stable capture identities did not reconcile");
|
||||
|
||||
const screenshot = await provider.captureScreenshot(
|
||||
captureWebScreenshotInputSchema.parse({
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [site.origin],
|
||||
approved: true,
|
||||
screenshot_approved: true,
|
||||
target_id: target,
|
||||
}),
|
||||
);
|
||||
if (!screenshot.ok) throw screenshot.error;
|
||||
if (
|
||||
screenshot.value.viewport.width < 1 ||
|
||||
screenshot.value.viewport.height < 1 ||
|
||||
screenshot.value.artifact.bytes < 1
|
||||
)
|
||||
throw new Error("Real Chrome screenshot artifact was empty");
|
||||
const screenshotDiff = await provider.compareScreenshots(
|
||||
compareWebScreenshotsInputSchema.parse({
|
||||
before: screenshot.value.artifact,
|
||||
after: screenshot.value.artifact,
|
||||
}),
|
||||
);
|
||||
if (!screenshotDiff.ok) throw screenshotDiff.error;
|
||||
if (
|
||||
screenshotDiff.value.status !== "identical" ||
|
||||
screenshotDiff.value.changed_pixels !== 0
|
||||
)
|
||||
throw new Error("Real Chrome PNG artifact did not compare identically");
|
||||
|
||||
const sessionPromise = provider.observeSession(
|
||||
observeWebSessionInputSchema.parse({
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [site.origin],
|
||||
approved: true,
|
||||
target_id: target,
|
||||
observation_ms: 1_500,
|
||||
}),
|
||||
);
|
||||
await delay(250);
|
||||
site.triggerSessionNavigation();
|
||||
const session = await sessionPromise;
|
||||
if (!session.ok) throw session.error;
|
||||
if (
|
||||
!session.value.timeline.some(
|
||||
({ type }) => type === "same_document_navigation",
|
||||
) ||
|
||||
!session.value.target.final_url?.includes("/app/session-1")
|
||||
)
|
||||
throw new Error("Real Chrome same-origin SPA timeline was missing");
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
@@ -102,6 +202,10 @@ try {
|
||||
networkRequests: observed.value.network.requests.length,
|
||||
consoleEvents: observed.value.console.events.length,
|
||||
websocketEvents: observed.value.network.websocket_events.length,
|
||||
bundleScripts: bundle.value.capture.scripts_analyzed,
|
||||
sourceMaps: bundle.value.observations.source_maps.processed,
|
||||
sessionEvents: session.value.timeline.length,
|
||||
screenshotBytes: screenshot.value.artifact.bytes,
|
||||
verified: true,
|
||||
})}\n`,
|
||||
);
|
||||
@@ -178,6 +282,13 @@ function assertObservation(result, origin) {
|
||||
);
|
||||
if (result.accessibility.nodes.length < 1)
|
||||
throw new Error("Real Chrome accessibility observation was empty");
|
||||
if (
|
||||
result.accessibility.text_capture.status !== "not_approved" ||
|
||||
result.accessibility.nodes.some(
|
||||
(node) => node.name !== null || node.description !== null,
|
||||
)
|
||||
)
|
||||
throw new Error("Accessibility text was retained without approval");
|
||||
if (!result.scripts.items.some((script) => script.url.includes("/app.js")))
|
||||
throw new Error("Real Chrome script metadata was missing");
|
||||
if (!result.resources.some((resource) => resource.url.includes("/app.js")))
|
||||
@@ -186,86 +297,121 @@ function assertObservation(result, origin) {
|
||||
throw new Error(
|
||||
"Real Chrome attach-window network observation was missing",
|
||||
);
|
||||
const initiatedRequest = result.network.requests.find((request) =>
|
||||
request.url.includes("/api"),
|
||||
);
|
||||
if (
|
||||
!initiatedRequest?.initiator.url?.includes("/app.js") ||
|
||||
initiatedRequest.initiator.line === null ||
|
||||
initiatedRequest.initiator.column === null
|
||||
)
|
||||
throw new Error("Real Chrome script initiator stack was not normalized");
|
||||
if (result.console.events.length < 1)
|
||||
throw new Error(
|
||||
"Real Chrome attach-window console observation was missing",
|
||||
);
|
||||
if (result.network.websocket_events.length < 1)
|
||||
throw new Error("Real Chrome WebSocket metadata was missing");
|
||||
if (
|
||||
result.console.events.some(
|
||||
(event) => event.text_capture.status !== "not_approved",
|
||||
) ||
|
||||
result.network.requests.some(
|
||||
(request) => request.body_shapes.status !== "not_approved",
|
||||
) ||
|
||||
result.network.websocket_events.some(
|
||||
(event) => event.payload_shape !== null,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Sensitive text or payload shapes were retained without approval",
|
||||
);
|
||||
if (!result.storage.local_storage_keys.includes("rea-storage-key"))
|
||||
throw new Error("Real Chrome local-storage key inventory was missing");
|
||||
if (!result.storage.indexed_db_names.includes("rea-browser-db"))
|
||||
throw new Error("Real Chrome IndexedDB name inventory was missing");
|
||||
if (!result.storage.cache_names.includes("rea-browser-cache"))
|
||||
throw new Error("Real Chrome cache name inventory was missing");
|
||||
if (
|
||||
!result.metadata.responses.some(({ csp }) =>
|
||||
csp.directives.some(({ name }) => name === "default-src"),
|
||||
) ||
|
||||
!result.metadata.agent_hints.some(
|
||||
({ declaration }) => declaration === "service-desc",
|
||||
)
|
||||
)
|
||||
throw new Error("Real Chrome safe response metadata was missing");
|
||||
}
|
||||
|
||||
async function startSite() {
|
||||
let port = 0;
|
||||
const server = createServer((request, response) => {
|
||||
if (request.url?.startsWith("/app.js") === true) {
|
||||
response.setHeader("content-type", "text/javascript");
|
||||
response.end(script(port));
|
||||
return;
|
||||
}
|
||||
if (request.url?.startsWith("/api") === true) {
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end('{"ok":true,"secret":"response-secret-value"}');
|
||||
return;
|
||||
}
|
||||
response.setHeader("content-type", "text/html");
|
||||
response.end(
|
||||
'<!doctype html><html><head><title>REA browser verifier</title></head><body><main><h1>Browser evidence</h1><button aria-label="Verify browser">Verify</button></main><script type="module" src="/app.js?token=script-query-secret"></script></body></html>',
|
||||
function assertBundleAnalysis(result) {
|
||||
if (
|
||||
result.capture.scripts_analyzed < 1 ||
|
||||
!result.observations.routes.some(({ value }) =>
|
||||
value.includes("/verified-route"),
|
||||
) ||
|
||||
!result.observations.endpoints.some(({ value }) => value.includes("/api"))
|
||||
)
|
||||
throw new Error("Real Chrome static bundle findings were missing");
|
||||
if (
|
||||
result.observations.source_maps.status !== "included" ||
|
||||
!result.observations.source_maps.items.some(
|
||||
({ status, original_sources, original_module_edges, mappings }) =>
|
||||
status === "included" &&
|
||||
original_sources.some(({ source }) =>
|
||||
source.includes("/src/main.ts"),
|
||||
) &&
|
||||
original_module_edges.some(
|
||||
({ specifier }) => specifier === "./dependency.ts",
|
||||
) &&
|
||||
mappings.length > 0,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Real Chrome approved source-map reconstruction was missing",
|
||||
);
|
||||
});
|
||||
const webSockets = new WebSocketServer({ noServer: true });
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
webSockets.handleUpgrade(request, socket, head, (webSocket) => {
|
||||
webSocket.on("message", () =>
|
||||
webSocket.send("websocket-response-secret"),
|
||||
);
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("Local browser verifier did not bind a TCP port");
|
||||
port = address.port;
|
||||
return {
|
||||
origin: `http://127.0.0.1:${String(port)}`,
|
||||
async close() {
|
||||
for (const client of webSockets.clients) client.terminate();
|
||||
await new Promise((resolve) => webSockets.close(resolve));
|
||||
await new Promise((resolve, reject) =>
|
||||
server.close((error) =>
|
||||
error === undefined ? resolve() : reject(error),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function script(port) {
|
||||
return `
|
||||
export const reaSourceMarker = "approved-source-marker";
|
||||
localStorage.setItem("rea-storage-key", "storage-secret-value");
|
||||
indexedDB.open("rea-browser-db", 1);
|
||||
caches.open("rea-browser-cache");
|
||||
const observe = () => {
|
||||
console.log("rea-browser-observation", "console-secret-value");
|
||||
fetch("/api?token=network-secret-value", {
|
||||
headers: { Authorization: "Bearer request-secret-value" }
|
||||
});
|
||||
const socket = new WebSocket("ws://127.0.0.1:${String(port)}/live?token=websocket-url-secret");
|
||||
socket.addEventListener("open", () => socket.send("websocket-secret-value"));
|
||||
socket.addEventListener("message", () => socket.close());
|
||||
};
|
||||
observe();
|
||||
setInterval(observe, 150);
|
||||
`;
|
||||
function assertSensitiveShapes(result) {
|
||||
const request = result.network.requests.find((item) =>
|
||||
item.url.includes("/api"),
|
||||
);
|
||||
const requestPaths = request?.body_shapes.request?.properties.map(
|
||||
({ path }) => path,
|
||||
);
|
||||
const responsePaths = request?.body_shapes.response?.properties.map(
|
||||
({ path }) => path,
|
||||
);
|
||||
if (!requestPaths?.includes("/token") || !responsePaths?.includes("/secret"))
|
||||
throw new Error("Real Chrome JSON request/response shapes were missing");
|
||||
const consoleText = result.console.events.flatMap(
|
||||
(event) => event.text_capture.values,
|
||||
);
|
||||
if (
|
||||
!consoleText.some(({ text }) => text.includes("[REDACTED]")) ||
|
||||
consoleText.some(({ text }) => text.includes("console-secret-value"))
|
||||
)
|
||||
throw new Error(
|
||||
"Real Chrome approved console text was not safely redacted",
|
||||
);
|
||||
if (
|
||||
!result.network.websocket_events.some(
|
||||
(event) =>
|
||||
event.payload_shape?.format === "json" &&
|
||||
event.payload_shape.json_shape?.properties.some(
|
||||
({ path }) => path === "/token",
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new Error("Real Chrome WebSocket JSON shape was missing");
|
||||
const serialized = JSON.stringify(result);
|
||||
for (const secret of [
|
||||
"request-body-secret-value",
|
||||
"response-secret-value",
|
||||
"websocket-secret-value",
|
||||
"console-secret-value",
|
||||
])
|
||||
if (serialized.includes(secret))
|
||||
throw new Error(`Approved shape capture retained raw value: ${secret}`);
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
|
||||
Reference in New Issue
Block a user