feat(inspector): add harExport (F4)

This commit is contained in:
diegosouzapw
2026-05-27 21:44:46 -03:00
parent 62f2fdc4c1
commit c5f697dbc6

View File

@@ -0,0 +1,188 @@
/**
* HAR (HTTP Archive) v1.2 export for the Traffic Inspector.
*
* HAR is the standard format consumed by Chrome DevTools, Charles, Fiddler,
* Postman, and most observability tools. Exporting lets users carry the
* trace out of OmniRoute into their existing workflow.
*
* Secrets are *always* masked on export, regardless of the UI state — see
* Hard Rule #1 (no credentials in artefacts). The OmniRoute capture source
* (agent-bridge / custom-host / http-proxy / system-proxy) is preserved as
* `_source`, a custom field allowed by the HAR spec's underscore convention.
*/
import { maskSecret } from "@/mitm/maskSecrets";
import type { InterceptedRequest } from "@/mitm/inspector/types";
const HAR_VERSION = "1.2";
const CREATOR_NAME = "OmniRoute Traffic Inspector";
const CREATOR_VERSION = "3.8.6";
interface HarNameValue {
name: string;
value: string;
}
interface HarPostData {
mimeType: string;
text: string;
}
interface HarRequest {
method: string;
url: string;
httpVersion: string;
headers: HarNameValue[];
queryString: HarNameValue[];
cookies: HarNameValue[];
headersSize: number;
bodySize: number;
postData?: HarPostData;
}
interface HarContent {
size: number;
mimeType: string;
text?: string;
}
interface HarResponse {
status: number;
statusText: string;
httpVersion: string;
headers: HarNameValue[];
cookies: HarNameValue[];
content: HarContent;
redirectURL: string;
headersSize: number;
bodySize: number;
}
interface HarTimings {
send: number;
wait: number;
receive: number;
}
interface HarEntry {
startedDateTime: string;
time: number;
request: HarRequest;
response: HarResponse;
cache: Record<string, never>;
timings: HarTimings;
serverIPAddress?: string;
_source?: string;
_agent?: string;
_detectedKind?: string;
_contextKey?: string;
_sessionId?: string;
_annotation?: string;
_note?: string;
_omniRouteId?: string;
}
export interface HarFile {
log: {
version: string;
creator: { name: string; version: string };
entries: HarEntry[];
};
}
function headersToList(headers: Record<string, string>): HarNameValue[] {
return Object.entries(headers).map(([name, value]) => ({
name,
value: maskSecret(value),
}));
}
function buildUrl(host: string, path: string): string {
// CONNECT entries carry path ":443" — treat them as opaque pseudo-URL.
if (path.startsWith(":")) return `https://${host}${path}`;
if (!host) return path;
return `https://${host}${path}`;
}
function buildPostData(req: InterceptedRequest): HarPostData | undefined {
if (!req.requestBody) return undefined;
const ct =
req.requestHeaders["content-type"] ??
req.requestHeaders["Content-Type"] ??
"application/octet-stream";
return { mimeType: ct, text: maskSecret(req.requestBody) };
}
function buildResponseContent(req: InterceptedRequest): HarContent {
const ct =
req.responseHeaders["content-type"] ??
req.responseHeaders["Content-Type"] ??
"application/octet-stream";
if (req.responseBody == null) {
return { size: req.responseSize, mimeType: ct };
}
return { size: req.responseSize, mimeType: ct, text: maskSecret(req.responseBody) };
}
function buildEntry(req: InterceptedRequest): HarEntry {
const numericStatus = typeof req.status === "number" ? req.status : 0;
const statusText = typeof req.status === "string" ? req.status : "";
const entry: HarEntry = {
startedDateTime: req.timestamp,
time: req.totalLatencyMs ?? 0,
request: {
method: req.method,
url: buildUrl(req.host, req.path),
httpVersion: "HTTP/1.1",
headers: headersToList(req.requestHeaders),
queryString: [],
cookies: [],
headersSize: -1,
bodySize: req.requestSize,
postData: buildPostData(req),
},
response: {
status: numericStatus,
statusText,
httpVersion: "HTTP/1.1",
headers: headersToList(req.responseHeaders),
cookies: [],
content: buildResponseContent(req),
redirectURL: "",
headersSize: -1,
bodySize: req.responseSize,
},
cache: {},
timings: {
send: 0,
wait: req.upstreamLatencyMs ?? 0,
receive: (req.totalLatencyMs ?? 0) - (req.upstreamLatencyMs ?? 0),
},
_source: req.source,
_omniRouteId: req.id,
};
if (req.agent) entry._agent = req.agent;
if (req.detectedKind) entry._detectedKind = req.detectedKind;
if (req.contextKey) entry._contextKey = req.contextKey;
if (req.sessionId) entry._sessionId = req.sessionId;
if (req.annotation) entry._annotation = req.annotation;
if (req.note) entry._note = req.note;
return entry;
}
/**
* Convert intercepted requests into a HAR v1.2 file. Always masks secrets in
* headers and bodies — callers do not need to pre-mask.
*/
export function toHar(requests: InterceptedRequest[]): HarFile {
return {
log: {
version: HAR_VERSION,
creator: { name: CREATOR_NAME, version: CREATOR_VERSION },
entries: requests.map(buildEntry),
},
};
}