From 4f0dfc5dca343eb613e72165e88e4018c90dacb8 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 11 Jun 2026 18:16:39 -0400 Subject: [PATCH 1/5] feat(pentest): let customers add context to findings so retests are informed Customers asked to explain findings (accepted-by-design rationale, remediation notes) so a retest doesn't blindly re-flag them (CS request: 'how do i run retest with the additional context i provided instead of another blind pentest'). - add SecurityPenetrationTestFindingContext table (+migration): one note per org+provider issue, grouped by normalized target URL - new /v1/pentest-finding-contexts endpoints: GET list by target (pentest:read), PUT/DELETE per finding (new pentest:update action, granted to owner/admin); MCP tool names + OpenAPI metadata included - createReport now composes Maced's additionalContext from the new optional additionalContext DTO field plus all saved notes for the target, so every re-run is an informed retest - app: permission-gated 'Retest context' editor on FindingDetail, optional run-level context field + auto-share hint on CreateRunPanel - regenerate packages/docs/openapi.json - tests: util/service/controller specs (api), FindingContextSection + CreateRunPanel payload tests (app); fix pre-existing controller spec load failures by mocking permission.guard/@db Co-Authored-By: Claude Fable 5 --- .../dto/create-penetration-test.dto.ts | 13 + .../dto/upsert-finding-context.dto.ts | 24 + .../finding-context.util.spec.ts | 83 +++ .../finding-context.util.ts | 63 ++ ...entest-finding-contexts.controller.spec.ts | 109 ++++ .../pentest-finding-contexts.controller.ts | 102 ++++ .../pentest-finding-contexts.service.spec.ts | 222 +++++++ .../pentest-finding-contexts.service.ts | 118 ++++ ...security-penetration-tests.billing.spec.ts | 10 + ...urity-penetration-tests.controller.spec.ts | 16 + .../security-penetration-tests.module.ts | 14 +- ...security-penetration-tests.service.spec.ts | 90 +++ .../security-penetration-tests.service.ts | 38 ++ .../_components/CreateRunPanel.test.tsx | 77 +++ .../_components/CreateRunPanel.tsx | 13 + .../_components/DetailPane.tsx | 12 +- .../FindingContextSection.test.tsx | 146 +++++ .../_components/FindingContextSection.tsx | 141 +++++ .../_components/FindingDetail.tsx | 20 +- .../_components/RunContextField.tsx | 54 ++ .../_components/SplitView.tsx | 1 + .../hooks/use-penetration-tests.ts | 1 + .../hooks/use-pentest-finding-contexts.ts | 126 ++++ .../lib/security/penetration-tests-client.ts | 17 + packages/auth/src/permissions.ts | 6 +- .../migration.sql | 24 + packages/db/prisma/schema/organization.prisma | 1 + ...ty-penetration-test-finding-context.prisma | 31 + packages/docs/openapi.json | 555 ++++++++++++++++-- 29 files changed, 2077 insertions(+), 50 deletions(-) create mode 100644 apps/api/src/security-penetration-tests/dto/upsert-finding-context.dto.ts create mode 100644 apps/api/src/security-penetration-tests/finding-context.util.spec.ts create mode 100644 apps/api/src/security-penetration-tests/finding-context.util.ts create mode 100644 apps/api/src/security-penetration-tests/pentest-finding-contexts.controller.spec.ts create mode 100644 apps/api/src/security-penetration-tests/pentest-finding-contexts.controller.ts create mode 100644 apps/api/src/security-penetration-tests/pentest-finding-contexts.service.spec.ts create mode 100644 apps/api/src/security-penetration-tests/pentest-finding-contexts.service.ts create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingContextSection.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingContextSection.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunContextField.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-pentest-finding-contexts.ts create mode 100644 packages/db/prisma/migrations/20260611174916_add_pentest_finding_context/migration.sql create mode 100644 packages/db/prisma/schema/security-penetration-test-finding-context.prisma diff --git a/apps/api/src/security-penetration-tests/dto/create-penetration-test.dto.ts b/apps/api/src/security-penetration-tests/dto/create-penetration-test.dto.ts index c85be302ac..ff21278567 100644 --- a/apps/api/src/security-penetration-tests/dto/create-penetration-test.dto.ts +++ b/apps/api/src/security-penetration-tests/dto/create-penetration-test.dto.ts @@ -4,7 +4,9 @@ import { IsBoolean, IsEnum, IsOptional, + IsString, IsUrl, + MaxLength, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; @@ -107,4 +109,15 @@ export class CreatePenetrationTestDto { @ArrayUnique() @IsEnum(pentestCheckValues, { each: true }) checks?: PentestCheck[]; + + @ApiPropertyOptional({ + description: + 'Free-text context shared with the testing agent, e.g. remediation notes or accepted-by-design explanations from a previous run. Saved per-finding context notes for the same target are appended automatically. Max 4000 characters.', + required: false, + maxLength: 4000, + }) + @IsOptional() + @IsString() + @MaxLength(4000) + additionalContext?: string; } diff --git a/apps/api/src/security-penetration-tests/dto/upsert-finding-context.dto.ts b/apps/api/src/security-penetration-tests/dto/upsert-finding-context.dto.ts new file mode 100644 index 0000000000..c6c4571e21 --- /dev/null +++ b/apps/api/src/security-penetration-tests/dto/upsert-finding-context.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class UpsertFindingContextDto { + @ApiProperty({ + description: 'Penetration test run ID the finding belongs to', + example: 'pentest-abc123', + }) + @IsString() + @IsNotEmpty() + runId!: string; + + @ApiProperty({ + description: + 'Context for the finding, e.g. an accepted-by-design rationale or remediation details. Shared with the testing agent on future scans of the same target. Max 2000 characters.', + example: + 'Read access to appConfiguration is accepted by design: the collection only holds non-secret bootstrap configuration and write access is restricted to privileged users.', + maxLength: 2000, + }) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + context!: string; +} diff --git a/apps/api/src/security-penetration-tests/finding-context.util.spec.ts b/apps/api/src/security-penetration-tests/finding-context.util.spec.ts new file mode 100644 index 0000000000..f6d680390f --- /dev/null +++ b/apps/api/src/security-penetration-tests/finding-context.util.spec.ts @@ -0,0 +1,83 @@ +import { + buildAdditionalContext, + normalizeTargetUrl, +} from './finding-context.util'; + +describe('normalizeTargetUrl', () => { + it('lowercases the host and strips trailing slashes', () => { + expect(normalizeTargetUrl('https://App.Example.com/')).toBe( + 'https://app.example.com', + ); + }); + + it('strips trailing slashes from paths but keeps the path itself', () => { + expect(normalizeTargetUrl('https://app.example.com/portal/')).toBe( + 'https://app.example.com/portal', + ); + }); + + it('drops URL fragments', () => { + expect(normalizeTargetUrl('https://app.example.com/#section')).toBe( + 'https://app.example.com', + ); + }); + + it('keeps query strings', () => { + expect(normalizeTargetUrl('https://app.example.com/?env=staging')).toBe( + 'https://app.example.com/?env=staging', + ); + }); + + it('returns non-URL input trimmed', () => { + expect(normalizeTargetUrl(' not a url ')).toBe('not a url'); + }); +}); + +describe('buildAdditionalContext', () => { + it('returns undefined when there is nothing to send', () => { + expect( + buildAdditionalContext({ findingContexts: [] }), + ).toBeUndefined(); + expect( + buildAdditionalContext({ + userProvidedContext: ' ', + findingContexts: [], + }), + ).toBeUndefined(); + }); + + it('returns only the user-provided context when there are no notes', () => { + expect( + buildAdditionalContext({ + userProvidedContext: ' Focus on auth flows. ', + findingContexts: [], + }), + ).toBe('Focus on auth flows.'); + }); + + it('formats stored notes as a numbered list with titles', () => { + const result = buildAdditionalContext({ + findingContexts: [ + { issueTitle: 'appConfiguration read access', context: 'By design.' }, + { issueTitle: 'Storage attachment access', context: 'Hardened.' }, + ], + }); + + expect(result).toContain('1. "appConfiguration read access": By design.'); + expect(result).toContain('2. "Storage attachment access": Hardened.'); + expect(result).toContain('Customer-provided context'); + }); + + it('places the user context before the stored notes', () => { + const result = buildAdditionalContext({ + userProvidedContext: 'Retest of the May findings.', + findingContexts: [{ issueTitle: 'Issue A', context: 'Fixed.' }], + }); + + expect(result).toBeDefined(); + const userIndex = (result as string).indexOf('Retest of the May findings.'); + const notesIndex = (result as string).indexOf('1. "Issue A": Fixed.'); + expect(userIndex).toBeGreaterThanOrEqual(0); + expect(notesIndex).toBeGreaterThan(userIndex); + }); +}); diff --git a/apps/api/src/security-penetration-tests/finding-context.util.ts b/apps/api/src/security-penetration-tests/finding-context.util.ts new file mode 100644 index 0000000000..c8f42737b9 --- /dev/null +++ b/apps/api/src/security-penetration-tests/finding-context.util.ts @@ -0,0 +1,63 @@ +/** + * Pure helpers for pentest finding-context notes. Kept free of Nest/DB + * imports so both the storage service and the run-creation path can use + * them, and so they unit-test without mocks. + */ + +export interface FindingContextNote { + issueTitle: string; + context: string; +} + +/** + * Canonical form of a scan target so notes written on a run match future + * runs of the same target regardless of casing or a trailing slash + * (`https://App.example.com/` ≡ `https://app.example.com`). Non-URL input + * is returned trimmed — the create DTO already enforces a valid URL. + */ +export function normalizeTargetUrl(value: string): string { + const trimmed = value.trim(); + try { + const url = new URL(trimmed); + url.hash = ''; + let normalized = url.toString(); + while (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized; + } catch { + return trimmed; + } +} + +/** + * Composes the `additionalContext` string sent to the pentest provider on + * run creation: the user's free-text context for this run (if any) followed + * by the stored per-finding notes from previous scans of the same target. + * Returns undefined when there is nothing to send. + */ +export function buildAdditionalContext(params: { + userProvidedContext?: string; + findingContexts: FindingContextNote[]; +}): string | undefined { + const sections: string[] = []; + + const userProvided = params.userProvidedContext?.trim(); + if (userProvided) { + sections.push(userProvided); + } + + if (params.findingContexts.length > 0) { + const header = + 'Customer-provided context on findings reported in previous scans of this target. ' + + 'Take it into account when validating and reporting findings (e.g. behavior that is ' + + 'accepted by design, or issues the customer has since remediated):'; + const lines = params.findingContexts.map( + (note, index) => + `${index + 1}. "${note.issueTitle.trim()}": ${note.context.trim()}`, + ); + sections.push([header, ...lines].join('\n')); + } + + return sections.length > 0 ? sections.join('\n\n') : undefined; +} diff --git a/apps/api/src/security-penetration-tests/pentest-finding-contexts.controller.spec.ts b/apps/api/src/security-penetration-tests/pentest-finding-contexts.controller.spec.ts new file mode 100644 index 0000000000..d9c3664b75 --- /dev/null +++ b/apps/api/src/security-penetration-tests/pentest-finding-contexts.controller.spec.ts @@ -0,0 +1,109 @@ +// The controller's runtime import chain reaches `@db`, which instantiates +// a Prisma client at module load. These tests never touch the database. +jest.mock('@db', () => ({ db: {} })); + +jest.mock('../auth/hybrid-auth.guard', () => ({ + HybridAuthGuard: class { + canActivate() { + return true; + } + }, +})); + +// The real PermissionGuard transitively imports better-auth's ESM bundle, +// which Jest cannot parse. The mock must keep PERMISSIONS_KEY's real value +// ('required_permissions') because @RequirePermission writes its metadata +// under it at class-definition time. +jest.mock('../auth/permission.guard', () => ({ + PermissionGuard: class { + canActivate() { + return true; + } + }, + PERMISSIONS_KEY: 'required_permissions', +})); + +import { PERMISSIONS_KEY } from '../auth/permission.guard'; +import type { RequiredPermission } from '../auth/permission.guard'; +import { PentestFindingContextsController } from './pentest-finding-contexts.controller'; +import type { PentestFindingContextsService } from './pentest-finding-contexts.service'; + +describe('PentestFindingContextsController', () => { + const listForTargetMock = jest.fn(); + const upsertMock = jest.fn(); + const removeMock = jest.fn(); + + const serviceMock = { + listForTarget: listForTargetMock, + upsert: upsertMock, + remove: removeMock, + } as unknown as jest.Mocked; + + const controller = new PentestFindingContextsController(serviceMock); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('lists context notes for a target', async () => { + const expected = [{ id: 'ptfc_1', providerIssueId: 'issue_1' }]; + listForTargetMock.mockResolvedValueOnce(expected); + + const response = await controller.list( + 'org_123', + 'https://app.example.com', + ); + + expect(listForTargetMock).toHaveBeenCalledWith( + 'org_123', + 'https://app.example.com', + ); + expect(response).toEqual(expected); + }); + + it('upserts a context note for a finding', async () => { + const expected = { id: 'ptfc_1', providerIssueId: 'issue_1' }; + upsertMock.mockResolvedValueOnce(expected); + + const response = await controller.upsert('org_123', 'issue_1', { + runId: 'run_123', + context: 'Accepted by design.', + }); + + expect(upsertMock).toHaveBeenCalledWith({ + organizationId: 'org_123', + issueId: 'issue_1', + runId: 'run_123', + context: 'Accepted by design.', + }); + expect(response).toEqual(expected); + }); + + it('removes a context note for a finding', async () => { + removeMock.mockResolvedValueOnce({ deleted: true }); + + const response = await controller.remove('org_123', 'issue_1'); + + expect(removeMock).toHaveBeenCalledWith({ + organizationId: 'org_123', + issueId: 'issue_1', + }); + expect(response).toEqual({ deleted: true }); + }); + + it('requires pentest permissions on every endpoint', () => { + const requirements = (['list', 'upsert', 'remove'] as const).map( + (method) => + Reflect.getMetadata( + PERMISSIONS_KEY, + PentestFindingContextsController.prototype[method], + ) as RequiredPermission[] | undefined, + ); + + expect(requirements).toEqual([ + [{ resource: 'pentest', actions: ['read'] }], + [{ resource: 'pentest', actions: ['update'] }], + [{ resource: 'pentest', actions: ['update'] }], + ]); + }); +}); diff --git a/apps/api/src/security-penetration-tests/pentest-finding-contexts.controller.ts b/apps/api/src/security-penetration-tests/pentest-finding-contexts.controller.ts new file mode 100644 index 0000000000..da9395caf4 --- /dev/null +++ b/apps/api/src/security-penetration-tests/pentest-finding-contexts.controller.ts @@ -0,0 +1,102 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBody, + ApiExtension, + ApiHeader, + ApiOperation, + ApiQuery, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { OrganizationId } from '../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; +import { UpsertFindingContextDto } from './dto/upsert-finding-context.dto'; +import { PentestFindingContextsService } from './pentest-finding-contexts.service'; + +@ApiTags('Security Penetration Tests') +@Controller({ path: 'pentest-finding-contexts', version: '1' }) +@ApiSecurity('apikey') +@ApiHeader({ + name: 'X-Organization-Id', + description: + 'Organization ID (required for session auth, optional for API key auth)', + required: false, +}) +@UseGuards(HybridAuthGuard, PermissionGuard) +export class PentestFindingContextsController { + constructor(private readonly service: PentestFindingContextsService) {} + + @Get() + @RequirePermission('pentest', 'read') + @ApiOperation({ + summary: 'List pentest finding context notes', + description: + 'Returns the customer-written context notes attached to pentest findings for a target URL. These notes are shared with the testing agent on future scans of the same target so retests are informed, not blind.', + }) + @ApiQuery({ + name: 'targetUrl', + required: true, + description: 'Target URL the notes are attached to', + }) + @ApiResponse({ status: 200, description: 'Context notes returned' }) + @ApiExtension('x-speakeasy-mcp', { name: 'list-pentest-finding-contexts' }) + async list( + @OrganizationId() organizationId: string, + @Query('targetUrl') targetUrl?: string, + ) { + return this.service.listForTarget(organizationId, targetUrl); + } + + @Put(':issueId') + @RequirePermission('pentest', 'update') + @ApiOperation({ + summary: 'Add context to a pentest finding', + description: + 'Saves a customer context note on a pentest finding, e.g. an accepted-by-design rationale or remediation details. Future scans of the same target pass the note to the testing agent so the issue is retested with that context.', + }) + @ApiBody({ type: UpsertFindingContextDto }) + @ApiResponse({ status: 200, description: 'Context note saved' }) + @ApiResponse({ status: 404, description: 'Run or finding not found' }) + @ApiExtension('x-speakeasy-mcp', { name: 'set-pentest-finding-context' }) + async upsert( + @OrganizationId() organizationId: string, + @Param('issueId') issueId: string, + @Body() body: UpsertFindingContextDto, + ) { + return this.service.upsert({ + organizationId, + issueId, + runId: body.runId, + context: body.context, + }); + } + + @Delete(':issueId') + @RequirePermission('pentest', 'update') + @ApiOperation({ + summary: 'Remove context from a pentest finding', + description: + 'Deletes the customer context note attached to a pentest finding so future scans of the target no longer receive it from the testing agent briefing.', + }) + @ApiResponse({ status: 200, description: 'Context note deleted' }) + @ApiResponse({ status: 404, description: 'No context found for finding' }) + @ApiExtension('x-speakeasy-mcp', { name: 'delete-pentest-finding-context' }) + async remove( + @OrganizationId() organizationId: string, + @Param('issueId') issueId: string, + ) { + return this.service.remove({ organizationId, issueId }); + } +} diff --git a/apps/api/src/security-penetration-tests/pentest-finding-contexts.service.spec.ts b/apps/api/src/security-penetration-tests/pentest-finding-contexts.service.spec.ts new file mode 100644 index 0000000000..f9faa0d8e1 --- /dev/null +++ b/apps/api/src/security-penetration-tests/pentest-finding-contexts.service.spec.ts @@ -0,0 +1,222 @@ +import { + BadRequestException, + HttpException, + HttpStatus, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import type { Issue } from '@maced/api-client'; +import { PentestFindingContextsService } from './pentest-finding-contexts.service'; +import type { SecurityPenetrationTestsService } from './security-penetration-tests.service'; + +jest.mock('@db', () => ({ + db: { + securityPenetrationTestFindingContext: { + findMany: jest.fn(), + upsert: jest.fn(), + deleteMany: jest.fn(), + }, + }, +})); + +type MockDb = { + securityPenetrationTestFindingContext: { + findMany: jest.Mock; + upsert: jest.Mock; + deleteMany: jest.Mock; + }; +}; + +const mockPenetrationTests: jest.Mocked< + Pick +> = { + getReport: jest.fn(), + getReportIssues: jest.fn(), +}; + +describe('PentestFindingContextsService', () => { + const mockedDb = db as unknown as MockDb; + let service: PentestFindingContextsService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new PentestFindingContextsService( + mockPenetrationTests as unknown as SecurityPenetrationTestsService, + ); + mockPenetrationTests.getReport.mockResolvedValue({ + id: 'run_123', + targetUrl: 'https://App.example.com/', + status: 'completed', + createdAt: '2026-06-01T00:00:00.000Z', + updatedAt: '2026-06-01T00:00:00.000Z', + }); + const issue: Issue = { + id: 'issue_1', + runId: 'run_123', + findingId: null, + title: 'appConfiguration read access', + summary: null, + severity: 'medium', + status: 'open', + cve: null, + cweId: null, + cvssScore: null, + affectedEndpoint: null, + proofOfConcept: null, + impact: null, + remediation: null, + validationSteps: null, + evidence: null, + attackPath: null, + createdAt: '2026-06-01T00:00:00.000Z', + updatedAt: '2026-06-01T00:00:00.000Z', + }; + mockPenetrationTests.getReportIssues.mockResolvedValue([issue]); + }); + + describe('listForTarget', () => { + it('rejects a missing targetUrl', async () => { + await expect(service.listForTarget('org_123', undefined)).rejects.toThrow( + BadRequestException, + ); + await expect(service.listForTarget('org_123', ' ')).rejects.toThrow( + BadRequestException, + ); + expect( + mockedDb.securityPenetrationTestFindingContext.findMany, + ).not.toHaveBeenCalled(); + }); + + it('queries by org and normalized target URL', async () => { + mockedDb.securityPenetrationTestFindingContext.findMany.mockResolvedValue( + [], + ); + + await service.listForTarget('org_123', 'https://App.example.com/'); + + expect( + mockedDb.securityPenetrationTestFindingContext.findMany, + ).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + organizationId: 'org_123', + targetUrl: 'https://app.example.com', + }, + }), + ); + }); + }); + + describe('upsert', () => { + const params = { + organizationId: 'org_123', + issueId: 'issue_1', + runId: 'run_123', + context: ' Accepted by design. ', + }; + + it('rejects whitespace-only context before touching the provider', async () => { + await expect( + service.upsert({ ...params, context: ' ' }), + ).rejects.toThrow(BadRequestException); + expect(mockPenetrationTests.getReport).not.toHaveBeenCalled(); + }); + + it('propagates ownership errors from getReport (cross-org run)', async () => { + mockPenetrationTests.getReport.mockRejectedValue( + new HttpException({ error: 'Report not found' }, HttpStatus.NOT_FOUND), + ); + + await expect(service.upsert(params)).rejects.toMatchObject({ + status: HttpStatus.NOT_FOUND, + }); + expect( + mockedDb.securityPenetrationTestFindingContext.upsert, + ).not.toHaveBeenCalled(); + }); + + it('rejects when the run has no target URL', async () => { + mockPenetrationTests.getReport.mockResolvedValue({ + id: 'run_123', + targetUrl: '', + status: 'completed', + createdAt: '', + updatedAt: '', + }); + + await expect(service.upsert(params)).rejects.toMatchObject({ + status: HttpStatus.BAD_GATEWAY, + }); + }); + + it('rejects when the issue is not part of the run', async () => { + await expect( + service.upsert({ ...params, issueId: 'issue_unknown' }), + ).rejects.toThrow(NotFoundException); + expect( + mockedDb.securityPenetrationTestFindingContext.upsert, + ).not.toHaveBeenCalled(); + }); + + it('upserts the trimmed note keyed by org + issue with a normalized target and title snapshot', async () => { + mockedDb.securityPenetrationTestFindingContext.upsert.mockResolvedValue({ + id: 'ptfc_1', + }); + + await service.upsert(params); + + const expectedShared = { + providerRunId: 'run_123', + targetUrl: 'https://app.example.com', + issueTitle: 'appConfiguration read access', + context: 'Accepted by design.', + }; + expect( + mockedDb.securityPenetrationTestFindingContext.upsert, + ).toHaveBeenCalledWith({ + where: { + organizationId_providerIssueId: { + organizationId: 'org_123', + providerIssueId: 'issue_1', + }, + }, + create: { + organizationId: 'org_123', + providerIssueId: 'issue_1', + ...expectedShared, + }, + update: expectedShared, + }); + }); + }); + + describe('remove', () => { + it('throws NotFound when no note exists', async () => { + mockedDb.securityPenetrationTestFindingContext.deleteMany.mockResolvedValue( + { count: 0 }, + ); + + await expect( + service.remove({ organizationId: 'org_123', issueId: 'issue_1' }), + ).rejects.toThrow(NotFoundException); + }); + + it('deletes the note scoped to the organization', async () => { + mockedDb.securityPenetrationTestFindingContext.deleteMany.mockResolvedValue( + { count: 1 }, + ); + + const result = await service.remove({ + organizationId: 'org_123', + issueId: 'issue_1', + }); + + expect( + mockedDb.securityPenetrationTestFindingContext.deleteMany, + ).toHaveBeenCalledWith({ + where: { organizationId: 'org_123', providerIssueId: 'issue_1' }, + }); + expect(result).toEqual({ deleted: true }); + }); + }); +}); diff --git a/apps/api/src/security-penetration-tests/pentest-finding-contexts.service.ts b/apps/api/src/security-penetration-tests/pentest-finding-contexts.service.ts new file mode 100644 index 0000000000..aff69bfc80 --- /dev/null +++ b/apps/api/src/security-penetration-tests/pentest-finding-contexts.service.ts @@ -0,0 +1,118 @@ +import { + BadRequestException, + HttpException, + HttpStatus, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import type { SecurityPenetrationTestFindingContext } from '@db'; +import { normalizeTargetUrl } from './finding-context.util'; +import { SecurityPenetrationTestsService } from './security-penetration-tests.service'; + +/** + * Storage + validation for customer context notes on pentest findings. + * + * Notes are keyed by the provider's issue id and grouped by normalized + * target URL. `SecurityPenetrationTestsService.createReport` aggregates + * the notes for a target into the provider's `additionalContext` field so + * retests run informed instead of blind. + */ +@Injectable() +export class PentestFindingContextsService { + constructor( + private readonly penetrationTests: SecurityPenetrationTestsService, + ) {} + + async listForTarget( + organizationId: string, + targetUrl: string | undefined, + ): Promise { + if (!targetUrl?.trim()) { + throw new BadRequestException( + 'targetUrl query parameter is required', + ); + } + + return db.securityPenetrationTestFindingContext.findMany({ + where: { organizationId, targetUrl: normalizeTargetUrl(targetUrl) }, + orderBy: { createdAt: 'asc' }, + }); + } + + async upsert(params: { + organizationId: string; + issueId: string; + runId: string; + context: string; + }): Promise { + const context = params.context.trim(); + if (!context) { + throw new BadRequestException('context must not be empty'); + } + + // getReport asserts run ownership (404 on other orgs' runs) and gives + // us the authoritative target URL — never trust a client-supplied one. + const run = await this.penetrationTests.getReport( + params.organizationId, + params.runId, + ); + if (!run.targetUrl) { + throw new HttpException( + { error: 'Run target URL unavailable from provider' }, + HttpStatus.BAD_GATEWAY, + ); + } + + const issues = await this.penetrationTests.getReportIssues( + params.organizationId, + params.runId, + ); + const issue = issues.find(({ id }) => id === params.issueId); + if (!issue) { + throw new NotFoundException( + 'Finding not found in this penetration test run', + ); + } + + const shared = { + providerRunId: params.runId, + targetUrl: normalizeTargetUrl(run.targetUrl), + issueTitle: issue.title, + context, + }; + + return db.securityPenetrationTestFindingContext.upsert({ + where: { + organizationId_providerIssueId: { + organizationId: params.organizationId, + providerIssueId: params.issueId, + }, + }, + create: { + organizationId: params.organizationId, + providerIssueId: params.issueId, + ...shared, + }, + update: shared, + }); + } + + async remove(params: { + organizationId: string; + issueId: string; + }): Promise<{ deleted: true }> { + const result = await db.securityPenetrationTestFindingContext.deleteMany({ + where: { + organizationId: params.organizationId, + providerIssueId: params.issueId, + }, + }); + + if (result.count === 0) { + throw new NotFoundException('No context found for this finding'); + } + + return { deleted: true }; + } +} diff --git a/apps/api/src/security-penetration-tests/security-penetration-tests.billing.spec.ts b/apps/api/src/security-penetration-tests/security-penetration-tests.billing.spec.ts index 830150233d..83153e7ccf 100644 --- a/apps/api/src/security-penetration-tests/security-penetration-tests.billing.spec.ts +++ b/apps/api/src/security-penetration-tests/security-penetration-tests.billing.spec.ts @@ -34,6 +34,9 @@ jest.mock('@db', () => ({ updateMany: jest.fn(), findUnique: jest.fn(), }, + securityPenetrationTestFindingContext: { + findMany: jest.fn(), + }, secret: { upsert: jest.fn(), }, @@ -47,6 +50,9 @@ type MockDb = { updateMany: jest.Mock; findUnique: jest.Mock; }; + securityPenetrationTestFindingContext: { + findMany: jest.Mock; + }; secret: { upsert: jest.Mock; }; @@ -102,6 +108,10 @@ describe('SecurityPenetrationTestsService billing usage', () => { mockedDb.securityPenetrationTestRun.updateMany.mockResolvedValue({ count: 1, }); + // Default: no stored finding-context notes for the target. + mockedDb.securityPenetrationTestFindingContext.findMany.mockResolvedValue( + [], + ); mockedDb.$transaction.mockImplementation( (callback: (tx: MockDb) => Promise) => callback(mockedDb), ); diff --git a/apps/api/src/security-penetration-tests/security-penetration-tests.controller.spec.ts b/apps/api/src/security-penetration-tests/security-penetration-tests.controller.spec.ts index cad7e6fff5..96e5fd2255 100644 --- a/apps/api/src/security-penetration-tests/security-penetration-tests.controller.spec.ts +++ b/apps/api/src/security-penetration-tests/security-penetration-tests.controller.spec.ts @@ -1,3 +1,7 @@ +// The controller's runtime import chain reaches `@db`, which instantiates +// a Prisma client at module load. These tests never touch the database. +jest.mock('@db', () => ({ db: {} })); + jest.mock('../auth/hybrid-auth.guard', () => ({ HybridAuthGuard: class { canActivate() { @@ -6,6 +10,18 @@ jest.mock('../auth/hybrid-auth.guard', () => ({ }, })); +// The real PermissionGuard transitively imports better-auth's ESM bundle, +// which Jest cannot parse. PERMISSIONS_KEY keeps its real value so +// @RequirePermission metadata stays intact for any metadata assertions. +jest.mock('../auth/permission.guard', () => ({ + PermissionGuard: class { + canActivate() { + return true; + } + }, + PERMISSIONS_KEY: 'required_permissions', +})); + import { SecurityPenetrationTestsController } from './security-penetration-tests.controller'; import type { SecurityPenetrationTestsService } from './security-penetration-tests.service'; import type { Request as ExpressRequest } from 'express'; diff --git a/apps/api/src/security-penetration-tests/security-penetration-tests.module.ts b/apps/api/src/security-penetration-tests/security-penetration-tests.module.ts index 1f648b8f34..6140e3fc9b 100644 --- a/apps/api/src/security-penetration-tests/security-penetration-tests.module.ts +++ b/apps/api/src/security-penetration-tests/security-penetration-tests.module.ts @@ -3,13 +3,23 @@ import { AuthModule } from '../auth/auth.module'; import { BillingModule } from '../billing/billing.module'; import { PentestCreditsController } from './pentest-credits.controller'; import { PentestCreditsService } from './pentest-credits.service'; +import { PentestFindingContextsController } from './pentest-finding-contexts.controller'; +import { PentestFindingContextsService } from './pentest-finding-contexts.service'; import { SecurityPenetrationTestsController } from './security-penetration-tests.controller'; import { SecurityPenetrationTestsService } from './security-penetration-tests.service'; @Module({ imports: [AuthModule, BillingModule], - controllers: [SecurityPenetrationTestsController, PentestCreditsController], - providers: [SecurityPenetrationTestsService, PentestCreditsService], + controllers: [ + SecurityPenetrationTestsController, + PentestCreditsController, + PentestFindingContextsController, + ], + providers: [ + SecurityPenetrationTestsService, + PentestCreditsService, + PentestFindingContextsService, + ], exports: [PentestCreditsService], }) export class SecurityPenetrationTestsModule {} diff --git a/apps/api/src/security-penetration-tests/security-penetration-tests.service.spec.ts b/apps/api/src/security-penetration-tests/security-penetration-tests.service.spec.ts index b3228195b9..aed06e563d 100644 --- a/apps/api/src/security-penetration-tests/security-penetration-tests.service.spec.ts +++ b/apps/api/src/security-penetration-tests/security-penetration-tests.service.spec.ts @@ -45,6 +45,9 @@ jest.mock('@db', () => ({ findUnique: jest.fn(), findMany: jest.fn(), }, + securityPenetrationTestFindingContext: { + findMany: jest.fn(), + }, secret: { upsert: jest.fn(), findUnique: jest.fn(), @@ -65,6 +68,9 @@ type MockDb = { findUnique: jest.Mock; findMany: jest.Mock; }; + securityPenetrationTestFindingContext: { + findMany: jest.Mock; + }; secret: { upsert: jest.Mock; findUnique: jest.Mock; @@ -139,6 +145,10 @@ describe('SecurityPenetrationTestsService', () => { mockedDb.securityPenetrationTestRun.findMany.mockResolvedValue([ { providerRunId: 'run_123' }, ]); + // Default: no stored finding-context notes for the target. + mockedDb.securityPenetrationTestFindingContext.findMany.mockResolvedValue( + [], + ); mockedDb.secret.upsert.mockResolvedValue({}); mockedDb.secret.findUnique.mockResolvedValue({ id: 'sec_default', @@ -233,6 +243,86 @@ describe('SecurityPenetrationTestsService', () => { expect(mockedDb.securityPenetrationTestRun.upsert).toHaveBeenCalledTimes(1); }); + it('omits additionalContext when there is no context to send', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'run_456', status: 'provisioning' }), { + status: 200, + }), + ); + + await service.createReport('org_123', { + targetUrl: 'https://app.example.com', + }); + + const requestBody = await getRequestBody(); + expect(requestBody).not.toHaveProperty('additionalContext'); + }); + + it('passes user-provided additional context to the provider', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'run_456', status: 'provisioning' }), { + status: 200, + }), + ); + + await service.createReport('org_123', { + targetUrl: 'https://app.example.com', + additionalContext: 'We deployed fixes for the auth findings last week.', + }); + + const requestBody = await getRequestBody(); + expect(requestBody.additionalContext).toBe( + 'We deployed fixes for the auth findings last week.', + ); + }); + + it('appends stored finding-context notes for the normalized target to additionalContext', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'run_456', status: 'provisioning' }), { + status: 200, + }), + ); + mockedDb.securityPenetrationTestFindingContext.findMany.mockResolvedValue([ + { + issueTitle: 'appConfiguration read access', + context: + 'Accepted by design — collection holds non-secret bootstrap config.', + }, + { + issueTitle: 'Unverified email access', + context: 'Email verification is now enabled in the tested environment.', + }, + ]); + + await service.createReport('org_123', { + // Mixed-case host + trailing slash — the stored-notes lookup must + // normalize before matching rows keyed by canonical target URL. + targetUrl: 'https://App.example.com/', + additionalContext: 'Focus on the three previously reported findings.', + }); + + expect( + mockedDb.securityPenetrationTestFindingContext.findMany, + ).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + organizationId: 'org_123', + targetUrl: 'https://app.example.com', + }, + }), + ); + + const requestBody = await getRequestBody(); + const additionalContext = requestBody.additionalContext as string; + expect(additionalContext).toContain( + 'Focus on the three previously reported findings.', + ); + expect(additionalContext).toContain('"appConfiguration read access"'); + expect(additionalContext).toContain( + 'Email verification is now enabled in the tested environment.', + ); + }); + it('accepts valid scan profile fields in the create DTO', async () => { const dto = Object.assign(new CreatePenetrationTestDto(), { targetUrl: 'https://app.example.com', diff --git a/apps/api/src/security-penetration-tests/security-penetration-tests.service.ts b/apps/api/src/security-penetration-tests/security-penetration-tests.service.ts index 7a85337663..920a651391 100644 --- a/apps/api/src/security-penetration-tests/security-penetration-tests.service.ts +++ b/apps/api/src/security-penetration-tests/security-penetration-tests.service.ts @@ -33,6 +33,10 @@ import { type ScanDepth, } from './dto/create-penetration-test.dto'; import { BillingEntitlementsService } from '../billing/billing-entitlements.service'; +import { + buildAdditionalContext, + normalizeTargetUrl, +} from './finding-context.util'; import { PentestCreditsService } from './pentest-credits.service'; /** @@ -243,6 +247,12 @@ export class SecurityPenetrationTestsService { payload: CreatePenetrationTestDto, ): Promise { const resolvedWebhookUrl = this.resolveWebhookUrl(payload.webhookUrl); + // Resolved before the billing reservation so a DB failure here can't + // leave a debited allowance behind. + const additionalContext = await this.resolveAdditionalContext( + organizationId, + payload, + ); const billingUsageSourceId = `pending:${randomUUID()}`; let consumedSubscriptionAllowance = false; @@ -322,6 +332,7 @@ export class SecurityPenetrationTestsService { ? { evidenceLevel: payload.evidenceLevel } : {}), ...(payload.checks ? { checks: payload.checks } : {}), + ...(additionalContext ? { additionalContext } : {}), // Attribution metadata — Maced persists this verbatim and returns it on // list/get. Gives us a second source of truth for the org↔run mapping // (our `security_penetration_test_runs` table is the primary one) so @@ -439,6 +450,33 @@ export class SecurityPenetrationTestsService { }; } + /** + * Composes the free-text briefing sent to the testing agent: the + * caller's own `additionalContext` (if any) plus the per-finding + * context notes customers saved on findings from previous scans of the + * same target (see PentestFindingContextsService). This is what makes a + * re-run an informed retest instead of a blind one. + */ + private async resolveAdditionalContext( + organizationId: string, + payload: CreatePenetrationTestDto, + ): Promise { + const findingContexts = + await db.securityPenetrationTestFindingContext.findMany({ + where: { + organizationId, + targetUrl: normalizeTargetUrl(payload.targetUrl), + }, + orderBy: { createdAt: 'asc' }, + select: { issueTitle: true, context: true }, + }); + + return buildAdditionalContext({ + userProvidedContext: payload.additionalContext, + findingContexts, + }); + } + async getReport( organizationId: string, id: string, diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.test.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.test.tsx index cb92aa75aa..c809784e7b 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.test.tsx @@ -16,6 +16,24 @@ vi.mock('sonner', () => ({ toast: { error: vi.fn() }, })); +// RunContextField fetches saved finding-context notes over SWR; tests must +// not hit the network. Override `notes` per test before rendering. +const findingContextsMock = vi.hoisted(() => ({ + notes: [] as Array<{ id: string; providerIssueId: string }>, +})); + +vi.mock('../hooks/use-pentest-finding-contexts', () => ({ + usePentestFindingContexts: () => ({ + contexts: findingContextsMock.notes, + contextByIssueId: new Map(), + isLoading: false, + error: undefined, + isSaving: false, + saveContext: vi.fn(), + removeContext: vi.fn(), + }), +})); + async function confirmAuthorization(user: ReturnType) { await user.click( screen.getByText('I own this target or have written authorization to test it.'), @@ -29,6 +47,65 @@ function checkInput(label: RegExp) { describe('CreateRunPanel', () => { beforeEach(() => { vi.clearAllMocks(); + findingContextsMock.notes = []; + }); + + it('includes trimmed additional context in the submit payload', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(async () => ({ id: 'run_ctx' })); + + render(); + + await user.type(screen.getByLabelText(/target url/i), 'app.example.com'); + await user.type( + screen.getByLabelText(/context for the agent/i), + ' We remediated the auth findings last week. ', + ); + await confirmAuthorization(user); + await user.click(screen.getByRole('button', { name: /start scan/i })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + additionalContext: 'We remediated the auth findings last week.', + }), + ); + }); + }); + + it('omits additionalContext when the context field is left empty', async () => { + const user = userEvent.setup(); + let submittedPayload: PentestCreateRequest | undefined; + const onSubmit = vi.fn(async (payload: PentestCreateRequest) => { + submittedPayload = payload; + return { id: 'run_no_ctx' }; + }); + + render(); + + await user.type(screen.getByLabelText(/target url/i), 'app.example.com'); + await confirmAuthorization(user); + await user.click(screen.getByRole('button', { name: /start scan/i })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled(); + }); + expect(submittedPayload).not.toHaveProperty('additionalContext'); + }); + + it('tells the user how many saved finding notes will be shared automatically', () => { + findingContextsMock.notes = [ + { id: 'ptfc_1', providerIssueId: 'issue_1' }, + { id: 'ptfc_2', providerIssueId: 'issue_2' }, + ]; + + render(); + + expect( + screen.getByText( + /2 saved finding context notes for this target will be shared with the agent automatically/i, + ), + ).toBeInTheDocument(); }); it('routes users without allowance to billing even when required fields are empty', async () => { diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.tsx index cbfbdbdd9a..21822c234a 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.tsx @@ -22,6 +22,7 @@ import { toast } from 'sonner'; import { z } from 'zod'; import { AuthorizationConsentField } from './AuthorizationConsentField'; import { CreateRunTargetFields } from './CreateRunTargetFields'; +import { RunContextField } from './RunContextField'; import { RunExpectationSummary } from './RunExpectationSummary'; import { ScanAdvancedOptions } from './ScanAdvancedOptions'; import { ScanProfilePicker } from './ScanProfilePicker'; @@ -71,6 +72,10 @@ const createRunSchema = z.object({ .refine((value) => value === true, { message: 'Confirm you own or are authorized to test this target.', }), + additionalContext: z + .string() + .max(4000, 'Keep context under 4000 characters.') + .optional(), }); export type CreateRunForm = z.infer; @@ -98,11 +103,14 @@ export function CreateRunPanel({ evidenceLevel: standardDefaults.evidenceLevel, checks: standardDefaults.checks, authorized: false, + additionalContext: '', }, }); const evidenceLevel = form.watch('evidenceLevel'); const checks = form.watch('checks'); const authorized = form.watch('authorized'); + const targetUrlValue = form.watch('targetUrl'); + const normalizedTargetUrl = useMemo(() => normalizeUrl(targetUrlValue), [targetUrlValue]); const effectiveMode = useMemo( () => resolveEffectiveScanMode({ @@ -180,6 +188,9 @@ export function CreateRunPanel({ scanDepth: submitScanDepth, evidenceLevel: values.evidenceLevel, checks: values.checks, + ...(values.additionalContext?.trim() + ? { additionalContext: values.additionalContext.trim() } + : {}), }); router.push(`/${orgId}/security/penetration-tests/${encodeURIComponent(result.id)}`); } catch { @@ -235,6 +246,8 @@ export function CreateRunPanel({ + + ; + return ( + + ); } if (isLoading && !run) { diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingContextSection.test.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingContextSection.test.tsx new file mode 100644 index 0000000000..e6792dc26b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingContextSection.test.tsx @@ -0,0 +1,146 @@ +import type { + PentestFindingContext, + PentestIssue, +} from '@/lib/security/penetration-tests-client'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FindingContextSection } from './FindingContextSection'; + +const permissionsMock = vi.hoisted(() => ({ + canUpdatePentest: false, +})); + +vi.mock('@/hooks/use-permissions', () => ({ + usePermissions: () => ({ + hasPermission: (resource: string, action: string) => + resource === 'pentest' && + (action === 'read' || + (action === 'update' && permissionsMock.canUpdatePentest)), + }), +})); + +const contextsMock = vi.hoisted(() => ({ + contextByIssueId: new Map(), + saveContext: vi.fn(), + removeContext: vi.fn(), +})); + +vi.mock('../hooks/use-pentest-finding-contexts', () => ({ + usePentestFindingContexts: () => ({ + contexts: [...contextsMock.contextByIssueId.values()], + contextByIssueId: contextsMock.contextByIssueId, + isLoading: false, + error: undefined, + isSaving: false, + saveContext: contextsMock.saveContext, + removeContext: contextsMock.removeContext, + }), +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +const issue: PentestIssue = { + id: 'issue_1', + runId: 'run_1', + title: 'appConfiguration read access', + severity: 'medium', + status: 'open', + createdAt: '2026-06-01T00:00:00.000Z', + updatedAt: '2026-06-01T00:00:00.000Z', +}; + +const savedNote: PentestFindingContext = { + id: 'ptfc_1', + organizationId: 'org_1', + providerIssueId: 'issue_1', + providerRunId: 'run_1', + targetUrl: 'https://app.example.com', + issueTitle: 'appConfiguration read access', + context: 'Accepted by design — non-secret bootstrap config.', + createdAt: '2026-06-01T00:00:00.000Z', + updatedAt: '2026-06-01T00:00:00.000Z', +}; + +function renderSection() { + return render( + , + ); +} + +describe('FindingContextSection', () => { + beforeEach(() => { + vi.clearAllMocks(); + permissionsMock.canUpdatePentest = false; + contextsMock.contextByIssueId = new Map(); + }); + + it('renders nothing for read-only users when there is no saved note', () => { + renderSection(); + + expect(screen.queryByText(/retest context/i)).not.toBeInTheDocument(); + }); + + it('shows the saved note read-only to users without pentest:update', () => { + contextsMock.contextByIssueId = new Map([['issue_1', savedNote]]); + + renderSection(); + + expect(screen.getByText(/retest context/i)).toBeInTheDocument(); + expect( + screen.getByText('Accepted by design — non-secret bootstrap config.'), + ).toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /save context/i }), + ).not.toBeInTheDocument(); + }); + + it('lets pentest:update users save context on a finding', async () => { + permissionsMock.canUpdatePentest = true; + contextsMock.saveContext.mockResolvedValue(undefined); + const user = userEvent.setup(); + + renderSection(); + + await user.type( + screen.getByRole('textbox'), + 'Accepted by design — reads are non-sensitive.', + ); + await user.click(screen.getByRole('button', { name: /save context/i })); + + await waitFor(() => { + expect(contextsMock.saveContext).toHaveBeenCalledWith({ + issueId: 'issue_1', + runId: 'run_1', + context: 'Accepted by design — reads are non-sensitive.', + }); + }); + }); + + it('lets pentest:update users remove a saved note', async () => { + permissionsMock.canUpdatePentest = true; + contextsMock.contextByIssueId = new Map([['issue_1', savedNote]]); + contextsMock.removeContext.mockResolvedValue(undefined); + const user = userEvent.setup(); + + renderSection(); + + expect( + screen.getByRole('button', { name: /update context/i }), + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /^remove$/i })); + + await waitFor(() => { + expect(contextsMock.removeContext).toHaveBeenCalledWith('issue_1'); + }); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingContextSection.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingContextSection.tsx new file mode 100644 index 0000000000..2256577760 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingContextSection.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { usePermissions } from '@/hooks/use-permissions'; +import type { PentestIssue } from '@/lib/security/penetration-tests-client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Textarea } from '@trycompai/design-system'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { usePentestFindingContexts } from '../hooks/use-pentest-finding-contexts'; + +const findingContextSchema = z.object({ + context: z + .string() + .trim() + .min(1, 'Context is required.') + .max(2000, 'Keep context under 2000 characters.'), +}); + +type FindingContextForm = z.infer; + +interface FindingContextSectionProps { + orgId: string; + issue: PentestIssue; + runId?: string | null; + targetUrl?: string | null; +} + +/** + * Customer context note on a single finding ("accepted by design + * because…", "fixed via…"). Saved notes are automatically shared with the + * testing agent on future scans of the same target, so retests validate + * against the customer's explanation instead of blindly re-flagging. + * Editing requires `pentest:update`; read-only roles see the saved note. + */ +export function FindingContextSection({ + orgId, + issue, + runId, + targetUrl, +}: FindingContextSectionProps) { + const { hasPermission } = usePermissions(); + const canEdit = hasPermission('pentest', 'update'); + const { contextByIssueId, isSaving, saveContext, removeContext } = + usePentestFindingContexts(orgId, targetUrl); + const existing = contextByIssueId.get(issue.id); + const resolvedRunId = issue.runId ?? runId ?? null; + + const form = useForm({ + resolver: zodResolver(findingContextSchema), + values: { context: existing?.context ?? '' }, + }); + + if (!targetUrl) return null; + if (!canEdit && !existing) return null; + + const handleSave = form.handleSubmit(async (values) => { + if (!resolvedRunId) return; + try { + await saveContext({ + issueId: issue.id, + runId: resolvedRunId, + context: values.context, + }); + toast.success('Context saved — future scans of this target will include it'); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Unable to save context'); + } + }); + + const handleRemove = async () => { + try { + await removeContext(issue.id); + form.reset({ context: '' }); + toast.success('Context removed'); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Unable to remove context'); + } + }; + + return ( +
+
+ Retest context +
+

+ Explain this finding — e.g. why it's accepted by design or how it was + remediated. Notes are shared with the testing agent on future scans of this + target, so retests take your context into account instead of re-flagging + blindly. +

+ + {canEdit ? ( +
+