feat(api): deterministic scan worker + AUD0 wire + tenant binding
CI (SIAX Cloud) / security (pull_request) Successful in 14s
CI (SIAX Cloud) / security (push) Successful in 14s
CI (SIAX Cloud) / contracts (pull_request) Successful in 15s
CI (SIAX Cloud) / contracts (push) Successful in 17s
CI (SIAX Cloud) / quality (push) Successful in 1m9s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m10s
CI (SIAX Cloud) / security (pull_request) Successful in 14s
CI (SIAX Cloud) / security (push) Successful in 14s
CI (SIAX Cloud) / contracts (pull_request) Successful in 15s
CI (SIAX Cloud) / contracts (push) Successful in 17s
CI (SIAX Cloud) / quality (push) Successful in 1m9s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m10s
- scan worker: atomic claim (FOR UPDATE SKIP LOCKED), Playwright-core + system chromium capture → typed EvidenceRecords (runtime-html/dom/computed-style/ stylesheet/asset/network-request/screenshot), confidence=measured - AUD0 emitter (fire-and-forget, advisory): c0py.registry_created/.scan_created/ .scan_completed/.scan_failed, tenant_id = Zitadel resourceowner (org) - tenant binding: resourceowner claim from introspection (deny-by-default 403), migration 002 tenant_id on registries+scans, all queries tenant+owner scoped - evidence gaps stay explicit (screenshot miss → no screenshot record) - 30/30 tests, canonical validator OK
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { EvidenceRecord } from "@siax/c0py-types";
|
||||
|
||||
export interface CapturedPage {
|
||||
finalUrl: string;
|
||||
status: number;
|
||||
contentType: string | null;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
lang: string | null;
|
||||
charset: string | null;
|
||||
viewportMeta: string | null;
|
||||
domCounts: Record<string, number>;
|
||||
stylesheets: string[];
|
||||
images: string[];
|
||||
computedStyle: Record<string, Record<string, string | null>>;
|
||||
networkRequests: { url: string; method: string; resourceType: string; status: number | null }[];
|
||||
screenshotBase64: string | null;
|
||||
}
|
||||
|
||||
export interface CaptureEvidence {
|
||||
evidence: EvidenceRecord[];
|
||||
summary: {
|
||||
url: string;
|
||||
status: number;
|
||||
title: string | null;
|
||||
domElementCount: number;
|
||||
stylesheetCount: number;
|
||||
imageCount: number;
|
||||
networkRequestCount: number;
|
||||
screenshotCaptured: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_PROFILE_ID = "c0py-default-public";
|
||||
export const DEFAULT_VIEWPORT = { width: 1280, height: 800, deviceScaleFactor: 1 };
|
||||
|
||||
function record<T>(
|
||||
targetId: string,
|
||||
route: string,
|
||||
kind: EvidenceRecord["kind"],
|
||||
payload: T,
|
||||
tool: string,
|
||||
): EvidenceRecord<T> {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
targetId,
|
||||
kind,
|
||||
route,
|
||||
capturedAt: new Date().toISOString(),
|
||||
method: "deterministic-browser-capture",
|
||||
confidence: "measured",
|
||||
captureProfileId: DEFAULT_PROFILE_ID,
|
||||
viewport: DEFAULT_VIEWPORT,
|
||||
source: { tool, toolVersion: process.env.CAPTURE_TOOL_VERSION ?? "unknown" },
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
// Pure builder — unit-testable without a browser.
|
||||
export function buildEvidence(targetId: string, page: CapturedPage): CaptureEvidence {
|
||||
const tool = "playwright-core+chromium";
|
||||
const evidence: EvidenceRecord[] = [
|
||||
record(targetId, page.finalUrl, "runtime-html", {
|
||||
finalUrl: page.finalUrl,
|
||||
status: page.status,
|
||||
contentType: page.contentType,
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
lang: page.lang,
|
||||
charset: page.charset,
|
||||
viewportMeta: page.viewportMeta,
|
||||
}, tool),
|
||||
record(targetId, page.finalUrl, "dom", {
|
||||
counts: page.domCounts,
|
||||
}, tool),
|
||||
record(targetId, page.finalUrl, "computed-style", page.computedStyle, tool),
|
||||
record(targetId, page.finalUrl, "stylesheet", {
|
||||
stylesheets: page.stylesheets,
|
||||
}, tool),
|
||||
record(targetId, page.finalUrl, "asset", {
|
||||
images: page.images,
|
||||
}, tool),
|
||||
record(targetId, page.finalUrl, "network-request", {
|
||||
requests: page.networkRequests,
|
||||
}, tool),
|
||||
];
|
||||
if (page.screenshotBase64) {
|
||||
evidence.push(
|
||||
record(targetId, page.finalUrl, "screenshot", {
|
||||
base64: page.screenshotBase64,
|
||||
contentType: "image/jpeg",
|
||||
}, tool),
|
||||
);
|
||||
}
|
||||
return {
|
||||
evidence,
|
||||
summary: {
|
||||
url: page.finalUrl,
|
||||
status: page.status,
|
||||
title: page.title,
|
||||
domElementCount: Object.values(page.domCounts).reduce((a, b) => a + b, 0),
|
||||
stylesheetCount: page.stylesheets.length,
|
||||
imageCount: page.images.length,
|
||||
networkRequestCount: page.networkRequests.length,
|
||||
screenshotCaptured: page.screenshotBase64 !== null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Browser-backed capture. Playwright is the canonical runtime per AGENTS.md;
|
||||
// chromium comes from the system package (apk chromium) via executablePath.
|
||||
export async function capturePage(
|
||||
url: string,
|
||||
opts: { executablePath?: string; timeoutMs: number; maxRequests?: number },
|
||||
): Promise<CapturedPage> {
|
||||
const { chromium } = await import("playwright-core");
|
||||
const executablePath =
|
||||
opts.executablePath ?? process.env.CHROMIUM_EXECUTABLE_PATH ?? "/usr/bin/chromium-browser";
|
||||
const browser = await chromium.launch({
|
||||
executablePath,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
const maxRequests = opts.maxRequests ?? 100;
|
||||
try {
|
||||
const context = await browser.newContext({ viewport: DEFAULT_VIEWPORT });
|
||||
const page = await context.newPage();
|
||||
const networkRequests: CapturedPage["networkRequests"] = [];
|
||||
page.on("response", (res) => {
|
||||
if (networkRequests.length < maxRequests) {
|
||||
networkRequests.push({
|
||||
url: res.url(),
|
||||
method: res.request().method(),
|
||||
resourceType: res.request().resourceType(),
|
||||
status: res.status(),
|
||||
});
|
||||
}
|
||||
});
|
||||
const response = await page.goto(url, { timeout: opts.timeoutMs, waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(500);
|
||||
const captured = await page.evaluate(() => {
|
||||
const count = (sel: string) => document.querySelectorAll(sel).length;
|
||||
const pick = (el: Element | null, props: string[]) => {
|
||||
if (!el) return Object.fromEntries(props.map((p) => [p, null]));
|
||||
const cs = getComputedStyle(el);
|
||||
return Object.fromEntries(props.map((p) => [p, cs.getPropertyValue(p) || null]));
|
||||
};
|
||||
const body = pick(document.body, ["font-family", "color", "background-color", "font-size"]);
|
||||
const h1 = pick(document.querySelector("h1"), ["font-family", "font-size", "font-weight", "color", "margin"]);
|
||||
return {
|
||||
title: document.title,
|
||||
description: document.querySelector('meta[name="description"]')?.getAttribute("content") ?? null,
|
||||
lang: document.documentElement.getAttribute("lang"),
|
||||
charset: document.characterSet,
|
||||
viewportMeta: document.querySelector('meta[name="viewport"]')?.getAttribute("content") ?? null,
|
||||
domCounts: {
|
||||
script: count("script"),
|
||||
link: count("link"),
|
||||
style: count("style"),
|
||||
img: count("img"),
|
||||
svg: count("svg"),
|
||||
form: count("form"),
|
||||
input: count("input"),
|
||||
button: count("button"),
|
||||
nav: count("nav"),
|
||||
header: count("header"),
|
||||
footer: count("footer"),
|
||||
main: count("main"),
|
||||
section: count("section"),
|
||||
h1: count("h1"),
|
||||
h2: count("h2"),
|
||||
h3: count("h3"),
|
||||
a: count("a"),
|
||||
table: count("table"),
|
||||
iframe: count("iframe"),
|
||||
video: count("video"),
|
||||
canvas: count("canvas"),
|
||||
},
|
||||
stylesheets: Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map((l) =>
|
||||
(l as HTMLLinkElement).href,
|
||||
),
|
||||
images: Array.from(document.querySelectorAll("img")).slice(0, 50).map((i) => i.src),
|
||||
computedStyle: { body, h1 },
|
||||
};
|
||||
});
|
||||
let screenshotBase64: string | null = null;
|
||||
try {
|
||||
screenshotBase64 = await page.screenshot({
|
||||
fullPage: true,
|
||||
type: "jpeg",
|
||||
quality: 60,
|
||||
timeout: Math.min(opts.timeoutMs, 10_000),
|
||||
}).then((b) => b.toString("base64"));
|
||||
} catch {
|
||||
// Screenshot failure is an evidence gap, not a capture failure.
|
||||
screenshotBase64 = null;
|
||||
}
|
||||
return {
|
||||
finalUrl: page.url(),
|
||||
status: response?.status() ?? 0,
|
||||
contentType: response?.headers()["content-type"] ?? null,
|
||||
...captured,
|
||||
networkRequests,
|
||||
screenshotBase64,
|
||||
};
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user