From 041fa29a998eae466b35d7d76d1478fdae77d3bf Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Thu, 27 Aug 2026 03:00:17 -0400 Subject: [PATCH 1/5] 171: align passive auth mapping tasks --- src/server/recon/index.ts | 7 + src/server/recon/passive-auth-surface.ts | 23 +- .../recon/passive-auth-task-alignment.ts | 290 ++++++++++++++++++ src/server/tasks/tracker.ts | 2 +- .../discovery-artifact-normalizer.test.ts | 17 +- .../passive-auth-task-alignment.test.ts | 229 ++++++++++++++ 6 files changed, 560 insertions(+), 8 deletions(-) create mode 100644 src/server/recon/passive-auth-task-alignment.ts create mode 100644 tests/integration/passive-auth-task-alignment.test.ts diff --git a/src/server/recon/index.ts b/src/server/recon/index.ts index a2a752667..dcd7b4aad 100644 --- a/src/server/recon/index.ts +++ b/src/server/recon/index.ts @@ -22,3 +22,10 @@ export { type PersistPassiveAuthSurfaceResult, persistPassiveAuthSurface, } from "./passive-auth-surface"; +export { + type CompletePassiveAuthMappingInput, + createPassiveAuthTaskAlignmentService, + type PassiveAuthMappingScope, + type PassiveAuthTaskAlignmentResult, + type PassiveAuthTaskAlignmentService, +} from "./passive-auth-task-alignment"; diff --git a/src/server/recon/passive-auth-surface.ts b/src/server/recon/passive-auth-surface.ts index 6624aa5d2..53b16156e 100644 --- a/src/server/recon/passive-auth-surface.ts +++ b/src/server/recon/passive-auth-surface.ts @@ -15,6 +15,11 @@ import { normalizeDiscoveryArtifacts, } from "./discovery-artifact-normalizer"; import { persistPassiveAuthBlockers } from "./passive-auth-blockers"; +import { + createPassiveAuthTaskAlignmentService, + type PassiveAuthTaskAlignmentResult, + type PassiveAuthTaskAlignmentService, +} from "./passive-auth-task-alignment"; export type PassiveAuthSurfaceSummary = { targetId: string; @@ -51,6 +56,7 @@ export type PersistPassiveAuthSurfaceResult = { summary: PassiveAuthSurfaceSummary; artifact: CreateArtifactResult; blockers: BlockerRecord[]; + taskAlignment: PassiveAuthTaskAlignmentResult; }; type ArtifactWriter = Pick; @@ -59,7 +65,9 @@ export async function persistPassiveAuthSurface( input: PersistPassiveAuthSurfaceInput, artifactWriter: ArtifactWriter, blockerService: BlockerService = getDefaultBlockerService(), + taskAlignment: PassiveAuthTaskAlignmentService = createPassiveAuthTaskAlignmentService(), ): Promise { + const mappingTask = await taskAlignment.start(input); const normalized = normalizeDiscoveryArtifacts(input.artifacts); const summary = buildPassiveAuthSurfaceSummary(input.targetId, normalized); const content = JSON.stringify(summary, null, 2); @@ -67,7 +75,7 @@ export async function persistPassiveAuthSurface( projectId: input.projectId, ...(input.threadId ? { threadId: input.threadId } : {}), targetId: input.targetId, - ...(input.taskId ? { taskId: input.taskId } : {}), + taskId: mappingTask.id, name: `passive-auth-surface-${input.targetId}.json`, kind: "report", contentType: "application/json", @@ -88,14 +96,23 @@ export async function persistPassiveAuthSurface( projectId: input.projectId, ...(input.threadId ? { threadId: input.threadId } : {}), targetId: input.targetId, - ...(input.taskId ? { taskId: input.taskId } : {}), + taskId: mappingTask.id, summaryArtifactId: artifact.id, summary, }, blockerService, ); + const aligned = await taskAlignment.complete({ + projectId: input.projectId, + ...(input.threadId ? { threadId: input.threadId } : {}), + targetId: input.targetId, + taskId: mappingTask.id, + reportArtifactId: artifact.id, + blockerIds: blockers.map((blocker) => blocker.id), + summary, + }); - return { normalized, summary, artifact, blockers }; + return { normalized, summary, artifact, blockers, taskAlignment: aligned }; } export function buildPassiveAuthSurfaceSummary( diff --git a/src/server/recon/passive-auth-task-alignment.ts b/src/server/recon/passive-auth-task-alignment.ts new file mode 100644 index 000000000..f19a42917 --- /dev/null +++ b/src/server/recon/passive-auth-task-alignment.ts @@ -0,0 +1,290 @@ +import { createHash } from "node:crypto"; + +import { + getDefaultNegativeResultsService, + type NegativeResultRecord, + type NegativeResultService, +} from "../negative-results"; +import { + listResearchTaskBoard, + type ResearchTask, + type ResearchTaskUpsertInput, + upsertResearchTasks, +} from "../tasks/tracker"; +import type { PassiveAuthSurfaceSummary } from "./passive-auth-surface"; + +const WORKFLOW = "passive-auth-surface-v1"; + +export type PassiveAuthMappingScope = { + projectId: string; + threadId?: string; + targetId: string; + taskId?: string; +}; + +export type CompletePassiveAuthMappingInput = PassiveAuthMappingScope & { + taskId: string; + reportArtifactId: string; + blockerIds: string[]; + summary: PassiveAuthSurfaceSummary; +}; + +export type PassiveAuthTaskAlignmentResult = { + mappingTask: ResearchTask; + followUpTasks: ResearchTask[]; + negativeResults: NegativeResultRecord[]; +}; + +export interface PassiveAuthTaskAlignmentService { + start(input: PassiveAuthMappingScope): Promise; + complete( + input: CompletePassiveAuthMappingInput, + ): Promise; +} + +type TaskStore = { + list(projectId: string): ReturnType; + upsert( + projectId: string, + inputs: ResearchTaskUpsertInput[], + options?: Parameters[2], + ): ReturnType; +}; + +const defaultTaskStore: TaskStore = { + list: (projectId) => listResearchTaskBoard(projectId), + upsert: (projectId, inputs, options) => + upsertResearchTasks(projectId, inputs, options), +}; + +export function createPassiveAuthTaskAlignmentService( + tasks: TaskStore = defaultTaskStore, + negativeResults: NegativeResultService = getDefaultNegativeResultsService(), +): PassiveAuthTaskAlignmentService { + return { + async start(input) { + const taskId = + input.taskId ?? + stableTaskId(`${input.projectId}\u0000${input.targetId}`, "mapping"); + const board = await tasks.list(input.projectId); + const existing = board.tasks.find((task) => task.id === taskId); + assertCompatibleScope(existing, input); + const updated = await tasks.upsert( + input.projectId, + [ + { + id: taskId, + ...(input.threadId ? { threadId: input.threadId } : {}), + targetId: input.targetId, + title: `Map passive authentication surface for ${input.targetId}`, + status: "in_progress", + priority: existing?.priority ?? 50, + details: + "Review supplied evidence only; do not schedule or execute active probes.", + metadata: mappingMetadata(), + }, + ], + { ...(input.threadId ? { threadId: input.threadId } : {}) }, + ); + return requireTask(updated.tasks, taskId); + }, + + async complete(input) { + const negativeCoverage = await recordNegativeCoverage( + input, + negativeResults, + ); + const followUps = buildFollowUpTasks(input); + const board = await tasks.upsert( + input.projectId, + [ + { + id: input.taskId, + ...(input.threadId ? { threadId: input.threadId } : {}), + targetId: input.targetId, + title: `Map passive authentication surface for ${input.targetId}`, + status: input.blockerIds.length > 0 ? "blocked" : "done", + priority: 50, + details: + input.blockerIds.length > 0 + ? "Passive mapping report saved; durable blockers must be resolved before further work." + : "Passive mapping report saved; suggested follow-ups remain review-only until separately approved.", + metadata: { + ...mappingMetadata(), + reportArtifactId: input.reportArtifactId, + blockerIds: [...input.blockerIds].sort(), + negativeResultIds: negativeCoverage + .map((record) => record.id) + .sort(), + }, + }, + ...followUps, + ], + { ...(input.threadId ? { threadId: input.threadId } : {}) }, + ); + return { + mappingTask: requireTask(board.tasks, input.taskId), + followUpTasks: followUps.map((task) => + requireTask(board.tasks, task.id ?? ""), + ), + negativeResults: negativeCoverage, + }; + }, + }; +} + +function mappingMetadata() { + return { + workflow: WORKFLOW, + role: "mapping", + passiveOnly: true, + executionPolicy: "not-scheduled", + }; +} + +function buildFollowUpTasks( + input: CompletePassiveAuthMappingInput, +): ResearchTaskUpsertInput[] { + const common = { + ...(input.threadId ? { threadId: input.threadId } : {}), + targetId: input.targetId, + parentTaskId: input.taskId, + status: "todo" as const, + priority: 40, + }; + return [ + { + ...common, + id: stableTaskId(input.taskId, "passive-review"), + title: `Review passive authentication evidence for ${input.targetId}`, + details: input.summary.nextSteps.passive.join(" "), + metadata: { + workflow: WORKFLOW, + role: "passive-review", + passiveOnly: true, + executionPolicy: "not-scheduled", + sourceReportArtifactId: input.reportArtifactId, + }, + }, + { + ...common, + id: stableTaskId(input.taskId, "approval-required"), + title: `Prepare approval-required auth validation for ${input.targetId}`, + details: input.summary.nextSteps.approvalGated.join(" "), + metadata: { + workflow: WORKFLOW, + role: "approval-required", + passiveOnly: false, + approvalRequired: true, + executionPolicy: "not-scheduled", + sourceReportArtifactId: input.reportArtifactId, + }, + }, + ]; +} + +async function recordNegativeCoverage( + input: CompletePassiveAuthMappingInput, + service: NegativeResultService, +) { + const common = { + projectId: input.projectId, + ...(input.threadId ? { threadId: input.threadId } : {}), + targetId: input.targetId, + taskId: input.taskId, + method: WORKFLOW, + evidence: [input.reportArtifactId, ...input.summary.rawArtifactIds], + actor: "system:passive-auth-mapping", + metadata: { + workflow: WORKFLOW, + reportArtifactId: input.reportArtifactId, + passiveOnly: true, + }, + }; + if (input.summary.authRoutes.length === 0) { + return [ + await recordNegativeResultIdempotently(service, { + ...common, + kind: "no-finding", + status: "no-finding", + subject: { type: "control", value: "authentication-route-signal" }, + reason: + "Supplied passive evidence established no authentication routes.", + }), + ]; + } + if ( + input.summary.authRoutes.every((route) => route.confidence === "medium") + ) { + return [ + await recordNegativeResultIdempotently(service, { + ...common, + kind: "no-finding", + status: "inconclusive", + subject: { + type: "control", + value: "high-confidence-authentication-route-signal", + }, + reason: + "Supplied passive evidence contained only weak authentication-route signals.", + }), + ]; + } + return []; +} + +async function recordNegativeResultIdempotently( + service: NegativeResultService, + input: Parameters[0], +) { + const current = await service.list({ + projectId: input.projectId, + ...(input.targetId ? { targetId: input.targetId } : {}), + ...(input.taskId ? { taskId: input.taskId } : {}), + kind: input.kind, + subjectType: input.subject.type, + subjectValue: input.subject.value, + ...(input.subject.secondary + ? { subjectSecondary: input.subject.secondary } + : {}), + ...(input.method ? { method: input.method } : {}), + }); + const reportArtifactId = input.metadata?.reportArtifactId; + const unchanged = current.find( + (record) => + record.status === (input.status ?? "no-finding") && + record.reason === input.reason && + record.metadata?.reportArtifactId === reportArtifactId, + ); + return unchanged ?? service.record(input); +} + +function assertCompatibleScope( + existing: ResearchTask | undefined, + input: PassiveAuthMappingScope, +) { + if (!existing) return; + if (existing.targetId && existing.targetId !== input.targetId) { + throw new Error(`Task ${existing.id} is bound to a different target.`); + } + if ( + input.threadId && + existing.threadId && + existing.threadId !== input.threadId + ) { + throw new Error(`Task ${existing.id} is bound to a different thread.`); + } +} + +function requireTask(tasks: ResearchTask[], id: string) { + const task = tasks.find((candidate) => candidate.id === id); + if (!task) throw new Error(`Task ${id} was not returned after alignment.`); + return task; +} + +function stableTaskId(scope: string, role: string) { + const digest = createHash("sha256") + .update(JSON.stringify([WORKFLOW, scope, role])) + .digest("hex"); + return `task_passive_auth_${digest.slice(0, 24)}`; +} diff --git a/src/server/tasks/tracker.ts b/src/server/tasks/tracker.ts index 6295b4de8..d72636e09 100644 --- a/src/server/tasks/tracker.ts +++ b/src/server/tasks/tracker.ts @@ -516,7 +516,7 @@ async function updateResearchTaskRow( input.priority ?? existing.priority, input.details !== undefined ? input.details : existing.details, input.assignedAgent !== undefined ? input.assignedAgent : existing.assigned_agent, - input.dueAt !== undefined ? input.dueAt : asIsoDate(existing.due_at), + input.dueAt !== undefined ? input.dueAt : (asIsoDate(existing.due_at) ?? null), JSON.stringify({ ...parseRecord(existing.metadata), ...asJsonObject(input.metadata), diff --git a/tests/integration/discovery-artifact-normalizer.test.ts b/tests/integration/discovery-artifact-normalizer.test.ts index 80fa803f0..cc3a89100 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, vi } from "vitest"; -import { createBlockerService, createInMemoryBlockerRepository } from "../../src/server/blockers"; +import { + createBlockerService, + createInMemoryBlockerRepository, +} from "../../src/server/blockers"; import { normalizeDiscoveryArtifacts, persistPassiveAuthSurface, @@ -111,6 +114,14 @@ describe("discovery artifact normalization", () => { }, { createArtifact } as never, createBlockerService(createInMemoryBlockerRepository()), + { + start: async () => ({ id: "task-1" }), + complete: async () => ({ + mappingTask: { id: "task-1" }, + followUpTasks: [], + negativeResults: [], + }), + } as never, ); expect(result.summary.authRoutes).toEqual([ @@ -133,9 +144,7 @@ describe("discovery artifact normalization", () => { taskId: "task-1", reason: "rate-limited", sourceRefId: "artifact-summary", - nextActions: [ - expect.objectContaining({ action: "queue_action" }), - ], + nextActions: [expect.objectContaining({ action: "queue_action" })], }), ]); expect(createArtifact).toHaveBeenCalledWith( diff --git a/tests/integration/passive-auth-task-alignment.test.ts b/tests/integration/passive-auth-task-alignment.test.ts new file mode 100644 index 000000000..ffb844061 --- /dev/null +++ b/tests/integration/passive-auth-task-alignment.test.ts @@ -0,0 +1,229 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + createBlockerService, + createDatabaseBlockerRepository, +} from "../../src/server/blockers"; +import { getProjectStore } from "../../src/server/chat/projectAdapter"; +import { withDatabase } from "../../src/server/db/client"; +import { + createDatabaseNegativeResultRepository, + createNegativeResultService, +} from "../../src/server/negative-results"; +import { + createPassiveAuthTaskAlignmentService, + persistPassiveAuthSurface, +} from "../../src/server/recon"; +import { upsertProjectTarget } from "../../src/server/targets"; +import { listResearchTaskBoard } from "../../src/server/tasks/tracker"; + +describe("passive authentication task alignment", () => { + let databaseRoot: string; + let previousDatabaseUrl: string | undefined; + + beforeEach(async () => { + previousDatabaseUrl = process.env.EH_APP_DB_URL; + databaseRoot = await mkdtemp( + join(tmpdir(), "passive-auth-task-alignment-"), + ); + process.env.EH_APP_DB_URL = `sqlite://${join(databaseRoot, "app.sqlite")}`; + }); + + afterEach(async () => { + if (previousDatabaseUrl === undefined) delete process.env.EH_APP_DB_URL; + else process.env.EH_APP_DB_URL = previousDatabaseUrl; + await rm(databaseRoot, { recursive: true, force: true }); + }); + + it("persists one completed mapping task, negative coverage, and unscheduled follow-ups", async () => { + const { projectId, threadId } = await createScope("No route mapping"); + const negativeResults = createNegativeResultService( + createDatabaseNegativeResultRepository(), + ); + const alignment = createPassiveAuthTaskAlignmentService( + undefined, + negativeResults, + ); + const input = { + projectId, + threadId, + targetId: "target-app", + artifacts: [ + { + artifactId: "artifact-raw", + source: "upload" as const, + content: + "A stored application overview with no routes or active requests.", + }, + ], + }; + const artifactWriter = fixedArtifactWriter("artifact-passive-report"); + const blockers = createBlockerService(createDatabaseBlockerRepository()); + const started = await alignment.start(input); + expect(started).toMatchObject({ + threadId, + targetId: "target-app", + status: "in_progress", + metadata: expect.objectContaining({ executionPolicy: "not-scheduled" }), + }); + + const first = await persistPassiveAuthSurface( + input, + artifactWriter, + blockers, + alignment, + ); + const second = await persistPassiveAuthSurface( + input, + artifactWriter, + blockers, + alignment, + ); + + expect(second.taskAlignment.mappingTask.id).toBe( + first.taskAlignment.mappingTask.id, + ); + expect(second.taskAlignment.mappingTask).toMatchObject({ + threadId, + targetId: "target-app", + status: "done", + metadata: expect.objectContaining({ + reportArtifactId: "artifact-passive-report", + blockerIds: [], + executionPolicy: "not-scheduled", + }), + }); + expect(second.taskAlignment.negativeResults).toEqual([ + expect.objectContaining({ + id: first.taskAlignment.negativeResults[0]?.id, + projectId, + threadId, + targetId: "target-app", + taskId: first.taskAlignment.mappingTask.id, + kind: "no-finding", + status: "no-finding", + }), + ]); + expect(second.taskAlignment.negativeResults[0]?.findingId).toBeUndefined(); + expect(second.taskAlignment.followUpTasks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: "todo", + metadata: expect.objectContaining({ + role: "passive-review", + passiveOnly: true, + executionPolicy: "not-scheduled", + }), + }), + expect.objectContaining({ + status: "todo", + metadata: expect.objectContaining({ + role: "approval-required", + approvalRequired: true, + executionPolicy: "not-scheduled", + }), + }), + ]), + ); + + const reloadedBoard = await listResearchTaskBoard(projectId, { + threadId, + targetId: "target-app", + }); + expect(reloadedBoard.tasks).toHaveLength(3); + expect(reloadedBoard.links).toHaveLength(2); + expect( + await negativeResults.list({ projectId, includeHistory: true }), + ).toHaveLength(1); + const scheduled = await withDatabase((database) => + database.query<{ count: number }>( + "SELECT count(*) AS count FROM scheduler_tasks WHERE project_id = $1", + [projectId], + ), + ); + expect(Number(scheduled.rows[0]?.count)).toBe(0); + }); + + it("links durable blockers and records weak signals as inconclusive coverage", async () => { + const { projectId, threadId } = await createScope("Blocked weak mapping"); + const negativeResults = createNegativeResultService( + createDatabaseNegativeResultRepository(), + ); + const result = await persistPassiveAuthSurface( + { + projectId, + threadId, + targetId: "target-app", + artifacts: [ + { + artifactId: "artifact-weak", + source: "reference", + content: + "https://app.example.test/api/token\nHTTP 429 Too Many Requests", + }, + ], + }, + fixedArtifactWriter("artifact-weak-report"), + createBlockerService(createDatabaseBlockerRepository()), + createPassiveAuthTaskAlignmentService(undefined, negativeResults), + ); + + expect(result.taskAlignment.mappingTask).toMatchObject({ + status: "blocked", + metadata: expect.objectContaining({ + reportArtifactId: "artifact-weak-report", + blockerIds: [result.blockers[0]?.id], + }), + }); + expect(result.taskAlignment.negativeResults).toEqual([ + expect.objectContaining({ + status: "inconclusive", + subject: { + type: "control", + value: "high-confidence-authentication-route-signal", + }, + }), + ]); + expect(result.taskAlignment.negativeResults[0]?.findingId).toBeUndefined(); + const reloaded = await listResearchTaskBoard(projectId, { threadId }); + expect( + reloaded.tasks.find( + (task) => task.id === result.taskAlignment.mappingTask.id, + ), + ).toEqual( + expect.objectContaining({ status: "blocked", targetId: "target-app" }), + ); + }); +}); + +async function createScope(name: string) { + const store = await getProjectStore(); + const project = await store.createProject({ name }); + const thread = await store.createThread(project.id, { title: name }); + await upsertProjectTarget(project.id, { + id: "target-app", + threadId: thread.id, + kind: "web", + label: "Application", + locator: "https://app.example.test", + }); + return { projectId: project.id, threadId: thread.id }; +} + +function fixedArtifactWriter(id: string) { + return { + async createArtifact() { + return { + id, + projectId: "ignored-by-test-double", + name: "passive-auth-report.json", + kind: "report", + indexing: { status: "indexed" as const, chunkCount: 1 }, + }; + }, + } as never; +} From 66c56911e4656405b0530576e32f5f7055d20bb9 Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Thu, 27 Aug 2026 03:01:19 -0400 Subject: [PATCH 2/5] 171: keep task retries scheduler-neutral --- src/server/recon/passive-auth-task-alignment.ts | 3 +++ src/server/tasks/tracker.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/server/recon/passive-auth-task-alignment.ts b/src/server/recon/passive-auth-task-alignment.ts index f19a42917..e1ac34499 100644 --- a/src/server/recon/passive-auth-task-alignment.ts +++ b/src/server/recon/passive-auth-task-alignment.ts @@ -79,6 +79,7 @@ export function createPassiveAuthTaskAlignmentService( title: `Map passive authentication surface for ${input.targetId}`, status: "in_progress", priority: existing?.priority ?? 50, + dueAt: null, details: "Review supplied evidence only; do not schedule or execute active probes.", metadata: mappingMetadata(), @@ -105,6 +106,7 @@ export function createPassiveAuthTaskAlignmentService( title: `Map passive authentication surface for ${input.targetId}`, status: input.blockerIds.length > 0 ? "blocked" : "done", priority: 50, + dueAt: null, details: input.blockerIds.length > 0 ? "Passive mapping report saved; durable blockers must be resolved before further work." @@ -151,6 +153,7 @@ function buildFollowUpTasks( parentTaskId: input.taskId, status: "todo" as const, priority: 40, + dueAt: null, }; return [ { diff --git a/src/server/tasks/tracker.ts b/src/server/tasks/tracker.ts index d72636e09..6295b4de8 100644 --- a/src/server/tasks/tracker.ts +++ b/src/server/tasks/tracker.ts @@ -516,7 +516,7 @@ async function updateResearchTaskRow( input.priority ?? existing.priority, input.details !== undefined ? input.details : existing.details, input.assignedAgent !== undefined ? input.assignedAgent : existing.assigned_agent, - input.dueAt !== undefined ? input.dueAt : (asIsoDate(existing.due_at) ?? null), + input.dueAt !== undefined ? input.dueAt : asIsoDate(existing.due_at), JSON.stringify({ ...parseRecord(existing.metadata), ...asJsonObject(input.metadata), From fcfebe61434fd2ad718829baf73c7cafcaebb888 Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Thu, 27 Aug 2026 08:08:33 -0400 Subject: [PATCH 3/5] 171: make passive mapping reconciliation atomic --- src/server/negative-results/repository.ts | 23 +- src/server/recon/passive-auth-blockers.ts | 3 + src/server/recon/passive-auth-surface.ts | 29 +- .../recon/passive-auth-task-alignment.ts | 368 +++++++++++------- .../discovery-artifact-normalizer.test.ts | 27 +- .../passive-auth-task-alignment.test.ts | 259 ++++++++++-- 6 files changed, 516 insertions(+), 193 deletions(-) diff --git a/src/server/negative-results/repository.ts b/src/server/negative-results/repository.ts index c9d0e3fe4..16e2fdfad 100644 --- a/src/server/negative-results/repository.ts +++ b/src/server/negative-results/repository.ts @@ -208,7 +208,10 @@ type NegativeResultRow = { }; export class DatabaseNegativeResultRepository implements NegativeResultRepository { - constructor(private readonly database?: Queryable) {} + constructor( + private readonly database?: Queryable, + private readonly manageTransactions = true, + ) {} private run(operation: (db: Queryable) => Promise): Promise { return this.database ? operation(this.database) : withDatabase(operation); @@ -261,7 +264,7 @@ export class DatabaseNegativeResultRepository implements NegativeResultRepositor input: UpdateNegativeResultInput, ): Promise { return this.run((db) => - withTransaction(db, async (tx) => { + this.inTransaction(db, async (tx) => { const current = await readCurrentRow(tx, id); if (!current) return undefined; const record = mapRow(current); @@ -291,7 +294,7 @@ export class DatabaseNegativeResultRepository implements NegativeResultRepositor async upsertBySubject(input: CreateNegativeResultInput): Promise { return this.run((db) => - withTransaction(db, async (tx) => { + this.inTransaction(db, async (tx) => { const normalized = normalizeCreateInput(input); await validateDatabaseInput(tx, normalized); const key = coverageKey(normalized); @@ -333,10 +336,20 @@ export class DatabaseNegativeResultRepository implements NegativeResultRepositor return result.rows.map(mapRow); }); } + + private inTransaction( + db: Queryable, + operation: (tx: Queryable) => Promise, + ): Promise { + return this.manageTransactions ? withTransaction(db, operation) : operation(db); + } } -export function createDatabaseNegativeResultRepository(db?: Queryable) { - return new DatabaseNegativeResultRepository(db); +export function createDatabaseNegativeResultRepository( + db?: Queryable, + options: { manageTransactions?: boolean } = {}, +) { + return new DatabaseNegativeResultRepository(db, options.manageTransactions ?? true); } export function createInMemoryNegativeResultRepository( diff --git a/src/server/recon/passive-auth-blockers.ts b/src/server/recon/passive-auth-blockers.ts index b2bda7e86..b442271ba 100644 --- a/src/server/recon/passive-auth-blockers.ts +++ b/src/server/recon/passive-auth-blockers.ts @@ -10,6 +10,7 @@ import type { PassiveAuthSurfaceSummary } from "./passive-auth-surface"; export type PersistPassiveAuthBlockersInput = { projectId: string; threadId?: string; + sourceThreadId?: string; targetId: string; taskId?: string; summaryArtifactId: string; @@ -43,6 +44,8 @@ export async function persistPassiveAuthBlockers( dedupeKey: passiveBlockerDedupeKey(input, signal.reason), metadata: { workflow: "passive-auth-surface-v1", + scope: "project-target", + sourceThreadId: input.sourceThreadId ?? input.threadId ?? null, summaryArtifactId: input.summaryArtifactId, sourceArtifactIds: [...signal.evidenceArtifactIds].sort(), passiveOnly: true, diff --git a/src/server/recon/passive-auth-surface.ts b/src/server/recon/passive-auth-surface.ts index 53b16156e..3c03d8d02 100644 --- a/src/server/recon/passive-auth-surface.ts +++ b/src/server/recon/passive-auth-surface.ts @@ -1,8 +1,4 @@ -import { - type BlockerRecord, - type BlockerService, - getDefaultBlockerService, -} from "../blockers"; +import type { BlockerRecord } from "../blockers"; import type { ArtifactServiceInstance, CreateArtifactResult, @@ -14,7 +10,6 @@ import { type NormalizedDiscovery, normalizeDiscoveryArtifacts, } from "./discovery-artifact-normalizer"; -import { persistPassiveAuthBlockers } from "./passive-auth-blockers"; import { createPassiveAuthTaskAlignmentService, type PassiveAuthTaskAlignmentResult, @@ -64,7 +59,6 @@ type ArtifactWriter = Pick; export async function persistPassiveAuthSurface( input: PersistPassiveAuthSurfaceInput, artifactWriter: ArtifactWriter, - blockerService: BlockerService = getDefaultBlockerService(), taskAlignment: PassiveAuthTaskAlignmentService = createPassiveAuthTaskAlignmentService(), ): Promise { const mappingTask = await taskAlignment.start(input); @@ -76,6 +70,7 @@ export async function persistPassiveAuthSurface( ...(input.threadId ? { threadId: input.threadId } : {}), targetId: input.targetId, taskId: mappingTask.id, + ...(input.taskId ? { sourceTaskId: input.taskId } : {}), name: `passive-auth-surface-${input.targetId}.json`, kind: "report", contentType: "application/json", @@ -91,28 +86,22 @@ export async function persistPassiveAuthSurface( authCategories: summary.authRoutes.map((route) => route.category), }, }); - const blockers = await persistPassiveAuthBlockers( - { - projectId: input.projectId, - ...(input.threadId ? { threadId: input.threadId } : {}), - targetId: input.targetId, - taskId: mappingTask.id, - summaryArtifactId: artifact.id, - summary, - }, - blockerService, - ); const aligned = await taskAlignment.complete({ projectId: input.projectId, ...(input.threadId ? { threadId: input.threadId } : {}), targetId: input.targetId, taskId: mappingTask.id, reportArtifactId: artifact.id, - blockerIds: blockers.map((blocker) => blocker.id), summary, }); - return { normalized, summary, artifact, blockers, taskAlignment: aligned }; + return { + normalized, + summary, + artifact, + blockers: aligned.blockers, + taskAlignment: aligned, + }; } export function buildPassiveAuthSurfaceSummary( diff --git a/src/server/recon/passive-auth-task-alignment.ts b/src/server/recon/passive-auth-task-alignment.ts index e1ac34499..64a08a066 100644 --- a/src/server/recon/passive-auth-task-alignment.ts +++ b/src/server/recon/passive-auth-task-alignment.ts @@ -1,19 +1,29 @@ import { createHash } from "node:crypto"; import { - getDefaultNegativeResultsService, + type BlockerRecord, + type BlockerService, + createBlockerService, + createDatabaseBlockerRepository, +} from "../blockers"; +import { type Queryable, withDatabase, withTransaction } from "../db/client"; +import { + createDatabaseNegativeResultRepository, + createNegativeResultService, type NegativeResultRecord, type NegativeResultService, } from "../negative-results"; import { - listResearchTaskBoard, type ResearchTask, type ResearchTaskUpsertInput, + readResearchTaskBoard, upsertResearchTasks, } from "../tasks/tracker"; +import { persistPassiveAuthBlockers } from "./passive-auth-blockers"; import type { PassiveAuthSurfaceSummary } from "./passive-auth-surface"; const WORKFLOW = "passive-auth-surface-v1"; +const MAPPING_SCOPE = "project-target"; export type PassiveAuthMappingScope = { projectId: string; @@ -24,8 +34,8 @@ export type PassiveAuthMappingScope = { export type CompletePassiveAuthMappingInput = PassiveAuthMappingScope & { taskId: string; + sourceTaskId?: string; reportArtifactId: string; - blockerIds: string[]; summary: PassiveAuthSurfaceSummary; }; @@ -33,6 +43,7 @@ export type PassiveAuthTaskAlignmentResult = { mappingTask: ResearchTask; followUpTasks: ResearchTask[]; negativeResults: NegativeResultRecord[]; + blockers: BlockerRecord[]; }; export interface PassiveAuthTaskAlignmentService { @@ -42,103 +53,134 @@ export interface PassiveAuthTaskAlignmentService { ): Promise; } -type TaskStore = { - list(projectId: string): ReturnType; - upsert( - projectId: string, - inputs: ResearchTaskUpsertInput[], - options?: Parameters[2], - ): ReturnType; -}; +type TransactionRunner = ( + operation: (database: Queryable) => Promise, +) => Promise; -const defaultTaskStore: TaskStore = { - list: (projectId) => listResearchTaskBoard(projectId), - upsert: (projectId, inputs, options) => - upsertResearchTasks(projectId, inputs, options), -}; +const runDatabaseTransaction: TransactionRunner = (operation) => + withDatabase((database) => withTransaction(database, operation)); +/** + * Maintains one mapping task per project and target. Threads describe where a + * report originated, but never own or split the durable mapping state. + */ export function createPassiveAuthTaskAlignmentService( - tasks: TaskStore = defaultTaskStore, - negativeResults: NegativeResultService = getDefaultNegativeResultsService(), + runTransaction: TransactionRunner = runDatabaseTransaction, ): PassiveAuthTaskAlignmentService { return { - async start(input) { - const taskId = - input.taskId ?? - stableTaskId(`${input.projectId}\u0000${input.targetId}`, "mapping"); - const board = await tasks.list(input.projectId); - const existing = board.tasks.find((task) => task.id === taskId); - assertCompatibleScope(existing, input); - const updated = await tasks.upsert( - input.projectId, - [ - { - id: taskId, - ...(input.threadId ? { threadId: input.threadId } : {}), - targetId: input.targetId, - title: `Map passive authentication surface for ${input.targetId}`, - status: "in_progress", - priority: existing?.priority ?? 50, - dueAt: null, - details: - "Review supplied evidence only; do not schedule or execute active probes.", - metadata: mappingMetadata(), - }, - ], - { ...(input.threadId ? { threadId: input.threadId } : {}) }, - ); - return requireTask(updated.tasks, taskId); - }, + start: (input) => + runTransaction(async (database) => { + const board = await readResearchTaskBoard(database, input.projectId); + const requested = input.taskId + ? board.tasks.find((task) => task.id === input.taskId) + : undefined; + assertCompatibleTarget(requested, input); + const existing = board.tasks.find( + (task) => + task.targetId === input.targetId && + task.metadata.workflow === WORKFLOW && + task.metadata.role === "mapping", + ); + const taskId = + existing?.id ?? + input.taskId ?? + stableTaskId(`${input.projectId}\u0000${input.targetId}`, "mapping"); + const updated = await upsertResearchTasks( + input.projectId, + [ + { + id: taskId, + threadId: null, + targetId: input.targetId, + title: `Map passive authentication surface for ${input.targetId}`, + status: "in_progress", + priority: existing?.priority ?? 50, + dueAt: null, + details: + "Review supplied evidence only; do not schedule or execute active probes.", + metadata: mappingMetadata(input.threadId, input.taskId), + }, + ], + { db: database }, + ); + return requireTask(updated.tasks, taskId); + }), - async complete(input) { - const negativeCoverage = await recordNegativeCoverage( - input, - negativeResults, - ); - const followUps = buildFollowUpTasks(input); - const board = await tasks.upsert( - input.projectId, - [ + complete: (input) => + runTransaction(async (database) => { + const blockerService = createBlockerService( + createDatabaseBlockerRepository(database), + ); + const negativeResults = createNegativeResultService( + createDatabaseNegativeResultRepository(database, { + manageTransactions: false, + }), + ); + const blockers = await persistPassiveAuthBlockers( { - id: input.taskId, - ...(input.threadId ? { threadId: input.threadId } : {}), + projectId: input.projectId, targetId: input.targetId, - title: `Map passive authentication surface for ${input.targetId}`, - status: input.blockerIds.length > 0 ? "blocked" : "done", - priority: 50, - dueAt: null, - details: - input.blockerIds.length > 0 - ? "Passive mapping report saved; durable blockers must be resolved before further work." - : "Passive mapping report saved; suggested follow-ups remain review-only until separately approved.", - metadata: { - ...mappingMetadata(), - reportArtifactId: input.reportArtifactId, - blockerIds: [...input.blockerIds].sort(), - negativeResultIds: negativeCoverage - .map((record) => record.id) - .sort(), - }, + taskId: input.taskId, + sourceThreadId: input.threadId, + summaryArtifactId: input.reportArtifactId, + summary: input.summary, }, - ...followUps, - ], - { ...(input.threadId ? { threadId: input.threadId } : {}) }, - ); - return { - mappingTask: requireTask(board.tasks, input.taskId), - followUpTasks: followUps.map((task) => - requireTask(board.tasks, task.id ?? ""), - ), - negativeResults: negativeCoverage, - }; - }, + blockerService, + ); + await resolveStaleBlockers(input, blockers, blockerService); + const negativeCoverage = await reconcileNegativeCoverage( + input, + blockers.length > 0, + negativeResults, + ); + const followUps = buildFollowUpTasks(input); + const board = await upsertResearchTasks( + input.projectId, + [ + { + id: input.taskId, + threadId: null, + targetId: input.targetId, + title: `Map passive authentication surface for ${input.targetId}`, + status: blockers.length > 0 ? "blocked" : "done", + priority: 50, + dueAt: null, + details: + blockers.length > 0 + ? "Passive mapping report saved; durable blockers must be resolved before further work." + : "Passive mapping report saved; suggested follow-ups remain review-only until separately approved.", + metadata: { + ...mappingMetadata(input.threadId, input.sourceTaskId), + reportArtifactId: input.reportArtifactId, + blockerIds: blockers.map((blocker) => blocker.id).sort(), + negativeResultIds: negativeCoverage + .map((record) => record.id) + .sort(), + }, + }, + ...followUps, + ], + { db: database }, + ); + return { + mappingTask: requireTask(board.tasks, input.taskId), + followUpTasks: followUps.map((task) => + requireTask(board.tasks, task.id ?? ""), + ), + negativeResults: negativeCoverage, + blockers, + }; + }), }; } -function mappingMetadata() { +function mappingMetadata(sourceThreadId?: string, sourceTaskId?: string) { return { workflow: WORKFLOW, role: "mapping", + scope: MAPPING_SCOPE, + sourceThreadId: sourceThreadId ?? null, + sourceTaskId: sourceTaskId ?? null, passiveOnly: true, executionPolicy: "not-scheduled", }; @@ -148,7 +190,7 @@ function buildFollowUpTasks( input: CompletePassiveAuthMappingInput, ): ResearchTaskUpsertInput[] { const common = { - ...(input.threadId ? { threadId: input.threadId } : {}), + threadId: null, targetId: input.targetId, parentTaskId: input.taskId, status: "todo" as const, @@ -164,6 +206,9 @@ function buildFollowUpTasks( metadata: { workflow: WORKFLOW, role: "passive-review", + scope: MAPPING_SCOPE, + sourceThreadId: input.threadId ?? null, + sourceTaskId: input.sourceTaskId ?? null, passiveOnly: true, executionPolicy: "not-scheduled", sourceReportArtifactId: input.reportArtifactId, @@ -177,6 +222,9 @@ function buildFollowUpTasks( metadata: { workflow: WORKFLOW, role: "approval-required", + scope: MAPPING_SCOPE, + sourceThreadId: input.threadId ?? null, + sourceTaskId: input.sourceTaskId ?? null, passiveOnly: false, approvalRequired: true, executionPolicy: "not-scheduled", @@ -186,54 +234,110 @@ function buildFollowUpTasks( ]; } -async function recordNegativeCoverage( +async function resolveStaleBlockers( input: CompletePassiveAuthMappingInput, + current: BlockerRecord[], + service: BlockerService, +) { + const currentIds = new Set(current.map((blocker) => blocker.id)); + const active = await service.list({ + projectId: input.projectId, + targetId: input.targetId, + taskId: input.taskId, + resolved: false, + limit: 500, + }); + for (const blocker of active) { + if ( + blocker.source === "passive-auth-surface" && + blocker.metadata?.workflow === WORKFLOW && + !currentIds.has(blocker.id) + ) { + await service.resolve(blocker.id); + } + } +} + +async function reconcileNegativeCoverage( + input: CompletePassiveAuthMappingInput, + isBlocked: boolean, service: NegativeResultService, ) { - const common = { + const desired = describeDesiredNegativeCoverage(input, isBlocked); + const current = await service.list({ projectId: input.projectId, - ...(input.threadId ? { threadId: input.threadId } : {}), targetId: input.targetId, taskId: input.taskId, method: WORKFLOW, - evidence: [input.reportArtifactId, ...input.summary.rawArtifactIds], - actor: "system:passive-auth-mapping", - metadata: { - workflow: WORKFLOW, - reportArtifactId: input.reportArtifactId, - passiveOnly: true, - }, - }; + limit: 100, + }); + for (const record of current) { + if (desired && sameSubject(record, desired.subject)) continue; + if (!desired && record.status === "rejected") continue; + await service.update(record.id, { + status: "rejected", + reason: "Superseded by a later passive authentication mapping report.", + metadata: { + ...(record.metadata ?? {}), + supersededByReportArtifactId: input.reportArtifactId, + }, + }); + } + if (!desired) return []; + return [ + await recordNegativeResultIdempotently(service, { + projectId: input.projectId, + targetId: input.targetId, + taskId: input.taskId, + kind: "no-finding", + status: desired.status, + subject: desired.subject, + method: WORKFLOW, + reason: desired.reason, + evidence: [input.reportArtifactId, ...input.summary.rawArtifactIds], + actor: "system:passive-auth-mapping", + metadata: { + workflow: WORKFLOW, + scope: MAPPING_SCOPE, + sourceThreadId: input.threadId ?? null, + sourceTaskId: input.sourceTaskId ?? null, + reportArtifactId: input.reportArtifactId, + passiveOnly: true, + }, + }), + ]; +} + +function describeDesiredNegativeCoverage( + input: CompletePassiveAuthMappingInput, + isBlocked: boolean, +) { if (input.summary.authRoutes.length === 0) { - return [ - await recordNegativeResultIdempotently(service, { - ...common, - kind: "no-finding", - status: "no-finding", - subject: { type: "control", value: "authentication-route-signal" }, - reason: - "Supplied passive evidence established no authentication routes.", - }), - ]; + return { + status: isBlocked ? ("inconclusive" as const) : ("no-finding" as const), + subject: { + type: "control" as const, + value: "authentication-route-signal", + }, + reason: isBlocked + ? "Passive evidence established no routes, but collection was blocked and coverage is incomplete." + : "Supplied passive evidence established no authentication routes.", + }; } if ( input.summary.authRoutes.every((route) => route.confidence === "medium") ) { - return [ - await recordNegativeResultIdempotently(service, { - ...common, - kind: "no-finding", - status: "inconclusive", - subject: { - type: "control", - value: "high-confidence-authentication-route-signal", - }, - reason: - "Supplied passive evidence contained only weak authentication-route signals.", - }), - ]; + return { + status: "inconclusive" as const, + subject: { + type: "control" as const, + value: "high-confidence-authentication-route-signal", + }, + reason: + "Supplied passive evidence contained only weak authentication-route signals.", + }; } - return []; + return undefined; } async function recordNegativeResultIdempotently( @@ -262,21 +366,23 @@ async function recordNegativeResultIdempotently( return unchanged ?? service.record(input); } -function assertCompatibleScope( +function sameSubject( + record: NegativeResultRecord, + subject: { type: string; value: string }, +) { + return ( + record.subject.type === subject.type && + record.subject.value === subject.value + ); +} + +function assertCompatibleTarget( existing: ResearchTask | undefined, input: PassiveAuthMappingScope, ) { - if (!existing) return; - if (existing.targetId && existing.targetId !== input.targetId) { + if (existing?.targetId && existing.targetId !== input.targetId) { throw new Error(`Task ${existing.id} is bound to a different target.`); } - if ( - input.threadId && - existing.threadId && - existing.threadId !== input.threadId - ) { - throw new Error(`Task ${existing.id} is bound to a different thread.`); - } } function requireTask(tasks: ResearchTask[], id: string) { diff --git a/tests/integration/discovery-artifact-normalizer.test.ts b/tests/integration/discovery-artifact-normalizer.test.ts index cc3a89100..dc24a9cee 100644 --- a/tests/integration/discovery-artifact-normalizer.test.ts +++ b/tests/integration/discovery-artifact-normalizer.test.ts @@ -5,7 +5,9 @@ import { createInMemoryBlockerRepository, } from "../../src/server/blockers"; import { + type CompletePassiveAuthMappingInput, normalizeDiscoveryArtifacts, + persistPassiveAuthBlockers, persistPassiveAuthSurface, } from "../../src/server/recon"; @@ -113,14 +115,27 @@ describe("discovery artifact normalization", () => { ], }, { createArtifact } as never, - createBlockerService(createInMemoryBlockerRepository()), { start: async () => ({ id: "task-1" }), - complete: async () => ({ - mappingTask: { id: "task-1" }, - followUpTasks: [], - negativeResults: [], - }), + complete: async (input: CompletePassiveAuthMappingInput) => { + const blockers = await persistPassiveAuthBlockers( + { + projectId: input.projectId, + threadId: input.threadId, + targetId: input.targetId, + taskId: input.taskId, + summaryArtifactId: input.reportArtifactId, + summary: input.summary, + }, + createBlockerService(createInMemoryBlockerRepository()), + ); + return { + mappingTask: { id: "task-1" }, + followUpTasks: [], + negativeResults: [], + blockers, + }; + }, } as never, ); diff --git a/tests/integration/passive-auth-task-alignment.test.ts b/tests/integration/passive-auth-task-alignment.test.ts index ffb844061..5c599ebc7 100644 --- a/tests/integration/passive-auth-task-alignment.test.ts +++ b/tests/integration/passive-auth-task-alignment.test.ts @@ -15,7 +15,9 @@ import { createNegativeResultService, } from "../../src/server/negative-results"; import { + buildPassiveAuthSurfaceSummary, createPassiveAuthTaskAlignmentService, + normalizeDiscoveryArtifacts, persistPassiveAuthSurface, } from "../../src/server/recon"; import { upsertProjectTarget } from "../../src/server/targets"; @@ -44,14 +46,12 @@ describe("passive authentication task alignment", () => { const negativeResults = createNegativeResultService( createDatabaseNegativeResultRepository(), ); - const alignment = createPassiveAuthTaskAlignmentService( - undefined, - negativeResults, - ); + const alignment = createPassiveAuthTaskAlignmentService(); const input = { projectId, threadId, targetId: "target-app", + taskId: "task-request-one", artifacts: [ { artifactId: "artifact-raw", @@ -62,25 +62,64 @@ describe("passive authentication task alignment", () => { ], }; const artifactWriter = fixedArtifactWriter("artifact-passive-report"); - const blockers = createBlockerService(createDatabaseBlockerRepository()); const started = await alignment.start(input); expect(started).toMatchObject({ - threadId, targetId: "target-app", status: "in_progress", - metadata: expect.objectContaining({ executionPolicy: "not-scheduled" }), + metadata: expect.objectContaining({ + scope: "project-target", + sourceThreadId: threadId, + executionPolicy: "not-scheduled", + }), }); + expect(started.threadId).toBeUndefined(); const first = await persistPassiveAuthSurface( input, artifactWriter, - blockers, alignment, ); const second = await persistPassiveAuthSurface( input, artifactWriter, - blockers, + alignment, + ); + const secondThread = await (await getProjectStore()).createThread( + projectId, + { + title: "Cross-thread passive retry", + }, + ); + const improved = await persistPassiveAuthSurface( + { + projectId, + threadId: secondThread.id, + targetId: "target-app", + taskId: "task-request-two", + artifacts: [ + { + artifactId: "artifact-improved", + source: "reference", + content: "https://app.example.test/login", + }, + ], + }, + fixedArtifactWriter("artifact-improved-report"), + alignment, + ); + const projectScoped = await persistPassiveAuthSurface( + { + projectId, + targetId: "target-app", + artifacts: [ + { + artifactId: "artifact-project-scoped", + source: "reference", + content: "https://app.example.test/login", + }, + ], + }, + fixedArtifactWriter("artifact-project-report"), alignment, ); @@ -88,7 +127,6 @@ describe("passive authentication task alignment", () => { first.taskAlignment.mappingTask.id, ); expect(second.taskAlignment.mappingTask).toMatchObject({ - threadId, targetId: "target-app", status: "done", metadata: expect.objectContaining({ @@ -101,14 +139,28 @@ describe("passive authentication task alignment", () => { expect.objectContaining({ id: first.taskAlignment.negativeResults[0]?.id, projectId, - threadId, targetId: "target-app", taskId: first.taskAlignment.mappingTask.id, kind: "no-finding", status: "no-finding", }), ]); + expect(second.taskAlignment.negativeResults[0]?.threadId).toBeUndefined(); expect(second.taskAlignment.negativeResults[0]?.findingId).toBeUndefined(); + expect(improved.taskAlignment.mappingTask.id).toBe( + second.taskAlignment.mappingTask.id, + ); + expect(improved.taskAlignment.negativeResults).toEqual([]); + expect(projectScoped.taskAlignment.mappingTask).toMatchObject({ + id: second.taskAlignment.mappingTask.id, + threadId: undefined, + metadata: expect.objectContaining({ + scope: "project-target", + sourceThreadId: null, + sourceTaskId: null, + reportArtifactId: "artifact-project-report", + }), + }); expect(second.taskAlignment.followUpTasks).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -136,9 +188,18 @@ describe("passive authentication task alignment", () => { }); expect(reloadedBoard.tasks).toHaveLength(3); expect(reloadedBoard.links).toHaveLength(2); - expect( - await negativeResults.list({ projectId, includeHistory: true }), - ).toHaveLength(1); + const negativeHistory = await negativeResults.list({ + projectId, + includeHistory: true, + }); + expect(negativeHistory).toHaveLength(2); + expect(negativeHistory[0]).toMatchObject({ + status: "rejected", + lifecycleStatus: "current", + }); + expect(await negativeResults.listCovered(projectId, "target-app")).toEqual( + [], + ); const scheduled = await withDatabase((database) => database.query<{ count: number }>( "SELECT count(*) AS count FROM scheduler_tasks WHERE project_id = $1", @@ -148,55 +209,191 @@ describe("passive authentication task alignment", () => { expect(Number(scheduled.rows[0]?.count)).toBe(0); }); - it("links durable blockers and records weak signals as inconclusive coverage", async () => { - const { projectId, threadId } = await createScope("Blocked weak mapping"); + it("keeps blocked zero-route coverage inconclusive and resolves it on a clean retry", async () => { + const { projectId, threadId } = await createScope( + "Blocked zero-route mapping", + ); const negativeResults = createNegativeResultService( createDatabaseNegativeResultRepository(), ); - const result = await persistPassiveAuthSurface( + const blockerService = createBlockerService( + createDatabaseBlockerRepository(), + ); + const alignment = createPassiveAuthTaskAlignmentService(); + const blocked = await persistPassiveAuthSurface( { projectId, threadId, targetId: "target-app", artifacts: [ { - artifactId: "artifact-weak", + artifactId: "artifact-blocked", source: "reference", - content: - "https://app.example.test/api/token\nHTTP 429 Too Many Requests", + content: "HTTP 429 Too Many Requests", }, ], }, - fixedArtifactWriter("artifact-weak-report"), - createBlockerService(createDatabaseBlockerRepository()), - createPassiveAuthTaskAlignmentService(undefined, negativeResults), + fixedArtifactWriter("artifact-blocked-report"), + alignment, ); - expect(result.taskAlignment.mappingTask).toMatchObject({ + expect(blocked.taskAlignment.mappingTask).toMatchObject({ status: "blocked", metadata: expect.objectContaining({ - reportArtifactId: "artifact-weak-report", - blockerIds: [result.blockers[0]?.id], + reportArtifactId: "artifact-blocked-report", + blockerIds: [blocked.blockers[0]?.id], }), }); - expect(result.taskAlignment.negativeResults).toEqual([ + expect(blocked.taskAlignment.negativeResults).toEqual([ expect.objectContaining({ status: "inconclusive", subject: { type: "control", - value: "high-confidence-authentication-route-signal", + value: "authentication-route-signal", }, }), ]); - expect(result.taskAlignment.negativeResults[0]?.findingId).toBeUndefined(); + expect(blocked.taskAlignment.negativeResults[0]?.findingId).toBeUndefined(); + expect(await negativeResults.listCovered(projectId, "target-app")).toEqual( + [], + ); + + const cleared = await persistPassiveAuthSurface( + { + projectId, + threadId, + targetId: "target-app", + artifacts: [ + { + artifactId: "artifact-clear", + source: "reference", + content: "https://app.example.test/login", + }, + ], + }, + fixedArtifactWriter("artifact-clear-report"), + alignment, + ); + expect(cleared.taskAlignment.mappingTask).toMatchObject({ + id: blocked.taskAlignment.mappingTask.id, + status: "done", + metadata: expect.objectContaining({ blockerIds: [] }), + }); + expect(cleared.taskAlignment.negativeResults).toEqual([]); + expect(await blockerService.listActive(projectId)).toEqual([]); + expect(await blockerService.list({ projectId, resolved: true })).toEqual([ + expect.objectContaining({ + id: blocked.blockers[0]?.id, + resolvedAt: expect.any(String), + }), + ]); + expect(await negativeResults.list({ projectId })).toEqual([ + expect.objectContaining({ + status: "rejected", + lifecycleStatus: "current", + }), + ]); const reloaded = await listResearchTaskBoard(projectId, { threadId }); expect( reloaded.tasks.find( - (task) => task.id === result.taskAlignment.mappingTask.id, + (task) => task.id === blocked.taskAlignment.mappingTask.id, ), ).toEqual( - expect.objectContaining({ status: "blocked", targetId: "target-app" }), + expect.objectContaining({ status: "done", targetId: "target-app" }), + ); + }); + + it("serializes concurrent retries into one mapping state", async () => { + const { projectId, threadId } = await createScope("Concurrent mapping"); + const input = { + projectId, + threadId, + targetId: "target-app", + artifacts: [ + { + artifactId: "artifact-concurrent", + source: "reference" as const, + content: + "No authentication routes were present in the stored evidence.", + }, + ], + }; + const [first, second] = await Promise.all([ + persistPassiveAuthSurface( + input, + fixedArtifactWriter("artifact-concurrent-report"), + ), + persistPassiveAuthSurface( + input, + fixedArtifactWriter("artifact-concurrent-report"), + ), + ]); + + expect(second.taskAlignment.mappingTask.id).toBe( + first.taskAlignment.mappingTask.id, ); + const board = await listResearchTaskBoard(projectId, { + targetId: "target-app", + }); + expect(board.tasks).toHaveLength(3); + expect( + await createNegativeResultService( + createDatabaseNegativeResultRepository(), + ).list({ projectId, includeHistory: true }), + ).toHaveLength(1); + }); + + it("rolls back completion state when its task write fails", async () => { + const { projectId, threadId } = await createScope("Atomic completion"); + const alignment = createPassiveAuthTaskAlignmentService(); + const mappingTask = await alignment.start({ + projectId, + threadId, + targetId: "target-app", + }); + await withDatabase((database) => + database.query( + `CREATE TRIGGER fail_passive_mapping_completion + BEFORE UPDATE OF status ON tasks + WHEN new.id = '${mappingTask.id}' AND new.status IN ('done', 'blocked') + BEGIN SELECT RAISE(ABORT, 'forced task completion failure'); END`, + ), + ); + const normalized = normalizeDiscoveryArtifacts([ + { + artifactId: "artifact-atomic", + source: "reference", + content: "HTTP 429 Too Many Requests", + }, + ]); + + await expect( + alignment.complete({ + projectId, + threadId, + targetId: "target-app", + taskId: mappingTask.id, + reportArtifactId: "artifact-atomic-report", + summary: buildPassiveAuthSurfaceSummary("target-app", normalized), + }), + ).rejects.toThrow("forced task completion failure"); + + expect( + await createBlockerService(createDatabaseBlockerRepository()).listActive( + projectId, + ), + ).toEqual([]); + expect( + await createNegativeResultService( + createDatabaseNegativeResultRepository(), + ).list({ projectId, includeHistory: true }), + ).toEqual([]); + const board = await listResearchTaskBoard(projectId, { + targetId: "target-app", + }); + expect(board.tasks).toEqual([ + expect.objectContaining({ id: mappingTask.id, status: "in_progress" }), + ]); }); }); From 07fe99ca79bfe2bb560e2a5dbc1ab785e8a3b5c8 Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Thu, 27 Aug 2026 08:10:05 -0400 Subject: [PATCH 4/5] 171: canonicalize mapping task identity --- src/server/recon/passive-auth-task-alignment.ts | 1 - tests/integration/passive-auth-task-alignment.test.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/recon/passive-auth-task-alignment.ts b/src/server/recon/passive-auth-task-alignment.ts index 64a08a066..3ff7a7198 100644 --- a/src/server/recon/passive-auth-task-alignment.ts +++ b/src/server/recon/passive-auth-task-alignment.ts @@ -83,7 +83,6 @@ export function createPassiveAuthTaskAlignmentService( ); const taskId = existing?.id ?? - input.taskId ?? stableTaskId(`${input.projectId}\u0000${input.targetId}`, "mapping"); const updated = await upsertResearchTasks( input.projectId, diff --git a/tests/integration/passive-auth-task-alignment.test.ts b/tests/integration/passive-auth-task-alignment.test.ts index 5c599ebc7..20547cf27 100644 --- a/tests/integration/passive-auth-task-alignment.test.ts +++ b/tests/integration/passive-auth-task-alignment.test.ts @@ -73,6 +73,7 @@ describe("passive authentication task alignment", () => { }), }); expect(started.threadId).toBeUndefined(); + expect(started.id).not.toBe("task-request-one"); const first = await persistPassiveAuthSurface( input, From d1b54978d11531e91e854558fd907561acc02f01 Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Thu, 27 Aug 2026 08:20:45 -0400 Subject: [PATCH 5/5] 171: validate canonical passive mapping completion --- src/server/recon/index.ts | 1 + src/server/recon/passive-auth-surface.ts | 3 +- .../recon/passive-auth-task-alignment.ts | 110 +++++++++- .../passive-auth-task-alignment.test.ts | 204 +++++++++++++++++- 4 files changed, 307 insertions(+), 11 deletions(-) diff --git a/src/server/recon/index.ts b/src/server/recon/index.ts index dcd7b4aad..181f99836 100644 --- a/src/server/recon/index.ts +++ b/src/server/recon/index.ts @@ -25,6 +25,7 @@ export { export { type CompletePassiveAuthMappingInput, createPassiveAuthTaskAlignmentService, + lockPassiveAuthMappingScope, type PassiveAuthMappingScope, type PassiveAuthTaskAlignmentResult, type PassiveAuthTaskAlignmentService, diff --git a/src/server/recon/passive-auth-surface.ts b/src/server/recon/passive-auth-surface.ts index 3c03d8d02..fcfe39d3a 100644 --- a/src/server/recon/passive-auth-surface.ts +++ b/src/server/recon/passive-auth-surface.ts @@ -70,7 +70,6 @@ export async function persistPassiveAuthSurface( ...(input.threadId ? { threadId: input.threadId } : {}), targetId: input.targetId, taskId: mappingTask.id, - ...(input.taskId ? { sourceTaskId: input.taskId } : {}), name: `passive-auth-surface-${input.targetId}.json`, kind: "report", contentType: "application/json", @@ -81,6 +80,7 @@ export async function persistPassiveAuthSurface( agentGenerated: true, metadata: { workflow: "passive-auth-surface-v1", + sourceTaskId: input.taskId ?? null, rawArtifactIds: summary.rawArtifactIds, blockerReasons: summary.blockers.map((blocker) => blocker.reason), authCategories: summary.authRoutes.map((route) => route.category), @@ -91,6 +91,7 @@ export async function persistPassiveAuthSurface( ...(input.threadId ? { threadId: input.threadId } : {}), targetId: input.targetId, taskId: mappingTask.id, + ...(input.taskId ? { sourceTaskId: input.taskId } : {}), reportArtifactId: artifact.id, summary, }); diff --git a/src/server/recon/passive-auth-task-alignment.ts b/src/server/recon/passive-auth-task-alignment.ts index 3ff7a7198..12ac7dd61 100644 --- a/src/server/recon/passive-auth-task-alignment.ts +++ b/src/server/recon/passive-auth-task-alignment.ts @@ -6,7 +6,12 @@ import { createBlockerService, createDatabaseBlockerRepository, } from "../blockers"; -import { type Queryable, withDatabase, withTransaction } from "../db/client"; +import { + type DatabaseConfig, + type Queryable, + withDatabase, + withTransaction, +} from "../db/client"; import { createDatabaseNegativeResultRepository, createNegativeResultService, @@ -54,11 +59,18 @@ export interface PassiveAuthTaskAlignmentService { } type TransactionRunner = ( - operation: (database: Queryable) => Promise, + operation: ( + database: Queryable, + backend: DatabaseConfig["backend"], + ) => Promise, ) => Promise; const runDatabaseTransaction: TransactionRunner = (operation) => - withDatabase((database) => withTransaction(database, operation)); + withDatabase((database, config) => + withTransaction(database, (transaction) => + operation(transaction, config.backend), + ), + ); /** * Maintains one mapping task per project and target. Threads describe where a @@ -69,7 +81,13 @@ export function createPassiveAuthTaskAlignmentService( ): PassiveAuthTaskAlignmentService { return { start: (input) => - runTransaction(async (database) => { + runTransaction(async (database, backend) => { + await lockPassiveAuthMappingScope( + database, + backend, + input.projectId, + input.targetId, + ); const board = await readResearchTaskBoard(database, input.projectId); const requested = input.taskId ? board.tasks.find((task) => task.id === input.taskId) @@ -106,7 +124,14 @@ export function createPassiveAuthTaskAlignmentService( }), complete: (input) => - runTransaction(async (database) => { + runTransaction(async (database, backend) => { + await lockPassiveAuthMappingScope( + database, + backend, + input.projectId, + input.targetId, + ); + await validateCompletionAttribution(database, input); const blockerService = createBlockerService( createDatabaseBlockerRepository(database), ); @@ -173,6 +198,65 @@ export function createPassiveAuthTaskAlignmentService( }; } +export async function lockPassiveAuthMappingScope( + database: Queryable, + backend: DatabaseConfig["backend"], + projectId: string, + targetId: string, +) { + if (backend === "postgres") { + await database.query("SELECT pg_advisory_xact_lock(hashtext($1))", [ + JSON.stringify([WORKFLOW, projectId, targetId]), + ]); + } +} + +async function validateCompletionAttribution( + database: Queryable, + input: CompletePassiveAuthMappingInput, +) { + if (input.summary.targetId !== input.targetId) { + throw new Error("Passive mapping summary is bound to a different target."); + } + const board = await readResearchTaskBoard(database, input.projectId); + const task = board.tasks.find((candidate) => candidate.id === input.taskId); + if ( + !task || + task.targetId !== input.targetId || + task.threadId !== undefined || + task.metadata.workflow !== WORKFLOW || + task.metadata.role !== "mapping" || + task.metadata.scope !== MAPPING_SCOPE + ) { + throw new Error("Passive mapping task attribution is not canonical."); + } + const report = await database.query<{ + project_id: string; + thread_id: string | null; + task_id: string | null; + kind: string; + metadata: unknown; + }>( + `SELECT project_id, thread_id, task_id, kind, metadata + FROM artifacts WHERE id = $1 AND project_id = $2 LIMIT 1`, + [input.reportArtifactId, input.projectId], + ); + const row = report.rows[0]; + const metadata = asRecord(row?.metadata); + if ( + !row || + row.project_id !== input.projectId || + row.thread_id !== (input.threadId ?? null) || + row.task_id !== input.taskId || + row.kind !== "report" || + metadata.workflow !== WORKFLOW || + metadata.targetId !== input.targetId || + metadata.sourceTaskId !== (input.sourceTaskId ?? null) + ) { + throw new Error("Passive mapping report attribution is not canonical."); + } +} + function mappingMetadata(sourceThreadId?: string, sourceTaskId?: string) { return { workflow: WORKFLOW, @@ -375,6 +459,22 @@ function sameSubject( ); } +function asRecord(value: unknown): Record { + if (typeof value === "string") { + try { + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } + } + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + function assertCompatibleTarget( existing: ResearchTask | undefined, input: PassiveAuthMappingScope, diff --git a/tests/integration/passive-auth-task-alignment.test.ts b/tests/integration/passive-auth-task-alignment.test.ts index 20547cf27..ef53590a6 100644 --- a/tests/integration/passive-auth-task-alignment.test.ts +++ b/tests/integration/passive-auth-task-alignment.test.ts @@ -17,11 +17,15 @@ import { import { buildPassiveAuthSurfaceSummary, createPassiveAuthTaskAlignmentService, + lockPassiveAuthMappingScope, normalizeDiscoveryArtifacts, persistPassiveAuthSurface, } from "../../src/server/recon"; import { upsertProjectTarget } from "../../src/server/targets"; -import { listResearchTaskBoard } from "../../src/server/tasks/tracker"; +import { + listResearchTaskBoard, + upsertResearchTasks, +} from "../../src/server/tasks/tracker"; describe("passive authentication task alignment", () => { let databaseRoot: string; @@ -41,6 +45,41 @@ describe("passive authentication task alignment", () => { await rm(databaseRoot, { recursive: true, force: true }); }); + it("takes a PostgreSQL transaction lock for each project-target mapping scope", async () => { + const calls: Array<{ sql: string; values?: readonly unknown[] }> = []; + const database = { + async query(sql: string, values?: readonly unknown[]) { + calls.push({ sql, values }); + return { rows: [] }; + }, + }; + await lockPassiveAuthMappingScope( + database, + "postgres", + "project-lock", + "target-lock", + ); + expect(calls).toEqual([ + { + sql: "SELECT pg_advisory_xact_lock(hashtext($1))", + values: [ + JSON.stringify([ + "passive-auth-surface-v1", + "project-lock", + "target-lock", + ]), + ], + }, + ]); + await lockPassiveAuthMappingScope( + database, + "sqlite", + "project-lock", + "target-lock", + ); + expect(calls).toHaveLength(1); + }); + it("persists one completed mapping task, negative coverage, and unscheduled follow-ups", async () => { const { projectId, threadId } = await createScope("No route mapping"); const negativeResults = createNegativeResultService( @@ -133,6 +172,7 @@ describe("passive authentication task alignment", () => { metadata: expect.objectContaining({ reportArtifactId: "artifact-passive-report", blockerIds: [], + sourceTaskId: "task-request-one", executionPolicy: "not-scheduled", }), }); @@ -144,6 +184,9 @@ describe("passive authentication task alignment", () => { taskId: first.taskAlignment.mappingTask.id, kind: "no-finding", status: "no-finding", + metadata: expect.objectContaining({ + sourceTaskId: "task-request-one", + }), }), ]); expect(second.taskAlignment.negativeResults[0]?.threadId).toBeUndefined(); @@ -168,6 +211,7 @@ describe("passive authentication task alignment", () => { status: "todo", metadata: expect.objectContaining({ role: "passive-review", + sourceTaskId: "task-request-one", passiveOnly: true, executionPolicy: "not-scheduled", }), @@ -176,6 +220,7 @@ describe("passive authentication task alignment", () => { status: "todo", metadata: expect.objectContaining({ role: "approval-required", + sourceTaskId: "task-request-one", approvalRequired: true, executionPolicy: "not-scheduled", }), @@ -344,6 +389,98 @@ describe("passive authentication task alignment", () => { ).toHaveLength(1); }); + it("rejects noncanonical task, summary, and report attribution before reconciliation", async () => { + const { projectId, threadId } = await createScope("Completion attribution"); + const alignment = createPassiveAuthTaskAlignmentService(); + const mappingTask = await alignment.start({ + projectId, + threadId, + targetId: "target-app", + }); + await upsertResearchTasks(projectId, [ + { + id: "task-unrelated", + threadId: null, + targetId: "target-app", + title: "Unrelated target work", + }, + ]); + const normalized = normalizeDiscoveryArtifacts([ + { + artifactId: "artifact-attribution-source", + source: "reference", + content: "HTTP 429 Too Many Requests", + }, + ]); + const summary = buildPassiveAuthSurfaceSummary("target-app", normalized); + await persistTestReport({ + id: "artifact-attribution-valid", + projectId, + threadId, + taskId: mappingTask.id, + targetId: "target-app", + sourceTaskId: null, + }); + await persistTestReport({ + id: "artifact-attribution-unrelated", + projectId, + threadId, + taskId: "task-unrelated", + targetId: "target-app", + sourceTaskId: null, + }); + await persistTestReport({ + id: "artifact-attribution-wrong-target", + projectId, + threadId, + taskId: mappingTask.id, + targetId: "target-other", + sourceTaskId: null, + }); + + await expect( + alignment.complete({ + projectId, + threadId, + targetId: "target-app", + taskId: "task-unrelated", + reportArtifactId: "artifact-attribution-unrelated", + summary, + }), + ).rejects.toThrow("task attribution is not canonical"); + await expect( + alignment.complete({ + projectId, + threadId, + targetId: "target-app", + taskId: mappingTask.id, + reportArtifactId: "artifact-attribution-valid", + summary: { ...summary, targetId: "target-other" }, + }), + ).rejects.toThrow("summary is bound to a different target"); + await expect( + alignment.complete({ + projectId, + threadId, + targetId: "target-app", + taskId: mappingTask.id, + reportArtifactId: "artifact-attribution-wrong-target", + summary, + }), + ).rejects.toThrow("report attribution is not canonical"); + + expect( + await createBlockerService(createDatabaseBlockerRepository()).listActive( + projectId, + ), + ).toEqual([]); + expect( + await createNegativeResultService( + createDatabaseNegativeResultRepository(), + ).list({ projectId, includeHistory: true }), + ).toEqual([]); + }); + it("rolls back completion state when its task write fails", async () => { const { projectId, threadId } = await createScope("Atomic completion"); const alignment = createPassiveAuthTaskAlignmentService(); @@ -367,6 +504,14 @@ describe("passive authentication task alignment", () => { content: "HTTP 429 Too Many Requests", }, ]); + await persistTestReport({ + id: "artifact-atomic-report", + projectId, + threadId, + taskId: mappingTask.id, + targetId: "target-app", + sourceTaskId: null, + }); await expect( alignment.complete({ @@ -414,14 +559,63 @@ async function createScope(name: string) { function fixedArtifactWriter(id: string) { return { - async createArtifact() { + async createArtifact(input: { + projectId: string; + threadId?: string; + taskId?: string; + targetId?: string; + name: string; + kind: string; + metadata?: Record; + }) { + await persistTestReport({ + id, + projectId: input.projectId, + threadId: input.threadId, + taskId: input.taskId ?? "", + targetId: input.targetId ?? "", + sourceTaskId: + typeof input.metadata?.sourceTaskId === "string" + ? input.metadata.sourceTaskId + : null, + }); return { id, - projectId: "ignored-by-test-double", - name: "passive-auth-report.json", - kind: "report", + projectId: input.projectId, + threadId: input.threadId ?? null, + name: input.name, + kind: input.kind, indexing: { status: "indexed" as const, chunkCount: 1 }, }; }, } as never; } + +async function persistTestReport(input: { + id: string; + projectId: string; + threadId?: string; + taskId: string; + targetId: string; + sourceTaskId: string | null; +}) { + await withDatabase((database) => + database.query( + `INSERT INTO artifacts + (id, project_id, thread_id, task_id, kind, name, metadata) + VALUES ($1, $2, $3, $4, 'report', 'passive-auth-report.json', $5::jsonb) + ON CONFLICT(id) DO NOTHING`, + [ + input.id, + input.projectId, + input.threadId ?? null, + input.taskId, + JSON.stringify({ + workflow: "passive-auth-surface-v1", + targetId: input.targetId, + sourceTaskId: input.sourceTaskId, + }), + ], + ), + ); +}