diff --git a/src/app/api/projects/[projectId]/artifacts/[artifactId]/route.ts b/src/app/api/projects/[projectId]/artifacts/[artifactId]/route.ts new file mode 100644 index 000000000..8de2c18f2 --- /dev/null +++ b/src/app/api/projects/[projectId]/artifacts/[artifactId]/route.ts @@ -0,0 +1,138 @@ +import { listArtifacts } from "../../../../../../server/chat/service"; +import { getArtifactService } from "../../../../../../server/evidence"; +import { + PASSIVE_AUTH_REPORT_MAX_BYTES, + parsePassiveAuthSurfaceSummary, +} from "../../../../../../server/recon/passive-auth-surface"; +import { handleApiError, notFound, ok } from "../../../../_shared/http"; + +export const dynamic = "force-dynamic"; + +const RAW_EVIDENCE_MAX_BYTES = 32 * 1024; +const NO_STORE = { headers: { "Cache-Control": "no-store" } }; + +type Context = { + params: Promise<{ projectId: string; artifactId: string }>; +}; + +export async function GET(_request: Request, context: Context) { + try { + const { projectId, artifactId } = await context.params; + const artifact = ( + await listArtifacts(projectId, { + artifactId, + limit: 1, + }) + )[0]; + + if (!artifact) { + return notFound(`Artifact ${artifactId} was not found in this project.`); + } + + const reader = getArtifactService().readArtifactText; + const report = await readPassiveAuthReport(artifact.metadata?.workflow, reader, { + projectId, + artifactId, + }); + if (!reader) { + return ok( + { + artifact, + ...(report ? { report } : {}), + evidence: { + status: "unavailable" as const, + message: "Text preview is unavailable for this artifact.", + maxBytes: RAW_EVIDENCE_MAX_BYTES, + }, + }, + NO_STORE, + ); + } + + try { + const content = await reader({ + projectId, + artifactId, + maxBytes: RAW_EVIDENCE_MAX_BYTES, + }); + return ok( + { + artifact, + ...(report ? { report } : {}), + evidence: { + status: "text" as const, + text: content.text, + truncated: content.truncated, + maxBytes: RAW_EVIDENCE_MAX_BYTES, + }, + }, + NO_STORE, + ); + } catch (error) { + const message = error instanceof Error ? error.message : ""; + const binary = /not a text artifact|binary/i.test(message); + return ok( + { + artifact, + ...(report ? { report } : {}), + evidence: { + status: binary ? ("binary" as const) : ("unavailable" as const), + message: binary + ? "This artifact is binary, so a text preview is not available." + : "The artifact is recorded, but its text preview is currently unavailable.", + maxBytes: RAW_EVIDENCE_MAX_BYTES, + }, + }, + NO_STORE, + ); + } + } catch (error) { + return handleApiError(error); + } +} + +async function readPassiveAuthReport( + workflow: unknown, + reader: ReturnType["readArtifactText"], + input: { projectId: string; artifactId: string }, +) { + if (workflow !== "passive-auth-surface-v1") return undefined; + if (!reader) { + return { + status: "unavailable" as const, + message: "The structured passive-auth report is currently unavailable.", + }; + } + + try { + const content = await reader({ ...input, maxBytes: PASSIVE_AUTH_REPORT_MAX_BYTES }); + if (content.truncated) { + return { + status: "over_limit" as const, + message: "This passive-auth report exceeds the structured report size limit.", + }; + } + let value: unknown; + try { + value = JSON.parse(content.text); + } catch { + return { + status: "invalid" as const, + message: "This passive-auth report is not valid JSON.", + }; + } + const parsed = parsePassiveAuthSurfaceSummary(value); + if (!parsed.success) { + return { + status: "invalid" as const, + message: "This passive-auth report does not match the supported report structure.", + }; + } + return { status: "ready" as const, data: parsed.data }; + } catch { + return { + status: "unavailable" as const, + message: "The structured passive-auth report is currently unavailable.", + }; + } +} diff --git a/src/components/ChatWorkspace.tsx b/src/components/ChatWorkspace.tsx index 836d56e40..8e13c825c 100644 --- a/src/components/ChatWorkspace.tsx +++ b/src/components/ChatWorkspace.tsx @@ -2837,6 +2837,7 @@ export function ChatWorkspace({ projectOverview={ project ? ( ; + blockers: Array<{ + reason: string; + evidence: string; + evidenceArtifactIds: string[]; + }>; + unknowns: string[]; + nextSteps: { + passive: string[]; + approvalGated: string[]; + reportOrPatch: string[]; + }; +}; + +export function ArtifactReportDialog({ + projectId, + artifactId, + onClose, +}: { + projectId: string; + artifactId: string; + onClose(): void; +}) { + const [detail, setDetail] = useState(null); + const [error, setError] = useState(null); + const [rawArtifactId, setRawArtifactId] = useState(null); + const dialogRef = useRef(null); + const restoreFocusRef = useRef(null); + + useEffect(() => { + const controller = new AbortController(); + setDetail(null); + setError(null); + setRawArtifactId(null); + void readArtifact(projectId, artifactId, controller.signal) + .then(setDetail) + .catch((cause: unknown) => { + if (!controller.signal.aborted) { + setError(cause instanceof Error ? cause.message : "Artifact preview is unavailable."); + } + }); + return () => controller.abort(); + }, [artifactId, projectId]); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if ( + !restoreFocusRef.current && + document.activeElement instanceof HTMLElement && + !dialog.contains(document.activeElement) + ) { + restoreFocusRef.current = document.activeElement; + } + focusFirstDialogControl(dialog); + const containFocus = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + return; + } + if (event.key !== "Tab") return; + trapDialogTabKey(dialog, event); + }; + dialog.addEventListener("keydown", containFocus); + return () => { + dialog.removeEventListener("keydown", containFocus); + window.requestAnimationFrame(() => { + if (!dialog.isConnected && restoreFocusRef.current?.isConnected) { + restoreFocusRef.current.focus(); + } + }); + }; + }, [onClose]); + + return ( +
+
event.stopPropagation()} + > +
+ + +

{detail?.artifact.name ?? "Artifact report"}

+ {artifactId} +
+ +
+ +
+ {error ?

{error}

: null} + {!detail && !error ?

Loading artifact…

: null} + {detail?.report?.status === "ready" ? ( + + ) : null} + {detail?.report && detail.report.status !== "ready" ? ( +

{detail.report.message}

+ ) : null} + {detail && detail.report?.status !== "ready" ? ( + + ) : null} + {rawArtifactId ? ( + setRawArtifactId(null)} + /> + ) : null} +
+
+
+ ); +} + +function PassiveAuthReportView({ + report, + onRevealEvidence, +}: { + report: PassiveAuthReport; + onRevealEvidence(artifactId: string): void; +}) { + return ( +
+
+ + Target +

{report.targetId}

+
+ +
+ + + {report.authRoutes.length ? ( +
+ {report.authRoutes.map((route) => ( +
+
+

{formatLabel(route.category)}

+ + {route.confidence} confidence + +
+
    + {route.urls.map((url) => ( +
  • {url}
  • + ))} +
+ +
+ ))} +
+ ) : ( +

No authentication routes were established.

+ )} +
+ + + {report.blockers.length ? ( +
+ {report.blockers.map((blocker, index) => ( +
+
+
+

{blocker.evidence}

+ +
+ ))} +
+ ) : ( +

No passive-review blockers were recorded.

+ )} +
+ + + {report.unknowns.length ? ( +
+ {report.unknowns.map((unknown) => ( +
+

{unknown}

+ +
+ ))} +
+ ) : ( +

+ No unresolved auth-surface unknowns were recorded. +

+ )} +
+ + + + + + +
+ ); +} + +function ReportSection({ + title, + count, + tone, + children, +}: { + title: string; + count?: number; + tone?: "warning"; + children: ReactNode; +}) { + return ( +
+
+

{title}

+ {typeof count === "number" ? {count} : null} +
+ {children} +
+ ); +} + +function NextStepGroup({ + title, + steps, + artifactIds, + onReveal, +}: { + title: string; + steps: string[]; + artifactIds: string[]; + onReveal(artifactId: string): void; +}) { + return ( +
+

{title}

+ {steps.length ? ( +
    + {steps.map((step) => ( +
  1. + {step} + +
  2. + ))} +
+ ) : ( +

No steps recorded.

+ )} +
+ ); +} + +function ClaimEvidence({ + artifactIds, + onReveal, +}: { + artifactIds: string[]; + onReveal(artifactId: string): void; +}) { + const ids = [...new Set(artifactIds)]; + return ( +
+ + Raw evidence · {ids.length} artifact ID{ids.length === 1 ? "" : "s"} + + {ids.length ? ( +
    + {ids.map((artifactId) => ( +
  • + +
  • + ))} +
+ ) : ( +

No raw artifact ID is linked to this claim.

+ )} +
+ ); +} + +function GenericArtifactView({ detail }: { detail: ArtifactDetail }) { + const [revealed, setRevealed] = useState(false); + return ( +
+

Artifact preview

+ {detail.evidence.status === "text" ? ( + revealed ? ( + + ) : ( + + ) + ) : ( +

{detail.evidence.message}

+ )} +
+ ); +} + +function RawEvidencePanel({ + projectId, + artifactId, + onClose, +}: { + projectId: string; + artifactId: string; + onClose(): void; +}) { + const [detail, setDetail] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setDetail(null); + setError(null); + void readArtifact(projectId, artifactId, controller.signal) + .then(setDetail) + .catch((cause: unknown) => { + if (!controller.signal.aborted) { + setError(cause instanceof Error ? cause.message : "Raw evidence is unavailable."); + } + }); + return () => controller.abort(); + }, [artifactId, projectId]); + + return ( + + ); +} + +function EvidenceText({ evidence }: { evidence: Extract }) { + return ( +
+

+

+
{evidence.text}
+ {evidence.truncated ?

Preview truncated at {formatBytes(evidence.maxBytes)}.

: null} +
+ ); +} + +async function readArtifact(projectId: string, artifactId: string, signal: AbortSignal) { + const response = await fetch( + `/api/projects/${encodeURIComponent(projectId)}/artifacts/${encodeURIComponent(artifactId)}`, + { cache: "no-store", signal }, + ); + const body = (await response.json().catch(() => null)) as + | ArtifactDetail + | { error?: string } + | null; + if (!response.ok) { + throw new Error(body && "error" in body && body.error ? body.error : "Artifact not found."); + } + if (!body || !("artifact" in body) || !("evidence" in body)) { + throw new Error("Artifact response was incomplete."); + } + return body; +} + +function formatLabel(value: string) { + return value.replaceAll("-", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +const FOCUSABLE_SELECTOR = [ + "button:not([disabled])", + "[href]", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +function focusFirstDialogControl(dialog: HTMLElement) { + const focusable = readFocusableElements(dialog); + (focusable[0] ?? dialog).focus(); +} + +function trapDialogTabKey(dialog: HTMLElement, event: KeyboardEvent) { + const focusable = readFocusableElements(dialog); + if (!focusable.length) { + event.preventDefault(); + dialog.focus(); + return; + } + const first = focusable[0]; + const last = focusable.at(-1); + if ( + event.shiftKey && + (document.activeElement === first || !dialog.contains(document.activeElement)) + ) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } +} + +function readFocusableElements(dialog: HTMLElement) { + return [...dialog.querySelectorAll(FOCUSABLE_SELECTOR)].filter( + (element) => + element.getClientRects().length > 0 && element.getAttribute("aria-hidden") !== "true", + ); +} + +function formatBytes(value: number) { + return value >= 1024 ? `${Math.round(value / 1024)} KiB` : `${value} bytes`; +} diff --git a/src/components/chat/ProjectThreadPanel.tsx b/src/components/chat/ProjectThreadPanel.tsx index 13f66d23a..b119275f8 100644 --- a/src/components/chat/ProjectThreadPanel.tsx +++ b/src/components/chat/ProjectThreadPanel.tsx @@ -17,11 +17,12 @@ import { Trash2, X, } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ApprovalDuration } from "../../server/approvals/types"; import type { RequestDecisionView } from "../chatWorkspaceView"; import { type ApprovalCardProps, ApprovalSplitButton } from "../tool-ui/approval-card"; import type { ChatProjectStatsView } from "../types"; +import { ArtifactReportDialog } from "./ArtifactReportDialog"; import type { ThreadView } from "./messageUtils"; import { type ArtifactMetadataView, formatCost, formatThreadTime } from "./messageUtils"; @@ -146,6 +147,7 @@ function formatRelativeTime(value?: string) { } export function ProjectThreadPanel({ + projectId, stats, artifacts, threads, @@ -168,6 +170,7 @@ export function ProjectThreadPanel({ yoloDurationMinutes, onYoloModeChange, }: { + projectId: string; stats: ProjectStatsView; artifacts: ArtifactMetadataView[]; threads: ThreadView[]; @@ -197,6 +200,7 @@ export function ProjectThreadPanel({ const [editingDecision, setEditingDecision] = useState(); const [isSaving, setIsSaving] = useState(false); const [countdownNow, setCountdownNow] = useState(() => Date.now()); + const [selectedArtifactId, setSelectedArtifactId] = useState(null); const totalCount = requestDecisions.length + (pendingApproval ? 1 : 0); const isYoloMode = approvalMode === "yolo"; const isLegacyAutoMode = approvalMode === "auto"; @@ -216,6 +220,26 @@ export function ProjectThreadPanel({ const interval = window.setInterval(() => setCountdownNow(Date.now()), 1000); return () => window.clearInterval(interval); }, [isYoloMode, yoloExpiresAt]); + + useEffect(() => { + const syncSelection = () => { + setSelectedArtifactId(new URL(window.location.href).searchParams.get("artifact")); + }; + syncSelection(); + window.addEventListener("popstate", syncSelection); + return () => window.removeEventListener("popstate", syncSelection); + }, [projectId]); + + const selectArtifact = useCallback((artifactId: string | null) => { + const url = new URL(window.location.href); + if (artifactId) url.searchParams.set("artifact", artifactId); + else url.searchParams.delete("artifact"); + window.history.pushState({}, "", url); + setSelectedArtifactId(artifactId); + }, []); + const closeArtifact = useCallback(() => { + selectArtifact(null); + }, [selectArtifact]); const accessStats = useMemo(() => { const targets = requestDecisions.filter((decision) => decision.kind === "target").length; const commands = requestDecisions.filter((decision) => decision.kind === "command").length; @@ -704,21 +728,24 @@ export function ProjectThreadPanel({ const description = artifactDescription(artifact); const timestamp = artifact.updatedAt || artifact.createdAt; return ( -
  • - - - {artifact.name} - {description} - - +
  • +
  • ); })} @@ -730,6 +757,14 @@ export function ProjectThreadPanel({ ) : null} + {selectedArtifactId ? ( + + ) : null} +
    { const values: unknown[] = [projectId]; const where = ["project_id = $1"]; + if (options.artifactId) { + values.push(options.artifactId); + where.push(`id = $${values.length}`); + } if (options.threadId) { values.push(options.threadId); where.push( diff --git a/src/server/chat/types.ts b/src/server/chat/types.ts index 4679e6271..32eec89da 100644 --- a/src/server/chat/types.ts +++ b/src/server/chat/types.ts @@ -168,6 +168,7 @@ export type ArtifactMetadata = { }; export type ArtifactListOptions = { + artifactId?: string; threadId?: string; includeProjectScoped?: boolean; limit?: number; diff --git a/src/server/evidence/artifact-service.ts b/src/server/evidence/artifact-service.ts index e7e1da78f..688ba0c6f 100644 --- a/src/server/evidence/artifact-service.ts +++ b/src/server/evidence/artifact-service.ts @@ -17,6 +17,7 @@ const DEFAULT_MAX_INLINE_BYTES = Number.parseInt( process.env.ARTIFACT_INLINE_MAX_BYTES ?? "1500000", 10, ); +const REDACTION_LOOKAHEAD_BYTES = 4 * 1024; export type ArtifactServiceConfig = { storage?: ObjectStorageClient | null; @@ -367,13 +368,16 @@ export function createArtifactService(config: ArtifactServiceConfig = {}) { ); } - const bytes = await storage.readObjectBytes({ + const ranged = await storage.readObjectRange({ key: row.storage_key, ...(row.storage_bucket ? { bucket: row.storage_bucket } : {}), + maxBytes: maxBytes + REDACTION_LOOKAHEAD_BYTES, }); - const truncated = bytes.byteLength > maxBytes; - const text = redactEvidenceSecrets( - new TextDecoder().decode(truncated ? bytes.slice(0, maxBytes) : bytes), + const redacted = redactEvidenceSecrets(new TextDecoder().decode(ranged.bytes)); + const redactedBytes = new TextEncoder().encode(redacted); + const truncated = ranged.truncated || redactedBytes.byteLength > maxBytes; + const text = new TextDecoder().decode( + truncated ? redactedBytes.slice(0, maxBytes) : redactedBytes, ); return { artifactId: row.id, @@ -628,7 +632,7 @@ async function insertArtifactRow(db: Queryable, row: ArtifactRowInsert) { WHERE project_id = $1 AND id = $3 AND metadata->>'targetId' = $16 - AND ((thread_id IS NULL AND $2::text IS NULL) OR thread_id = $2) + AND (thread_id IS NULL OR thread_id = $2) )) RETURNING id`, [...values, input.targetId], @@ -661,7 +665,7 @@ async function lockArtifactAttribution(db: Queryable, input: CreateArtifactInput WHERE project_id = $1 AND id = $2 AND metadata->>'targetId' = $3 - AND ((thread_id IS NULL AND $4::text IS NULL) OR thread_id = $4) + AND (thread_id IS NULL OR thread_id = $4) FOR UPDATE`, [input.projectId, input.taskId, input.targetId, input.threadId ?? null], ); diff --git a/src/server/recon/index.ts b/src/server/recon/index.ts index 7e0a81b3b..bce09af41 100644 --- a/src/server/recon/index.ts +++ b/src/server/recon/index.ts @@ -17,6 +17,7 @@ export { } from "./passive-auth-blockers"; export { buildPassiveAuthSurfaceSummary, + PASSIVE_AUTH_REPORT_MAX_BYTES, type PassiveAuthSurfaceSummary, type PersistPassiveAuthSurfaceInput, type PersistPassiveAuthSurfaceResult, diff --git a/src/server/recon/passive-auth-surface.ts b/src/server/recon/passive-auth-surface.ts index e9eda1792..fbb0302f6 100644 --- a/src/server/recon/passive-auth-surface.ts +++ b/src/server/recon/passive-auth-surface.ts @@ -1,241 +1,285 @@ +import { z } from "zod"; import type { BlockerRecord } from "../blockers"; -import type { - ArtifactServiceInstance, - CreateArtifactResult, -} from "../evidence"; +import type { ArtifactServiceInstance, CreateArtifactResult } from "../evidence"; import { - type AuthSurfaceCategory, - type DiscoveryArtifactInput, - type DiscoveryBlockerReason, - type NormalizedDiscovery, - normalizeDiscoveryArtifacts, + type AuthSurfaceCategory, + type DiscoveryArtifactInput, + type DiscoveryBlockerReason, + type NormalizedDiscovery, + normalizeDiscoveryArtifacts, } from "./discovery-artifact-normalizer"; import { - type PassiveAuthSystemMapProjectionResult, - projectPassiveAuthReportToSystemMap, + type PassiveAuthSystemMapProjectionResult, + projectPassiveAuthReportToSystemMap, } from "./passive-auth-system-map"; import { - createPassiveAuthTaskAlignmentService, - type PassiveAuthTaskAlignmentResult, - type PassiveAuthTaskAlignmentService, + createPassiveAuthTaskAlignmentService, + type PassiveAuthTaskAlignmentResult, + type PassiveAuthTaskAlignmentService, } from "./passive-auth-task-alignment"; export type PassiveAuthSurfaceSummary = { - targetId: string; - rawArtifactIds: string[]; - rawArtifacts: Array<{ - artifactId: string; - source: DiscoveryArtifactInput["source"]; - }>; - authRoutes: Array<{ - category: AuthSurfaceCategory; - confidence: "high" | "medium"; - urls: string[]; - evidenceArtifactIds: string[]; - }>; - blockers: Array<{ - reason: DiscoveryBlockerReason; - evidenceArtifactIds: string[]; - evidence: string; - }>; - unknowns: string[]; - nextSteps: { - passive: string[]; - approvalGated: string[]; - reportOrPatch: string[]; - }; + targetId: string; + rawArtifactIds: string[]; + rawArtifacts: Array<{ artifactId: string; source: DiscoveryArtifactInput["source"] }>; + authRoutes: Array<{ + category: AuthSurfaceCategory; + confidence: "high" | "medium"; + urls: string[]; + evidenceArtifactIds: string[]; + }>; + blockers: Array<{ + reason: DiscoveryBlockerReason; + evidenceArtifactIds: string[]; + evidence: string; + }>; + unknowns: string[]; + nextSteps: { passive: string[]; approvalGated: string[]; reportOrPatch: string[] }; }; +export const PASSIVE_AUTH_REPORT_MAX_BYTES = 512 * 1024; +const artifactIdSchema = z.string().min(1).max(512); +const reportTextSchema = z.string().max(8_192); +const discoverySourceSchema = z.enum([ + "upload", + "terminal-note", + "command-transcript", + "http-probe", + "reference", +]); + +export const passiveAuthSurfaceSummarySchema: z.ZodType = z + .object({ + targetId: z.string().min(1).max(1_024), + rawArtifactIds: z.array(artifactIdSchema).max(2_048), + rawArtifacts: z + .array(z.object({ artifactId: artifactIdSchema, source: discoverySourceSchema }).strict()) + .max(2_048) + .default([]), + authRoutes: z + .array( + z + .object({ + category: z.enum([ + "login", + "logout", + "registration", + "password-recovery", + "oauth", + "sso", + "token", + "session", + "api-key", + "admin", + ]), + confidence: z.enum(["high", "medium"]), + urls: z.array(z.string().min(1).max(8_192)).max(2_048), + evidenceArtifactIds: z.array(artifactIdSchema).max(2_048), + }) + .strict(), + ) + .max(512), + blockers: z + .array( + z + .object({ + reason: z.enum([ + "bot-block-detected", + "captcha-detected", + "waf-denied", + "rate-limited", + "auth-required", + "approval-required", + "target-authorization-required", + "workspace-locked", + "network-profile-blocked", + "tool-unavailable", + ]), + evidenceArtifactIds: z.array(artifactIdSchema).max(2_048), + evidence: reportTextSchema, + }) + .strict(), + ) + .max(512), + unknowns: z.array(reportTextSchema).max(2_048), + nextSteps: z + .object({ + passive: z.array(reportTextSchema).max(512), + approvalGated: z.array(reportTextSchema).max(512), + reportOrPatch: z.array(reportTextSchema).max(512), + }) + .strict(), + }) + .strict(); + +export function parsePassiveAuthSurfaceSummary(value: unknown) { + return passiveAuthSurfaceSummarySchema.safeParse(value); +} + export type PersistPassiveAuthSurfaceInput = { - projectId: string; - threadId?: string; - targetId: string; - taskId?: string; - artifacts: readonly DiscoveryArtifactInput[]; + projectId: string; + threadId?: string; + targetId: string; + taskId?: string; + artifacts: readonly DiscoveryArtifactInput[]; }; export type PersistPassiveAuthSurfaceResult = { - normalized: NormalizedDiscovery; - summary: PassiveAuthSurfaceSummary; - artifact: CreateArtifactResult; - systemMap: PassiveAuthSystemMapProjectionResult; - blockers: BlockerRecord[]; - taskAlignment: PassiveAuthTaskAlignmentResult; + normalized: NormalizedDiscovery; + summary: PassiveAuthSurfaceSummary; + artifact: CreateArtifactResult; + systemMap: PassiveAuthSystemMapProjectionResult; + blockers: BlockerRecord[]; + taskAlignment: PassiveAuthTaskAlignmentResult; }; type ArtifactWriter = Pick; type SystemMapProjector = (input: { - projectId: string; - reportArtifactId: string; + projectId: string; + reportArtifactId: string; }) => Promise; - -const projectPersistedReport: SystemMapProjector = ({ - projectId, - reportArtifactId, -}) => projectPassiveAuthReportToSystemMap(projectId, reportArtifactId); +const projectPersistedReport: SystemMapProjector = ({ projectId, reportArtifactId }) => + projectPassiveAuthReportToSystemMap(projectId, reportArtifactId); export async function persistPassiveAuthSurface( - input: PersistPassiveAuthSurfaceInput, - artifactWriter: ArtifactWriter, - taskAlignment: PassiveAuthTaskAlignmentService = createPassiveAuthTaskAlignmentService(), - projectSystemMap: SystemMapProjector = projectPersistedReport, + input: PersistPassiveAuthSurfaceInput, + artifactWriter: ArtifactWriter, + taskAlignment: PassiveAuthTaskAlignmentService = createPassiveAuthTaskAlignmentService(), + projectSystemMap: SystemMapProjector = projectPersistedReport, ): Promise { - const mappingTask = await taskAlignment.start(input); - const normalized = normalizeDiscoveryArtifacts(input.artifacts); - const summary = buildPassiveAuthSurfaceSummary( - input.targetId, - normalized, - input.artifacts, - ); - const content = JSON.stringify(summary, null, 2); - const artifact = await artifactWriter.createArtifact({ - projectId: input.projectId, - ...(input.threadId ? { threadId: input.threadId } : {}), - targetId: input.targetId, - taskId: mappingTask.id, - name: `passive-auth-surface-${input.targetId}.json`, - kind: "report", - contentType: "application/json", - content, - indexText: content, - source: "passive-recon", - indexForRag: true, - agentGenerated: true, - attributionPolicy: "project-thread-task-target", - metadata: { - workflow: "passive-auth-surface-v1", - sourceTaskId: input.taskId ?? null, - rawArtifactIds: summary.rawArtifactIds, - rawArtifacts: summary.rawArtifacts, - blockerReasons: summary.blockers.map((blocker) => blocker.reason), - authCategories: summary.authRoutes.map((route) => route.category), - }, - }); - const aligned = await taskAlignment.complete({ - projectId: input.projectId, - ...(input.threadId ? { threadId: input.threadId } : {}), - targetId: input.targetId, - taskId: mappingTask.id, - ...(input.taskId ? { sourceTaskId: input.taskId } : {}), - reportArtifactId: artifact.id, - summary, - }); - const systemMap = await projectSystemMap({ - projectId: input.projectId, - reportArtifactId: artifact.id, - }); - - return { - normalized, - summary, - artifact, - blockers: aligned.blockers, - taskAlignment: aligned, - systemMap, - }; + const mappingTask = await taskAlignment.start(input); + const normalized = normalizeDiscoveryArtifacts(input.artifacts); + const builtSummary = buildPassiveAuthSurfaceSummary(input.targetId, normalized, input.artifacts); + const parsed = parsePassiveAuthSurfaceSummary(builtSummary); + if (!parsed.success) { + throw new Error( + "The passive-auth report could not be saved because the normalized evidence exceeds supported report bounds. Reduce or split the passive evidence set and try again.", + ); + } + const summary = parsed.data; + const content = JSON.stringify(summary, null, 2); + const serializedBytes = new TextEncoder().encode(content).byteLength; + if (serializedBytes > PASSIVE_AUTH_REPORT_MAX_BYTES) { + throw new Error( + `The passive-auth report is too large to save safely (${serializedBytes} bytes; limit ${PASSIVE_AUTH_REPORT_MAX_BYTES}). Reduce or split the passive evidence set and try again.`, + ); + } + const artifact = await artifactWriter.createArtifact({ + projectId: input.projectId, + ...(input.threadId ? { threadId: input.threadId } : {}), + targetId: input.targetId, + taskId: mappingTask.id, + name: `passive-auth-surface-${input.targetId}.json`, + kind: "report", + contentType: "application/json", + content, + indexText: content, + source: "passive-recon", + indexForRag: true, + agentGenerated: true, + attributionPolicy: "project-thread-task-target", + metadata: { + workflow: "passive-auth-surface-v1", + sourceTaskId: input.taskId ?? null, + rawArtifactIds: summary.rawArtifactIds, + rawArtifacts: summary.rawArtifacts, + blockerReasons: summary.blockers.map((blocker) => blocker.reason), + authCategories: summary.authRoutes.map((route) => route.category), + }, + }); + const aligned = await taskAlignment.complete({ + projectId: input.projectId, + ...(input.threadId ? { threadId: input.threadId } : {}), + targetId: input.targetId, + taskId: mappingTask.id, + ...(input.taskId ? { sourceTaskId: input.taskId } : {}), + reportArtifactId: artifact.id, + summary, + }); + const systemMap = await projectSystemMap({ + projectId: input.projectId, + reportArtifactId: artifact.id, + }); + return { + normalized, + summary, + artifact, + blockers: aligned.blockers, + taskAlignment: aligned, + systemMap, + }; } export function buildPassiveAuthSurfaceSummary( - targetId: string, - normalized: NormalizedDiscovery, - sourceArtifacts: readonly Pick< - DiscoveryArtifactInput, - "artifactId" | "source" - >[] = [], + targetId: string, + normalized: NormalizedDiscovery, + sourceArtifacts: readonly Pick[] = [], ): PassiveAuthSurfaceSummary { - const authRoutes = new Map< - AuthSurfaceCategory, - { - confidence: "high" | "medium"; - urls: Set; - evidenceArtifactIds: Set; - } - >(); - - for (const candidate of normalized.authCandidates) { - for (const category of candidate.categories) { - const group = authRoutes.get(category) ?? { - confidence: "medium" as const, - urls: new Set(), - evidenceArtifactIds: new Set(), - }; - if (candidate.confidence === "high") group.confidence = "high"; - group.urls.add(candidate.url); - for (const artifactId of candidate.sourceArtifactIds) { - group.evidenceArtifactIds.add(artifactId); - } - authRoutes.set(category, group); - } - } - - const groupedRoutes = [...authRoutes.entries()] - .map(([category, group]) => ({ - category, - confidence: group.confidence, - urls: [...group.urls].sort(), - evidenceArtifactIds: [...group.evidenceArtifactIds].sort(), - })) - .sort((left, right) => left.category.localeCompare(right.category)); - const blockerReasons = new Set( - normalized.blockerSignals.map((blocker) => blocker.reason), - ); - - return { - targetId, - rawArtifactIds: [...normalized.rawArtifactIds], - rawArtifacts: sourceArtifacts.map(({ artifactId, source }) => ({ - artifactId, - source, - })), - authRoutes: groupedRoutes, - blockers: normalized.blockerSignals.map((blocker) => ({ - reason: blocker.reason, - evidenceArtifactIds: [...blocker.sourceArtifactIds], - evidence: blocker.evidence, - })), - unknowns: buildUnknowns( - new Set(groupedRoutes.map((route) => route.category)), - ), - nextSteps: { - passive: [ - "Review retrieved reference material and existing project evidence for the mapped routes.", - "Resolve remaining auth-flow unknowns from supplied source, manifests, and historical artifacts.", - ], - approvalGated: blockerReasons.size - ? [ - "Resolve recorded blockers before proposing any active validation.", - "Request a target-bound approval for the narrowest reproducible probe only if passive evidence is insufficient.", - ] - : [ - "Request a target-bound approval before any live request, browser interaction, or credential test.", - ], - reportOrPatch: [ - "Keep mapped routes as evidence-backed hypotheses until validation establishes impact.", - ], - }, - }; + const authRoutes = new Map< + AuthSurfaceCategory, + { confidence: "high" | "medium"; urls: Set; evidenceArtifactIds: Set } + >(); + for (const candidate of normalized.authCandidates) { + for (const category of candidate.categories) { + const group = authRoutes.get(category) ?? { + confidence: "medium" as const, + urls: new Set(), + evidenceArtifactIds: new Set(), + }; + if (candidate.confidence === "high") group.confidence = "high"; + group.urls.add(candidate.url); + for (const artifactId of candidate.sourceArtifactIds) + group.evidenceArtifactIds.add(artifactId); + authRoutes.set(category, group); + } + } + const groupedRoutes = [...authRoutes.entries()] + .map(([category, group]) => ({ + category, + confidence: group.confidence, + urls: [...group.urls].sort(), + evidenceArtifactIds: [...group.evidenceArtifactIds].sort(), + })) + .sort((left, right) => left.category.localeCompare(right.category)); + const blockerReasons = new Set(normalized.blockerSignals.map((blocker) => blocker.reason)); + return { + targetId, + rawArtifactIds: [...normalized.rawArtifactIds], + rawArtifacts: sourceArtifacts.map(({ artifactId, source }) => ({ artifactId, source })), + authRoutes: groupedRoutes, + blockers: normalized.blockerSignals.map((blocker) => ({ + reason: blocker.reason, + evidenceArtifactIds: [...blocker.sourceArtifactIds], + evidence: blocker.evidence, + })), + unknowns: buildUnknowns(new Set(groupedRoutes.map((route) => route.category))), + nextSteps: { + passive: [ + "Review retrieved reference material and existing project evidence for the mapped routes.", + "Resolve remaining auth-flow unknowns from supplied source, manifests, and historical artifacts.", + ], + approvalGated: blockerReasons.size + ? [ + "Resolve recorded blockers before proposing any active validation.", + "Request a target-bound approval for the narrowest reproducible probe only if passive evidence is insufficient.", + ] + : [ + "Request a target-bound approval before any live request, browser interaction, or credential test.", + ], + reportOrPatch: [ + "Keep mapped routes as evidence-backed hypotheses until validation establishes impact.", + ], + }, + }; } function buildUnknowns(categories: ReadonlySet): string[] { - const unknowns: string[] = []; - if ( - !categories.has("login") && - !categories.has("sso") && - !categories.has("oauth") - ) { - unknowns.push( - "Primary authentication entry point is not established by current evidence.", - ); - } - if (!categories.has("session") && !categories.has("token")) { - unknowns.push( - "Session or token lifecycle is not established by current evidence.", - ); - } - if (!categories.has("password-recovery")) { - unknowns.push( - "Account recovery surface is not established by current evidence.", - ); - } - return unknowns; + const unknowns: string[] = []; + if (!categories.has("login") && !categories.has("sso") && !categories.has("oauth")) + unknowns.push("Primary authentication entry point is not established by current evidence."); + if (!categories.has("session") && !categories.has("token")) + unknowns.push("Session or token lifecycle is not established by current evidence."); + if (!categories.has("password-recovery")) + unknowns.push("Account recovery surface is not established by current evidence."); + return unknowns; } diff --git a/src/server/recon/passive-auth-system-map.ts b/src/server/recon/passive-auth-system-map.ts index 24ec422c5..f177d7d53 100644 --- a/src/server/recon/passive-auth-system-map.ts +++ b/src/server/recon/passive-auth-system-map.ts @@ -20,93 +20,93 @@ const artifactIdSchema = z.string().min(1).max(256); const boundedTextSchema = z.string().min(1).max(2_048); const passiveAuthSurfaceSummarySchema = z.object({ - targetId: z.string().min(1).max(256), - rawArtifactIds: z.array(artifactIdSchema).max(MAX_ARTIFACT_IDS), - authRoutes: z - .array( - z.object({ - category: z.enum([ - "login", - "logout", - "registration", - "password-recovery", - "oauth", - "sso", - "token", - "session", - "api-key", - "admin", - ]), - confidence: z.enum(["high", "medium"]), - urls: z.array(z.string().min(1).max(8_192)).max(MAX_URLS_PER_GROUP), - evidenceArtifactIds: z.array(artifactIdSchema).max(MAX_ARTIFACT_IDS), - }), - ) - .max(MAX_AUTH_ROUTE_GROUPS), - blockers: z - .array( - z.object({ - reason: z.enum([ - "bot-block-detected", - "captcha-detected", - "waf-denied", - "rate-limited", - "auth-required", - "approval-required", - "target-authorization-required", - "workspace-locked", - "network-profile-blocked", - "tool-unavailable", - ]), - evidenceArtifactIds: z.array(artifactIdSchema).max(MAX_ARTIFACT_IDS), - evidence: boundedTextSchema, - }), - ) - .max(MAX_BLOCKERS), - unknowns: z.array(boundedTextSchema).max(MAX_OPEN_QUESTIONS), - nextSteps: z.object({ - passive: z.array(boundedTextSchema).max(32), - approvalGated: z.array(boundedTextSchema).max(32), - reportOrPatch: z.array(boundedTextSchema).max(32), - }), + targetId: z.string().min(1).max(256), + rawArtifactIds: z.array(artifactIdSchema).max(MAX_ARTIFACT_IDS), + authRoutes: z + .array( + z.object({ + category: z.enum([ + "login", + "logout", + "registration", + "password-recovery", + "oauth", + "sso", + "token", + "session", + "api-key", + "admin", + ]), + confidence: z.enum(["high", "medium"]), + urls: z.array(z.string().min(1).max(8_192)).max(MAX_URLS_PER_GROUP), + evidenceArtifactIds: z.array(artifactIdSchema).max(MAX_ARTIFACT_IDS), + }), + ) + .max(MAX_AUTH_ROUTE_GROUPS), + blockers: z + .array( + z.object({ + reason: z.enum([ + "bot-block-detected", + "captcha-detected", + "waf-denied", + "rate-limited", + "auth-required", + "approval-required", + "target-authorization-required", + "workspace-locked", + "network-profile-blocked", + "tool-unavailable", + ]), + evidenceArtifactIds: z.array(artifactIdSchema).max(MAX_ARTIFACT_IDS), + evidence: boundedTextSchema, + }), + ) + .max(MAX_BLOCKERS), + unknowns: z.array(boundedTextSchema).max(MAX_OPEN_QUESTIONS), + nextSteps: z.object({ + passive: z.array(boundedTextSchema).max(32), + approvalGated: z.array(boundedTextSchema).max(32), + reportOrPatch: z.array(boundedTextSchema).max(32), + }), }); type PassiveAuthSystemMapProjectionInput = { - projectId: string; - reportArtifactId: string; - summary: PassiveAuthSurfaceSummary; + projectId: string; + reportArtifactId: string; + summary: PassiveAuthSurfaceSummary; }; export type PassiveAuthSystemMapProjectionResult = { - reportArtifactId: string; - targetId: string; - entityIds: string[]; - assertionIds: string[]; + reportArtifactId: string; + targetId: string; + entityIds: string[]; + assertionIds: string[]; }; type ArtifactRow = { - id: string; - thread_id: string | null; - metadata: unknown; + id: string; + thread_id: string | null; + metadata: unknown; }; type ProjectedEntity = { - id: string; - kind: "host" | "route" | "auth-boundary" | "open-question"; - label: string; - description: string; - metadata: Record; + id: string; + kind: "host" | "route" | "auth-boundary" | "open-question"; + label: string; + description: string; + metadata: Record; }; type ProjectedAssertion = { - id: string; - subjectEntityId: string; - predicate: "INDICATES" | "OBSERVED_ON" | "REACHABLE_VIA"; - objectEntityId: string; - epistemicStatus: "proposed"; - confidence: number; - evidenceArtifactIds: string[]; - metadata: Record; + id: string; + subjectEntityId: string; + predicate: "INDICATES" | "OBSERVED_ON" | "REACHABLE_VIA"; + objectEntityId: string; + epistemicStatus: "proposed"; + confidence: number; + evidenceArtifactIds: string[]; + metadata: Record; }; /** @@ -114,60 +114,50 @@ type ProjectedAssertion = { * This reads local evidence only and never dispatches a target-facing capability. */ export async function projectPassiveAuthReportToSystemMap( - projectId: string, - reportArtifactId: string, + projectId: string, + reportArtifactId: string, ): Promise { - const artifacts = getArtifactService(); - if (!artifacts.readArtifactText) { - throw new Error( - "Artifact text reads are unavailable for passive system-map projection.", - ); - } - const report = await artifacts.readArtifactText({ - projectId, - artifactId: reportArtifactId, - }); - if (report.truncated) { - throw new Error( - `Passive auth report ${reportArtifactId} is too large to project safely.`, - ); - } - let decoded: unknown; - try { - decoded = JSON.parse(report.text); - } catch { - throw new Error( - `Passive auth report ${reportArtifactId} is not valid JSON.`, - ); - } - const parsed = passiveAuthSurfaceSummarySchema.safeParse(decoded); - if (!parsed.success) { - throw new Error( - `Artifact ${reportArtifactId} is not a passive auth surface report.`, - ); - } - const routeCount = parsed.data.authRoutes.reduce( - (total, group) => total + group.urls.length, - 0, - ); - if (routeCount > MAX_PROJECTED_ROUTES) { - throw new Error( - `Passive auth report ${reportArtifactId} exceeds the system-map route limit.`, - ); - } - if ( - referencedEvidenceIds(parsed.data as PassiveAuthSurfaceSummary).length > - MAX_REFERENCED_ARTIFACTS - ) { - throw new Error( - `Passive auth report ${reportArtifactId} exceeds the evidence reference limit.`, - ); - } - return projectPassiveAuthSurfaceSummaryToSystemMap({ - projectId, - reportArtifactId, - summary: parsed.data as PassiveAuthSurfaceSummary, - }); + const artifacts = getArtifactService(); + if (!artifacts.readArtifactText) { + throw new Error("Artifact text reads are unavailable for passive system-map projection."); + } + const report = await artifacts.readArtifactText({ + projectId, + artifactId: reportArtifactId, + }); + if (report.truncated) { + throw new Error(`Passive auth report ${reportArtifactId} is too large to project safely.`); + } + let decoded: unknown; + try { + decoded = JSON.parse(report.text); + } catch { + throw new Error(`Passive auth report ${reportArtifactId} is not valid JSON.`); + } + const parsed = passiveAuthSurfaceSummarySchema.safeParse(decoded); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + throw new Error( + `Artifact ${reportArtifactId} is not a passive auth surface report${issue ? ` (${issue.path.join(".") || "root"}: ${issue.message})` : ""}.`, + ); + } + const routeCount = parsed.data.authRoutes.reduce((total, group) => total + group.urls.length, 0); + if (routeCount > MAX_PROJECTED_ROUTES) { + throw new Error(`Passive auth report ${reportArtifactId} exceeds the system-map route limit.`); + } + if ( + referencedEvidenceIds(parsed.data as PassiveAuthSurfaceSummary).length > + MAX_REFERENCED_ARTIFACTS + ) { + throw new Error( + `Passive auth report ${reportArtifactId} exceeds the evidence reference limit.`, + ); + } + return projectPassiveAuthSurfaceSummaryToSystemMap({ + projectId, + reportArtifactId, + summary: parsed.data as PassiveAuthSurfaceSummary, + }); } /** @@ -175,507 +165,445 @@ export async function projectPassiveAuthReportToSystemMap( * existing Investigation Entity/Assertion/Citation model used by the Research Map API and UI. */ async function projectPassiveAuthSurfaceSummaryToSystemMap( - input: PassiveAuthSystemMapProjectionInput, - options: { db?: Queryable; notify?: boolean } = {}, + input: PassiveAuthSystemMapProjectionInput, + options: { db?: Queryable; notify?: boolean } = {}, ): Promise { - if ( - !input.projectId.trim() || - !input.reportArtifactId.trim() || - !input.summary.targetId.trim() - ) { - throw new Error( - "Project, report artifact, and target are required for system-map projection.", - ); - } - const run = (db: Queryable) => - withTransaction(db, async (tx) => { - const targetEntityId = await ensureTargetAndCanonicalEntity( - tx, - input.projectId, - input.summary.targetId, - ); - const report = await requirePassiveReport(tx, input); - await requireEvidenceArtifacts( - tx, - input.projectId, - referencedEvidenceIds(input.summary), - input.summary.targetId, - report.thread_id, - ); - - const { entities, assertions } = buildProjection( - input, - targetEntityId, - report.thread_id, - ); - requireBoundedProjectionWork( - input.reportArtifactId, - entities, - assertions, - ); - for (const entity of entities) - await insertEntity(tx, input.projectId, entity); - for (const assertion of assertions) { - await insertAssertion(tx, input.projectId, assertion); - await insertCitations( - tx, - input.projectId, - input.reportArtifactId, - assertion, - ); - } - - return { - reportArtifactId: input.reportArtifactId, - targetId: input.summary.targetId, - entityIds: entities.map((entity) => entity.id), - assertionIds: assertions.map((assertion) => assertion.id), - }; - }); - - const result = options.db ? await run(options.db) : await withDatabase(run); - if (options.notify !== false) { - notifyProjectChanged(input.projectId, { - topic: "graph", - workspaceScoped: true, - }); - } - return result; + if (!input.projectId.trim() || !input.reportArtifactId.trim() || !input.summary.targetId.trim()) { + throw new Error("Project, report artifact, and target are required for system-map projection."); + } + const run = (db: Queryable) => + withTransaction(db, async (tx) => { + const targetEntityId = await ensureTargetAndCanonicalEntity( + tx, + input.projectId, + input.summary.targetId, + ); + const report = await requirePassiveReport(tx, input); + await requireEvidenceArtifacts( + tx, + input.projectId, + referencedEvidenceIds(input.summary), + input.summary.targetId, + report.thread_id, + ); + + const { entities, assertions } = buildProjection(input, targetEntityId, report.thread_id); + requireBoundedProjectionWork(input.reportArtifactId, entities, assertions); + for (const entity of entities) await insertEntity(tx, input.projectId, entity); + for (const assertion of assertions) { + await insertAssertion(tx, input.projectId, assertion); + await insertCitations(tx, input.projectId, input.reportArtifactId, assertion); + } + + return { + reportArtifactId: input.reportArtifactId, + targetId: input.summary.targetId, + entityIds: entities.map((entity) => entity.id), + assertionIds: assertions.map((assertion) => assertion.id), + }; + }); + + const result = options.db ? await run(options.db) : await withDatabase(run); + if (options.notify !== false) { + notifyProjectChanged(input.projectId, { + topic: "graph", + workspaceScoped: true, + }); + } + return result; } function requireBoundedProjectionWork( - reportArtifactId: string, - entities: readonly ProjectedEntity[], - assertions: readonly ProjectedAssertion[], + reportArtifactId: string, + entities: readonly ProjectedEntity[], + assertions: readonly ProjectedAssertion[], ): void { - const citationWrites = assertions.reduce( - (total, assertion) => - total + 1 + uniqueSorted(assertion.evidenceArtifactIds).length, - 0, - ); - const totalWrites = entities.length + assertions.length + citationWrites; - if (totalWrites > MAX_PROJECTED_WRITES) { - throw new Error( - `Passive auth report ${reportArtifactId} exceeds the system-map write limit.`, - ); - } + const citationWrites = assertions.reduce( + (total, assertion) => total + 1 + uniqueSorted(assertion.evidenceArtifactIds).length, + 0, + ); + const totalWrites = entities.length + assertions.length + citationWrites; + if (totalWrites > MAX_PROJECTED_WRITES) { + throw new Error(`Passive auth report ${reportArtifactId} exceeds the system-map write limit.`); + } } function buildProjection( - input: PassiveAuthSystemMapProjectionInput, - targetEntityId: string, - threadId: string | null, + input: PassiveAuthSystemMapProjectionInput, + targetEntityId: string, + threadId: string | null, ): { entities: ProjectedEntity[]; assertions: ProjectedAssertion[] } { - const entityById = new Map(); - const assertionById = new Map(); - const hostEvidence = new Map>(); - const commonMetadata = { - workflow: "passive-auth-system-map-v1", - reportArtifactId: input.reportArtifactId, - targetId: input.summary.targetId, - ...(threadId ? { threadId } : {}), - }; - - for (const routeGroup of input.summary.authRoutes) { - const boundaryId = stableId( - "investigationEntity", - input.projectId, - input.reportArtifactId, - "auth-boundary", - routeGroup.category, - ); - entityById.set(boundaryId, { - id: boundaryId, - kind: "auth-boundary", - label: `${routeGroup.category} authentication boundary`, - description: `Passive evidence suggests a ${routeGroup.category} boundary; validation is still required.`, - metadata: { - ...commonMetadata, - category: routeGroup.category, - confidence: routeGroup.confidence, - hypothesis: true, - }, - }); - - for (const rawUrl of routeGroup.urls) { - const parsed = parseHttpUrl(redactEvidenceUrlSecrets(rawUrl)); - const hostKey = parsed.host.toLowerCase(); - const hostId = stableId( - "investigationEntity", - input.projectId, - input.reportArtifactId, - "host", - hostKey, - ); - const routeId = stableId( - "investigationEntity", - input.projectId, - input.reportArtifactId, - "route", - parsed.toString(), - ); - const evidenceIds = uniqueSorted(routeGroup.evidenceArtifactIds); - const accumulatedHostEvidence = - hostEvidence.get(hostId) ?? new Set(); - for (const artifactId of evidenceIds) - accumulatedHostEvidence.add(artifactId); - hostEvidence.set(hostId, accumulatedHostEvidence); - - entityById.set(hostId, { - id: hostId, - kind: "host", - label: parsed.host, - description: `Host present in passive auth evidence for target ${input.summary.targetId}.`, - metadata: { ...commonMetadata, host: parsed.host, hypothesis: false }, - }); - entityById.set(routeId, { - id: routeId, - kind: "route", - label: parsed.toString(), - description: `Passively observed ${routeGroup.category} route candidate.`, - metadata: { - ...commonMetadata, - url: parsed.toString(), - path: parsed.pathname, - category: routeGroup.category, - confidence: routeGroup.confidence, - hypothesis: true, - }, - }); - - addAssertion(assertionById, input, { - semanticKey: `route-host:${parsed.toString()}:${hostKey}`, - subjectEntityId: routeId, - predicate: "REACHABLE_VIA", - objectEntityId: hostId, - epistemicStatus: "proposed", - confidence: confidenceNumber(routeGroup.confidence), - evidenceArtifactIds: evidenceIds, - metadata: { ...commonMetadata, hypothesis: true }, - }); - addAssertion(assertionById, input, { - semanticKey: `route-boundary:${parsed.toString()}:${routeGroup.category}`, - subjectEntityId: routeId, - predicate: "INDICATES", - objectEntityId: boundaryId, - epistemicStatus: "proposed", - confidence: confidenceNumber(routeGroup.confidence), - evidenceArtifactIds: evidenceIds, - metadata: { ...commonMetadata, hypothesis: true }, - }); - } - } - - for (const [hostId, evidenceIds] of hostEvidence) { - addAssertion(assertionById, input, { - semanticKey: `host-target:${hostId}:${input.summary.targetId}`, - subjectEntityId: hostId, - predicate: "OBSERVED_ON", - objectEntityId: targetEntityId, - epistemicStatus: "proposed", - confidence: 0.9, - evidenceArtifactIds: uniqueSorted(evidenceIds), - metadata: { ...commonMetadata, hypothesis: false }, - }); - } - - for (const rawQuestion of uniqueSorted(input.summary.unknowns)) { - const question = redactEvidenceUrlSecrets(rawQuestion); - const questionId = stableId( - "investigationEntity", - input.projectId, - input.reportArtifactId, - "open-question", - question, - ); - entityById.set(questionId, { - id: questionId, - kind: "open-question", - label: question, - description: - "An unresolved question retained from passive auth evidence.", - metadata: { ...commonMetadata, hypothesis: true, open: true }, - }); - addAssertion(assertionById, input, { - semanticKey: `target-question:${input.summary.targetId}:${question}`, - subjectEntityId: targetEntityId, - predicate: "INDICATES", - objectEntityId: questionId, - epistemicStatus: "proposed", - confidence: 0.5, - evidenceArtifactIds: input.summary.rawArtifactIds, - metadata: { ...commonMetadata, hypothesis: true, openQuestion: true }, - }); - } - - return { - entities: [...entityById.values()], - assertions: [...assertionById.values()], - }; + const entityById = new Map(); + const assertionById = new Map(); + const hostEvidence = new Map>(); + const commonMetadata = { + workflow: "passive-auth-system-map-v1", + reportArtifactId: input.reportArtifactId, + targetId: input.summary.targetId, + ...(threadId ? { threadId } : {}), + }; + + for (const routeGroup of input.summary.authRoutes) { + const boundaryId = stableId( + "investigationEntity", + input.projectId, + input.reportArtifactId, + "auth-boundary", + routeGroup.category, + ); + entityById.set(boundaryId, { + id: boundaryId, + kind: "auth-boundary", + label: `${routeGroup.category} authentication boundary`, + description: `Passive evidence suggests a ${routeGroup.category} boundary; validation is still required.`, + metadata: { + ...commonMetadata, + category: routeGroup.category, + confidence: routeGroup.confidence, + hypothesis: true, + }, + }); + + for (const rawUrl of routeGroup.urls) { + const parsed = parseHttpUrl(redactEvidenceUrlSecrets(rawUrl)); + const hostKey = parsed.host.toLowerCase(); + const hostId = stableId( + "investigationEntity", + input.projectId, + input.reportArtifactId, + "host", + hostKey, + ); + const routeId = stableId( + "investigationEntity", + input.projectId, + input.reportArtifactId, + "route", + parsed.toString(), + ); + const evidenceIds = uniqueSorted(routeGroup.evidenceArtifactIds); + const accumulatedHostEvidence = hostEvidence.get(hostId) ?? new Set(); + for (const artifactId of evidenceIds) accumulatedHostEvidence.add(artifactId); + hostEvidence.set(hostId, accumulatedHostEvidence); + + entityById.set(hostId, { + id: hostId, + kind: "host", + label: parsed.host, + description: `Host present in passive auth evidence for target ${input.summary.targetId}.`, + metadata: { ...commonMetadata, host: parsed.host, hypothesis: false }, + }); + entityById.set(routeId, { + id: routeId, + kind: "route", + label: parsed.toString(), + description: `Passively observed ${routeGroup.category} route candidate.`, + metadata: { + ...commonMetadata, + url: parsed.toString(), + path: parsed.pathname, + category: routeGroup.category, + confidence: routeGroup.confidence, + hypothesis: true, + }, + }); + + addAssertion(assertionById, input, { + semanticKey: `route-host:${parsed.toString()}:${hostKey}`, + subjectEntityId: routeId, + predicate: "REACHABLE_VIA", + objectEntityId: hostId, + epistemicStatus: "proposed", + confidence: confidenceNumber(routeGroup.confidence), + evidenceArtifactIds: evidenceIds, + metadata: { ...commonMetadata, hypothesis: true }, + }); + addAssertion(assertionById, input, { + semanticKey: `route-boundary:${parsed.toString()}:${routeGroup.category}`, + subjectEntityId: routeId, + predicate: "INDICATES", + objectEntityId: boundaryId, + epistemicStatus: "proposed", + confidence: confidenceNumber(routeGroup.confidence), + evidenceArtifactIds: evidenceIds, + metadata: { ...commonMetadata, hypothesis: true }, + }); + } + } + + for (const [hostId, evidenceIds] of hostEvidence) { + addAssertion(assertionById, input, { + semanticKey: `host-target:${hostId}:${input.summary.targetId}`, + subjectEntityId: hostId, + predicate: "OBSERVED_ON", + objectEntityId: targetEntityId, + epistemicStatus: "proposed", + confidence: 0.9, + evidenceArtifactIds: uniqueSorted(evidenceIds), + metadata: { ...commonMetadata, hypothesis: false }, + }); + } + + for (const rawQuestion of uniqueSorted(input.summary.unknowns)) { + const question = redactEvidenceUrlSecrets(rawQuestion); + const questionId = stableId( + "investigationEntity", + input.projectId, + input.reportArtifactId, + "open-question", + question, + ); + entityById.set(questionId, { + id: questionId, + kind: "open-question", + label: question, + description: "An unresolved question retained from passive auth evidence.", + metadata: { ...commonMetadata, hypothesis: true, open: true }, + }); + addAssertion(assertionById, input, { + semanticKey: `target-question:${input.summary.targetId}:${question}`, + subjectEntityId: targetEntityId, + predicate: "INDICATES", + objectEntityId: questionId, + epistemicStatus: "proposed", + confidence: 0.5, + evidenceArtifactIds: input.summary.rawArtifactIds, + metadata: { ...commonMetadata, hypothesis: true, openQuestion: true }, + }); + } + + return { + entities: [...entityById.values()], + assertions: [...assertionById.values()], + }; } function addAssertion( - assertions: Map, - input: PassiveAuthSystemMapProjectionInput, - assertion: Omit & { semanticKey: string }, + assertions: Map, + input: PassiveAuthSystemMapProjectionInput, + assertion: Omit & { semanticKey: string }, ): void { - const id = stableId( - "investigationAssertion", - input.projectId, - input.reportArtifactId, - assertion.semanticKey, - ); - assertions.set(id, { ...assertion, id }); + const id = stableId( + "investigationAssertion", + input.projectId, + input.reportArtifactId, + assertion.semanticKey, + ); + assertions.set(id, { ...assertion, id }); } async function ensureTargetAndCanonicalEntity( - db: Queryable, - projectId: string, - targetId: string, + db: Queryable, + projectId: string, + targetId: string, ): Promise { - const target = await db.query<{ id: string; label: string }>( - "SELECT id, label FROM targets WHERE project_id = $1 AND id = $2 LIMIT 1", - [projectId, targetId], - ); - if (!target.rows[0]) - throw new Error(`Target ${targetId} was not found in this project.`); - const candidateId = stableId( - "investigationEntity", - projectId, - "target", - targetId, - ); - await db.query( - `INSERT INTO investigation_entities + const target = await db.query<{ id: string; label: string }>( + "SELECT id, label FROM targets WHERE project_id = $1 AND id = $2 LIMIT 1", + [projectId, targetId], + ); + if (!target.rows[0]) throw new Error(`Target ${targetId} was not found in this project.`); + const candidateId = stableId("investigationEntity", projectId, "target", targetId); + await db.query( + `INSERT INTO investigation_entities (id, project_id, kind, label, canonical_type, canonical_id, created_by, metadata) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb) ON CONFLICT (project_id, canonical_type, canonical_id) DO NOTHING`, - [ - candidateId, - projectId, - "canonical:target", - target.rows[0].label, - "target", - targetId, - "agent:passive-auth-system-map", - JSON.stringify({ targetId }), - ], - ); - const entity = await db.query<{ id: string }>( - `SELECT id FROM investigation_entities + [ + candidateId, + projectId, + "canonical:target", + target.rows[0].label, + "target", + targetId, + "agent:passive-auth-system-map", + JSON.stringify({ targetId }), + ], + ); + const entity = await db.query<{ id: string }>( + `SELECT id FROM investigation_entities WHERE project_id = $1 AND canonical_type = 'target' AND canonical_id = $2 LIMIT 1`, - [projectId, targetId], - ); - if (!entity.rows[0]) - throw new Error( - `Target ${targetId} could not be linked to the system map.`, - ); - return entity.rows[0].id; + [projectId, targetId], + ); + if (!entity.rows[0]) throw new Error(`Target ${targetId} could not be linked to the system map.`); + return entity.rows[0].id; } async function requirePassiveReport( - db: Queryable, - input: PassiveAuthSystemMapProjectionInput, + db: Queryable, + input: PassiveAuthSystemMapProjectionInput, ): Promise { - const result = await db.query( - "SELECT id, thread_id, metadata FROM artifacts WHERE project_id = $1 AND id = $2 LIMIT 1", - [input.projectId, input.reportArtifactId], - ); - const report = result.rows[0]; - if (!report) - throw new Error( - `Artifact ${input.reportArtifactId} was not found in this project.`, - ); - const metadata = asRecord(report.metadata); - if ( - metadata.workflow !== "passive-auth-surface-v1" || - metadata.source !== "passive-recon" - ) { - throw new Error( - `Artifact ${input.reportArtifactId} is not a passive auth surface report.`, - ); - } - if (metadata.targetId !== input.summary.targetId) { - throw new Error( - `Passive auth report ${input.reportArtifactId} is not bound to this target.`, - ); - } - return report; + const result = await db.query( + "SELECT id, thread_id, metadata FROM artifacts WHERE project_id = $1 AND id = $2 LIMIT 1", + [input.projectId, input.reportArtifactId], + ); + const report = result.rows[0]; + if (!report) throw new Error(`Artifact ${input.reportArtifactId} was not found in this project.`); + const metadata = asRecord(report.metadata); + if (metadata.workflow !== "passive-auth-surface-v1" || metadata.source !== "passive-recon") { + throw new Error(`Artifact ${input.reportArtifactId} is not a passive auth surface report.`); + } + if (metadata.targetId !== input.summary.targetId) { + throw new Error(`Passive auth report ${input.reportArtifactId} is not bound to this target.`); + } + return report; } async function requireEvidenceArtifacts( - db: Queryable, - projectId: string, - artifactIds: readonly string[], - targetId: string, - reportThreadId: string | null, + db: Queryable, + projectId: string, + artifactIds: readonly string[], + targetId: string, + reportThreadId: string | null, ): Promise { - const ids = uniqueSorted(artifactIds); - if (ids.length === 0) return; - const placeholders = ids.map((_, index) => `$${index + 2}`).join(","); - const result = await db.query( - `SELECT id, thread_id, metadata FROM artifacts + const ids = uniqueSorted(artifactIds); + if (ids.length === 0) return; + const placeholders = ids.map((_, index) => `$${index + 2}`).join(","); + const result = await db.query( + `SELECT id, thread_id, metadata FROM artifacts WHERE project_id = $1 AND id IN (${placeholders})`, - [projectId, ...ids], - ); - const byId = new Map(result.rows.map((row) => [row.id, row])); - for (const artifactId of ids) { - const artifact = byId.get(artifactId); - if (!artifact) { - throw new Error( - `Evidence artifact ${artifactId} was not found in this project.`, - ); - } - if (artifact.thread_id !== null && artifact.thread_id !== reportThreadId) { - throw new Error( - `Evidence artifact ${artifactId} is attributed to a different thread than this report.`, - ); - } - if (!artifactMatchesTarget(asRecord(artifact.metadata), targetId)) { - throw new Error( - `Evidence artifact ${artifactId} is not scoped to target ${targetId}.`, - ); - } - } + [projectId, ...ids], + ); + const byId = new Map(result.rows.map((row) => [row.id, row])); + for (const artifactId of ids) { + const artifact = byId.get(artifactId); + if (!artifact) { + throw new Error(`Evidence artifact ${artifactId} was not found in this project.`); + } + if (artifact.thread_id !== null && artifact.thread_id !== reportThreadId) { + throw new Error( + `Evidence artifact ${artifactId} is attributed to a different thread than this report.`, + ); + } + if (!artifactMatchesTarget(asRecord(artifact.metadata), targetId)) { + throw new Error(`Evidence artifact ${artifactId} is not scoped to target ${targetId}.`); + } + } } -function artifactMatchesTarget( - metadata: Record, - targetId: string, -) { - const targetIds = new Set(); - if (typeof metadata.targetId === "string") targetIds.add(metadata.targetId); - if (Array.isArray(metadata.targetIds)) { - for (const value of metadata.targetIds) { - if (typeof value === "string") targetIds.add(value); - } - } - return targetIds.size > 0 - ? targetIds.has(targetId) - : metadata.targetScope === "project"; +function artifactMatchesTarget(metadata: Record, targetId: string) { + const targetIds = new Set(); + if (typeof metadata.targetId === "string") targetIds.add(metadata.targetId); + if (Array.isArray(metadata.targetIds)) { + for (const value of metadata.targetIds) { + if (typeof value === "string") targetIds.add(value); + } + } + return targetIds.size > 0 ? targetIds.has(targetId) : metadata.targetScope === "project"; } -async function insertEntity( - db: Queryable, - projectId: string, - entity: ProjectedEntity, -) { - await db.query( - `INSERT INTO investigation_entities +async function insertEntity(db: Queryable, projectId: string, entity: ProjectedEntity) { + await db.query( + `INSERT INTO investigation_entities (id, project_id, kind, label, description, created_by, metadata) VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb) ON CONFLICT (id) DO NOTHING`, - [ - entity.id, - projectId, - entity.kind, - entity.label, - entity.description, - "agent:passive-auth-system-map", - JSON.stringify(entity.metadata), - ], - ); + [ + entity.id, + projectId, + entity.kind, + entity.label, + entity.description, + "agent:passive-auth-system-map", + JSON.stringify(entity.metadata), + ], + ); } -async function insertAssertion( - db: Queryable, - projectId: string, - assertion: ProjectedAssertion, -) { - await db.query( - `INSERT INTO investigation_assertions +async function insertAssertion(db: Queryable, projectId: string, assertion: ProjectedAssertion) { + await db.query( + `INSERT INTO investigation_assertions (id, project_id, subject_entity_id, predicate, object_type, object_entity_id, polarity, epistemic_status, lifecycle_status, confidence, actor, metadata) VALUES ($1,$2,$3,$4,'investigation_entity',$5,'positive',$6,'current',$7,$8,$9::jsonb) ON CONFLICT (id) DO NOTHING`, - [ - assertion.id, - projectId, - assertion.subjectEntityId, - assertion.predicate, - assertion.objectEntityId, - assertion.epistemicStatus, - assertion.confidence, - "agent:passive-auth-system-map", - JSON.stringify(assertion.metadata), - ], - ); + [ + assertion.id, + projectId, + assertion.subjectEntityId, + assertion.predicate, + assertion.objectEntityId, + assertion.epistemicStatus, + assertion.confidence, + "agent:passive-auth-system-map", + JSON.stringify(assertion.metadata), + ], + ); } async function insertCitations( - db: Queryable, - projectId: string, - reportArtifactId: string, - assertion: ProjectedAssertion, + db: Queryable, + projectId: string, + reportArtifactId: string, + assertion: ProjectedAssertion, ) { - const citations = [ - { artifactId: reportArtifactId, role: "support" }, - ...uniqueSorted(assertion.evidenceArtifactIds).map((artifactId) => ({ - artifactId, - role: "context", - })), - ] as const; - for (const citation of citations) { - const id = stableId( - "investigationCitation", - projectId, - assertion.id, - citation.artifactId, - citation.role, - ); - await db.query( - `INSERT INTO investigation_citations + const citations = [ + { artifactId: reportArtifactId, role: "support" }, + ...uniqueSorted(assertion.evidenceArtifactIds).map((artifactId) => ({ + artifactId, + role: "context", + })), + ] as const; + for (const citation of citations) { + const id = stableId( + "investigationCitation", + projectId, + assertion.id, + citation.artifactId, + citation.role, + ); + await db.query( + `INSERT INTO investigation_citations (id, project_id, assertion_id, source_type, source_id, locator_type, role) VALUES ($1,$2,$3,'artifact',$4,'record',$5) ON CONFLICT (id) DO NOTHING`, - [id, projectId, assertion.id, citation.artifactId, citation.role], - ); - } + [id, projectId, assertion.id, citation.artifactId, citation.role], + ); + } } function parseHttpUrl(value: string): URL { - let parsed: URL; - try { - parsed = new URL(value); - } catch { - throw new Error("Passive auth report contains an invalid route URL."); - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error("Passive auth report contains a non-HTTP route URL."); - } - parsed.hash = ""; - return parsed; + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error("Passive auth report contains an invalid route URL."); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Passive auth report contains a non-HTTP route URL."); + } + parsed.hash = ""; + return parsed; } function confidenceNumber(confidence: "high" | "medium") { - return confidence === "high" ? 0.9 : 0.65; + return confidence === "high" ? 0.9 : 0.65; } function stableId(prefix: string, ...parts: string[]) { - return `${prefix}_${createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 24)}`; + return `${prefix}_${createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 24)}`; } function uniqueSorted(values: Iterable) { - return [...new Set(values)].sort(); + return [...new Set(values)].sort(); } function referencedEvidenceIds(summary: PassiveAuthSurfaceSummary) { - return uniqueSorted([ - ...summary.rawArtifactIds, - ...summary.authRoutes.flatMap((route) => route.evidenceArtifactIds), - ...summary.blockers.flatMap((blocker) => blocker.evidenceArtifactIds), - ]); + return uniqueSorted([ + ...summary.rawArtifactIds, + ...summary.authRoutes.flatMap((route) => route.evidenceArtifactIds), + ...summary.blockers.flatMap((blocker) => blocker.evidenceArtifactIds), + ]); } function asRecord(value: unknown): Record { - if (typeof value === "string") { - try { - return asRecord(JSON.parse(value)); - } catch { - return {}; - } - } - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; + if (typeof value === "string") { + try { + return asRecord(JSON.parse(value)); + } catch { + return {}; + } + } + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; } diff --git a/src/server/storage/s3.ts b/src/server/storage/s3.ts index 562160098..9001c3569 100644 --- a/src/server/storage/s3.ts +++ b/src/server/storage/s3.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, open, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join, normalize, resolve } from "node:path"; import { CreateBucketCommand, @@ -44,6 +44,15 @@ export interface GetObjectInput { bucket?: string; } +export interface ReadObjectRangeInput extends GetObjectInput { + maxBytes: number; +} + +export interface ReadObjectRangeResult { + bytes: Uint8Array; + truncated: boolean; +} + export interface CreatePresignedPutObjectUrlInput { key: string; contentType?: string; @@ -67,6 +76,7 @@ export interface ObjectStorageClient { putObject(input: PutObjectInput): Promise; getObject(input: GetObjectInput): Promise; readObjectBytes(input: GetObjectInput): Promise; + readObjectRange(input: ReadObjectRangeInput): Promise; createPresignedPutObjectUrl( input: CreatePresignedPutObjectUrlInput, ): Promise; @@ -164,6 +174,23 @@ function createS3StorageClient(config: ObjectStorageConfig): ObjectStorageClient ); return readObjectBody(output.Body); }, + async readObjectRange(input) { + const maxBytes = normalizeRangeLimit(input.maxBytes); + const output = await client.send( + new GetObjectCommand({ + Bucket: input.bucket ?? config.bucket, + Key: resolveKey(input.key), + Range: `bytes=0-${maxBytes}`, + }), + ); + const bytes = await readObjectBody(output.Body, maxBytes + 1); + const totalBytes = readContentRangeTotal(output.ContentRange); + return { + bytes: bytes.slice(0, maxBytes), + truncated: + bytes.byteLength > maxBytes || (totalBytes !== undefined && totalBytes > maxBytes), + }; + }, async createPresignedPutObjectUrl(input) { const bucket = input.bucket ?? config.bucket; const expiresIn = input.expiresInSeconds ?? 15 * 60; @@ -282,6 +309,21 @@ function createFileObjectStorageClient(config: { async readObjectBytes(input) { return readFile(resolveKey(input.key)); }, + async readObjectRange(input) { + const maxBytes = normalizeRangeLimit(input.maxBytes); + const handle = await open(resolveKey(input.key), "r"); + try { + const stats = await handle.stat(); + const buffer = new Uint8Array(maxBytes); + const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0); + return { + bytes: buffer.slice(0, bytesRead), + truncated: stats.size > bytesRead, + }; + } finally { + await handle.close(); + } + }, async createPresignedPutObjectUrl() { throw new Error("Filesystem object storage does not support presigned browser uploads."); }, @@ -328,24 +370,30 @@ async function ensureBucketCors(client: S3Client, bucket: string, allowedOrigins } catch {} } -async function readObjectBody(body: unknown): Promise { +async function readObjectBody(body: unknown, maxBytes?: number): Promise { if (!body) { return new Uint8Array(); } if (body instanceof Uint8Array) { - return body; + return maxBytes === undefined ? body : body.slice(0, maxBytes); } if (body instanceof ArrayBuffer) { - return new Uint8Array(body); + const bytes = new Uint8Array(body); + return maxBytes === undefined ? bytes : bytes.slice(0, maxBytes); + } + if (maxBytes !== undefined && isAsyncIterable(body)) { + return readAsyncIterable(body, maxBytes); } if (hasTransformToByteArray(body)) { - return new Uint8Array(await body.transformToByteArray()); + const bytes = new Uint8Array(await body.transformToByteArray()); + return maxBytes === undefined ? bytes : bytes.slice(0, maxBytes); } if (hasArrayBuffer(body)) { - return new Uint8Array(await body.arrayBuffer()); + const bytes = new Uint8Array(await body.arrayBuffer()); + return maxBytes === undefined ? bytes : bytes.slice(0, maxBytes); } if (isWebReadableStream(body)) { - return readWebReadableStream(body); + return readWebReadableStream(body, maxBytes); } if (isAsyncIterable(body)) { const chunks: Uint8Array[] = []; @@ -360,6 +408,21 @@ async function readObjectBody(body: unknown): Promise { throw new Error("Unsupported object storage response body."); } +async function readAsyncIterable(body: AsyncIterable, maxBytes: number) { + const chunks: Uint8Array[] = []; + let totalBytes = 0; + for await (const chunk of body) { + const bytes = chunkToBytes(chunk); + const remaining = maxBytes - totalBytes; + if (remaining <= 0) break; + const bounded = bytes.byteLength > remaining ? bytes.slice(0, remaining) : bytes; + chunks.push(bounded); + totalBytes += bounded.byteLength; + if (totalBytes >= maxBytes) break; + } + return concatenateBytes(chunks, totalBytes); +} + function hasTransformToByteArray( value: unknown, ): value is { transformToByteArray(): Promise } { @@ -390,7 +453,7 @@ function isAsyncIterable(value: unknown): value is AsyncIterable { ); } -async function readWebReadableStream(stream: ReadableStream) { +async function readWebReadableStream(stream: ReadableStream, maxBytes?: number) { const reader = stream.getReader(); const chunks: Uint8Array[] = []; let totalBytes = 0; @@ -401,8 +464,18 @@ async function readWebReadableStream(stream: ReadableStream) { break; } const bytes = chunkToBytes(value); - chunks.push(bytes); - totalBytes += bytes.byteLength; + const remaining = maxBytes === undefined ? bytes.byteLength : maxBytes - totalBytes; + if (remaining <= 0) { + await reader.cancel(); + break; + } + const bounded = bytes.byteLength > remaining ? bytes.slice(0, remaining) : bytes; + chunks.push(bounded); + totalBytes += bounded.byteLength; + if (maxBytes !== undefined && totalBytes >= maxBytes) { + await reader.cancel(); + break; + } } } finally { reader.releaseLock(); @@ -410,6 +483,20 @@ async function readWebReadableStream(stream: ReadableStream) { return concatenateBytes(chunks, totalBytes); } +function normalizeRangeLimit(value: number) { + if (!Number.isFinite(value) || value < 1) { + throw new Error("Object range maxBytes must be a positive number."); + } + return Math.floor(value); +} + +function readContentRangeTotal(value: string | undefined) { + const total = value?.match(/\/(\d+)$/)?.[1]; + if (!total) return undefined; + const parsed = Number(total); + return Number.isSafeInteger(parsed) ? parsed : undefined; +} + function chunkToBytes(chunk: unknown) { if (chunk instanceof Uint8Array) { return chunk; diff --git a/src/styles/chat.css b/src/styles/chat.css index a417a0588..c5505810d 100644 --- a/src/styles/chat.css +++ b/src/styles/chat.css @@ -3285,9 +3285,14 @@ align-items: start; gap: 0.42rem; min-width: 0; + width: 100%; border: 1px solid transparent; border-radius: 0.375rem; + background: transparent; + color: inherit; padding: 0.4rem; + cursor: pointer; + text-align: left; transition: border-color 140ms ease, background 140ms ease; @@ -3298,6 +3303,342 @@ background: rgb(255 255 255 / 4%); } +.artifact-sidebar-item:focus-visible { + border-color: var(--accent); + outline: none; +} + +.artifact-report-backdrop { + position: fixed; + inset: 0; + z-index: 70; + display: grid; + place-items: center; + padding: 1.25rem; + background: rgb(5 7 10 / 68%); + backdrop-filter: blur(8px); +} + +.artifact-report-dialog { + width: min(940px, 100%); + max-height: min(880px, calc(100vh - 2.5rem)); + overflow: hidden; + border: 1px solid rgb(146 163 157 / 24%); + border-radius: 10px; + background: #101716; + color: #eef5f1; + box-shadow: 0 24px 80px rgb(0 0 0 / 48%); +} + +.artifact-report-header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.8rem; + align-items: start; + padding: 1rem 1.1rem; + border-bottom: 1px solid rgb(146 163 157 / 18%); +} + +.artifact-report-header h2, +.artifact-report-header code, +.artifact-report-section h3, +.artifact-claim h4, +.artifact-next-step-group h4, +.raw-evidence-panel h3, +.generic-artifact-view h3 { + margin: 0; +} + +.artifact-report-header h2 { + overflow: hidden; + color: #f0f7f3; + font-size: 1rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.artifact-report-header code { + display: block; + margin-top: 0.22rem; + color: #8ea49d; + font-size: 0.72rem; +} + +.artifact-report-icon { + display: grid; + place-items: center; + width: 2.1rem; + height: 2.1rem; + border-radius: 8px; + background: rgb(83 199 167 / 14%); + color: #9ff0d2; +} + +.artifact-report-content { + display: grid; + gap: 1rem; + max-height: calc(min(880px, 100vh - 2.5rem) - 4.3rem); + padding: 1rem 1.1rem 1.25rem; + overflow: auto; +} + +.passive-auth-report { + display: grid; + gap: 1rem; +} + +.artifact-report-summary { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 0.9rem; + border: 1px solid rgb(83 199 167 / 22%); + border-radius: 8px; + background: rgb(83 199 167 / 7%); +} + +.artifact-report-summary h3 { + margin: 0.12rem 0 0; + font-size: 1.05rem; +} + +.artifact-report-eyebrow { + color: #8ea49d; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.artifact-report-section { + display: grid; + gap: 0.7rem; +} + +.artifact-report-section > header { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.artifact-report-section > header h3, +.generic-artifact-view h3, +.raw-evidence-panel h3 { + color: #e8f1ed; + font-size: 0.9rem; +} + +.artifact-report-section > header > span { + display: inline-grid; + place-items: center; + min-width: 1.35rem; + height: 1.35rem; + border: 1px solid rgb(146 163 157 / 22%); + border-radius: 999px; + color: #9cafaa; + font-size: 0.68rem; +} + +.artifact-claim-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(270px, 100%), 1fr)); + gap: 0.6rem; +} + +.artifact-claim, +.artifact-next-step-group { + display: grid; + align-content: start; + gap: 0.55rem; + min-width: 0; + padding: 0.75rem; + border: 1px solid rgb(146 163 157 / 18%); + border-radius: 8px; + background: rgb(255 255 255 / 2.5%); +} + +.artifact-claim.is-warning { + border-color: rgb(235 177 76 / 24%); + background: rgb(235 177 76 / 5%); +} + +.artifact-claim header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.55rem; +} + +.artifact-claim h4, +.artifact-next-step-group h4 { + color: #dce8e3; + font-size: 0.8rem; +} + +.artifact-claim p, +.artifact-claim ul, +.artifact-next-step-group ol, +.artifact-report-empty, +.claim-evidence p { + margin: 0; + color: #a9bbb5; + font-size: 0.78rem; + line-height: 1.48; +} + +.artifact-claim ul, +.artifact-next-step-group ol { + padding-left: 1.2rem; +} + +.artifact-next-step-group + .artifact-next-step-group { + margin-top: 0.1rem; +} + +.artifact-next-step-group li + li { + margin-top: 0.65rem; +} + +.confidence-badge { + flex: none; + padding: 0.18rem 0.42rem; + border: 1px solid rgb(146 163 157 / 22%); + border-radius: 999px; + color: #aec0ba; + font-size: 0.66rem; + text-transform: capitalize; +} + +.confidence-badge.is-high { + border-color: rgb(83 199 167 / 28%); + color: #9ff0d2; +} + +.claim-evidence { + min-width: 0; +} + +.claim-evidence summary { + width: fit-content; + color: #91cdbb; + cursor: pointer; + font-size: 0.7rem; + font-weight: 650; +} + +.claim-evidence ul { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin: 0.5rem 0 0; + padding: 0; + list-style: none; +} + +.claim-evidence button, +.artifact-reveal-button { + display: inline-flex; + align-items: center; + gap: 0.32rem; + border: 1px solid rgb(83 199 167 / 24%); + border-radius: 6px; + background: rgb(83 199 167 / 8%); + color: #a9e3d1; + padding: 0.34rem 0.5rem; + cursor: pointer; + font-size: 0.7rem; +} + +.claim-evidence button:hover, +.artifact-reveal-button:hover { + background: rgb(83 199 167 / 14%); +} + +.artifact-report-notice { + margin: 0; + padding: 0.8rem; + border: 1px solid rgb(146 163 157 / 18%); + border-radius: 8px; + background: rgb(255 255 255 / 3%); + color: #a9bbb5; + font-size: 0.82rem; +} + +.artifact-report-notice.is-error { + border-color: rgb(255 111 133 / 28%); + color: #ffc0ca; +} + +.generic-artifact-view, +.raw-evidence-panel { + display: grid; + gap: 0.7rem; +} + +.raw-evidence-panel { + padding: 0.85rem; + border: 1px solid rgb(83 199 167 / 24%); + border-radius: 8px; + background: #0c1211; +} + +.raw-evidence-panel > header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.7rem; +} + +.raw-evidence-text { + display: grid; + gap: 0.45rem; +} + +.raw-evidence-text p { + display: flex; + align-items: center; + gap: 0.32rem; + margin: 0; + color: #8fa49d; + font-size: 0.7rem; +} + +.raw-evidence-text pre { + max-height: 18rem; + margin: 0; + padding: 0.75rem; + overflow: auto; + border: 1px solid rgb(146 163 157 / 16%); + border-radius: 6px; + background: #090e0d; + color: #cfddd8; + font-size: 0.72rem; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +@media (max-width: 640px) { + .artifact-report-backdrop { + align-items: stretch; + padding: 0; + } + + .artifact-report-dialog { + max-height: 100vh; + border-radius: 0; + } + + .artifact-report-content { + max-height: calc(100vh - 4.3rem); + } + + .artifact-report-summary { + display: grid; + } +} + .artifact-sidebar-icon { display: grid; place-items: center; diff --git a/tests/integration/artifact-report-api.test.ts b/tests/integration/artifact-report-api.test.ts new file mode 100644 index 000000000..9951140ad --- /dev/null +++ b/tests/integration/artifact-report-api.test.ts @@ -0,0 +1,430 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GET } from "../../src/app/api/projects/[projectId]/artifacts/[artifactId]/route"; +import { createSqlitePool, getDatabaseConfig } from "../../src/server/db/client"; +import { createArtifactService, setArtifactService } from "../../src/server/evidence"; +import { + buildPassiveAuthSurfaceSummary, + normalizeDiscoveryArtifacts, + PASSIVE_AUTH_REPORT_MAX_BYTES, + persistPassiveAuthSurface, + type DiscoveryArtifactInput, +} from "../../src/server/recon"; + +const tempRoots: string[] = []; +let previousDatabaseUrl: string | undefined; + +afterEach(async () => { + setArtifactService(undefined); + if (previousDatabaseUrl === undefined) delete process.env.EH_APP_DB_URL; + else process.env.EH_APP_DB_URL = previousDatabaseUrl; + previousDatabaseUrl = undefined; + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("artifact report API", () => { + it("returns a bounded redacted text preview and hides artifacts from other projects", async () => { + const pool = await useTempDatabase(); + try { + await pool.query("INSERT INTO projects (id, name, slug) VALUES ($1, $2, $3)", [ + "project-report", + "Report project", + "report-project", + ]); + await pool.query("INSERT INTO projects (id, name, slug) VALUES ($1, $2, $3)", [ + "project-other", + "Other project", + "other-project", + ]); + + const service = createArtifactService({ storage: null }); + setArtifactService(service); + const report = await service.createArtifact({ + projectId: "project-report", + projectScoped: true, + name: "passive-auth-surface-target.json", + kind: "report", + contentType: "application/json", + content: `Authorization: Bearer sk-testabcdefghijklmnopqrstuvwxyz1234567890\n${"x".repeat(40_000)}`, + source: "passive-recon", + indexForRag: false, + metadata: { workflow: "passive-auth-surface-v1" }, + }); + const other = await service.createArtifact({ + projectId: "project-other", + projectScoped: true, + name: "other.txt", + content: "private to another project", + source: "terminal-note", + indexForRag: false, + }); + + const response = await readArtifact("project-report", report.id); + const body = (await response.json()) as { + artifact: { id: string }; + evidence: { status: string; text: string; truncated: boolean; maxBytes: number }; + }; + + expect(response.status).toBe(200); + expect(body.artifact.id).toBe(report.id); + expect(body.evidence).toMatchObject({ + status: "text", + truncated: true, + maxBytes: 32 * 1024, + }); + expect(body.evidence.text).toContain("Authorization: [redacted]"); + expect(body.evidence.text).not.toContain("sk-testabcdefghijklmnopqrstuvwxyz1234567890"); + + const crossProjectResponse = await readArtifact("project-report", other.id); + expect(crossProjectResponse.status).toBe(404); + } finally { + await pool.end(); + } + }); + + it("returns a complete bounded passive report separately from its truncated raw preview", async () => { + const pool = await useTempDatabase(); + try { + await pool.query("INSERT INTO projects (id, name, slug) VALUES ($1, $2, $3)", [ + "project-large-report", + "Large report project", + "large-report-project", + ]); + const service = createArtifactService({ storage: null }); + setArtifactService(service); + const finalUnknown = "Final unknown remains present after the raw preview boundary."; + const unknowns = [ + ...Array.from({ length: 480 }, (_, index) => `Unknown ${index}: ${"x".repeat(72)}`), + finalUnknown, + ]; + const summary = passiveAuthSummary({ unknowns }); + const report = await service.createArtifact({ + projectId: "project-large-report", + projectScoped: true, + name: "passive-auth-surface-target-large.json", + kind: "report", + contentType: "application/json", + content: JSON.stringify(summary), + source: "passive-recon", + indexForRag: false, + metadata: { workflow: "passive-auth-surface-v1" }, + }); + + const response = await readArtifact("project-large-report", report.id); + const body = (await response.json()) as { + evidence: { status: string; truncated: boolean }; + report: { status: string; data?: { targetId: string; unknowns: string[] } }; + }; + + expect(response.status).toBe(200); + expect(body.evidence).toMatchObject({ status: "text", truncated: true }); + expect(body.report).toMatchObject({ + status: "ready", + data: { targetId: "target-large" }, + }); + expect(body.report.data?.unknowns).toHaveLength(unknowns.length); + expect(body.report.data?.unknowns.at(-1)).toBe(finalUnknown); + } finally { + await pool.end(); + } + }); + + it("persists the largest supported passive report for GET and rejects the next oversized report", async () => { + const pool = await useTempDatabase(); + try { + await pool.query("INSERT INTO projects (id, name, slug) VALUES ($1, $2, $3)", [ + "project-produced-report", + "Produced report project", + "produced-report-project", + ]); + await pool.query( + `INSERT INTO targets (id, project_id, kind, label, locator) + VALUES ($1, $2, $3, $4, $5)`, + [ + "target-produced-report", + "project-produced-report", + "repository", + "Produced report target", + "local://produced-report", + ], + ); + const service = createArtifactService({ storage: null }); + setArtifactService(service); + const { valid, oversized } = passiveArtifactsAcrossSizeBoundary(); + + const persisted = await persistPassiveAuthSurface( + { + projectId: "project-produced-report", + targetId: "target-produced-report", + artifacts: valid, + }, + service, + undefined, + noOpSystemMapProjector, + ); + const response = await readArtifact("project-produced-report", persisted.artifact.id); + const body = (await response.json()) as { + report: { status: string; data?: { rawArtifactIds: string[] } }; + }; + + expect(response.status).toBe(200); + expect(body.report.status).toBe("ready"); + expect(body.report.data?.rawArtifactIds).toHaveLength(valid.length); + + await expect( + persistPassiveAuthSurface( + { + projectId: "project-produced-report", + targetId: "target-produced-report", + artifacts: oversized, + }, + service, + undefined, + noOpSystemMapProjector, + ), + ).rejects.toThrow(); + const rows = await pool.query<{ count: number }>( + "SELECT COUNT(*) AS count FROM artifacts WHERE project_id = $1", + ["project-produced-report"], + ); + expect(Number(rows.rows[0]?.count)).toBe(1); + } finally { + await pool.end(); + } + }); + + it("returns calm report states for invalid and over-limit passive report structures", async () => { + const pool = await useTempDatabase(); + try { + await pool.query("INSERT INTO projects (id, name, slug) VALUES ($1, $2, $3)", [ + "project-invalid-report", + "Invalid report project", + "invalid-report-project", + ]); + const service = createArtifactService({ storage: null }); + setArtifactService(service); + const invalid = await service.createArtifact({ + projectId: "project-invalid-report", + projectScoped: true, + name: "passive-auth-invalid.json", + kind: "report", + contentType: "application/json", + content: JSON.stringify({ targetId: "missing-fields" }), + source: "passive-recon", + indexForRag: false, + metadata: { workflow: "passive-auth-surface-v1" }, + }); + const overLimit = await service.createArtifact({ + projectId: "project-invalid-report", + projectScoped: true, + name: "passive-auth-over-limit.json", + kind: "report", + contentType: "application/json", + content: JSON.stringify(passiveAuthSummary({ unknowns: ["x".repeat(600_000)] })), + source: "passive-recon", + indexForRag: false, + metadata: { workflow: "passive-auth-surface-v1" }, + }); + + const invalidBody = (await ( + await readArtifact("project-invalid-report", invalid.id) + ).json()) as { report: { status: string; message: string } }; + const overLimitBody = (await ( + await readArtifact("project-invalid-report", overLimit.id) + ).json()) as { report: { status: string; message: string } }; + + expect(invalidBody.report.status).toBe("invalid"); + expect(overLimitBody.report.status).toBe("over_limit"); + } finally { + await pool.end(); + } + }); + + it("uses a bounded external-object range read for raw evidence previews", async () => { + const pool = await useTempDatabase(); + try { + await pool.query("INSERT INTO projects (id, name, slug) VALUES ($1, $2, $3)", [ + "project-ranged-read", + "Ranged read project", + "ranged-read-project", + ]); + await pool.query( + `INSERT INTO artifacts + (id, project_id, kind, name, content_type, storage_bucket, storage_key, size_bytes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + "artifact-large-object", + "project-ranged-read", + "file", + "large.txt", + "text/plain", + "evidence", + "objects/large.txt", + 8_000_000, + ], + ); + const readObjectRange = vi.fn(async ({ maxBytes }: { maxBytes: number }) => ({ + bytes: new TextEncoder() + .encode(`API_KEY=secret-value\n${"x".repeat(maxBytes)}`) + .slice(0, maxBytes), + truncated: true, + })); + const readObjectBytes = vi.fn(async () => { + throw new Error("whole-object read must not run"); + }); + setArtifactService( + createArtifactService({ + storage: { + bucket: "evidence", + mode: "s3", + readObjectRange, + readObjectBytes, + } as never, + }), + ); + + const response = await readArtifact("project-ranged-read", "artifact-large-object"); + const body = (await response.json()) as { + evidence: { status: string; text: string; truncated: boolean }; + }; + + expect(response.status).toBe(200); + expect(body.evidence).toMatchObject({ status: "text", truncated: true }); + expect(body.evidence.text).toContain("API_KEY=[redacted]"); + expect(readObjectRange).toHaveBeenCalledWith({ + bucket: "evidence", + key: "objects/large.txt", + maxBytes: expect.any(Number), + }); + expect(readObjectRange.mock.calls[0]?.[0].maxBytes).toBeLessThanOrEqual(40 * 1024); + expect(readObjectBytes).not.toHaveBeenCalled(); + } finally { + await pool.end(); + } + }); + + it("describes binary and unavailable evidence without failing the artifact view", async () => { + const pool = await useTempDatabase(); + try { + await pool.query("INSERT INTO projects (id, name, slug) VALUES ($1, $2, $3)", [ + "project-evidence", + "Evidence project", + "evidence-project", + ]); + await pool.query( + `INSERT INTO artifacts + (id, project_id, kind, name, content_type, storage_key, inline_content) + VALUES + ($1, $2, $3, $4, $5, $6, NULL), + ($7, $2, $3, $8, $9, NULL, NULL)`, + [ + "artifact-binary", + "project-evidence", + "file", + "capture.bin", + "application/octet-stream", + "objects/capture.bin", + "artifact-unavailable", + "missing.txt", + "text/plain", + ], + ); + setArtifactService(createArtifactService({ storage: null })); + + const binary = (await (await readArtifact("project-evidence", "artifact-binary")).json()) as { + evidence: { status: string }; + }; + const unavailable = (await ( + await readArtifact("project-evidence", "artifact-unavailable") + ).json()) as { evidence: { status: string } }; + + expect(binary.evidence.status).toBe("binary"); + expect(unavailable.evidence.status).toBe("unavailable"); + } finally { + await pool.end(); + } + }); +}); + +async function useTempDatabase() { + previousDatabaseUrl = process.env.EH_APP_DB_URL; + const root = await mkdtemp(join(tmpdir(), "exploit-hunter-artifact-report-")); + tempRoots.push(root); + process.env.EH_APP_DB_URL = `sqlite://${join(root, "app.sqlite")}`; + const pool = createSqlitePool(getDatabaseConfig()); + await pool.query("SELECT 1"); + return pool; +} + +function readArtifact(projectId: string, artifactId: string) { + return GET( + new Request(`http://localhost:3210/api/projects/${projectId}/artifacts/${artifactId}`), + { + params: Promise.resolve({ projectId, artifactId }), + }, + ); +} + +const noOpSystemMapProjector = async (input: { projectId: string; reportArtifactId: string }) => ({ + reportArtifactId: input.reportArtifactId, + targetId: "target-produced-report", + entityIds: [], + assertionIds: [], +}); + +function passiveAuthSummary({ unknowns }: { unknowns: string[] }) { + return { + targetId: "target-large", + rawArtifactIds: ["artifact-source"], + authRoutes: [ + { + category: "login", + confidence: "high", + urls: ["https://example.test/login"], + evidenceArtifactIds: ["artifact-source"], + }, + ], + blockers: [], + unknowns, + nextSteps: { + passive: ["Review saved source evidence."], + approvalGated: ["Request approval before any live request."], + reportOrPatch: ["Keep the route as a hypothesis until validated."], + }, + }; +} + +function passiveArtifactsAcrossSizeBoundary() { + const artifacts = Array.from({ length: 2_048 }, (_, index) => ({ + artifactId: `artifact-${String(index).padStart(4, "0")}-${"x".repeat(234)}`, + content: "", + source: "upload", + })) satisfies DiscoveryArtifactInput[]; + let low = 0; + let high = artifacts.length; + while (low + 1 < high) { + const middle = Math.floor((low + high) / 2); + if (passiveReportBytes(artifacts.slice(0, middle)) <= PASSIVE_AUTH_REPORT_MAX_BYTES) { + low = middle; + } else { + high = middle; + } + } + const valid = artifacts.slice(0, low); + const oversized = artifacts.slice(0, high); + expect(passiveReportBytes(valid)).toBeLessThanOrEqual(PASSIVE_AUTH_REPORT_MAX_BYTES); + expect(passiveReportBytes(oversized)).toBeGreaterThan(PASSIVE_AUTH_REPORT_MAX_BYTES); + return { valid, oversized }; +} + +function passiveReportBytes(artifacts: DiscoveryArtifactInput[]) { + const summary = buildPassiveAuthSurfaceSummary( + "target-produced-report", + normalizeDiscoveryArtifacts(artifacts), + artifacts, + ); + return new TextEncoder().encode(JSON.stringify(summary, null, 2)).byteLength; +} diff --git a/tests/integration/artifact-service.test.ts b/tests/integration/artifact-service.test.ts index a62306230..6864215e0 100644 --- a/tests/integration/artifact-service.test.ts +++ b/tests/integration/artifact-service.test.ts @@ -22,8 +22,8 @@ import { setArtifactService, setEvidenceIngestor, } from "../../src/server/evidence"; -import type { ObjectStorageClient } from "../../src/server/storage"; import { normalizeSecurityActionIntent } from "../../src/server/security-actions/execution"; +import type { ObjectStorageClient } from "../../src/server/storage"; import { createTargetAuthorization, inferTargetKind, @@ -255,6 +255,7 @@ describe("artifact service", () => { putObject: vi.fn(async () => undefined), getObject: vi.fn(async () => ({})), readObjectBytes: vi.fn(async () => new Uint8Array()), + readObjectRange: vi.fn(async () => ({ bytes: new Uint8Array(), truncated: false })), createPresignedPutObjectUrl: vi.fn(async () => ({ url: "http://localhost/upload", bucket: "evidence", diff --git a/tests/integration/stored-passive-auth-surface-api.test.ts b/tests/integration/stored-passive-auth-surface-api.test.ts index 74d77467c..c9ac533cd 100644 --- a/tests/integration/stored-passive-auth-surface-api.test.ts +++ b/tests/integration/stored-passive-auth-surface-api.test.ts @@ -294,7 +294,11 @@ describe("stored passive auth-surface API", () => { setArtifactService({ ...guardedArtifactService, async createArtifact(input) { - if (input.source === "passive-recon" && input.taskId === "task-race") { + if ( + input.source === "passive-recon" && + input.metadata?.sourceTaskId === "task-race" && + input.taskId + ) { await withDatabase((db) => db.query( `UPDATE tasks @@ -302,7 +306,7 @@ describe("stored passive auth-surface API", () => { WHERE project_id = $1 AND id = $2`, [ project.id, - "task-race", + input.taskId, JSON.stringify({ targetId: "target-other" }), ], ), @@ -340,8 +344,8 @@ describe("stored passive auth-surface API", () => { ], }), ); - expect(response.status).toBe(200); const body = (await response.json()) as Record; + expect(response.status).toBe(200); const serializedBody = JSON.stringify(body); expect(serializedBody).not.toMatch( /url-user|p%40ssword|query-secret|tail-secret|fragment-secret|java-session|aws-signature|google-signature|asp-session|oauth-verifier|oauth-assertion/u, @@ -385,8 +389,11 @@ describe("stored passive auth-surface API", () => { projectId: project.id, threadId: thread.id, targetId: "target-app", - taskId: "task-passive-map", + taskId: expect.any(String), source: "passive-recon", + metadata: expect.objectContaining({ + sourceTaskId: "task-passive-map", + }), }), }), ); @@ -418,7 +425,7 @@ describe("stored passive auth-surface API", () => { ); expect(persistedAttribution.rows[0]).toEqual({ thread_id: thread.id, - task_id: "task-passive-map", + task_id: expect.any(String), }); }, 15_000); }); @@ -460,6 +467,10 @@ function createObjectStorageStub( ensureBucket: vi.fn(async () => undefined), getObject: vi.fn(async () => ({})), readObjectBytes: vi.fn(async () => new Uint8Array()), + readObjectRange: vi.fn(async () => ({ + bytes: new Uint8Array(), + truncated: false, + })), deleteObject: vi.fn(async () => undefined), createPresignedPutObjectUrl: vi.fn(async () => ({ url: "http://localhost/upload", diff --git a/tests/integration/upload-artifact-usage-e2e.test.ts b/tests/integration/upload-artifact-usage-e2e.test.ts index da643896b..6042c5ef0 100644 --- a/tests/integration/upload-artifact-usage-e2e.test.ts +++ b/tests/integration/upload-artifact-usage-e2e.test.ts @@ -522,6 +522,10 @@ describe("uploaded artifact usage through the thread sandbox", () => { getObject: vi.fn(async () => undefined), deleteObject: vi.fn(async () => undefined), readObjectBytes: vi.fn(async () => objectBytes), + readObjectRange: vi.fn(async ({ maxBytes }: { maxBytes: number }) => ({ + bytes: objectBytes.slice(0, maxBytes), + truncated: objectBytes.byteLength > maxBytes, + })), createPresignedPutObjectUrl: vi.fn( async (input: { key: string; @@ -695,6 +699,10 @@ describe("uploaded artifact usage through the thread sandbox", () => { getObject: vi.fn(async () => undefined), deleteObject: vi.fn(async () => undefined), readObjectBytes: vi.fn(async () => objectBytes), + readObjectRange: vi.fn(async ({ maxBytes }: { maxBytes: number }) => ({ + bytes: objectBytes.slice(0, maxBytes), + truncated: objectBytes.byteLength > maxBytes, + })), createPresignedPutObjectUrl: vi.fn( async (input: { key: string; @@ -837,6 +845,10 @@ describe("uploaded artifact usage through the thread sandbox", () => { getObject: vi.fn(async () => undefined), deleteObject: vi.fn(async () => undefined), readObjectBytes: vi.fn(async () => objectBytes), + readObjectRange: vi.fn(async ({ maxBytes }: { maxBytes: number }) => ({ + bytes: objectBytes.slice(0, maxBytes), + truncated: objectBytes.byteLength > maxBytes, + })), createPresignedPutObjectUrl: vi.fn( async (input: { key: string; diff --git a/tests/playwright/artifact-report-focus.spec.ts b/tests/playwright/artifact-report-focus.spec.ts new file mode 100644 index 000000000..91a70182c --- /dev/null +++ b/tests/playwright/artifact-report-focus.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from "@playwright/test"; + +const appBaseUrl = process.env.ARTIFACT_FOCUS_BASE_URL ?? ""; + +test("artifact report contains focus and restores its trigger after close or Back", async ({ + page, +}) => { + test.setTimeout(240_000); + const setup = await createWorkspace(page); + try { + const artifact = await page.evaluate( + async ({ projectId, threadId }) => { + const form = new FormData(); + form.set("threadId", threadId); + form.set("targetMode", "none"); + form.append( + "files", + new File(["Saved passive evidence."], "artifact-focus-evidence.txt", { + type: "text/plain", + }), + ); + const response = await fetch(`/api/projects/${encodeURIComponent(projectId)}/uploads`, { + method: "POST", + body: form, + }); + if (!response.ok) throw new Error(await response.text()); + const result = (await response.json()) as { + items: Array<{ artifact?: { id: string; name: string } }>; + }; + const saved = result.items[0]?.artifact; + if (!saved) throw new Error("Upload did not return an artifact."); + return saved; + }, + { projectId: setup.project.id, threadId: setup.thread.id }, + ); + + await page.goto( + `${appBaseUrl}/projects/${setup.project.id}?thread=${encodeURIComponent(setup.thread.id)}`, + ); + const artifactPanel = page.getByRole("button", { name: /^Artifacts 1$/ }); + await expect(artifactPanel).toBeVisible({ timeout: 30_000 }); + await artifactPanel.click(); + const trigger = page.getByRole("button", { name: new RegExp(artifact.name) }); + await trigger.click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => { + const active = document.activeElement; + const modal = document.querySelector("[role='dialog'][aria-modal='true']"); + return Boolean(active && modal?.contains(active)); + }), + ) + .toBe(true); + + for (let index = 0; index < 12; index += 1) { + await page.keyboard.press(index % 3 === 0 ? "Shift+Tab" : "Tab"); + await expect + .poll(() => + page.evaluate(() => { + const active = document.activeElement; + const modal = document.querySelector("[role='dialog'][aria-modal='true']"); + return Boolean(active && modal?.contains(active)); + }), + ) + .toBe(true); + } + + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + await expect(trigger).toBeFocused(); + + await trigger.click(); + await expect(dialog).toBeVisible(); + await page.goBack(); + await expect(dialog).toBeHidden(); + await expect(trigger).toBeFocused(); + } finally { + await page.request.delete( + `${appBaseUrl}/api/projects/${encodeURIComponent(setup.project.id)}`, + ); + } +}); + +async function createWorkspace(page: import("@playwright/test").Page) { + await page.goto(`${appBaseUrl}/`); + return page.evaluate(async () => { + const response = await fetch("/api/projects/create", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + project: { name: `Artifact focus ${Date.now()}` }, + thread: { title: "Artifact focus" }, + model: { modelUri: "llm://openrouter/deepseek/deepseek-v4-flash" }, + workspace: { resolve: true }, + }), + }); + if (!response.ok) throw new Error(await response.text()); + return response.json() as Promise<{ project: { id: string }; thread: { id: string } }>; + }); +}