diff --git a/src/server/evidence/ingestion.ts b/src/server/evidence/ingestion.ts index a5554206a..98275c677 100644 --- a/src/server/evidence/ingestion.ts +++ b/src/server/evidence/ingestion.ts @@ -23,6 +23,7 @@ export const EVIDENCE_SOURCES = [ "patch-verification", "patch-remediation", "stage-handoff", + "passive-recon", ] as const; export type EvidenceSource = (typeof EVIDENCE_SOURCES)[number]; diff --git a/src/server/recon/index.ts b/src/server/recon/index.ts index 1f6e2a9ec..fd5cd7b44 100644 --- a/src/server/recon/index.ts +++ b/src/server/recon/index.ts @@ -10,3 +10,10 @@ export { type NormalizedDiscoveryUrl, normalizeDiscoveryArtifacts, } from "./discovery-artifact-normalizer"; +export { + buildPassiveAuthSurfaceSummary, + type PassiveAuthSurfaceSummary, + type PersistPassiveAuthSurfaceInput, + type PersistPassiveAuthSurfaceResult, + persistPassiveAuthSurface, +} from "./passive-auth-surface"; diff --git a/src/server/recon/passive-auth-surface.ts b/src/server/recon/passive-auth-surface.ts new file mode 100644 index 000000000..cd6af7101 --- /dev/null +++ b/src/server/recon/passive-auth-surface.ts @@ -0,0 +1,177 @@ +import type { + ArtifactServiceInstance, + CreateArtifactResult, +} from "../evidence"; +import { + type AuthSurfaceCategory, + type DiscoveryArtifactInput, + type DiscoveryBlockerReason, + type NormalizedDiscovery, + normalizeDiscoveryArtifacts, +} from "./discovery-artifact-normalizer"; + +export type PassiveAuthSurfaceSummary = { + targetId: string; + rawArtifactIds: string[]; + 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 type PersistPassiveAuthSurfaceInput = { + projectId: string; + threadId?: string; + targetId: string; + taskId?: string; + artifacts: readonly DiscoveryArtifactInput[]; +}; + +export type PersistPassiveAuthSurfaceResult = { + normalized: NormalizedDiscovery; + summary: PassiveAuthSurfaceSummary; + artifact: CreateArtifactResult; +}; + +type ArtifactWriter = Pick; + +export async function persistPassiveAuthSurface( + input: PersistPassiveAuthSurfaceInput, + artifactWriter: ArtifactWriter, +): Promise { + const normalized = normalizeDiscoveryArtifacts(input.artifacts); + const summary = buildPassiveAuthSurfaceSummary(input.targetId, normalized); + const content = JSON.stringify(summary, null, 2); + const artifact = await artifactWriter.createArtifact({ + projectId: input.projectId, + ...(input.threadId ? { threadId: input.threadId } : {}), + targetId: input.targetId, + ...(input.taskId ? { taskId: input.taskId } : {}), + name: `passive-auth-surface-${input.targetId}.json`, + kind: "report", + contentType: "application/json", + content, + indexText: content, + source: "passive-recon", + indexForRag: true, + agentGenerated: true, + metadata: { + workflow: "passive-auth-surface-v1", + rawArtifactIds: summary.rawArtifactIds, + blockerReasons: summary.blockers.map((blocker) => blocker.reason), + authCategories: summary.authRoutes.map((route) => route.category), + }, + }); + + return { normalized, summary, artifact }; +} + +export function buildPassiveAuthSurfaceSummary( + targetId: string, + normalized: NormalizedDiscovery, +): 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], + 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; +} diff --git a/tests/integration/discovery-artifact-normalizer.test.ts b/tests/integration/discovery-artifact-normalizer.test.ts index e355b2f73..11cd5e9f6 100644 --- a/tests/integration/discovery-artifact-normalizer.test.ts +++ b/tests/integration/discovery-artifact-normalizer.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { normalizeDiscoveryArtifacts } from "../../src/server/recon"; +import { + normalizeDiscoveryArtifacts, + persistPassiveAuthSurface, +} from "../../src/server/recon"; describe("discovery artifact normalization", () => { it("turns mixed passive evidence into deduplicated, attributable auth-surface signals", () => { @@ -77,4 +80,62 @@ describe("discovery artifact normalization", () => { ]); expect(result.observations.token).toEqual(["www-authenticate: Bearer"]); }); + + it("persists a redaction-ready, RAG-indexed summary linked to its raw evidence", async () => { + const createArtifact = vi.fn(async () => ({ + id: "artifact-summary", + projectId: "project-1", + threadId: "thread-1", + name: "passive-auth-surface-target-1.json", + kind: "report", + indexing: { status: "indexed" as const, chunkCount: 1 }, + })); + + const result = await persistPassiveAuthSurface( + { + projectId: "project-1", + threadId: "thread-1", + targetId: "target-1", + taskId: "task-1", + artifacts: [ + { + artifactId: "artifact-raw", + source: "upload", + content: [ + "https://app.example.test/login", + "HTTP 429 Too Many Requests", + ].join("\n"), + }, + ], + }, + { createArtifact } as never, + ); + + expect(result.summary.authRoutes).toEqual([ + { + category: "login", + confidence: "high", + urls: ["https://app.example.test/login"], + evidenceArtifactIds: ["artifact-raw"], + }, + ]); + expect(result.summary.blockers[0]).toMatchObject({ + reason: "rate-limited", + evidenceArtifactIds: ["artifact-raw"], + }); + expect(createArtifact).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-1", + threadId: "thread-1", + targetId: "target-1", + taskId: "task-1", + source: "passive-recon", + indexForRag: true, + metadata: expect.objectContaining({ + rawArtifactIds: ["artifact-raw"], + blockerReasons: ["rate-limited"], + }), + }), + ); + }); });