From 6db25abf5611acf680042dbb501fbee6eeb29de4 Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Thu, 27 Aug 2026 02:22:28 -0400 Subject: [PATCH] 167: expose passive auth surface tool --- src/lib/tools/catalog.ts | 11 + .../agents/security-research/stage-tools.ts | 2 + src/mastra/tools/index.ts | 3 + src/mastra/tools/passive-auth-surface.ts | 85 ++++++ .../passive-auth-surface-tool.test.ts | 241 ++++++++++++++++++ tests/integration/stage-agents.test.ts | 20 +- 6 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 src/mastra/tools/passive-auth-surface.ts create mode 100644 tests/integration/passive-auth-surface-tool.test.ts diff --git a/src/lib/tools/catalog.ts b/src/lib/tools/catalog.ts index 45f2078c5..65af5ce13 100644 --- a/src/lib/tools/catalog.ts +++ b/src/lib/tools/catalog.ts @@ -77,6 +77,7 @@ const PROJECT_MUTATING_TOOL_IDS = new Set([ "negativeCoverageTool", "nucleiImportTool", "openApiImportTool", + "passiveAuthSurfaceTool", "patchLoopTool", "postmanImportTool", "proxyTrafficImportTool", @@ -144,6 +145,7 @@ export const LOCAL_TOOL_IDS = [ "negativeCoverageTool", "nucleiImportTool", "openApiImportTool", + "passiveAuthSurfaceTool", "patchLoopTool", "postmanImportTool", "productSkillRegistryTool", @@ -406,6 +408,14 @@ const localToolOptions: RuntimeToolOption[] = [ kind: "tool", risk: "passive", }, + { + id: "tool:passiveAuthSurfaceTool", + label: "Passive auth surface", + description: + "Build a canonical authentication-surface report from authorized stored evidence without contacting the target.", + kind: "tool", + risk: "passive", + }, { id: "tool:patchLoopTool", label: "Patch loop", @@ -699,6 +709,7 @@ function resolveCapabilityControllerCategory( function resolveCapabilityStages(option: RuntimeToolOption): readonly SecurityCapabilityStage[] { const id = option.id.slice("tool:".length); if (id === "agentLabForegroundCommandTool") return []; + if (id === "passiveAuthSurfaceTool") return ["recon", "composition"]; if ( option.risk === "approval-gated" || id === "approvalGateTool" || diff --git a/src/mastra/agents/security-research/stage-tools.ts b/src/mastra/agents/security-research/stage-tools.ts index 0c95eb840..5aa498851 100644 --- a/src/mastra/agents/security-research/stage-tools.ts +++ b/src/mastra/agents/security-research/stage-tools.ts @@ -31,6 +31,7 @@ import { iocExportTool } from "../../tools/ioc-export"; import { negativeCoverageTool } from "../../tools/negative-coverage"; import { nucleiImportTool } from "../../tools/nuclei-import"; import { openApiImportTool } from "../../tools/openapi-import"; +import { passiveAuthSurfaceTool } from "../../tools/passive-auth-surface"; import { patchLoopTool } from "../../tools/patch-loop"; import { postmanImportTool } from "../../tools/postman-import"; import { productSkillRegistryTool } from "../../tools/product-skill-registry"; @@ -85,6 +86,7 @@ const stageToolRegistry = { negativeCoverageTool, nucleiImportTool, openApiImportTool, + passiveAuthSurfaceTool, patchLoopTool, postmanImportTool, productSkillRegistryTool, diff --git a/src/mastra/tools/index.ts b/src/mastra/tools/index.ts index fcef4c9eb..03fcc5e9c 100644 --- a/src/mastra/tools/index.ts +++ b/src/mastra/tools/index.ts @@ -23,6 +23,7 @@ export { iocExportTool } from "./ioc-export"; export { negativeCoverageTool } from "./negative-coverage"; export { nucleiImportTool } from "./nuclei-import"; export { openApiImportTool } from "./openapi-import"; +export { passiveAuthSurfaceTool } from "./passive-auth-surface"; export { patchLoopTool } from "./patch-loop"; export { postmanImportTool } from "./postman-import"; export { productSkillRegistryTool } from "./product-skill-registry"; @@ -79,6 +80,7 @@ import { iocExportTool } from "./ioc-export"; import { negativeCoverageTool } from "./negative-coverage"; import { nucleiImportTool } from "./nuclei-import"; import { openApiImportTool } from "./openapi-import"; +import { passiveAuthSurfaceTool } from "./passive-auth-surface"; import { patchLoopTool } from "./patch-loop"; import { postmanImportTool } from "./postman-import"; import { productSkillRegistryTool } from "./product-skill-registry"; @@ -132,6 +134,7 @@ export const securityResearchTools = { negativeCoverageTool, nucleiImportTool, openApiImportTool, + passiveAuthSurfaceTool, patchLoopTool, postmanImportTool, productSkillRegistryTool, diff --git a/src/mastra/tools/passive-auth-surface.ts b/src/mastra/tools/passive-auth-surface.ts new file mode 100644 index 000000000..71a3055ea --- /dev/null +++ b/src/mastra/tools/passive-auth-surface.ts @@ -0,0 +1,85 @@ +import { createTool } from "@mastra/core/tools"; +import { z } from "zod"; + +import { createStoredPassiveAuthSurface } from "../../server/recon/stored-passive-auth-surface"; + +const storedArtifactRefSchema = z.object({ + artifactId: z.string(), +}); + +export const passiveAuthSurfaceTool = createTool({ + id: "security-passive-auth-surface", + description: + "Builds a canonical passive authentication-surface report from authorized, target-bound project artifacts. It does not contact the target or return raw evidence.", + inputSchema: z.object({ + targetId: z.string(), + taskId: z.string().optional(), + artifacts: z.array(storedArtifactRefSchema), + }), + outputSchema: z.object({ + status: z.literal("ok"), + targetId: z.string(), + taskId: z.string().optional(), + reportArtifactId: z.string(), + authorizationId: z.string(), + routeCategories: z.array( + z.object({ + category: z.string(), + count: z.number(), + confidence: z.enum(["high", "medium"]), + }), + ), + blockers: z.array( + z.object({ + reason: z.string(), + evidenceArtifactIds: z.array(z.string()), + }), + ), + unknowns: z.array(z.string()), + sourceArtifactIds: z.array(z.string()), + }), + execute: async (input, context) => { + const projectId = readContextString( + context.requestContext?.get("projectId"), + ); + const threadId = readContextString(context.requestContext?.get("threadId")); + if (!projectId || !threadId) { + throw new Error( + "security-passive-auth-surface requires projectId and threadId in request context.", + ); + } + + const result = await createStoredPassiveAuthSurface({ + projectId, + threadId, + targetId: input.targetId, + ...(input.taskId ? { taskId: input.taskId } : {}), + artifacts: input.artifacts, + }); + + return { + status: "ok" as const, + targetId: input.targetId, + ...(input.taskId ? { taskId: input.taskId } : {}), + reportArtifactId: result.artifact.id, + authorizationId: result.authorizationId, + routeCategories: result.summary.authRoutes.map((route) => ({ + category: route.category, + count: route.urls.length, + confidence: route.confidence, + })), + blockers: result.summary.blockers.map((blocker) => ({ + reason: blocker.reason, + evidenceArtifactIds: blocker.evidenceArtifactIds, + })), + unknowns: result.summary.unknowns, + sourceArtifactIds: result.sourceArtifacts.map( + (artifact) => artifact.artifactId, + ), + }; + }, +}); + +function readContextString(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} diff --git a/tests/integration/passive-auth-surface-tool.test.ts b/tests/integration/passive-auth-surface-tool.test.ts new file mode 100644 index 000000000..1e00c77ae --- /dev/null +++ b/tests/integration/passive-auth-surface-tool.test.ts @@ -0,0 +1,241 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { RequestContext } from "@mastra/core/request-context"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + MODEL_TOOL_DISALLOWED_JSON_SCHEMA_KEYWORDS, + maybeZodToJsonSchema, +} from "../../src/lib/json-schema/zod"; +import { passiveAuthSurfaceTool } from "../../src/mastra/tools/passive-auth-surface"; +import { getProjectStore } from "../../src/server/chat/projectAdapter"; +import { withDatabase } from "../../src/server/db/client"; +import { + createArtifactService, + createEvidenceIngestor, + setArtifactService, + setEvidenceIngestor, +} from "../../src/server/evidence"; +import { + createTargetAuthorization, + upsertProjectTarget, +} from "../../src/server/targets"; +import { upsertResearchTasks } from "../../src/server/tasks/tracker"; + +describe("passive auth-surface tool", () => { + let databaseRoot: string; + let previousDatabaseUrl: string | undefined; + + beforeEach(async () => { + previousDatabaseUrl = process.env.EH_APP_DB_URL; + databaseRoot = await mkdtemp(join(tmpdir(), "passive-auth-surface-tool-")); + process.env.EH_APP_DB_URL = `sqlite://${join(databaseRoot, "app.sqlite")}`; + setArtifactService(createArtifactService({ storage: null })); + }); + + afterEach(async () => { + setArtifactService(undefined); + setEvidenceIngestor(undefined); + 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("executes through trusted context and returns a bounded canonical-report summary", async () => { + const indexed = vi.fn(async () => 1); + setEvidenceIngestor( + createEvidenceIngestor({ indexer: { index: indexed } }), + ); + + const store = await getProjectStore(); + const project = await store.createProject({ + name: "Tool passive auth map", + }); + const thread = await store.createThread(project.id, { title: "Map auth" }); + await upsertProjectTarget(project.id, { + id: "target-app", + threadId: thread.id, + kind: "web", + label: "Authorized app", + locator: "https://app.example.test", + }); + await upsertResearchTasks( + project.id, + [ + { + id: "task-passive-map", + threadId: thread.id, + targetId: "target-app", + title: "Map auth surface", + }, + ], + { threadId: thread.id }, + ); + const authorization = await createTargetAuthorization(project.id, { + targetId: "target-app", + status: "approved", + grantedBy: "operator", + scope: { activity: "passive" }, + }); + const artifactService = createArtifactService({ storage: null }); + setArtifactService(artifactService); + const source = await artifactService.createArtifact({ + projectId: project.id, + threadId: thread.id, + targetId: "target-app", + name: "passive-urls.txt", + kind: "log", + contentType: "text/plain", + content: + "raw-marker-should-not-return https://app.example.test/login\nhttps://app.example.test/api/session", + source: "upload", + indexForRag: false, + }); + const countBefore = await countArtifacts(project.id); + + await expect( + executeTool( + { + targetId: "target-app", + taskId: "task-passive-map", + artifacts: [{ artifactId: source.id }], + }, + new RequestContext([["projectId", project.id]]), + ), + ).rejects.toThrow("requires projectId and threadId in request context"); + expect(await countArtifacts(project.id)).toBe(countBefore); + expect(indexed).not.toHaveBeenCalled(); + + const result = await executeTool( + { + targetId: "target-app", + taskId: "task-passive-map", + artifacts: [{ artifactId: source.id }], + }, + new RequestContext([ + ["projectId", project.id], + ["threadId", thread.id], + ]), + ); + + expect(result).toMatchObject({ + status: "ok", + targetId: "target-app", + taskId: "task-passive-map", + authorizationId: authorization.id, + routeCategories: expect.arrayContaining([ + { category: "login", count: 1, confidence: "high" }, + { category: "session", count: 1, confidence: "medium" }, + ]), + blockers: [], + sourceArtifactIds: [source.id], + }); + expect(result.reportArtifactId).toEqual(expect.any(String)); + expect(JSON.stringify(result)).not.toContain( + "raw-marker-should-not-return", + ); + expect(JSON.stringify(result)).not.toContain("https://app.example.test"); + + const storedReport = await withDatabase((db) => + db.query<{ + thread_id: string | null; + task_id: string | null; + metadata: unknown; + }>( + `SELECT thread_id, task_id, metadata + FROM artifacts + WHERE project_id = $1 AND id = $2`, + [project.id, result.reportArtifactId], + ), + ); + expect(storedReport.rows[0]).toMatchObject({ + thread_id: thread.id, + task_id: "task-passive-map", + }); + expect(readMetadata(storedReport.rows[0]?.metadata)).toMatchObject({ + targetId: "target-app", + targetIds: ["target-app"], + }); + expect(indexed).toHaveBeenCalledTimes(1); + expect(indexed).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + projectId: project.id, + threadId: thread.id, + taskId: "task-passive-map", + targetId: "target-app", + artifactId: result.reportArtifactId, + }), + }), + ); + }); + + it("exposes only provider-compatible model inputs", () => { + const schema = maybeZodToJsonSchema(passiveAuthSurfaceTool.inputSchema, { + target: "model-tool", + }); + expect(schema).toBeDefined(); + expect(schema?.additionalProperties).toBe(false); + expect(schema?.properties).toMatchObject({ + targetId: { type: "string" }, + taskId: { type: "string" }, + artifacts: { type: "array" }, + }); + expect(schema?.properties).not.toHaveProperty("projectId"); + expect(schema?.properties).not.toHaveProperty("threadId"); + expect(findDisallowedKeywords(schema)).toEqual([]); + }); +}); + +async function executeTool( + input: { + targetId: string; + taskId?: string; + artifacts: Array<{ artifactId: string }>; + }, + requestContext: RequestContext, +) { + if (!passiveAuthSurfaceTool.execute) + throw new Error("Tool execute function is unavailable."); + const result = await passiveAuthSurfaceTool.execute(input, { + requestContext, + } as never); + if ( + !result || + typeof result !== "object" || + !("reportArtifactId" in result) + ) { + throw new Error("Tool did not return a passive auth-surface report."); + } + return result; +} + +async function countArtifacts(projectId: string) { + return withDatabase(async (db) => { + const result = await db.query<{ count: number }>( + "SELECT COUNT(*) AS count FROM artifacts WHERE project_id = $1", + [projectId], + ); + return Number(result.rows[0]?.count ?? 0); + }); +} + +function findDisallowedKeywords(value: unknown): string[] { + if (!value || typeof value !== "object") return []; + if (Array.isArray(value)) return value.flatMap(findDisallowedKeywords); + return Object.entries(value as Record).flatMap( + ([key, entry]) => [ + ...(MODEL_TOOL_DISALLOWED_JSON_SCHEMA_KEYWORDS.has(key) ? [key] : []), + ...findDisallowedKeywords(entry), + ], + ); +} + +function readMetadata(value: unknown): Record { + if (typeof value === "string") { + return JSON.parse(value) as Record; + } + return (value ?? {}) as Record; +} diff --git a/tests/integration/stage-agents.test.ts b/tests/integration/stage-agents.test.ts index 712d0f9b3..b8657a6ea 100644 --- a/tests/integration/stage-agents.test.ts +++ b/tests/integration/stage-agents.test.ts @@ -37,13 +37,31 @@ describe("security research stage agents", () => { approvalIntent: "target-action", evidenceExpectation: "artifact-when-valuable", }); + expect(getSecurityCapability("passiveAuthSurfaceTool")).toMatchObject({ + risk: "passive", + controllerCategory: "edit", + mutation: "project", + stages: ["recon", "composition"], + readOnly: false, + idempotent: false, + concurrencyClass: "serialized-project", + backgroundEligible: false, + approvalIntent: null, + evidenceExpectation: "canonical-record", + }); }); it("keeps active tools limited to hunt and trace stages", async () => { expect(Object.keys(selectSecurityResearchStageTools("recon", undefined))).not.toEqual( expect.arrayContaining(["agentLabCommandTool"]), ); expect(Object.keys(selectSecurityResearchStageTools("recon", undefined))).toEqual( - expect.arrayContaining(["codeReviewScanTool"]), + expect.arrayContaining(["codeReviewScanTool", "passiveAuthSurfaceTool"]), + ); + expect(Object.keys(selectSecurityResearchStageTools("composition", undefined))).toEqual( + expect.arrayContaining(["passiveAuthSurfaceTool"]), + ); + expect(Object.keys(selectSecurityResearchStageTools("planning", undefined))).not.toEqual( + expect.arrayContaining(["passiveAuthSurfaceTool"]), ); expect(Object.keys(selectSecurityResearchStageTools("validation", undefined))).not.toEqual( expect.arrayContaining(["httpProbeTool"]),