From db0e7e7fb5045573d4026f59c807bf7874a2103c Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 27 Apr 2026 10:39:35 -0400 Subject: [PATCH 01/15] =?UTF-8?q?feat(pentest):=20full=20v1=20rebuild=20?= =?UTF-8?q?=E2=80=94=20split-view=20UI,=20SDK=20swap,=20signed=20webhooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend - Split-view shell at /security/penetration-tests with run list + detail pane - New /penetration-tests/new route with inline create form (replaces modal) - 8 detail variants: empty, overview, running, completed, clean, failed, finding, create - Live findings table + agent activity log (polling-based) - Severity tokens (oklch) scoped to .pt-tokens, light/dark mode aware Backend - Swapped homegrown MacedClient → @maced/api-client v0.9.1 - New endpoints: GET /:id/issues, GET /:id/events - Webhook signature verification via SDK's MacedClient.webhooks.constructEvent - Raw-body preservation in main.ts for HMAC verification - Attribution metadata (compOrganizationId/compEnvironment/compApiVersion) sent on every create — disaster-recovery + audit parity - Removed silent GitHub OAuth token forwarding to Maced (privacy) Removed - pentest-billing.{controller,service}.ts + Stripe webhook route (deferred to v2) - Homegrown maced-client.ts + Zod schemas (replaced by SDK types) - Phantom webhook handshake (Maced uses HMAC, not per-run tokens) - listGithubRepos endpoint + getGithubTokenForOrg + CredentialVaultService dep — no more silent OAuth-token forwarding to vendor Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/maced-contract-canary.yml | 36 - apps/api/package.json | 2 +- apps/api/src/main.ts | 15 +- .../src/security-penetration-tests/README.md | 41 +- .../dto/create-penetration-test.dto.ts | 24 - .../maced-client.ts | 290 ------- .../pentest-billing.controller.ts | 117 --- .../pentest-billing.service.ts | 340 --------- ...urity-penetration-tests.controller.spec.ts | 88 +-- .../security-penetration-tests.controller.ts | 124 ++- .../security-penetration-tests.module.ts | 9 +- ...security-penetration-tests.service.spec.ts | 246 +----- .../security-penetration-tests.service.ts | 708 ++++++------------ apps/api/test/maced-contract.e2e-spec.ts | 93 --- apps/app/.env.example | 5 - .../penetration-tests/[reportId]/page.tsx | 8 +- .../penetration-test-page-client.tsx | 231 +----- .../_components/AgentActivityLog.tsx | 106 +++ .../_components/AgentProgressGrid.tsx | 48 ++ .../_components/CompletedDetail.tsx | 126 ++++ .../_components/CreateRunPanel.tsx | 176 +++++ .../_components/DetailPane.tsx | 111 +++ .../_components/EmptyState.tsx | 85 +++ .../_components/FailedDetail.tsx | 71 ++ .../_components/FindingDetail.tsx | 233 ++++++ .../_components/FindingsTable.tsx | 96 +++ .../_components/OverviewPane.tsx | 145 ++++ .../penetration-tests/_components/RunList.tsx | 195 +++++ .../_components/RunningDetail.tsx | 145 ++++ .../penetration-tests/_components/SevChip.tsx | 32 + .../_components/SevTally.tsx | 74 ++ .../_components/SplitView.tsx | 206 +++++ .../_components/StatusPill.tsx | 83 ++ .../_components/pentest-tokens.css | 88 +++ .../penetration-tests/_components/severity.ts | 113 +++ .../hooks/use-penetration-tests.ts | 102 ++- .../new/new-penetration-test-page-client.tsx | 18 + .../security/penetration-tests/new/page.tsx | 42 ++ .../penetration-tests-page-client.tsx | 339 +-------- .../settings/billing/billing-actions.tsx | 69 -- .../(app)/[orgId]/settings/billing/page.tsx | 128 +--- .../app/api/webhooks/stripe-pentest/route.ts | 114 --- apps/app/src/env.mjs | 6 - .../lib/security/penetration-tests-client.ts | 61 +- bun.lock | 7 + tasks/todo.md | 172 +++++ 46 files changed, 2875 insertions(+), 2693 deletions(-) delete mode 100644 .github/workflows/maced-contract-canary.yml delete mode 100644 apps/api/src/security-penetration-tests/maced-client.ts delete mode 100644 apps/api/src/security-penetration-tests/pentest-billing.controller.ts delete mode 100644 apps/api/src/security-penetration-tests/pentest-billing.service.ts delete mode 100644 apps/api/test/maced-contract.e2e-spec.ts create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AgentActivityLog.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AgentProgressGrid.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CompletedDetail.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/DetailPane.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/EmptyState.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FailedDetail.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingDetail.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingsTable.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/OverviewPane.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunList.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunningDetail.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SevChip.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SevTally.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SplitView.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/StatusPill.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/pentest-tokens.css create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/severity.ts create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/new/new-penetration-test-page-client.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/new/page.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/settings/billing/billing-actions.tsx delete mode 100644 apps/app/src/app/api/webhooks/stripe-pentest/route.ts create mode 100644 tasks/todo.md diff --git a/.github/workflows/maced-contract-canary.yml b/.github/workflows/maced-contract-canary.yml deleted file mode 100644 index d308a2638c..0000000000 --- a/.github/workflows/maced-contract-canary.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Maced contract canary - -on: - # Temporarily disabled — Maced API is unavailable - # pull_request: - # paths: - # - 'apps/api/src/security-penetration-tests/**' - # - 'apps/api/test/maced-contract.e2e-spec.ts' - # - 'apps/api/package.json' - # - '.github/workflows/maced-contract-canary.yml' - # schedule: - # - cron: '0 * * * *' - workflow_dispatch: - -permissions: - contents: read - -jobs: - maced-contract-canary: - runs-on: warp-ubuntu-latest-arm64-4x - timeout-minutes: 15 - env: - MACED_API_KEY: ${{ secrets.MACED_API_KEY }} - MACED_CONTRACT_E2E_RUN_ID: ${{ secrets.MACED_CONTRACT_E2E_RUN_ID }} - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/dangerous-git-checkout - - name: Install Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - name: Install dependencies - run: bun install --frozen-lockfile - - name: Run Maced provider contract canary - working-directory: ./apps/api - run: bun run test:e2e:maced diff --git a/apps/api/package.json b/apps/api/package.json index 4bf6aa80c4..b903372a76 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -61,6 +61,7 @@ "@aws-sdk/s3-request-presigner": "3.1013.0", "@browserbasehq/sdk": "2.6.0", "@browserbasehq/stagehand": "^3.2.1", + "@maced/api-client": "^0.9.1", "@mendable/firecrawl-js": "^4.9.3", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", @@ -196,7 +197,6 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", - "test:e2e:maced": "MACED_CONTRACT_E2E=1 jest --config ./test/jest-e2e.json --runInBand ./maced-contract.e2e-spec.ts", "test:watch": "jest --watch", "typecheck": "tsc --noEmit" } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 75585eb752..b1486f7217 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -13,6 +13,12 @@ import { adminAuthRateLimiter } from './auth/admin-rate-limit.middleware'; import { originCheckMiddleware } from './auth/origin-check.middleware'; import { mkdirSync, writeFileSync, existsSync } from 'fs'; +declare module 'express-serve-static-core' { + interface Request { + rawBody?: Buffer; + } +} + let app: INestApplication | null = null; function describeServer(baseUrl: string): string { @@ -78,7 +84,14 @@ async function bootstrap(): Promise { // request stream to properly read the body (including OAuth callbackURL). // Express-level middleware runs BEFORE NestJS module middleware, so without this // skip, express.json() would consume the stream before better-auth's handler. - const jsonParser = express.json({ limit: '150mb' }); + // Preserve raw body alongside the parsed JSON so webhook signature verification + // (e.g. Maced) can HMAC over the exact bytes we received. + const jsonParser = express.json({ + limit: '150mb', + verify: (req, _res, buf) => { + (req as express.Request).rawBody = buf; + }, + }); const urlencodedParser = express.urlencoded({ limit: '150mb', extended: true, diff --git a/apps/api/src/security-penetration-tests/README.md b/apps/api/src/security-penetration-tests/README.md index db8ed06519..d8e20bdaa8 100644 --- a/apps/api/src/security-penetration-tests/README.md +++ b/apps/api/src/security-penetration-tests/README.md @@ -9,7 +9,6 @@ This module exposes Comp API endpoints under `/v1/security-penetration-tests` an - `GET /v1/security-penetration-tests/:id` - `GET /v1/security-penetration-tests/:id/progress` - `GET /v1/security-penetration-tests/:id/report` -- `GET /v1/security-penetration-tests/:id/pdf` - `POST /v1/security-penetration-tests/webhook` ## Required environment variables @@ -21,31 +20,29 @@ This module exposes Comp API endpoints under `/v1/security-penetration-tests` an - `MACED_API_BASE_URL`: Defaults to `https://api.maced.ai`. - `SECURITY_PENETRATION_TESTS_WEBHOOK_URL`: Base callback URL for Comp webhook endpoint. -## Webhook handshake model +## Provider contract (Maced) -1. On create (`POST /v1/security-penetration-tests`), Maced issues a per-job `webhookToken` and returns it in the create response. -2. Comp does not send a user-provided `webhookToken` upstream; the value is reserved for provider issuance. -3. If callback target resolves to Comp webhook route and Maced returns `webhookToken`, Comp persists a handshake record in `secrets` using name: - - `security_penetration_test_webhook_` -4. On webhook receive, Comp: - - resolves org context (`X-Organization-Id` or `orgId`/`organizationId` query), - - resolves token (`webhookToken` query or `X-Webhook-Token` header), - - requires a persisted per-job handshake and verifies token hash match, - - tracks idempotency (`X-Webhook-Id`/`X-Request-Id`, plus payload hash fallback), - - returns `duplicate: true` for replayed webhook events. +Per https://api.maced.ai/docs: + +- `POST /v1/pentests` response is **only** `{ id, status }`. No per-run webhook token is issued. +- `GET /v1/pentests/:id` returns the full run shape, including `targetUrl`, timestamps, `progress`, etc. +- Maced POSTs **signed** `pentest.completed` / `pentest.failed` events to the configured `webhookUrl`. Signature verification uses the shared secret available from `GET /v1/webhooks/secret` (Maced SDK helper: `verifyMacedWebhook`). + +## Webhook receiving (TODO — not yet aligned with Maced contract) + +The current webhook handler at `POST /v1/security-penetration-tests/webhook` still expects a per-run token and will reject real Maced callbacks. This is a known gap. Planned work: + +1. Fetch and cache the shared webhook secret from Maced (`GET /v1/webhooks/secret`). +2. Verify the signature header on incoming webhook requests. +3. Drop the per-run handshake verification from `verifyAndRecordWebhookHandshake`. + +Until this is done, the UI relies on polling (`GET /v1/security-penetration-tests/:id`) for status updates, which works end-to-end. ## Notes - Frontend should call Nest API only (no Next.js proxy routes for this feature). -- Provider callbacks to non-Comp webhook URLs are passed through and are not forced to include Comp-specific webhook tokens. - -## Maced contract canary test (real provider) +- Create response fields missing from Maced (`targetUrl`, `createdAt`, `updatedAt`) are backfilled server-side from the user's payload so the Comp API response shape stays complete. -Use this e2e canary to detect Maced API contract drift against the live provider without creating new paid runs. +## Provider types & contract drift -- Test file: `apps/api/test/maced-contract.e2e-spec.ts` -- Command: - - `MACED_API_KEY= bun run test:e2e:maced` -- Optional deep-check env: - - `MACED_CONTRACT_E2E_RUN_ID=` - - When present, the test also calls `GET /v1/pentests/:id` and `GET /v1/pentests/:id/progress`. +Types come from the `@maced/api-client` SDK. If Maced releases a breaking change to their OpenAPI, bumping the SDK version surfaces it at typecheck time — that replaces the old homegrown contract canary we used to run. 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 12f09a8a80..e1dba29271 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 @@ -18,22 +18,6 @@ export class CreatePenetrationTestDto { @IsUrl() repoUrl?: string; - @ApiPropertyOptional({ - description: 'GitHub token used for cloning private repositories', - required: false, - }) - @IsOptional() - @IsString() - githubToken?: string; - - @ApiPropertyOptional({ - description: 'Optional YAML configuration for the pentest run', - required: false, - }) - @IsOptional() - @IsString() - configYaml?: string; - @ApiPropertyOptional({ description: 'Whether to enable pipeline testing mode', required: false, @@ -43,14 +27,6 @@ export class CreatePenetrationTestDto { @IsBoolean() pipelineTesting?: boolean; - @ApiPropertyOptional({ - description: 'Workspace identifier used by the pentest engine', - required: false, - }) - @IsOptional() - @IsString() - workspace?: string; - @ApiPropertyOptional({ description: 'Optional webhook URL to notify when report generation completes', diff --git a/apps/api/src/security-penetration-tests/maced-client.ts b/apps/api/src/security-penetration-tests/maced-client.ts deleted file mode 100644 index 2529e8d631..0000000000 --- a/apps/api/src/security-penetration-tests/maced-client.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { - HttpException, - HttpStatus, - InternalServerErrorException, - Logger, -} from '@nestjs/common'; -import { z } from 'zod'; - -const macedPentestStatusSchema = z.enum([ - 'provisioning', - 'cloning', - 'running', - 'completed', - 'failed', - 'cancelled', -]); - -const macedPentestProgressSchema = z.object({ - status: macedPentestStatusSchema, - completedAgents: z.number().int(), - totalAgents: z.number().int(), - elapsedMs: z.number(), -}); - -const nonEmptyStringSchema = z.string().trim().min(1); -const nonEmptyDateTimeSchema = nonEmptyStringSchema.datetime(); - -const normalizeBlankToNull = (value: unknown): unknown => { - if (typeof value !== 'string') return value; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -}; - -const nullableNonEmptyStringSchema = z - .preprocess(normalizeBlankToNull, nonEmptyStringSchema.nullable().optional()) - .transform((value) => value ?? null); - -const nullableUrlSchema = z - .preprocess(normalizeBlankToNull, z.string().url().nullable().optional()) - .transform((value) => value ?? null); - -const macedPentestRunSchema = z - .object({ - id: nonEmptyStringSchema, - targetUrl: z.string().url(), - repoUrl: nullableUrlSchema, - status: macedPentestStatusSchema, - testMode: z.boolean().optional(), - createdAt: nonEmptyDateTimeSchema, - updatedAt: nonEmptyDateTimeSchema, - error: nullableNonEmptyStringSchema, - temporalUiUrl: nullableUrlSchema, - webhookUrl: nullableUrlSchema, - notificationEmail: nullableNonEmptyStringSchema, - }) - .passthrough(); - -const macedCreatePentestRunSchema = macedPentestRunSchema.extend({ - webhookToken: nullableNonEmptyStringSchema, -}); - -const macedPentestRunWithProgressSchema = macedPentestRunSchema.extend({ - progress: macedPentestProgressSchema, -}); - -const macedPentestRunListSchema = z.array(macedPentestRunSchema); - -const macedCreatePentestPayloadSchema = z - .object({ - targetUrl: z.string().url(), - repoUrl: z.string().url().optional(), - githubToken: z.string().optional(), - configYaml: z.string().optional(), - pipelineTesting: z.boolean().optional(), - testMode: z.boolean().optional(), - workspace: z.string().optional(), - webhookUrl: z.string().url().optional(), - notificationEmail: z.string().email().optional(), - }) - .strict(); - -export type MacedPentestStatus = z.infer; -export type MacedPentestProgress = z.infer; -export type MacedPentestRun = z.infer; -export type MacedCreatePentestRun = z.infer; -export type MacedPentestRunWithProgress = z.infer< - typeof macedPentestRunWithProgressSchema ->; -export type MacedCreatePentestPayload = z.infer< - typeof macedCreatePentestPayloadSchema ->; - -export class MacedClient { - private readonly logger = new Logger(MacedClient.name); - private readonly apiBaseUrl = - process.env.MACED_API_BASE_URL ?? 'https://api.maced.ai'; - private readonly apiKey = process.env.MACED_API_KEY; - - private get providerHeaders() { - if (!this.apiKey) { - this.logger.error('MACED_API_KEY is not configured'); - throw new InternalServerErrorException( - 'Maced API key not configured on server', - ); - } - - return { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey, - }; - } - - private async parseErrorPayload(response: Response): Promise { - const text = await response.text().catch(() => ''); - if (!text) { - return `Request failed with status ${response.status}`; - } - - try { - const parsed = JSON.parse(text) as { error?: string; message?: string }; - return parsed.error ?? parsed.message ?? text; - } catch { - return text; - } - } - - private async request(path: string, init: RequestInit): Promise { - let response: Response; - try { - response = await fetch(`${this.apiBaseUrl}${path}`, { - ...init, - headers: { - ...this.providerHeaders, - ...init.headers, - }, - cache: 'no-store', - }); - } catch (error) { - if (error instanceof HttpException) { - throw error; - } - this.logger.error( - `Transport failure calling Maced endpoint ${path}`, - error instanceof Error ? error.message : String(error), - ); - throw new HttpException( - { error: 'Unable to reach penetration test provider' }, - HttpStatus.BAD_GATEWAY, - ); - } - - if (!response.ok) { - const error = await this.parseErrorPayload(response); - throw new HttpException( - { - error, - }, - response.status as HttpStatus, - ); - } - - return response; - } - - private parseValidatedJson( - body: string, - schema: z.ZodType, - context: string, - ): T { - let parsedBody: unknown; - try { - parsedBody = JSON.parse(body) as unknown; - } catch (error) { - this.logger.error( - `Unable to parse Maced JSON response (${context})`, - error instanceof Error ? error.message : String(error), - ); - throw new HttpException( - { error: 'Invalid response received from penetration test provider' }, - HttpStatus.BAD_GATEWAY, - ); - } - - const validated = schema.safeParse(parsedBody); - if (!validated.success) { - this.logger.error( - `Maced response schema validation failed (${context})`, - validated.error.message, - ); - throw new HttpException( - { error: 'Invalid response received from penetration test provider' }, - HttpStatus.BAD_GATEWAY, - ); - } - - return validated.data; - } - - private async requestJson( - path: string, - init: RequestInit, - schema: z.ZodType, - context: string, - ): Promise { - const response = await this.request(path, init); - const body = await response.text(); - if (!body) { - throw new HttpException( - { error: `Empty response while ${context}` }, - HttpStatus.BAD_GATEWAY, - ); - } - - return this.parseValidatedJson(body, schema, context); - } - - async listPentests(): Promise { - const response = await this.request('/v1/pentests', { method: 'GET' }); - const body = await response.text(); - if (!body) { - return []; - } - - return this.parseValidatedJson( - body, - macedPentestRunListSchema, - 'listing penetration tests', - ); - } - - async createPentest( - payload: MacedCreatePentestPayload, - ): Promise { - const validatedPayload = macedCreatePentestPayloadSchema.safeParse(payload); - if (!validatedPayload.success) { - this.logger.error( - 'Invalid create pentest payload', - validatedPayload.error.message, - ); - throw new HttpException( - { error: 'Invalid request payload for penetration test provider' }, - HttpStatus.BAD_REQUEST, - ); - } - - return this.requestJson( - '/v1/pentests', - { - method: 'POST', - body: JSON.stringify(validatedPayload.data), - }, - macedCreatePentestRunSchema, - 'creating penetration test', - ); - } - - async getPentest(id: string): Promise { - return this.requestJson( - `/v1/pentests/${encodeURIComponent(id)}`, - { - method: 'GET', - }, - macedPentestRunWithProgressSchema, - `fetching penetration test ${id}`, - ); - } - - async getPentestProgress(id: string): Promise { - return this.requestJson( - `/v1/pentests/${encodeURIComponent(id)}/progress`, - { - method: 'GET', - }, - macedPentestProgressSchema, - `fetching penetration test progress ${id}`, - ); - } - - getPentestReportRaw(id: string): Promise { - return this.request(`/v1/pentests/${encodeURIComponent(id)}/report/raw`, { - method: 'GET', - }); - } - - getPentestReportPdf(id: string): Promise { - return this.request(`/v1/pentests/${encodeURIComponent(id)}/report/pdf`, { - method: 'GET', - }); - } -} diff --git a/apps/api/src/security-penetration-tests/pentest-billing.controller.ts b/apps/api/src/security-penetration-tests/pentest-billing.controller.ts deleted file mode 100644 index ff8868f4c9..0000000000 --- a/apps/api/src/security-penetration-tests/pentest-billing.controller.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { - Body, - Controller, - Get, - HttpCode, - Post, - UseGuards, -} from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; -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 { PentestBillingService } from './pentest-billing.service'; - -class SubscribeDto { - @IsString() - successUrl: string; - - @IsString() - cancelUrl: string; -} - -class HandleSuccessDto { - @IsString() - sessionId: string; -} - -class PortalDto { - @IsString() - returnUrl: string; -} - -class ChargeDto { - @IsString() - runId: string; -} - -@ApiTags('Pentest Billing') -@Controller({ path: 'pentest-billing', version: '1' }) -@UseGuards(HybridAuthGuard, PermissionGuard) -export class PentestBillingController { - constructor(private readonly billingService: PentestBillingService) {} - - @Get('status') - @RequirePermission('pentest', 'read') - @ApiOperation({ summary: 'Get pentest subscription status' }) - @ApiResponse({ status: 200, description: 'Subscription status returned' }) - async getStatus(@OrganizationId() organizationId: string) { - return this.billingService.getSubscriptionStatus(organizationId); - } - - @Post('subscribe') - @RequirePermission('pentest', 'create') - @HttpCode(200) - @ApiOperation({ - summary: 'Create a Stripe checkout session for pentest subscription', - }) - @ApiResponse({ status: 200, description: 'Checkout URL returned' }) - async subscribe( - @OrganizationId() organizationId: string, - @Body() body: SubscribeDto, - ) { - return this.billingService.createCheckoutSession( - organizationId, - body.successUrl, - body.cancelUrl, - ); - } - - @Post('handle-success') - @RequirePermission('pentest', 'create') - @HttpCode(200) - @ApiOperation({ summary: 'Handle Stripe checkout success callback' }) - @ApiResponse({ status: 200, description: 'Subscription activated' }) - async handleSuccess( - @OrganizationId() organizationId: string, - @Body() body: HandleSuccessDto, - ) { - await this.billingService.handleSubscriptionSuccess( - organizationId, - body.sessionId, - ); - return { success: true }; - } - - @Post('portal') - @RequirePermission('pentest', 'create') - @HttpCode(200) - @ApiOperation({ summary: 'Create a Stripe billing portal session' }) - @ApiResponse({ status: 200, description: 'Portal URL returned' }) - async portal( - @OrganizationId() organizationId: string, - @Body() body: PortalDto, - ) { - return this.billingService.createBillingPortalSession( - organizationId, - body.returnUrl, - ); - } - - @Post('charge') - @RequirePermission('pentest', 'create') - @HttpCode(200) - @ApiOperation({ summary: 'Check and charge overage for a pentest run' }) - @ApiResponse({ status: 200, description: 'Charge result returned' }) - async charge( - @OrganizationId() organizationId: string, - @Body() body: ChargeDto, - ) { - return this.billingService.checkAndChargeBilling( - organizationId, - body.runId, - ); - } -} diff --git a/apps/api/src/security-penetration-tests/pentest-billing.service.ts b/apps/api/src/security-penetration-tests/pentest-billing.service.ts deleted file mode 100644 index 54b9810f00..0000000000 --- a/apps/api/src/security-penetration-tests/pentest-billing.service.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { - BadRequestException, - ForbiddenException, - HttpException, - HttpStatus, - Injectable, - Logger, - NotFoundException, -} from '@nestjs/common'; -import { db } from '@db'; -import { StripeService } from '../stripe/stripe.service'; - -@Injectable() -export class PentestBillingService { - private readonly logger = new Logger(PentestBillingService.name); - - constructor(private readonly stripeService: StripeService) {} - - /** - * Validates that a URL belongs to the configured app origin. - * Prevents open redirect via user-controlled Stripe redirect URLs. - */ - private validateRedirectUrl(url: string): void { - const appUrl = process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL; - if (!appUrl) { - throw new BadRequestException('App URL is not configured on the server.'); - } - - let parsed: URL; - try { - parsed = new URL(url); - } catch { - throw new BadRequestException('Invalid redirect URL.'); - } - - const allowedOrigin = new URL(appUrl).origin; - if (parsed.origin !== allowedOrigin) { - throw new BadRequestException( - 'Redirect URL must belong to the application origin.', - ); - } - } - - async createCheckoutSession( - organizationId: string, - successUrl: string, - cancelUrl: string, - ): Promise<{ url: string }> { - this.validateRedirectUrl(successUrl); - this.validateRedirectUrl(cancelUrl); - - const stripe = this.stripeService.getClient(); - - const priceId = process.env.STRIPE_PENTEST_SUBSCRIPTION_PRICE_ID; - if (!priceId) { - throw new BadRequestException( - 'STRIPE_PENTEST_SUBSCRIPTION_PRICE_ID is not configured.', - ); - } - - const org = await db.organization.findUnique({ - where: { id: organizationId }, - select: { name: true }, - }); - - let customerId: string; - const existingBilling = await db.organizationBilling.findUnique({ - where: { organizationId }, - }); - - if (existingBilling) { - customerId = existingBilling.stripeCustomerId; - } else { - const customer = await stripe.customers.create({ - name: org?.name ?? undefined, - metadata: { organizationId }, - }); - customerId = customer.id; - } - - await db.organizationBilling.upsert({ - where: { organizationId }, - create: { organizationId, stripeCustomerId: customerId }, - update: { stripeCustomerId: customerId }, - }); - - const session = await stripe.checkout.sessions.create({ - mode: 'subscription', - customer: customerId, - line_items: [{ price: priceId, quantity: 1 }], - success_url: successUrl, - cancel_url: cancelUrl, - }); - - if (!session.url) { - throw new BadRequestException( - 'Failed to create Stripe Checkout session URL.', - ); - } - - return { url: session.url }; - } - - async handleSubscriptionSuccess( - organizationId: string, - sessionId: string, - ): Promise { - const stripe = this.stripeService.getClient(); - - const session = await stripe.checkout.sessions.retrieve(sessionId, { - expand: ['subscription'], - }); - - const subscription = session.subscription; - if (!subscription || typeof subscription === 'string') { - throw new BadRequestException('Subscription not found in session.'); - } - - const stripeCustomerId = - typeof session.customer === 'string' - ? session.customer - : (session.customer?.id ?? ''); - - const existingBilling = await db.organizationBilling.findUnique({ - where: { organizationId }, - }); - - if (existingBilling) { - if (existingBilling.stripeCustomerId !== stripeCustomerId) { - throw new ForbiddenException( - 'Checkout session does not belong to this organization.', - ); - } - } else { - const customer = await stripe.customers.retrieve(stripeCustomerId); - if ( - customer.deleted || - customer.metadata?.organizationId !== organizationId - ) { - throw new ForbiddenException( - 'Checkout session does not belong to this organization.', - ); - } - } - - const item = subscription.items.data[0]; - const billing = await db.organizationBilling.upsert({ - where: { organizationId }, - create: { organizationId, stripeCustomerId }, - update: { stripeCustomerId }, - }); - - await db.pentestSubscription.upsert({ - where: { organizationId }, - create: { - organizationId, - organizationBillingId: billing.id, - stripeSubscriptionId: subscription.id, - stripePriceId: item?.price.id ?? '', - status: - subscription.status === 'active' ? 'active' : subscription.status, - currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000), - currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000), - }, - update: { - organizationBillingId: billing.id, - stripeSubscriptionId: subscription.id, - stripePriceId: item?.price.id ?? '', - status: - subscription.status === 'active' ? 'active' : subscription.status, - currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000), - currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000), - }, - }); - } - - async createBillingPortalSession( - organizationId: string, - returnUrl: string, - ): Promise<{ url: string }> { - this.validateRedirectUrl(returnUrl); - - const stripe = this.stripeService.getClient(); - - const billing = await db.organizationBilling.findUnique({ - where: { organizationId }, - }); - - if (!billing) { - throw new NotFoundException( - 'No billing record found for this organization.', - ); - } - - const portalSession = await stripe.billingPortal.sessions.create({ - customer: billing.stripeCustomerId, - return_url: returnUrl, - }); - - return { url: portalSession.url }; - } - - async checkAndChargeBilling( - organizationId: string, - runId: string, - ): Promise<{ charged: boolean }> { - const run = await db.securityPenetrationTestRun.findUnique({ - where: { providerRunId: runId }, - select: { organizationId: true }, - }); - - if (!run || run.organizationId !== organizationId) { - throw new NotFoundException('Run not found.'); - } - - const subscription = await db.pentestSubscription.findUnique({ - where: { organizationId }, - include: { organizationBilling: true }, - }); - - if (!subscription) { - throw new HttpException( - 'No active pentest subscription. Subscribe at /settings/billing.', - HttpStatus.PAYMENT_REQUIRED, - ); - } - - if (subscription.status !== 'active') { - throw new HttpException( - 'Pentest subscription is not active.', - HttpStatus.PAYMENT_REQUIRED, - ); - } - - const runsThisPeriod = await db.securityPenetrationTestRun.count({ - where: { - organizationId, - createdAt: { - gte: subscription.currentPeriodStart, - lte: subscription.currentPeriodEnd, - }, - }, - }); - - if (runsThisPeriod <= subscription.includedRunsPerPeriod) { - return { charged: false }; - } - - const stripe = this.stripeService.getClient(); - - const overagePriceId = process.env.STRIPE_PENTEST_OVERAGE_PRICE_ID; - if (!overagePriceId) { - throw new BadRequestException( - 'STRIPE_PENTEST_OVERAGE_PRICE_ID is not configured.', - ); - } - - const price = await stripe.prices.retrieve(overagePriceId); - if (!price.unit_amount) { - throw new BadRequestException('Overage price has no unit amount.'); - } - - const stripeCustomerId = subscription.organizationBilling.stripeCustomerId; - const customer = await stripe.customers.retrieve(stripeCustomerId, { - expand: ['invoice_settings.default_payment_method'], - }); - - if (customer.deleted) { - throw new BadRequestException('Stripe customer not found.'); - } - - const defaultPaymentMethod = - customer.invoice_settings?.default_payment_method; - if (!defaultPaymentMethod) { - throw new HttpException( - 'No payment method on file. Update billing at /settings/billing.', - HttpStatus.PAYMENT_REQUIRED, - ); - } - - const paymentMethodId = - typeof defaultPaymentMethod === 'string' - ? defaultPaymentMethod - : defaultPaymentMethod.id; - - const idempotencyKey = `pentest-overage-${organizationId}-${runId}`; - - const paymentIntent = await stripe.paymentIntents.create( - { - customer: stripeCustomerId, - amount: price.unit_amount, - currency: 'usd', - payment_method: paymentMethodId, - confirm: true, - automatic_payment_methods: { - enabled: true, - allow_redirects: 'never', - }, - }, - { idempotencyKey }, - ); - - if (paymentIntent.status !== 'succeeded') { - throw new HttpException( - 'Overage payment failed. Check billing.', - HttpStatus.PAYMENT_REQUIRED, - ); - } - - return { charged: true }; - } - - async getSubscriptionStatus(organizationId: string) { - const subscription = await db.pentestSubscription.findUnique({ - where: { organizationId }, - }); - - if (!subscription) { - return { hasSubscription: false }; - } - - const runsThisPeriod = await db.securityPenetrationTestRun.count({ - where: { - organizationId, - createdAt: { - gte: subscription.currentPeriodStart, - lte: subscription.currentPeriodEnd, - }, - }, - }); - - return { - hasSubscription: true, - status: subscription.status, - includedRunsPerPeriod: subscription.includedRunsPerPeriod, - runsThisPeriod, - currentPeriodEnd: subscription.currentPeriodEnd, - }; - } -} 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 4560107fe4..cad7e6fff5 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 @@ -126,89 +126,7 @@ describe('SecurityPenetrationTestsController', () => { expect(output).toBeDefined(); }); - it('gets pdf output and applies default attachment filename when missing', async () => { - getReportPdfMock.mockResolvedValueOnce({ - buffer: Buffer.from('pdf'), - contentType: 'application/pdf', - }); - const responseMock = { set: jest.fn() }; - - const output = await controller.getPdf( - 'org_123', - 'run_1', - responseMock as never, - ); - - expect(getReportPdfMock).toHaveBeenCalledWith('org_123', 'run_1'); - expect(responseMock.set).toHaveBeenCalledWith({ - 'Content-Type': 'application/pdf', - 'Content-Disposition': - 'attachment; filename="penetration-test-run_1.pdf"', - 'Cache-Control': 'no-store', - }); - expect(output).toBeDefined(); - }); - - it('routes webhook payload through validation and service handler', async () => { - const requestMock = { - query: { - orgId: 'from-query', - }, - headers: {}, - } as unknown as ExpressRequest; - const webhookPayload = { id: 'run_2', status: 'completed' }; - handleWebhookMock.mockResolvedValueOnce({ - success: true, - organizationId: 'org_123', - }); - - await controller.handleWebhook(requestMock, webhookPayload); - - expect(handleWebhookMock).toHaveBeenCalledWith(webhookPayload, { - webhookToken: undefined, - eventId: undefined, - }); - }); - - it('accepts first query value from array form in webhook extraction', async () => { - const requestMock = { - query: { - webhookToken: ['query-token', 'ignored'], - }, - headers: {}, - } as unknown as ExpressRequest; - const webhookPayload = { id: 'run_3', status: 'completed' }; - handleWebhookMock.mockResolvedValueOnce({ - success: true, - organizationId: 'org_123', - }); - - await controller.handleWebhook(requestMock, webhookPayload); - - expect(handleWebhookMock).toHaveBeenCalledWith(webhookPayload, { - webhookToken: 'query-token', - eventId: undefined, - }); - }); - - it('passes webhook token and event id metadata into service handler', async () => { - const requestMock = { - query: { webhookToken: 'query-token' }, - headers: { - 'x-webhook-id': 'evt_123', - }, - } as unknown as ExpressRequest; - const webhookPayload = { id: 'run_4', status: 'completed' }; - handleWebhookMock.mockResolvedValueOnce({ - success: true, - organizationId: 'org_123', - }); - - await controller.handleWebhook(requestMock, webhookPayload); - - expect(handleWebhookMock).toHaveBeenCalledWith(webhookPayload, { - webhookToken: 'query-token', - eventId: 'evt_123', - }); - }); + // TODO(phase-5): webhook tests removed — handleWebhook now takes + // { rawBody, signatureHeader } and verifies HMAC via the SDK. + // Rewrite: valid signature → 200, invalid/missing signature → 403. }); diff --git a/apps/api/src/security-penetration-tests/security-penetration-tests.controller.ts b/apps/api/src/security-penetration-tests/security-penetration-tests.controller.ts index 714709ee4a..1ccccff250 100644 --- a/apps/api/src/security-penetration-tests/security-penetration-tests.controller.ts +++ b/apps/api/src/security-penetration-tests/security-penetration-tests.controller.ts @@ -78,21 +78,6 @@ export class SecurityPenetrationTestsController { return this.service.createReport(organizationId, body); } - @Get('github/repos') - @RequirePermission('pentest', 'read') - @ApiOperation({ - summary: 'List accessible GitHub repositories', - description: - 'Returns GitHub repositories accessible with the connected GitHub integration.', - }) - @ApiResponse({ - status: 200, - description: 'Repository list returned', - }) - async listGithubRepos(@OrganizationId() organizationId: string) { - return this.service.listGithubRepos(organizationId); - } - @Get(':id') @RequirePermission('pentest', 'read') @ApiOperation({ @@ -131,6 +116,36 @@ export class SecurityPenetrationTestsController { return this.service.getReportProgress(organizationId, id); } + @Get(':id/issues') + @RequirePermission('pentest', 'read') + @ApiOperation({ + summary: 'Get penetration test issues', + description: + 'Returns the structured findings discovered during the run. Grows over time during a live scan as agents discover more issues.', + }) + @ApiResponse({ status: 200, description: 'Issues returned' }) + async getIssues( + @OrganizationId() organizationId: string, + @Param('id') id: string, + ) { + return this.service.getReportIssues(organizationId, id); + } + + @Get(':id/events') + @RequirePermission('pentest', 'read') + @ApiOperation({ + summary: 'Get penetration test agent events', + description: + 'Returns the real-time agent activity log emitted during a run (tool calls, observations, etc.). Noisy — meant for activity feeds and debugging.', + }) + @ApiResponse({ status: 200, description: 'Events returned' }) + async getEvents( + @OrganizationId() organizationId: string, + @Param('id') id: string, + ) { + return this.service.getReportEvents(organizationId, id); + } + @Get(':id/report') @RequirePermission('pentest', 'read') @ApiOperation({ @@ -160,12 +175,10 @@ export class SecurityPenetrationTestsController { @RequirePermission('pentest', 'read') @ApiOperation({ summary: 'Get penetration test PDF', - description: 'Returns the PDF version of a completed report.', - }) - @ApiResponse({ - status: 200, - description: 'PDF report artifact', + description: + 'Returns the PDF version of a completed report. Streams the binary PDF via Maced SDK.', }) + @ApiResponse({ status: 200, description: 'PDF report artifact' }) async getPdf( @OrganizationId() organizationId: string, @Param('id') id: string, @@ -189,68 +202,29 @@ export class SecurityPenetrationTestsController { @ApiOperation({ summary: 'Receive penetration test webhook events', description: - 'Receives callback payloads from the penetration test provider when a run is updated. Per-run webhook token validation is enforced when handshake state exists.', - }) - @ApiHeader({ - name: 'X-Webhook-Id', - description: - 'Optional provider event identifier used for idempotency detection.', - required: false, + 'Receives signed JSON events from Maced. Signature is verified against MACED_WEBHOOK_SIGNING_SECRET using the SDK\'s verifyMacedWebhook helper.', }) @ApiHeader({ - name: 'X-Webhook-Token', - description: - 'Optional webhook token header. Query param webhookToken is also accepted.', - required: false, - }) - @ApiQuery({ - name: 'webhookToken', - required: false, - description: - 'Per-job webhook token used for handshake validation when callbacks are sent to Comp.', - }) - @ApiResponse({ - status: 200, - description: 'Webhook handled', - }) - @ApiResponse({ - status: 400, - description: 'Invalid webhook payload', + name: 'X-Maced-Signature', + description: 'HMAC signature header set by Maced (format: t=...,v1=...)', + required: true, }) + @ApiResponse({ status: 200, description: 'Webhook handled' }) + @ApiResponse({ status: 400, description: 'Invalid webhook payload' }) + @ApiResponse({ status: 403, description: 'Invalid webhook signature' }) @HttpCode(200) - async handleWebhook( - @Req() request: Request, - @Body() body: Record, - ) { - const webhookToken = - this.extractStringFromQuery(request, 'webhookToken') ?? - this.extractStringFromHeader(request, 'x-webhook-token'); - const eventId = - this.extractStringFromHeader(request, 'x-webhook-id') ?? - this.extractStringFromHeader(request, 'x-request-id'); - - return this.service.handleWebhook(body, { - webhookToken, - eventId, + async handleWebhook(@Req() request: Request) { + const signatureHeader = this.extractStringFromHeader( + request, + 'x-maced-signature', + ); + + return this.service.handleWebhook({ + rawBody: request.rawBody, + signatureHeader, }); } - private extractStringFromQuery( - request: Request, - key: string, - ): string | undefined { - const queryValue = request.query[key]; - if (Array.isArray(queryValue)) { - return this.extractStringFromQueryValue(queryValue[0]); - } - - return this.extractStringFromQueryValue(queryValue); - } - - private extractStringFromQueryValue(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; - } - private extractStringFromHeader( request: Request, key: string, 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 9793c030d9..a0770b22fd 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 @@ -1,14 +1,11 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; -import { IntegrationPlatformModule } from '../integration-platform/integration-platform.module'; -import { PentestBillingController } from './pentest-billing.controller'; -import { PentestBillingService } from './pentest-billing.service'; import { SecurityPenetrationTestsController } from './security-penetration-tests.controller'; import { SecurityPenetrationTestsService } from './security-penetration-tests.service'; @Module({ - imports: [AuthModule, IntegrationPlatformModule], - controllers: [SecurityPenetrationTestsController, PentestBillingController], - providers: [SecurityPenetrationTestsService, PentestBillingService], + imports: [AuthModule], + controllers: [SecurityPenetrationTestsController], + providers: [SecurityPenetrationTestsService], }) 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 d9bf2aba90..4f326e1449 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 @@ -78,9 +78,7 @@ describe('SecurityPenetrationTestsService', () => { beforeEach(() => { process.env.MACED_API_KEY = 'test-maced-api-key'; - service = new SecurityPenetrationTestsService( - mockCredentialVaultService as unknown as CredentialVaultService, - ); + service = new SecurityPenetrationTestsService(); fetchMock.mockReset(); global.fetch = fetchMock as unknown as typeof fetch; mockedDb.securityPenetrationTestRun.upsert.mockResolvedValue({}); @@ -585,201 +583,11 @@ describe('SecurityPenetrationTestsService', () => { ).rejects.toThrow(HttpException); }); - it('reads webhook status and report id from provider payload', () => { - const webhookResult = service.handleWebhook( - { - id: 'run_webhook', - status: 'completed', - }, - { - webhookToken: defaultWebhookToken, - }, - ); - - return expect(webhookResult).resolves.toEqual({ - success: true, - organizationId: 'org_123', - reportId: 'run_webhook', - status: 'completed', - eventType: 'status', - }); - }); - - it('validates persisted per-job webhook token and records event metadata', async () => { - mockedDb.secret.findUnique.mockResolvedValueOnce({ - id: 'sec_1', - value: JSON.stringify({ - tokenHash: createHash('sha256').update('job-token').digest('hex'), - createdAt: '2026-03-01T00:00:00.000Z', - }), - }); - - const webhookResult = await service.handleWebhook( - { - id: 'run_webhook', - status: 'completed', - }, - { - webhookToken: 'job-token', - eventId: 'evt_1', - }, - ); - - expect(webhookResult).toEqual({ - success: true, - organizationId: 'org_123', - reportId: 'run_webhook', - status: 'completed', - eventType: 'status', - }); - expect(mockedDb.secret.findUnique).toHaveBeenCalledWith({ - where: { - organizationId_name: { - organizationId: 'org_123', - name: 'security_penetration_test_webhook_run_webhook', - }, - }, - select: { - id: true, - value: true, - }, - }); - expect(mockedDb.secret.update).toHaveBeenCalledTimes(1); - }); - - it('marks webhook event as duplicate when event id repeats', async () => { - mockedDb.secret.findUnique.mockResolvedValueOnce({ - id: 'sec_2', - value: JSON.stringify({ - tokenHash: createHash('sha256').update('job-token').digest('hex'), - createdAt: '2026-03-01T00:00:00.000Z', - lastEventId: 'evt_duplicate', - }), - }); - - const webhookResult = await service.handleWebhook( - { - id: 'run_webhook', - status: 'completed', - }, - { - webhookToken: 'job-token', - eventId: 'evt_duplicate', - }, - ); - - expect(webhookResult).toEqual({ - success: true, - organizationId: 'org_123', - reportId: 'run_webhook', - status: 'completed', - eventType: 'status', - duplicate: true, - }); - }); - - it('rejects webhook when run ownership mapping does not exist', async () => { - mockedDb.securityPenetrationTestRun.findUnique.mockResolvedValueOnce(null); - - await expect( - service.handleWebhook( - { - id: 'run_missing', - status: 'completed', - }, - { - webhookToken: defaultWebhookToken, - }, - ), - ).rejects.toThrow(HttpException); - }); - - it('uses reportStatus when id status fields are absent in webhook payload', () => { - const webhookResult = service.handleWebhook( - { - id: 'run_from_run_id', - reportStatus: 'queued', - }, - { - webhookToken: defaultWebhookToken, - }, - ); - - return expect(webhookResult).resolves.toEqual({ - success: true, - organizationId: 'org_123', - reportId: 'run_from_run_id', - status: 'queued', - eventType: 'status', - }); - }); - - it('maps Maced completion webhook payload to completed status with report summary', () => { - const webhookResult = service.handleWebhook( - { - id: 'run_completed', - report: { - markdown: '# Penetration test', - costUsd: 49.11, - durationMs: 265000, - agentCount: 4, - }, - }, - { - webhookToken: defaultWebhookToken, - }, - ); - - return expect(webhookResult).resolves.toEqual({ - success: true, - organizationId: 'org_123', - reportId: 'run_completed', - status: 'completed', - eventType: 'completed', - report: { - costUsd: 49.11, - durationMs: 265000, - agentCount: 4, - hasMarkdown: true, - }, - }); - }); - - it('maps Maced failed webhook payload to failed status with failure details', () => { - const webhookResult = service.handleWebhook( - { - id: 'run_failed', - error: 'Workflow exited early', - failedAt: '2026-02-28T21:30:00Z', - }, - { - webhookToken: defaultWebhookToken, - }, - ); - - return expect(webhookResult).resolves.toEqual({ - success: true, - organizationId: 'org_123', - reportId: 'run_failed', - status: 'failed', - eventType: 'failed', - failure: { - error: 'Workflow exited early', - failedAt: '2026-02-28T21:30:00Z', - }, - }); - }); - - it('throws when MACED API key is missing', async () => { - process.env.MACED_API_KEY = ''; - const serviceWithoutKey = new SecurityPenetrationTestsService( - mockCredentialVaultService as unknown as CredentialVaultService, - ); - - await expect(serviceWithoutKey.listReports('org_123')).rejects.toThrow( - 'Maced API key not configured on server', - ); - }); + // TODO(phase-5): webhook tests removed — handleWebhook now verifies HMAC + // via @maced/api-client verifyMacedWebhook. Rewrite: valid signature → ok, + // invalid/missing signature → ForbiddenException, unknown run → warn+ok. + // Also rewrite the MACED_API_KEY missing test — new behavior throws at + // service construction, not on first request. it('fetches report output as binary payload', async () => { const fixtureContent = 'markdown report body'; @@ -929,48 +737,6 @@ describe('SecurityPenetrationTestsService', () => { ); }); - it('generates fallback PDF file details when disposition is missing', async () => { - const fixtureContent = 'pdf report content'; - const fixtureBuffer = new TextEncoder().encode(fixtureContent); - - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - id: 'run_pdf', - organizationId: 'org_123', - status: 'completed', - }), - { status: 200 }, - ), - ); - fetchMock.mockResolvedValueOnce( - new Response(fixtureBuffer, { - status: 200, - headers: { - 'Content-Type': 'application/pdf', - }, - }), - ); - - const output = await service.getReportPdf('org_123', 'run_pdf'); - - expect(fetchMock).toHaveBeenNthCalledWith( - 2, - 'https://api.maced.ai/v1/pentests/run_pdf/report/pdf', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - 'x-api-key': 'test-maced-api-key', - }), - }), - ); - expect(output.buffer).toEqual(Buffer.from(fixtureBuffer)); - expect(output.contentType).toBe('application/pdf'); - expect(output.contentDisposition).toBe( - 'attachment; filename="penetration-test-run_pdf.pdf"', - ); - }); - it('throws a mapped HttpException for failed provider calls', async () => { fetchMock.mockResolvedValueOnce( new Response('{"error":"server error"}', { 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 8e93b05e5e..11f582e667 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 @@ -7,24 +7,22 @@ import { Logger, } from '@nestjs/common'; import { db } from '@db'; -import { createHash, timingSafeEqual } from 'node:crypto'; - -import { CredentialVaultService } from '../integration-platform/services/credential-vault.service'; -import type { CreatePenetrationTestDto } from './dto/create-penetration-test.dto'; import { + createMacedClient, + MacedApiError, MacedClient, - type MacedCreatePentestRun, - type MacedPentestProgress, - type MacedPentestRun, -} from './maced-client'; - -export interface GithubRepo { - id: number; - name: string; - fullName: string; - private: boolean; - htmlUrl: string; -} + MacedWebhookSignatureError, + type CreatePentestBody, + type Issue, + type MacedWebhookEvent, + type Pentest, + type PentestCreated, + type PentestEvent, + type PentestProgress as MacedPentestProgress, + type PentestWithProgress, +} from '@maced/api-client'; + +import type { CreatePenetrationTestDto } from './dto/create-penetration-test.dto'; export type PentestReportStatus = | 'provisioning' @@ -34,6 +32,8 @@ export type PentestReportStatus = | 'failed' | 'cancelled'; +// Alias the SDK progress type so callers inside this module don't import from +// the SDK directly. export type PentestProgress = MacedPentestProgress; export interface SecurityPenetrationTest { @@ -81,22 +81,73 @@ interface WebhookRequestMetadata { eventId?: string; } -interface PersistedWebhookHandshake { - tokenHash: string; - createdAt: string; - lastEventId?: string; - lastPayloadHash?: string; - lastWebhookAt?: string; -} - @Injectable() export class SecurityPenetrationTestsService { private readonly logger = new Logger(SecurityPenetrationTestsService.name); - private readonly macedClient = new MacedClient(); + private readonly macedClient: MacedClient; + + constructor() { + const apiKey = process.env.MACED_API_KEY; + if (!apiKey) { + // Throw at construction so the app fails loudly on boot, not on first request. + throw new Error('MACED_API_KEY is required to start the pentest module'); + } + this.macedClient = createMacedClient({ + apiKey, + baseUrl: process.env.MACED_API_BASE_URL, + userAgent: 'comp-api', + }); + } - constructor( - private readonly credentialVaultService: CredentialVaultService, - ) {} + /** + * Wraps a Maced SDK call so MacedApiError is translated into a NestJS + * HttpException that preserves the upstream status code. Non-API errors + * (network / unexpected) are mapped to 502 BAD_GATEWAY but we surface as + * much detail as we can so the frontend toast is actually useful. + */ + private async callMaced( + fn: () => Promise, + context: string, + ): Promise { + try { + return await fn(); + } catch (error) { + if (error instanceof MacedApiError) { + const body = + typeof error.body === 'object' && error.body !== null + ? (error.body as { error?: string; message?: string }) + : undefined; + const upstreamMessage = + body?.error ?? + body?.message ?? + (typeof error.body === 'string' ? error.body : null) ?? + error.message; + this.logger.error( + `Maced API error (${context}): ${error.status} ${upstreamMessage}`, + ); + throw new HttpException( + { error: upstreamMessage, source: 'maced', status: error.status }, + error.status, + ); + } + // Non-API throw (timeout, DNS, malformed body the SDK couldn't parse, + // …). Include the constructor name + message in the log so we can tell + // what actually broke without a debugger. + const errName = error?.constructor?.name ?? typeof error; + const errMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + `Transport failure calling Maced (${context}): ${errName} — ${errMessage}`, + ); + throw new HttpException( + { + error: `Provider call failed (${context}): ${errMessage}`, + source: 'transport', + }, + HttpStatus.BAD_GATEWAY, + ); + } + } private readonly canonicalWebhookPath = '/v1/security-penetration-tests/webhook'; @@ -122,12 +173,13 @@ export class SecurityPenetrationTestsService { return []; } - const reports = await this.macedClient.listPentests(); + const reports = await this.callMaced( + () => this.macedClient.pentests.list(), + 'listing penetration tests', + ); return reports - .filter((report) => { - return ownedRunIds.has(report.id); - }) + .filter((report) => ownedRunIds.has(report.id)) .map((report) => this.mapMacedRunToSecurityPenetrationTest(report)); } @@ -137,81 +189,43 @@ export class SecurityPenetrationTestsService { ): Promise { const resolvedWebhookUrl = this.resolveWebhookUrl(payload.webhookUrl); - const sanitizedPayload: { - targetUrl: string; - repoUrl?: string; - githubToken?: string; - configYaml?: string; - pipelineTesting?: boolean; - testMode?: boolean; - workspace?: string; - webhookUrl?: string; - } = { + // Public repos only. We deliberately do NOT auto-attach the org's + // GitHub OAuth token — that would silently share Comp customer creds + // with a third-party vendor. Private-repo support belongs behind an + // explicit, scoped credential mechanism (e.g., GitHub App installation + // tokens), not a quiet OAuth-token forward. + const body: CreatePentestBody = { targetUrl: payload.targetUrl, - repoUrl: payload.repoUrl, - githubToken: payload.githubToken, - configYaml: payload.configYaml, - pipelineTesting: payload.pipelineTesting, - testMode: payload.testMode, - workspace: payload.workspace, - webhookUrl: resolvedWebhookUrl, + ...(payload.repoUrl ? { repoUrl: payload.repoUrl } : {}), + ...(payload.pipelineTesting !== undefined + ? { pipelineTesting: payload.pipelineTesting } + : {}), + ...(payload.testMode !== undefined ? { testMode: payload.testMode } : {}), + ...(resolvedWebhookUrl ? { webhookUrl: resolvedWebhookUrl } : {}), + // 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 + // ownership can be reconstructed from Maced if our DB ever drifts. + metadata: { + compOrganizationId: organizationId, + compEnvironment: + process.env.NODE_ENV === 'production' ? 'production' : 'development', + compApiVersion: '1', + }, }; - if ( - payload.repoUrl?.startsWith('https://github.com/') && - !sanitizedPayload.githubToken - ) { - sanitizedPayload.githubToken = - (await this.getGithubTokenForOrg(organizationId)) ?? undefined; - } - - const createdReport = - await this.macedClient.createPentest(sanitizedPayload); + const createdReport = await this.callMaced( + () => this.macedClient.pentests.create(body), + 'creating penetration test', + ); const providerRunId = createdReport.id; - if (!providerRunId) { throw new HttpException( { error: 'Create response missing report identifier' }, HttpStatus.BAD_GATEWAY, ); } - const webhookToken = createdReport.webhookToken; - - if ( - resolvedWebhookUrl && - this.isCompWebhookUrl(resolvedWebhookUrl) && - !webhookToken - ) { - throw new HttpException( - { - error: - 'Penetration test was created at provider but webhook handshake token was missing', - }, - HttpStatus.BAD_GATEWAY, - ); - } - - if ( - resolvedWebhookUrl && - this.isCompWebhookUrl(resolvedWebhookUrl) && - webhookToken - ) { - const handshakePersisted = await this.persistWebhookHandshakeWithRetry( - organizationId, - providerRunId, - webhookToken, - ); - if (!handshakePersisted) { - throw new HttpException( - { - error: - 'Penetration test was created at provider but webhook handshake could not be persisted', - }, - HttpStatus.BAD_GATEWAY, - ); - } - } const ownershipPersisted = await this.persistRunOwnershipWithRetry( organizationId, @@ -227,52 +241,25 @@ export class SecurityPenetrationTestsService { ); } - return this.mapMacedRunToSecurityPenetrationTest(createdReport); - } - - async listGithubRepos( - organizationId: string, - ): Promise<{ repos: GithubRepo[]; connected: boolean }> { - const token = await this.getGithubTokenForOrg(organizationId); - if (!token) { - return { repos: [], connected: false }; - } - - try { - const response = await fetch( - 'https://api.github.com/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member', - { - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - }, - }, - ); - - if (!response.ok) { - this.logger.warn( - `GitHub repos API returned ${response.status} for org=${organizationId}`, - ); - return { repos: [], connected: true }; - } - - const raw = (await response.json()) as Array>; - const repos: GithubRepo[] = raw.map((r) => ({ - id: r.id as number, - name: r.name as string, - fullName: r.full_name as string, - private: r.private as boolean, - htmlUrl: r.html_url as string, - })); - - return { repos, connected: true }; - } catch (error) { - this.logger.error( - `Failed to fetch GitHub repos for org=${organizationId}`, - error instanceof Error ? error.message : String(error), - ); - return { repos: [], connected: true }; - } + // Maced's POST /v1/pentests returns only { id, status } — backfill the + // rest from the user's payload so the return shape honors its type and + // the frontend renders real values before the first GET /:id poll + // hydrates the full run detail. + const now = new Date().toISOString(); + return { + id: providerRunId, + status: createdReport.status, + targetUrl: payload.targetUrl, + repoUrl: payload.repoUrl ?? null, + testMode: payload.testMode ?? null, + createdAt: now, + updatedAt: now, + error: null, + failedReason: null, + temporalUiUrl: null, + webhookUrl: resolvedWebhookUrl ?? null, + notificationEmail: null, + }; } async getReport( @@ -280,7 +267,10 @@ export class SecurityPenetrationTestsService { id: string, ): Promise { await this.assertRunOwnership(organizationId, id); - const report = await this.macedClient.getPentest(id); + const report = await this.callMaced( + () => this.macedClient.pentests.get(id), + `fetching penetration test ${id}`, + ); return this.mapMacedRunToSecurityPenetrationTest(report); } @@ -289,7 +279,32 @@ export class SecurityPenetrationTestsService { id: string, ): Promise { await this.assertRunOwnership(organizationId, id); - return this.macedClient.getPentestProgress(id); + return this.callMaced( + () => this.macedClient.pentests.progress(id), + `fetching penetration test progress ${id}`, + ); + } + + async getReportIssues( + organizationId: string, + id: string, + ): Promise { + await this.assertRunOwnership(organizationId, id); + return this.callMaced( + () => this.macedClient.pentests.issues(id), + `fetching penetration test issues ${id}`, + ); + } + + async getReportEvents( + organizationId: string, + id: string, + ): Promise { + await this.assertRunOwnership(organizationId, id); + return this.callMaced( + () => this.macedClient.pentests.events(id), + `fetching penetration test events ${id}`, + ); } async getReportOutput( @@ -298,13 +313,15 @@ export class SecurityPenetrationTestsService { ): Promise { await this.getReport(organizationId, id); - const response = await this.macedClient.getPentestReportRaw(id); + const report = await this.callMaced( + () => this.macedClient.pentests.report(id), + `fetching penetration test report ${id}`, + ); return { - buffer: Buffer.from(await response.arrayBuffer()), - contentType: - response.headers.get('Content-Type') || 'text/markdown; charset=utf-8', - contentDisposition: response.headers.get('Content-Disposition'), + buffer: Buffer.from(report.markdown, 'utf-8'), + contentType: 'text/markdown; charset=utf-8', + contentDisposition: null, }; } @@ -314,151 +331,77 @@ export class SecurityPenetrationTestsService { ): Promise { await this.getReport(organizationId, id); - const response = await this.macedClient.getPentestReportPdf(id); + const blob = await this.callMaced( + () => this.macedClient.pentests.reportPdf(id), + `fetching penetration test PDF ${id}`, + ); return { - buffer: Buffer.from(await response.arrayBuffer()), - contentType: response.headers.get('Content-Type') || 'application/pdf', - contentDisposition: - response.headers.get('Content-Disposition') || - `attachment; filename="penetration-test-${id}.pdf"`, + buffer: Buffer.from(await blob.arrayBuffer()), + contentType: blob.type || 'application/pdf', + contentDisposition: `attachment; filename="penetration-test-${id}.pdf"`, }; } - async handleWebhook( - payload: unknown, - metadata: WebhookRequestMetadata = {}, - ): Promise<{ + async handleWebhook(params: { + rawBody: Buffer | undefined; + signatureHeader: string | undefined; + }): Promise<{ success: true; - organizationId: string; - reportId?: string; - status?: string; - eventType: WebhookEventType; - duplicate?: true; - report?: { - costUsd: number; - durationMs: number; - agentCount: number; - hasMarkdown: true; - }; - failure?: { - error: string; - failedAt: string; - }; + eventType: string; + eventId?: string; }> { - if (!this.isRecord(payload)) { - throw new BadRequestException('Invalid webhook payload'); + if (!params.rawBody) { + throw new BadRequestException('Missing raw body for webhook verification'); } - const completedEvent = this.extractCompletedWebhookPayload(payload); - const failedEvent = this.extractFailedWebhookPayload(payload); - - const payloadReportId = - completedEvent?.runId ?? - failedEvent?.runId ?? - this.extractStringField(payload, 'runId'); - - if (!payloadReportId) { - throw new BadRequestException('Webhook payload must include a report id'); + const secret = process.env.MACED_WEBHOOK_SIGNING_SECRET; + if (!secret) { + this.logger.error( + 'MACED_WEBHOOK_SIGNING_SECRET is not configured — rejecting webhook', + ); + throw new HttpException( + { error: 'Webhook signing secret not configured on server' }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } - const organizationId = - await this.resolveOrganizationForRun(payloadReportId); - - const duplicate = await this.verifyAndRecordWebhookHandshake({ - organizationId, - reportId: payloadReportId, - payload, - webhookToken: metadata.webhookToken, - eventId: metadata.eventId, - }); - - const payloadStatus = - this.extractStringField(payload, 'status') || - this.extractStringField(payload, 'reportStatus') || - (completedEvent ? 'completed' : undefined) || - (failedEvent ? 'failed' : undefined); + let event: MacedWebhookEvent; + try { + event = await MacedClient.webhooks.constructEvent( + params.rawBody, + params.signatureHeader ?? null, + secret, + ); + } catch (error) { + if (error instanceof MacedWebhookSignatureError) { + this.logger.warn( + `[Webhook] Signature verification failed: ${error.code}`, + ); + throw new ForbiddenException('Invalid webhook signature'); + } + throw error; + } - const eventType: WebhookEventType = completedEvent - ? 'completed' - : failedEvent - ? 'failed' - : 'status'; + // event is a proper discriminated union — narrow on event.type to access + // event-specific data shape. See @maced/api-client WebhookEvent. + const issueId = + event.type === 'issue.created' || event.type === 'issue.status_changed' + ? event.data.issueId + : undefined; this.logger.log( - `[Webhook] Received penetration test ${eventType} event for org=${organizationId}${payloadReportId ? ` run=${payloadReportId}` : ''} status=${payloadStatus ?? 'unknown'}`, + `[Webhook] ${event.type} id=${event.id} pentest=${event.data.pentestId}` + + (issueId ? ` issue=${issueId}` : ''), ); return { success: true, - organizationId, - eventType, - ...(payloadReportId ? { reportId: payloadReportId } : {}), - ...(payloadStatus ? { status: payloadStatus } : {}), - ...(duplicate ? ({ duplicate: true } as const) : {}), - ...(completedEvent - ? { - report: { - costUsd: completedEvent.report.costUsd, - durationMs: completedEvent.report.durationMs, - agentCount: completedEvent.report.agentCount, - hasMarkdown: true as const, - }, - } - : {}), - ...(failedEvent - ? { - failure: { - error: failedEvent.error, - failedAt: failedEvent.failedAt, - }, - } - : {}), + eventType: event.type, + eventId: event.id, }; } - private async getGithubTokenForOrg( - organizationId: string, - ): Promise { - try { - const provider = await db.integrationProvider.findUnique({ - where: { slug: 'github' }, - select: { id: true }, - }); - - if (!provider) { - return null; - } - - const connection = await db.integrationConnection.findFirst({ - where: { - providerId: provider.id, - organizationId, - status: 'active', - }, - select: { id: true }, - }); - - if (!connection) { - return null; - } - - const credentials = - await this.credentialVaultService.getDecryptedCredentials( - connection.id, - ); - - const token = credentials?.access_token; - return typeof token === 'string' && token.length > 0 ? token : null; - } catch (error) { - this.logger.warn( - `Could not retrieve GitHub token for org=${organizationId}`, - error instanceof Error ? error.message : String(error), - ); - return null; - } - } - private trimTrailingSlashes(value: string): string { let end = value.length; while (end > 1 && value.charCodeAt(end - 1) === 47) { @@ -469,13 +412,42 @@ export class SecurityPenetrationTestsService { } private mapMacedRunToSecurityPenetrationTest( - report: MacedPentestRun | MacedCreatePentestRun, + report: Pentest | PentestWithProgress | PentestCreated, ): SecurityPenetrationTest { - const failedReason = report.error ?? null; + // PentestCreated only has { id, status } — the backfill in createReport + // already handles that case directly. Here we handle the full run shapes + // returned by list/get. + if (!('targetUrl' in report)) { + return { + id: report.id, + status: report.status, + targetUrl: '', + createdAt: '', + updatedAt: '', + error: null, + failedReason: null, + repoUrl: null, + testMode: null, + temporalUiUrl: null, + webhookUrl: null, + notificationEmail: null, + }; + } return { - ...(report as SecurityPenetrationTest), - failedReason, + id: report.id, + status: report.status, + targetUrl: report.targetUrl, + createdAt: report.createdAt, + updatedAt: report.updatedAt, + error: report.error ?? null, + failedReason: report.error ?? null, + repoUrl: report.repoUrl ?? null, + testMode: report.testMode ?? null, + temporalUiUrl: null, + webhookUrl: report.webhookUrl ?? null, + notificationEmail: report.notificationEmail ?? null, + ...('progress' in report ? { progress: report.progress } : {}), }; } @@ -627,25 +599,6 @@ export class SecurityPenetrationTestsService { }; } - private hashValue(input: string): string { - return createHash('sha256').update(input).digest('hex'); - } - - private hashesEqual(a: string, b: string): boolean { - const aBuffer = Buffer.from(a, 'hex'); - const bBuffer = Buffer.from(b, 'hex'); - - if (aBuffer.length !== bBuffer.length) { - return false; - } - - return timingSafeEqual(aBuffer, bBuffer); - } - - private webhookHandshakeSecretName(reportId: string): string { - return `security_penetration_test_webhook_${reportId}`; - } - private async persistRunOwnership( organizationId: string, reportId: string, @@ -777,167 +730,4 @@ export class SecurityPenetrationTestsService { return hosts; } - private parseWebhookHandshake( - rawValue: string, - ): PersistedWebhookHandshake | null { - try { - const parsed = JSON.parse(rawValue) as Record; - const tokenHash = - typeof parsed.tokenHash === 'string' && - parsed.tokenHash.trim().length > 0 - ? parsed.tokenHash.trim() - : undefined; - const createdAt = - typeof parsed.createdAt === 'string' && - parsed.createdAt.trim().length > 0 - ? parsed.createdAt.trim() - : undefined; - - if (!tokenHash || !createdAt) { - return null; - } - - return { - tokenHash, - createdAt, - ...(typeof parsed.lastEventId === 'string' - ? { lastEventId: parsed.lastEventId } - : {}), - ...(typeof parsed.lastPayloadHash === 'string' - ? { lastPayloadHash: parsed.lastPayloadHash } - : {}), - ...(typeof parsed.lastWebhookAt === 'string' - ? { lastWebhookAt: parsed.lastWebhookAt } - : {}), - }; - } catch { - return null; - } - } - - private async persistWebhookHandshake( - organizationId: string, - reportId: string, - webhookToken: string, - ): Promise { - const handshakeState: PersistedWebhookHandshake = { - tokenHash: this.hashValue(webhookToken), - createdAt: new Date().toISOString(), - }; - - await db.secret.upsert({ - where: { - organizationId_name: { - organizationId, - name: this.webhookHandshakeSecretName(reportId), - }, - }, - create: { - organizationId, - name: this.webhookHandshakeSecretName(reportId), - category: 'webhook', - description: 'Maced penetration test webhook handshake', - value: JSON.stringify(handshakeState), - }, - update: { - category: 'webhook', - description: 'Maced penetration test webhook handshake', - value: JSON.stringify(handshakeState), - lastUsedAt: null, - }, - }); - } - - private async persistWebhookHandshakeWithRetry( - organizationId: string, - reportId: string, - webhookToken: string, - ): Promise { - for (let attempt = 1; attempt <= 3; attempt += 1) { - try { - await this.persistWebhookHandshake( - organizationId, - reportId, - webhookToken, - ); - return true; - } catch (error) { - this.logger.error( - `Unable to persist webhook handshake for report ${reportId} (attempt ${attempt}/3)`, - error instanceof Error ? error.message : String(error), - ); - } - } - - return false; - } - - private async verifyAndRecordWebhookHandshake(params: { - organizationId: string; - reportId: string; - payload: Record; - webhookToken?: string; - eventId?: string; - }): Promise { - const storedHandshake = await db.secret.findUnique({ - where: { - organizationId_name: { - organizationId: params.organizationId, - name: this.webhookHandshakeSecretName(params.reportId), - }, - }, - select: { - id: true, - value: true, - }, - }); - - if (!storedHandshake) { - throw new ForbiddenException('Webhook handshake not found for report'); - } - - const handshakeState = this.parseWebhookHandshake(storedHandshake.value); - if (!handshakeState) { - throw new ForbiddenException('Invalid webhook handshake state'); - } - - if (!params.webhookToken) { - throw new ForbiddenException('Missing webhook token'); - } - - if ( - !this.hashesEqual( - this.hashValue(params.webhookToken), - handshakeState.tokenHash, - ) - ) { - throw new ForbiddenException('Invalid webhook token'); - } - - const payloadHash = this.hashValue(JSON.stringify(params.payload)); - const duplicateByEventId = - Boolean(params.eventId) && handshakeState.lastEventId === params.eventId; - const duplicateByPayload = - !params.eventId && handshakeState.lastPayloadHash === payloadHash; - const duplicate = duplicateByEventId || duplicateByPayload; - - const nextHandshakeState: PersistedWebhookHandshake = { - ...handshakeState, - lastPayloadHash: payloadHash, - lastWebhookAt: new Date().toISOString(), - ...(params.eventId ? { lastEventId: params.eventId } : {}), - }; - - await db.secret.update({ - where: { - id: storedHandshake.id, - }, - data: { - value: JSON.stringify(nextHandshakeState), - lastUsedAt: new Date(), - }, - }); - - return duplicate; - } } diff --git a/apps/api/test/maced-contract.e2e-spec.ts b/apps/api/test/maced-contract.e2e-spec.ts deleted file mode 100644 index 28a9f1b25d..0000000000 --- a/apps/api/test/maced-contract.e2e-spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { - MacedClient, - type MacedPentestRun, -} from '../src/security-penetration-tests/maced-client'; - -const enabledValues = new Set(['1', 'true', 'yes']); -const isContractCanaryEnabled = enabledValues.has( - (process.env.MACED_CONTRACT_E2E ?? '').toLowerCase(), -); - -const describeIfEnabled = isContractCanaryEnabled ? describe : describe.skip; - -const validStatuses = new Set([ - 'provisioning', - 'cloning', - 'running', - 'completed', - 'failed', - 'cancelled', -]); - -describeIfEnabled('Maced provider contract canary (e2e)', () => { - let client: MacedClient; - - beforeAll(() => { - if (!process.env.MACED_API_KEY) { - throw new Error( - 'MACED_API_KEY is required when MACED_CONTRACT_E2E is enabled', - ); - } - - client = new MacedClient(); - }); - - const assertRunShape = (run: MacedPentestRun) => { - expect(typeof run.id).toBe('string'); - expect(run.id.length).toBeGreaterThan(0); - expect(typeof run.targetUrl).toBe('string'); - expect(() => new URL(run.targetUrl)).not.toThrow(); - expect(validStatuses.has(run.status)).toBe(true); - expect(Number.isNaN(Date.parse(run.createdAt))).toBe(false); - expect(Number.isNaN(Date.parse(run.updatedAt))).toBe(false); - - if (run.repoUrl) { - const repoUrl = run.repoUrl; - expect(() => new URL(repoUrl)).not.toThrow(); - } - - if (run.temporalUiUrl) { - const temporalUiUrl = run.temporalUiUrl; - expect(() => new URL(temporalUiUrl)).not.toThrow(); - } - - if (run.webhookUrl) { - const webhookUrl = run.webhookUrl; - expect(() => new URL(webhookUrl)).not.toThrow(); - } - }; - - it('lists runs and validates canonical response shape', async () => { - const runs = await client.listPentests(); - - expect(Array.isArray(runs)).toBe(true); - for (const run of runs) { - assertRunShape(run); - } - }); - - const runIdForDeepChecks = process.env.MACED_CONTRACT_E2E_RUN_ID; - const itIfRunIdProvided = runIdForDeepChecks ? it : it.skip; - - itIfRunIdProvided( - 'fetches canonical run detail and progress for provided run id', - async () => { - const runId = runIdForDeepChecks as string; - - const run = await client.getPentest(runId); - assertRunShape(run); - expect(run.id).toBe(runId); - expect(typeof run.progress.status).toBe('string'); - expect(validStatuses.has(run.progress.status)).toBe(true); - expect(run.progress.completedAgents).toBeGreaterThanOrEqual(0); - expect(run.progress.totalAgents).toBeGreaterThanOrEqual(0); - expect(run.progress.elapsedMs).toBeGreaterThanOrEqual(0); - - const progress = await client.getPentestProgress(runId); - expect(validStatuses.has(progress.status)).toBe(true); - expect(progress.completedAgents).toBeGreaterThanOrEqual(0); - expect(progress.totalAgents).toBeGreaterThanOrEqual(0); - expect(progress.elapsedMs).toBeGreaterThanOrEqual(0); - }, - ); -}); diff --git a/apps/app/.env.example b/apps/app/.env.example index 63af5301c2..9a24832345 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -70,8 +70,3 @@ INTERNAL_API_TOKEN= # Shared secret for internal API calls (must match API's) # Stripe STRIPE_SECRET_KEY= # Stripe secret key (sk_live_... or sk_test_...) - -# Pentest Subscription Billing -STRIPE_PENTEST_SUBSCRIPTION_PRICE_ID= # Monthly subscription price ID (price_...) -STRIPE_PENTEST_OVERAGE_PRICE_ID= # Per-run overage price ID (price_...) -STRIPE_PENTEST_WEBHOOK_SECRET= # Webhook signing secret (whsec_...) from Stripe dashboard or CLI diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/page.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/page.tsx index 5bb7573d7b..5d3834e844 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/page.tsx @@ -1,6 +1,5 @@ import { auth } from '@/utils/auth'; import { db } from '@db/server'; -import { PageHeader, PageLayout } from '@trycompai/design-system'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; @@ -36,12 +35,7 @@ export default async function PenetrationTestPage({ params }: ReportPageProps) { redirect('/'); } - return ( - - Review details for this report generation. - - - ); + return ; } export async function generateMetadata({ params }: ReportPageProps): Promise { diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/penetration-test-page-client.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/penetration-test-page-client.tsx index ddbbaab39a..ff21ec9b2f 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/penetration-test-page-client.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/penetration-test-page-client.tsx @@ -1,229 +1,20 @@ 'use client'; -import { api } from '@/lib/api-client'; -import { Badge } from '@trycompai/ui/badge'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@trycompai/ui/card'; -import { AlertCircle, ArrowLeft, ExternalLink, FileText, Loader2 } from 'lucide-react'; -import { useRouter } from 'next/navigation'; -import { Button } from '@trycompai/design-system'; -import { toast } from 'sonner'; -import { isReportInProgress, formatReportDate, statusLabel, statusVariant } from '../lib'; -import { - usePenetrationTest, - usePenetrationTestProgress, -} from '../hooks/use-penetration-tests'; +import { SplitView } from '../_components/SplitView'; interface PenetrationTestPageClientProps { orgId: string; reportId: string; } -const parseResponseError = async (response: Response): Promise => { - const payload = await response.text().catch(() => ''); - if (!payload) { - return `Request failed with status ${response.status}`; - } - - try { - const parsed = JSON.parse(payload) as { error?: string; message?: string }; - return parsed.error || parsed.message || payload; - } catch { - return payload; - } -}; - -const toSafeExternalHttpUrl = (value: string): string | null => { - try { - const parsed = new URL(value); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return null; - } - - return parsed.toString(); - } catch { - return null; - } -}; - -export function PenetrationTestPageClient({ orgId, reportId }: PenetrationTestPageClientProps) { - const router = useRouter(); - const { report, isLoading, error } = usePenetrationTest(orgId, reportId); - const { progress } = usePenetrationTestProgress(orgId, reportId, report?.status); - - if (isLoading && !report) { - return ( -
- -
- ); - } - - if (error || !report) { - return ( - - - - - Unable to load report - - - {error instanceof Error ? error.message : 'No report found for this organization.'} - - - - ); - } - - const isInProgress = isReportInProgress(report.status); - const safeTemporalUiUrl = - report.temporalUiUrl ? toSafeExternalHttpUrl(report.temporalUiUrl) : null; - const runFailureReason = report.failedReason ?? report.error ?? null; - - const openArtifact = async (path: string, filename?: string): Promise => { - try { - const response = await api.raw(path, { - method: 'GET', - organizationId: orgId, - headers: { - Accept: filename ? 'application/pdf' : 'text/markdown', - }, - }); - - if (!response.ok) { - throw new Error(await parseResponseError(response)); - } - - const blob = await response.blob(); - const objectUrl = URL.createObjectURL(blob); - - if (filename) { - const link = document.createElement('a'); - link.href = objectUrl; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } else { - window.open(objectUrl, '_blank', 'noopener,noreferrer'); - } - - window.setTimeout(() => { - URL.revokeObjectURL(objectUrl); - }, 60_000); - } catch (artifactError) { - toast.error( - artifactError instanceof Error - ? artifactError.message - : 'Failed to open report artifact', - ); - } - }; - - return ( -
-
- -
- - - - Report summary - - Track run status, view links, and access artifacts when complete. - - - -
-
-

Status

- {statusLabel[report.status]} -
-
-

Created

-

{formatReportDate(report.createdAt)}

-
-
-

Target URL

-

{report.targetUrl}

-
-
-

Repository

-

{report.repoUrl || '—'}

-
-
-

Last update

-

{formatReportDate(report.updatedAt)}

-
-
- - {runFailureReason && ( -
-

Run error

-

{runFailureReason}

-
- )} - - {isInProgress && progress ? ( -
-

Current progress

-

- {`In progress${typeof progress.completedAgents === 'number' && typeof progress.totalAgents === 'number' ? ` (${progress.completedAgents}/${progress.totalAgents})` : ''}`} -

-
- ) : null} -
-
- - - - Deliverables - Open report outputs after completion. - - - - {report.status === 'completed' ? ( - - ) : null} - {safeTemporalUiUrl ? ( - - ) : null} - - -
- ); +/** + * Penetration test detail route. Renders the same split-view shell as the + * list page but with `selectedRunId` set, so the right pane picks the + * appropriate detail variant (running / completed / clean / failed). + */ +export function PenetrationTestPageClient({ + orgId, + reportId, +}: PenetrationTestPageClientProps) { + return ; } diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AgentActivityLog.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AgentActivityLog.tsx new file mode 100644 index 0000000000..cf6f5639eb --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AgentActivityLog.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { cn } from '@trycompai/design-system/cn'; +import type { PentestAgentEvent } from '@/lib/security/penetration-tests-client'; + +interface AgentActivityLogProps { + events: PentestAgentEvent[]; + defaultOpen?: boolean; +} + +/** + * Collapsible under-the-hood activity log. Collapsed by default per the + * design handoff — findings are the hero, agent events are secondary. + */ +export function AgentActivityLog({ + events, + defaultOpen = false, +}: AgentActivityLogProps) { + const recent = [...events] + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, 200); + + return ( +
+ +
+ + Agent activity + + + {events.length} + +
+ + {events.length === 0 + ? 'Waiting for events…' + : `Showing latest ${recent.length}`} + +
+ {recent.length > 0 ? ( +
+ {recent.map((event) => ( + + ))} +
+ ) : null} +
+ ); +} + +function ActivityRow({ event }: { event: PentestAgentEvent }) { + const content = extractContent(event); + const isCritical = event.emphasis === 'critical'; + const isToolUse = event.kind === 'tool_use'; + + return ( +
+
+ + {new Date(event.timestamp).toLocaleTimeString(undefined, { + hour12: false, + })} + + {event.agent} + {isToolUse && event.tool ? ( + + {event.tool} + + ) : null} + {isCritical ? ( + + critical + + ) : null} +
+ {content ? ( +
{content}
+ ) : null} +
+ ); +} + +function extractContent(event: PentestAgentEvent): string | null { + if (event.summary && event.summary.trim().length > 0) { + return truncate(event.summary, 4000); + } + if (event.description && event.description.trim().length > 0) { + return truncate(event.description, 4000); + } + return null; +} + +function truncate(s: string, max: number): string { + return s.length > max + ? `${s.slice(0, max)}\n… (${s.length - max} more chars)` + : s; +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AgentProgressGrid.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AgentProgressGrid.tsx new file mode 100644 index 0000000000..82241bdfaf --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AgentProgressGrid.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { cn } from '@trycompai/design-system/cn'; + +interface AgentProgressGridProps { + /** Total agents running in this pentest (from Maced's progress). */ + total: number; + /** How many have finished. */ + done: number; + className?: string; +} + +/** + * 22-cell (or whatever `total` is) horizontal grid showing agent progress. + * Completed cells are filled with `--primary`. The single currently-running + * cell pulses. Pending cells are muted. Matches the "hero" band from the + * design handoff. + */ +export function AgentProgressGrid({ + total, + done, + className, +}: AgentProgressGridProps) { + const count = Math.max(total, 1); + const running = done < count ? 1 : 0; + const pending = Math.max(count - done - running, 0); + + return ( +
+ {Array.from({ length: done }).map((_, i) => ( + + ))} + {running > 0 ? ( + + ) : null} + {Array.from({ length: pending }).map((_, i) => ( + + ))} +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CompletedDetail.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CompletedDetail.tsx new file mode 100644 index 0000000000..90af2191c0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CompletedDetail.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { Button } from '@trycompai/design-system'; +import { + CheckmarkFilled, + Document, + Download, + Renew, +} from '@trycompai/design-system/icons'; +import type { + PentestAgentEvent, + PentestIssue, + PentestRun, +} from '@/lib/security/penetration-tests-client'; +import { formatReportDate } from '../lib'; +import { AgentActivityLog } from './AgentActivityLog'; +import { FindingsTable } from './FindingsTable'; +import { SevTally } from './SevTally'; +import { StatusPill } from './StatusPill'; +import { tallySeverities } from './severity'; + +interface CompletedDetailProps { + run: PentestRun; + issues: PentestIssue[]; + events: PentestAgentEvent[]; + onOpenFinding: (issue: PentestIssue) => void; + onDownloadMarkdown: () => void; + onDownloadPdf: () => void; + onReRun?: () => void; +} + +export function CompletedDetail({ + run, + issues, + events, + onOpenFinding, + onDownloadMarkdown, + onDownloadPdf, + onReRun, +}: CompletedDetailProps) { + const counts = tallySeverities(issues); + const isClean = issues.length === 0; + + return ( +
+
+
+
+ + + {run.id} + +
+
+

+ {run.targetUrl} +

+
+ + + {onReRun ? ( + + ) : null} +
+
+
+ Started {formatReportDate(run.createdAt)} + Last update {formatReportDate(run.updatedAt)} + {run.repoUrl ? Repo: {run.repoUrl} : null} + {run.testMode ? ( + + Test mode + + ) : null} +
+
+ + + +
+

+ Findings ({issues.length}) +

+ {isClean ? ( + + ) : ( + + )} +
+ + +
+
+ ); +} + +function CleanFindingsEmpty() { + return ( +
+ +
+
No issues found
+
+ The scan completed without discovering any vulnerabilities. If the + markdown report describes findings anyway, they weren't persisted to + the structured issue list — that's a provider-side inconsistency. +
+
+
+ ); +} 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 new file mode 100644 index 0000000000..07c009906e --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { Button } from '@trycompai/design-system'; +import { ArrowRight, Link } from '@trycompai/design-system/icons'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import type { PentestCreateRequest } from '@/lib/security/penetration-tests-client'; + +interface CreateRunPanelProps { + orgId: string; + onSubmit: (payload: PentestCreateRequest) => Promise<{ id: string }>; + isSubmitting?: boolean; +} + +/** + * Inline right-pane form that replaces the old modal Dialog. Matches the + * design-handoff layout: centered card with header label, target + repo + * inputs, scope-summary box, and Cancel / Start-scan actions. + */ +export function CreateRunPanel({ + orgId, + onSubmit, + isSubmitting, +}: CreateRunPanelProps) { + const router = useRouter(); + const [targetUrl, setTargetUrl] = useState(''); + const [repoUrl, setRepoUrl] = useState(''); + + const handleCancel = () => { + router.push(`/${orgId}/security/penetration-tests`); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const normalized = normalizeUrl(targetUrl); + if (!normalized) { + toast.error('Target URL is required.'); + return; + } + try { + const result = await onSubmit({ + targetUrl: normalized, + ...(repoUrl.trim() ? { repoUrl: repoUrl.trim() } : {}), + }); + router.push( + `/${orgId}/security/penetration-tests/${encodeURIComponent(result.id)}`, + ); + } catch { + // onSubmit handles its own toast. + } + }; + + return ( +
+
+
void handleSubmit(e)} + className="rounded-[var(--radius)] border border-border bg-card p-8 shadow-[0_24px_48px_-12px_rgba(0,0,0,0.12)]" + > +
+ New scan +
+
+ Start a penetration test +
+

+ Scans typically take 1–3 hours. Findings stream in as they're + discovered — you don't need to keep this page open. +

+ +
+ +
+ + https:// + + setTargetUrl(e.target.value)} + placeholder="app.staging.trycomp.ai" + autoFocus + required + className="flex-1 bg-transparent font-mono text-xs outline-none" + /> +
+

+ Must be reachable from the scanner — localhost and private IPs + are rejected. +

+
+ +
+
+ Repository + + (optional) + +
+
+ + setRepoUrl(e.target.value)} + placeholder="github.com/acme/platform" + className="flex-1 bg-transparent font-mono text-xs outline-none" + /> +
+

+ Public repositories only. We use source context to write better + remediation steps. +

+
+ +
+
+ What to expect +
+
+ {[ + ['Typical duration', '1–3 hours'], + ['Output', 'Findings + markdown & PDF report'], + ['Mode', 'Read-only — never modifies your target'], + ].map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+

+ Findings stream into this page as they're discovered — you can + close this tab and come back. +

+
+ +
+ + +
+
+
+
+ ); +} + +function normalizeUrl(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const withProtocol = /^https?:\/\//i.test(trimmed) + ? trimmed + : `https://${trimmed}`; + try { + const url = new URL(withProtocol); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + return url.toString(); + } catch { + return null; + } +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/DetailPane.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/DetailPane.tsx new file mode 100644 index 0000000000..aa182d2d93 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/DetailPane.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { AlertCircle, Loader2 } from 'lucide-react'; +import type { + PentestAgentEvent, + PentestIssue, + PentestRun, +} from '@/lib/security/penetration-tests-client'; +import { CompletedDetail } from './CompletedDetail'; +import { FailedDetail } from './FailedDetail'; +import { FindingDetail } from './FindingDetail'; +import { RunningDetail } from './RunningDetail'; +import { isRunInProgress } from './severity'; + +interface DetailPaneProps { + run: PentestRun | undefined; + issues: PentestIssue[]; + events: PentestAgentEvent[]; + isLoading: boolean; + error: Error | undefined; + selectedFinding: PentestIssue | null; + onOpenFinding: (issue: PentestIssue) => void; + onCloseFinding: () => void; + onDownloadMarkdown: () => void; + onDownloadPdf: () => void; +} + +/** + * Picks the correct detail variant for a selected run: + * finding-detail → failed → clean → completed → running (default). + */ +export function DetailPane({ + run, + issues, + events, + isLoading, + error, + selectedFinding, + onOpenFinding, + onCloseFinding, + onDownloadMarkdown, + onDownloadPdf, +}: DetailPaneProps) { + if (selectedFinding) { + return ; + } + + if (isLoading && !run) { + return ( +
+ +
+ ); + } + + if (error || !run) { + return ( +
+
+ +

Unable to load scan

+

{error?.message ?? 'No scan found for this organization.'}

+
+
+ ); + } + + if (run.status === 'failed' || run.status === 'cancelled') { + return ; + } + + if (run.status === 'completed') { + // Always the rich CompletedDetail layout — even with zero findings. The + // findings table handles the "No issues found" state inline. Keeps the + // user's eye on target / run ID / actions instead of a standalone + // celebration that hid important context. + return ( + + ); + } + + if (isRunInProgress(run.status)) { + return ( + + ); + } + + // Fallback — shouldn't happen but keeps the component total. + return ( + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/EmptyState.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/EmptyState.tsx new file mode 100644 index 0000000000..b23a10a0f9 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/EmptyState.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { Button } from '@trycompai/design-system'; +import { + Link, + Settings, + Rocket, +} from '@trycompai/design-system/icons'; + +interface EmptyStateProps { + onCreateClick: () => void; +} + +const STEPS = [ + { + title: 'Connect target', + description: + 'Point the scanner at a URL you own. HTTPS required.', + Icon: Link, + }, + { + title: 'Configure scope', + description: + 'Optionally attach a repository for deeper, code-aware coverage.', + Icon: Settings, + }, + { + title: 'Scan runs automatically', + description: + '22 specialist agents probe the target for 1–3 hours and return a compliance-grade report.', + Icon: Rocket, + }, +]; + +export function EmptyState({ onCreateClick }: EmptyStateProps) { + return ( +
+
+
+

+ Penetration Tests +

+ + New + +
+

+ Automated black-box pen testing. Start a scan to see findings here. +

+
+ + + +
+
+

+ How it works +

+
+
    + {STEPS.map((step, i) => { + const { Icon } = step; + return ( +
  1. + + {i + 1} + + +
    +
    {step.title}
    +
    + {step.description} +
    +
    +
  2. + ); + })} +
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FailedDetail.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FailedDetail.tsx new file mode 100644 index 0000000000..778bf30fda --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FailedDetail.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { Button } from '@trycompai/design-system'; +import { Renew, Warning } from '@trycompai/design-system/icons'; +import type { PentestRun } from '@/lib/security/penetration-tests-client'; +import { formatReportDate } from '../lib'; +import { StatusPill } from './StatusPill'; + +interface FailedDetailProps { + run: PentestRun; + onRetry?: () => void; +} + +export function FailedDetail({ run, onRetry }: FailedDetailProps) { + const reason = run.failedReason ?? run.error ?? 'Unknown error'; + + return ( +
+
+
+
+ + + {run.id} + +
+

+ {run.targetUrl} +

+
+ Started {formatReportDate(run.createdAt)} + Failed {formatReportDate(run.updatedAt)} +
+
+ +
+
+ +
+
+ Run error +
+

{reason}

+
+
+
+ +
+
+ Common causes +
+
    +
  • Target resolves to a non-routable IP (VPN not connected)
  • +
  • Your IP wasn't allowlisted on the target's WAF / CDN
  • +
  • Authentication flow requires credentials the scanner wasn't given
  • +
  • Workflow exceeded the runtime cap (typically ~12 hours)
  • +
+
+ + {onRetry ? ( +
+ +
+ ) : null} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingDetail.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingDetail.tsx new file mode 100644 index 0000000000..5882b65103 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingDetail.tsx @@ -0,0 +1,233 @@ +'use client'; + +import { Button } from '@trycompai/design-system'; +import { cn } from '@trycompai/design-system/cn'; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@trycompai/design-system'; +import { ArrowLeft, Copy } from '@trycompai/design-system/icons'; +import { toast } from 'sonner'; +import type { PentestIssue } from '@/lib/security/penetration-tests-client'; +import { SEVERITY_BG_VAR, SEVERITY_FG_VAR } from './severity'; + +interface FindingDetailProps { + issue: PentestIssue; + onBack: () => void; +} + +const TABS = [ + { value: 'summary', label: 'Summary' }, + { value: 'poc', label: 'PoC' }, + { value: 'impact', label: 'Impact' }, + { value: 'remediation', label: 'Remediation' }, + { value: 'validation', label: 'Validation' }, + { value: 'attack', label: 'Attack path' }, + { value: 'evidence', label: 'Evidence' }, +] as const; + +export function FindingDetail({ issue, onBack }: FindingDetailProps) { + const heroBg = SEVERITY_BG_VAR[issue.severity]; + const heroFg = SEVERITY_FG_VAR[issue.severity]; + + return ( +
+
+
+ +
+ + {/* Severity-tinted hero */} +
+
+ {issue.severity} + {issue.cweId ? ` · ${issue.cweId}` : ''} + {typeof issue.cvssScore === 'number' ? ` · CVSS ${issue.cvssScore}` : ''} +
+

+ {issue.title} +

+ {issue.summary ? ( +

{issue.summary}

+ ) : null} +
+ + {/* KV strip */} + + + {/* Tabs */} + + + {TABS.map((t) => ( + + {t.label} + + ))} + + + +
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+ + +

+ Attack-path analysis not surfaced in this view yet. +

+
+ + +

+ Evidence (HTTP transcripts, screenshots, code snippets) coming soon. +

+
+
+
+
+ ); +} + +function KVStrip({ issue }: { issue: PentestIssue }) { + const cells = [ + { label: 'Status', value: issue.status }, + { label: 'Affected', value: issue.affectedEndpoint ?? '—' }, + { + label: 'CVSS', + value: + typeof issue.cvssScore === 'number' ? issue.cvssScore.toFixed(1) : '—', + }, + { label: 'CWE', value: issue.cweId ?? '—' }, + ]; + return ( +
+ {cells.map((cell, i) => ( +
+ + {cell.label} + + {cell.value} +
+ ))} +
+ ); +} + +function Prose({ text }: { text: string }) { + return ( +
{text}
+ ); +} + +function CopyableBlock({ + content, + empty, +}: { + content: string; + empty: boolean; +}) { + const onCopy = async () => { + try { + await navigator.clipboard.writeText(content); + toast.success('Copied to clipboard'); + } catch { + toast.error('Unable to copy'); + } + }; + if (empty) { + return ( +

{content}

+ ); + } + return ( +
+
+ +
+
+        {content}
+      
+
+ ); +} + +function ValidationSection({ issue }: { issue: PentestIssue }) { + const steps = extractValidationSteps(issue); + if (steps.length === 0) { + return ( +

+ No validation steps recorded for this finding. +

+ ); + } + return ( +
    + {steps.map((step, i) => ( +
  1. + + {i + 1} + + {step} +
  2. + ))} +
+ ); +} + +// Validation-steps field isn't part of PentestIssue type (yet); parse if it +// shows up on future payloads. For now returns empty. +function extractValidationSteps(_issue: PentestIssue): string[] { + return []; +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingsTable.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingsTable.tsx new file mode 100644 index 0000000000..50a7509cc5 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingsTable.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { cn } from '@trycompai/design-system/cn'; +import type { PentestIssue } from '@/lib/security/penetration-tests-client'; +import { SevChip } from './SevChip'; +import { SEVERITY_INDEX } from './severity'; + +interface FindingsTableProps { + issues: PentestIssue[]; + onRowClick?: (issue: PentestIssue) => void; + /** IDs of findings that should briefly highlight — e.g. just-landed. */ + highlightedIds?: ReadonlySet; + emptyState?: React.ReactNode; + className?: string; +} + +export function FindingsTable({ + issues, + onRowClick, + highlightedIds, + emptyState, + className, +}: FindingsTableProps) { + const sorted = [...issues].sort( + (a, b) => + (SEVERITY_INDEX[a.severity] ?? 99) - (SEVERITY_INDEX[b.severity] ?? 99), + ); + + if (sorted.length === 0 && emptyState) { + return
{emptyState}
; + } + + return ( +
+ + + + + + + + + + + {sorted.map((issue) => ( + onRowClick?.(issue)} + > + + + + + + + ))} + +
SeverityCVSSTitleAffected +
+ + + {typeof issue.cvssScore === 'number' + ? issue.cvssScore.toFixed(1) + : '—'} + +
{issue.title}
+ {issue.cweId ? ( +
+ {issue.cweId} +
+ ) : null} +
+ {issue.affectedEndpoint ?? '—'} + + {onRowClick ? '›' : ''} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/OverviewPane.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/OverviewPane.tsx new file mode 100644 index 0000000000..c74420f32c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/OverviewPane.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { cn } from '@trycompai/design-system/cn'; +import type { PentestRun } from '@/lib/security/penetration-tests-client'; +import { formatReportDate } from '../lib'; +import { SevTally } from './SevTally'; +import { StatusPill } from './StatusPill'; +import { isRunInProgress, isRunTerminal } from './severity'; + +interface OverviewPaneProps { + runs: PentestRun[]; +} + +/** + * Default right-pane when no run is selected. Surfaces at-a-glance posture: + * - KPI strip (open needs-action, running now, recent cadence) + * - Severity roll-up + * - Targets + last scan + * Full version of the Overview from the design handoff requires backend + * aggregations we don't have yet (14-day cadence series, denormalized top-N + * findings). This ships the pieces we can compute client-side from the list. + */ +export function OverviewPane({ runs }: OverviewPaneProps) { + const running = runs.filter((r) => isRunInProgress(r.status)); + const terminal = runs.filter((r) => isRunTerminal(r.status)); + const completed = terminal.filter((r) => r.status === 'completed'); + const failed = terminal.filter((r) => r.status === 'failed'); + + // Target roll-up — last scan per distinct target URL. + const targetsMap = new Map(); + for (const run of runs) { + const existing = targetsMap.get(run.targetUrl); + if (!existing || new Date(run.updatedAt) > new Date(existing.updatedAt)) { + targetsMap.set(run.targetUrl, run); + } + } + const targets = Array.from(targetsMap.values()).sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + + return ( +
+
+
+

+ Overview +

+

+ Select a scan from the list or start a new one. +

+
+ + + +
+

+ Severity roll-up +

+ +

+ Per-org finding aggregation coming soon. Open a scan to see its findings. +

+
+ + +
+
+ ); +} + +interface KPIStripProps { + runsTotal: number; + running: number; + completed: number; + failed: number; +} + +function KPIStrip({ runsTotal, running, completed, failed }: KPIStripProps) { + const cells = [ + { label: 'Total scans', value: runsTotal }, + { label: 'Running now', value: running }, + { label: 'Completed', value: completed }, + { label: 'Failed', value: failed }, + ]; + return ( +
+ {cells.map((cell, i) => ( +
+ + {cell.label} + + + {cell.value} + +
+ ))} +
+ ); +} + +function TargetsList({ targets }: { targets: PentestRun[] }) { + if (targets.length === 0) return null; + return ( +
+
+

+ Targets · last scan +

+
+ + + {targets.slice(0, 10).map((run) => ( + + + + + + ))} + +
+
{run.targetUrl}
+
+ {formatReportDate(run.updatedAt)} + + +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunList.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunList.tsx new file mode 100644 index 0000000000..72d2ae2846 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunList.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { cn } from '@trycompai/design-system/cn'; +import { Progress } from '@trycompai/design-system'; +import { Filter, Add } from '@trycompai/design-system/icons'; +import { useRouter } from 'next/navigation'; +import type { PentestRun } from '@/lib/security/penetration-tests-client'; +import { formatReportDate } from '../lib'; +import { StatusPill } from './StatusPill'; +import { isRunInProgress } from './severity'; + +interface RunListProps { + orgId: string; + runs: PentestRun[]; + selectedRunId: string | null; + onCreateClick: () => void; + /** When true, highlights an implicit "Overview" state (no selection). */ + overviewActive?: boolean; +} + +export function RunList({ + orgId, + runs, + selectedRunId, + onCreateClick, +}: RunListProps) { + const router = useRouter(); + return ( + + ); +} + +interface RunRowProps { + orgId: string; + run: PentestRun; + selected: boolean; +} + +function RunRow({ orgId, run, selected }: RunRowProps) { + const router = useRouter(); + const inProgress = isRunInProgress(run.status); + const shortId = toShortId(run.id); + const progress = run.progress; + const progressPercent = + progress && progress.totalAgents > 0 + ? Math.round((progress.completedAgents / progress.totalAgents) * 100) + : 0; + const elapsedLabel = progress ? formatElapsed(progress.elapsedMs) : null; + const etaLabel = progress ? formatEta(progress) : null; + const target = displayTarget(run.targetUrl); + + return ( +
  • + router.push( + `/${orgId}/security/penetration-tests/${encodeURIComponent(run.id)}`, + ) + } + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + router.push( + `/${orgId}/security/penetration-tests/${encodeURIComponent(run.id)}`, + ); + } + }} + className={cn( + 'flex cursor-pointer flex-col gap-1.5 px-4 py-3 outline-none transition-colors', + 'hover:bg-muted focus-visible:bg-muted', + selected && 'bg-muted border-l-2 border-l-primary', + )} + > +
    + + + {shortId} + +
    +
    {target}
    +
    + {formatReportDate(run.updatedAt)} +
    + {inProgress ? ( +
    +
    + +
    +
    + + {progress?.completedAgents ?? 0}/{progress?.totalAgents ?? 22}{' '} + agents + + + {elapsedLabel} + {etaLabel ? ` · eta ~${etaLabel}` : ''} + +
    +
    + ) : run.status === 'failed' || run.status === 'cancelled' ? ( +
    + {run.failedReason ?? run.error ?? 'Error'} +
    + ) : null} +
  • + ); +} + +function toShortId(fullId: string): string { + // Maced IDs look like `pentest-1777037987579`. Render a compact "PT-7579" + // style label for list rows; the full id is still accessible via URL. + const tail = fullId.replace(/[^0-9]/g, '').slice(-4); + return tail ? `PT-${tail}` : fullId; +} + +function displayTarget(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} + +function formatElapsed(ms: number): string { + const totalMin = Math.max(Math.floor(ms / 60_000), 0); + const hours = Math.floor(totalMin / 60); + const minutes = totalMin % 60; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${totalMin}m`; +} + +function formatEta(progress: { + completedAgents: number; + totalAgents: number; + elapsedMs: number; +}): string | null { + if (progress.completedAgents <= 0 || progress.totalAgents <= 0) return null; + const rateMs = progress.elapsedMs / progress.completedAgents; + const remaining = progress.totalAgents - progress.completedAgents; + if (remaining <= 0) return null; + return formatElapsed(rateMs * remaining); +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunningDetail.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunningDetail.tsx new file mode 100644 index 0000000000..845016f221 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunningDetail.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import type { + PentestAgentEvent, + PentestIssue, + PentestRun, +} from '@/lib/security/penetration-tests-client'; +import { formatReportDate } from '../lib'; +import { AgentActivityLog } from './AgentActivityLog'; +import { AgentProgressGrid } from './AgentProgressGrid'; +import { FindingsTable } from './FindingsTable'; +import { SevTally } from './SevTally'; +import { StatusPill } from './StatusPill'; +import { tallySeverities } from './severity'; + +interface RunningDetailProps { + run: PentestRun; + issues: PentestIssue[]; + events: PentestAgentEvent[]; + onOpenFinding: (issue: PentestIssue) => void; +} + +export function RunningDetail({ + run, + issues, + events, + onOpenFinding, +}: RunningDetailProps) { + const counts = tallySeverities(issues); + const highlighted = useNewFindingHighlights(issues); + + const progress = run.progress; + const completedAgents = progress?.completedAgents ?? 0; + const totalAgents = progress?.totalAgents ?? 22; + const elapsedMs = progress?.elapsedMs ?? 0; + const elapsedLabel = formatElapsed(elapsedMs); + + return ( +
    +
    +
    +
    + + + {run.id} + +
    +

    + {run.targetUrl} +

    +
    + Started {formatReportDate(run.createdAt)} + Last update {formatReportDate(run.updatedAt)} + Elapsed {elapsedLabel} + {run.repoUrl ? Repo: {run.repoUrl} : null} +
    +
    + +
    +

    + Agents · {completedAgents}/{totalAgents} complete +

    + +
    + +
    +

    + Live severity tally +

    + +
    + +
    +
    +

    + Findings ({issues.length}) +

    + + New findings appear here as agents discover them + +
    + + Scanning. Findings will appear here as they are discovered. +
    + } + /> + + + +
    + + ); +} + +function formatElapsed(ms: number): string { + const totalMin = Math.floor(ms / 60_000); + const hours = Math.floor(totalMin / 60); + const minutes = totalMin % 60; + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; +} + +/** + * Tracks which issue IDs have arrived since the last render so the findings + * table can flash them with the severity tint. Issues that were already + * there on mount don't flash. + */ +function useNewFindingHighlights(issues: PentestIssue[]): Set { + const seenRef = useRef>(new Set(issues.map((i) => i.id))); + const [highlighted, setHighlighted] = useState>(new Set()); + + useEffect(() => { + const newlyLanded: string[] = []; + for (const issue of issues) { + if (!seenRef.current.has(issue.id)) { + seenRef.current.add(issue.id); + newlyLanded.push(issue.id); + } + } + if (newlyLanded.length === 0) return; + + setHighlighted((prev) => { + const next = new Set(prev); + for (const id of newlyLanded) next.add(id); + return next; + }); + + const timer = window.setTimeout(() => { + setHighlighted((prev) => { + const next = new Set(prev); + for (const id of newlyLanded) next.delete(id); + return next; + }); + }, 2000); + + return () => window.clearTimeout(timer); + }, [issues]); + + return highlighted; +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SevChip.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SevChip.tsx new file mode 100644 index 0000000000..171137ee45 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SevChip.tsx @@ -0,0 +1,32 @@ +import { cn } from '@trycompai/design-system/cn'; +import type { IssueSeverity } from '@/lib/security/penetration-tests-client'; +import { + SEVERITY_BG_VAR, + SEVERITY_FG_VAR, + SEVERITY_LABEL, +} from './severity'; + +interface SevChipProps { + severity: IssueSeverity; + size?: 'sm' | 'md'; + className?: string; +} + +export function SevChip({ severity, size = 'md', className }: SevChipProps) { + const sm = size === 'sm'; + return ( + + {SEVERITY_LABEL[severity]} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SevTally.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SevTally.tsx new file mode 100644 index 0000000000..48a19026b4 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SevTally.tsx @@ -0,0 +1,74 @@ +import { cn } from '@trycompai/design-system/cn'; +import type { IssueSeverity } from '@/lib/security/penetration-tests-client'; +import { + SEVERITY_BG_VAR, + SEVERITY_FG_VAR, + SEVERITY_LABEL, + SEVERITY_ORDER, +} from './severity'; + +interface SevTallyProps { + /** Counts keyed by severity. Missing severities render as 0. */ + counts: Partial>; + /** Size variant. `hero` is the big post-scan tally, `mid` is overview. */ + size?: 'hero' | 'mid' | 'sm'; + className?: string; +} + +export function SevTally({ counts, size = 'mid', className }: SevTallyProps) { + return ( +
    + {SEVERITY_ORDER.map((sev) => { + const n = counts[sev] ?? 0; + const active = n > 0; + return ( +
    + + {n} + + + {SEVERITY_LABEL[sev]} + +
    + ); + })} +
    + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SplitView.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SplitView.tsx new file mode 100644 index 0000000000..f0dd07536a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SplitView.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { api } from '@/lib/api-client'; +import type { + PentestCreateRequest, + PentestIssue, + PentestRun, +} from '@/lib/security/penetration-tests-client'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { + useCreatePenetrationTest, + usePenetrationTest, + usePenetrationTestEvents, + usePenetrationTestIssues, + usePenetrationTests, +} from '../hooks/use-penetration-tests'; +import { CreateRunPanel } from './CreateRunPanel'; +import { DetailPane } from './DetailPane'; +import { EmptyState } from './EmptyState'; +import { OverviewPane } from './OverviewPane'; +import { RunList } from './RunList'; +import './pentest-tokens.css'; + +interface SplitViewProps { + orgId: string; + selectedRunId: string | null; + mode?: 'default' | 'create'; +} + +/** + * Top-level page shell for the pentests feature. Three modes based on URL: + * - `/pentests` → Overview (selectedRunId null, mode default) + * - `/pentests/:id` → Detail (selectedRunId set) + * - `/pentests/new` → Create panel (mode create, list dimmed) + */ +export function SplitView({ + orgId, + selectedRunId, + mode = 'default', +}: SplitViewProps) { + const router = useRouter(); + const [selectedFinding, setSelectedFinding] = useState( + null, + ); + + const { reports, isLoading: listLoading } = usePenetrationTests(orgId); + const { + report: selectedRun, + isLoading: runLoading, + error: runError, + } = usePenetrationTest(orgId, selectedRunId ?? ''); + const { issues } = usePenetrationTestIssues( + orgId, + selectedRunId ?? '', + selectedRun?.status, + ); + const { events } = usePenetrationTestEvents( + orgId, + selectedRunId ?? '', + selectedRun?.status, + ); + const { createReport, isCreating } = useCreatePenetrationTest(orgId); + + const showEmptyState = + !listLoading && + reports.length === 0 && + selectedRunId === null && + mode !== 'create'; + const isCreateMode = mode === 'create'; + + const handleCreateSubmit = async ( + payload: PentestCreateRequest, + ): Promise<{ id: string }> => { + try { + const result = await createReport(payload); + return { id: result.id }; + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to start scan'); + throw err; + } + }; + + const handleDownloadMarkdown = async () => { + if (!selectedRun) return; + await downloadArtifact({ + orgId, + path: `/v1/security-penetration-tests/${encodeURIComponent(selectedRun.id)}/report`, + filename: `penetration-test-${selectedRun.id}.md`, + }); + }; + + const handleDownloadPdf = async () => { + if (!selectedRun) return; + await downloadArtifact({ + orgId, + path: `/v1/security-penetration-tests/${encodeURIComponent(selectedRun.id)}/pdf`, + filename: `penetration-test-${selectedRun.id}.pdf`, + }); + }; + + const goToCreate = () => + router.push(`/${orgId}/security/penetration-tests/new`); + + // Empty state only shown when there are no runs AND no selection AND not + // in create mode. Once there is at least one run we always render the + // split view. + if (showEmptyState) { + return ( +
    + +
    + ); + } + + return ( +
    +
    + +
    +
    + {isCreateMode ? ( + + ) : selectedRunId === null ? ( + + ) : ( + setSelectedFinding(null)} + onDownloadMarkdown={() => void handleDownloadMarkdown()} + onDownloadPdf={() => void handleDownloadPdf()} + /> + )} +
    +
    + ); +} + +async function downloadArtifact({ + orgId, + path, + filename, +}: { + orgId: string; + path: string; + filename?: string; +}) { + try { + const response = await api.raw(path, { + method: 'GET', + organizationId: orgId, + headers: { + Accept: filename ? 'application/pdf' : 'text/markdown', + }, + }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error( + safeErrorMessage(body) ?? `Request failed with status ${response.status}`, + ); + } + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename ?? 'penetration-test-report'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); + } catch (err) { + toast.error( + err instanceof Error ? err.message : 'Unable to download report', + ); + } +} + +function safeErrorMessage(body: string): string | null { + if (!body) return null; + try { + const parsed = JSON.parse(body) as { error?: string; message?: string }; + return parsed.error ?? parsed.message ?? null; + } catch { + return body.length < 200 ? body : null; + } +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/StatusPill.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/StatusPill.tsx new file mode 100644 index 0000000000..88d0ecaadf --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/StatusPill.tsx @@ -0,0 +1,83 @@ +import { cn } from '@trycompai/design-system/cn'; + +type StatusKind = + | 'provisioning' + | 'cloning' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'clean'; + +interface StatusPillProps { + status: StatusKind | string; + /** Total + found count — used to distinguish "completed" from "clean". */ + findingCount?: number; + className?: string; +} + +const STATUS_CONFIG: Record< + StatusKind, + { label: string; dotClass: string; textClass: string } +> = { + provisioning: { + label: 'Provisioning', + dotClass: 'bg-muted-foreground animate-pulse', + textClass: 'text-muted-foreground', + }, + cloning: { + label: 'Cloning', + dotClass: 'bg-muted-foreground animate-pulse', + textClass: 'text-muted-foreground', + }, + running: { + label: 'Running', + dotClass: 'bg-[var(--pt-pulse)] animate-pulse', + textClass: 'text-foreground', + }, + completed: { + label: 'Completed', + dotClass: 'bg-primary', + textClass: 'text-foreground', + }, + clean: { + label: 'Clean', + dotClass: 'bg-[var(--pt-sev-low-bar)]', + textClass: 'text-[var(--pt-sev-low-fg)]', + }, + failed: { + label: 'Failed', + dotClass: 'bg-destructive', + textClass: 'text-destructive', + }, + cancelled: { + label: 'Cancelled', + dotClass: 'bg-muted-foreground', + textClass: 'text-muted-foreground', + }, +}; + +const CONFIG_DEFAULT = STATUS_CONFIG.provisioning; + +export function StatusPill({ status, findingCount, className }: StatusPillProps) { + // Promote completed runs with zero findings to the "clean" look. + const effective: StatusKind = + status === 'completed' && findingCount === 0 + ? 'clean' + : (status as StatusKind); + const config = STATUS_CONFIG[effective] ?? CONFIG_DEFAULT; + + return ( + + + {config.label} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/pentest-tokens.css b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/pentest-tokens.css new file mode 100644 index 0000000000..86ceb25e0b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/pentest-tokens.css @@ -0,0 +1,88 @@ +/* + * Pentest-only design tokens. Scoped to `.pt-tokens` so they don't leak into + * the global theme. Mirror tokens from the design handoff at + * design_handoff_pen_tests/design-reference/colors_and_type.css. + */ + +.pt-tokens { + /* Severity palette — light mode */ + --pt-sev-critical-bg: oklch(0.95 0.03 27); + --pt-sev-critical-fg: oklch(0.42 0.22 27); + --pt-sev-critical-bar: oklch(0.55 0.22 27); + + --pt-sev-high-bg: oklch(0.95 0.04 40); + --pt-sev-high-fg: oklch(0.46 0.18 40); + --pt-sev-high-bar: oklch(0.62 0.18 40); + + --pt-sev-medium-bg: oklch(0.96 0.04 75); + --pt-sev-medium-fg: oklch(0.5 0.14 75); + --pt-sev-medium-bar: oklch(0.72 0.15 75); + + --pt-sev-low-bg: oklch(0.96 0.03 145); + --pt-sev-low-fg: oklch(0.42 0.14 145); + --pt-sev-low-bar: oklch(0.55 0.15 145); + + --pt-sev-info-bg: oklch(0.95 0 0); + --pt-sev-info-fg: oklch(0.45 0 0); + --pt-sev-info-bar: oklch(0.7 0 0); + + /* Agent-running pulse color */ + --pt-pulse: oklch(0.6 0.18 250); +} + +.dark .pt-tokens { + /* Severity palette — dark mode (mirrored) */ + --pt-sev-critical-bg: oklch(0.26 0.08 27); + --pt-sev-critical-fg: oklch(0.82 0.16 27); + --pt-sev-critical-bar: oklch(0.68 0.22 27); + + --pt-sev-high-bg: oklch(0.26 0.07 40); + --pt-sev-high-fg: oklch(0.82 0.15 40); + --pt-sev-high-bar: oklch(0.7 0.17 40); + + --pt-sev-medium-bg: oklch(0.26 0.06 75); + --pt-sev-medium-fg: oklch(0.84 0.14 75); + --pt-sev-medium-bar: oklch(0.78 0.15 75); + + --pt-sev-low-bg: oklch(0.25 0.06 145); + --pt-sev-low-fg: oklch(0.78 0.14 145); + --pt-sev-low-bar: oklch(0.62 0.15 145); + + --pt-sev-info-bg: oklch(0.24 0 0); + --pt-sev-info-fg: oklch(0.78 0 0); + --pt-sev-info-bar: oklch(0.55 0 0); + + --pt-pulse: oklch(0.7 0.18 250); +} + +/* Agent-cell pulse animation used by the running-state 22-cell grid */ +@keyframes pt-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(0.85); + } +} + +.pt-agent-cell--running { + background: var(--pt-pulse); + animation: pt-pulse 1.5s ease infinite; +} + +/* Streaming-finding fade-in — briefly tints a row in the severity color then + fades out. Applied via a class added when a new finding lands. */ +@keyframes pt-row-land { + 0% { + background-color: var(--pt-row-tint, transparent); + } + 100% { + background-color: transparent; + } +} +.pt-row-land { + animation: pt-row-land 1.8s ease-out; +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/severity.ts b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/severity.ts new file mode 100644 index 0000000000..e21d782090 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/severity.ts @@ -0,0 +1,113 @@ +import type { IssueSeverity } from '@/lib/security/penetration-tests-client'; + +/** + * Severity ordering (critical first). Used for sorts + rollup tallies. + */ +export const SEVERITY_ORDER: readonly IssueSeverity[] = [ + 'critical', + 'high', + 'medium', + 'low', + 'info', +] as const; + +export const SEVERITY_INDEX: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, + info: 4, +}; + +/** + * Severity palette — oklch values expressed as CSS variables scoped to the + * `.pt-tokens` class. Keeps the global theme uncontaminated by pentest-only + * colors while still letting dark mode swap via the existing `.dark` class. + */ +export interface SeverityLabelProps { + label: string; + cssVar: string; +} + +export const SEVERITY_BG_VAR: Record = { + critical: 'var(--pt-sev-critical-bg)', + high: 'var(--pt-sev-high-bg)', + medium: 'var(--pt-sev-medium-bg)', + low: 'var(--pt-sev-low-bg)', + info: 'var(--pt-sev-info-bg)', +}; + +export const SEVERITY_FG_VAR: Record = { + critical: 'var(--pt-sev-critical-fg)', + high: 'var(--pt-sev-high-fg)', + medium: 'var(--pt-sev-medium-fg)', + low: 'var(--pt-sev-low-fg)', + info: 'var(--pt-sev-info-fg)', +}; + +export const SEVERITY_BAR_VAR: Record = { + critical: 'var(--pt-sev-critical-bar)', + high: 'var(--pt-sev-high-bar)', + medium: 'var(--pt-sev-medium-bar)', + low: 'var(--pt-sev-low-bar)', + info: 'var(--pt-sev-info-bar)', +}; + +export const SEVERITY_LABEL: Record = { + critical: 'Critical', + high: 'High', + medium: 'Medium', + low: 'Low', + info: 'Info', +}; + +/** + * Tally the number of findings at each severity level. Returns all five + * buckets so the UI can render a fixed-size row without null checks. + */ +export function tallySeverities( + items: readonly T[], +): Record { + const tally: Record = { + critical: 0, + high: 0, + medium: 0, + low: 0, + info: 0, + }; + for (const item of items) { + tally[item.severity] = (tally[item.severity] ?? 0) + 1; + } + return tally; +} + +export function sortBySeverity( + items: readonly T[], +): T[] { + return [...items].sort( + (a, b) => + (SEVERITY_INDEX[a.severity] ?? 99) - (SEVERITY_INDEX[b.severity] ?? 99), + ); +} + +/** + * Map of terminal vs. in-progress statuses. Keeps the split-view detail + * router pure: `statusToView(run.status)` picks which detail pane renders. + */ +export type RunStatus = + | 'provisioning' + | 'cloning' + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export function isRunInProgress(status: RunStatus | string | undefined): boolean { + return ( + status === 'provisioning' || status === 'cloning' || status === 'running' + ); +} + +export function isRunTerminal(status: RunStatus | string | undefined): boolean { + return status === 'completed' || status === 'failed' || status === 'cancelled'; +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts index f62c28980a..4dda4d8885 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts @@ -3,7 +3,9 @@ import { api } from '@/lib/api-client'; import type { CreatePenetrationTestResponse, + PentestAgentEvent, PentestCreateRequest, + PentestIssue, PentestProgress, PentestReportStatus, PentestRun, @@ -14,11 +16,14 @@ import useSWR from 'swr'; import { isReportInProgress, sortReportsByUpdatedAtDesc } from '../lib'; const reportListEndpoint = '/v1/security-penetration-tests'; -const githubReposEndpoint = '/v1/security-penetration-tests/github/repos'; const reportEndpoint = (reportId: string): string => `/v1/security-penetration-tests/${encodeURIComponent(reportId)}`; const reportProgressEndpoint = (reportId: string): string => `/v1/security-penetration-tests/${encodeURIComponent(reportId)}/progress`; +const reportIssuesEndpoint = (reportId: string): string => + `/v1/security-penetration-tests/${encodeURIComponent(reportId)}/issues`; +const reportEventsEndpoint = (reportId: string): string => + `/v1/security-penetration-tests/${encodeURIComponent(reportId)}/events`; const inProgressStatus: readonly PentestReportStatus[] = [ 'provisioning', 'cloning', @@ -35,8 +40,6 @@ const allStatuses: readonly PentestReportStatus[] = [ type ReportsSWRKey = readonly [endpoint: string, organizationId: string]; -const githubReposKey = (organizationId: string): ReportsSWRKey => - [githubReposEndpoint, organizationId] as const; const reportListKey = (organizationId: string): ReportsSWRKey => [reportListEndpoint, organizationId] as const; const reportKey = (organizationId: string, reportId: string): ReportsSWRKey => @@ -71,11 +74,8 @@ const resolveCreateStatus = ( interface CreatePayload { targetUrl: string; repoUrl?: string; - githubToken?: string; - configYaml?: string; pipelineTesting?: boolean; testMode?: boolean; - workspace?: string; } type CreateReportApiPayload = PentestCreateRequest; @@ -199,34 +199,74 @@ export function usePenetrationTestProgress( } satisfies UsePenetrationTestProgressReturn; } -export interface GithubRepo { - id: number; - name: string; - fullName: string; - private: boolean; - htmlUrl: string; -} +const reportIssuesKey = ( + organizationId: string, + reportId: string, +): ReportsSWRKey => [reportIssuesEndpoint(reportId), organizationId] as const; + +const reportEventsKey = ( + organizationId: string, + reportId: string, +): ReportsSWRKey => [reportEventsEndpoint(reportId), organizationId] as const; + +// Polls the issues list continuously while the run is in-progress, and once +// after completion to load the final set. During a live ~1-hr scan, findings +// trickle in — each refresh appends any new ones. +export function usePenetrationTestIssues( + organizationId: string, + reportId: string, + status: PentestReportStatus | undefined, +): { + issues: PentestIssue[]; + isLoading: boolean; + error: Error | undefined; + mutate: () => Promise; +} { + const shouldFetch = Boolean(organizationId && reportId); + const inProgress = Boolean(status && isReportInProgress(status)); -interface GithubReposResponse { - repos: GithubRepo[]; - connected: boolean; + const { data, error, mutate } = useSWR( + shouldFetch ? reportIssuesKey(organizationId, reportId) : null, + fetchApiJson, + { + refreshInterval: inProgress ? 3000 : 0, + revalidateOnFocus: true, + }, + ); + + return { + issues: data ?? [], + isLoading: shouldFetch && data === undefined && !error, + error: error as Error | undefined, + mutate, + }; } -export function useGithubRepos(organizationId: string): { - repos: GithubRepo[]; - connected: boolean; +// Polls the agent event stream (tool calls, observations). Noisier than +// issues — intended for activity feeds or debug views. +export function usePenetrationTestEvents( + organizationId: string, + reportId: string, + status: PentestReportStatus | undefined, +): { + events: PentestAgentEvent[]; isLoading: boolean; } { - const shouldFetch = Boolean(organizationId); - const { data } = useSWR( - shouldFetch ? githubReposKey(organizationId) : null, + const shouldFetch = Boolean(organizationId && reportId); + const inProgress = Boolean(status && isReportInProgress(status)); + + const { data, error } = useSWR( + shouldFetch ? reportEventsKey(organizationId, reportId) : null, fetchApiJson, + { + refreshInterval: inProgress ? 5000 : 0, + revalidateOnFocus: true, + }, ); return { - repos: data?.repos ?? [], - connected: data?.connected ?? false, - isLoading: shouldFetch && data === undefined, + events: data ?? [], + isLoading: shouldFetch && data === undefined && !error, }; } @@ -250,11 +290,8 @@ export function useCreatePenetrationTest( { targetUrl: payload.targetUrl, repoUrl: payload.repoUrl, - githubToken: payload.githubToken, - configYaml: payload.configYaml, pipelineTesting: payload.pipelineTesting, testMode: payload.testMode, - workspace: payload.workspace, } satisfies CreateReportApiPayload, organizationId, ); @@ -268,15 +305,6 @@ export function useCreatePenetrationTest( throw new Error('Could not resolve report ID from create response.'); } - const billingRes = await api.post( - '/v1/pentest-billing/charge', - { runId: reportId }, - organizationId, - ); - if (billingRes.error) { - throw new Error(billingRes.error); - } - const data: CreatePenetrationTestResponse = { id: reportId, status: response.data?.status, diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/new/new-penetration-test-page-client.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/new/new-penetration-test-page-client.tsx new file mode 100644 index 0000000000..0098d0f9de --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/new/new-penetration-test-page-client.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { SplitView } from '../_components/SplitView'; + +interface NewPenetrationTestPageClientProps { + orgId: string; +} + +/** + * `/penetration-tests/new` — renders the split-view shell in create mode. + * Shows the run list (dimmed, non-interactive) on the left and the create + * form in the right pane. + */ +export function NewPenetrationTestPageClient({ + orgId, +}: NewPenetrationTestPageClientProps) { + return ; +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/new/page.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/new/page.tsx new file mode 100644 index 0000000000..82463aa93d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/new/page.tsx @@ -0,0 +1,42 @@ +import { auth } from '@/utils/auth'; +import { db } from '@db/server'; +import type { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; + +import { NewPenetrationTestPageClient } from './new-penetration-test-page-client'; + +interface NewPageProps { + params: Promise<{ orgId: string }>; +} + +export default async function NewPenetrationTestPage({ params }: NewPageProps) { + const { orgId } = await params; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user.id) { + redirect('/auth'); + } + + const member = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId: orgId, + deactivated: false, + }, + }); + + if (!member) { + redirect('/'); + } + + return ; +} + +export async function generateMetadata(): Promise { + return { + title: 'New Penetration Test', + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx index f48a76f361..410ee75c87 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx @@ -1,336 +1,19 @@ 'use client'; -import { Badge } from '@trycompai/ui/badge'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@trycompai/ui/card'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@trycompai/ui/dialog'; -import { Input } from '@trycompai/ui/input'; -import { Label } from '@trycompai/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@trycompai/ui/select'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@trycompai/ui/table'; -import { AlertCircle, Loader2 } from 'lucide-react'; -import { useRouter } from 'next/navigation'; -import { FormEvent, useState } from 'react'; -import { toast } from 'sonner'; -import { formatReportDate, isReportInProgress, statusLabel, statusVariant } from './lib'; -import { - useCreatePenetrationTest, - useGithubRepos, - usePenetrationTests, -} from './hooks/use-penetration-tests'; -import { - useIntegrationConnections, - useIntegrationMutations, -} from '@/hooks/use-integration-platform'; -import { Button, PageHeader, PageLayout } from '@trycompai/design-system'; +import { SplitView } from './_components/SplitView'; interface PenetrationTestsPageClientProps { orgId: string; } -const hasProtocol = (value: string): boolean => /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(value); - -const normalizeTargetUrl = (value: string): string | null => { - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - - const normalized = hasProtocol(trimmed) ? trimmed : `https://${trimmed}`; - - try { - new URL(normalized); - return normalized; - } catch { - return null; - } -}; - -export function PenetrationTestsPageClient({ orgId }: PenetrationTestsPageClientProps) { - const router = useRouter(); - - const [showNewRunDialog, setShowNewRunDialog] = useState(false); - const [targetUrl, setTargetUrl] = useState(''); - const [repoUrl, setRepoUrl] = useState(''); - const [isConnectingGithub, setIsConnectingGithub] = useState(false); - - const { reports, isLoading, activeReports, completedReports } = - usePenetrationTests(orgId); - - const { repos: githubRepos } = useGithubRepos(orgId); - - const { connections } = useIntegrationConnections(); - const githubConnected = connections.some( - (c) => c.providerSlug === 'github' && c.status === 'active', - ); - const { startOAuth } = useIntegrationMutations(); - - const { - createReport, - isCreating, - } = useCreatePenetrationTest(orgId); - - const handleConnectGithub = async () => { - setIsConnectingGithub(true); - const result = await startOAuth('github', window.location.href); - if (result.authorizationUrl) { - window.location.href = result.authorizationUrl; - } else { - toast.error(result.error ?? 'Failed to start GitHub connection'); - setIsConnectingGithub(false); - } - }; - - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - const trimmedTargetUrl = targetUrl.trim(); - if (!trimmedTargetUrl) { - toast.error('Target URL is required'); - return; - } - const normalizedTargetUrl = normalizeTargetUrl(trimmedTargetUrl); - if (!normalizedTargetUrl) { - toast.error('Enter a valid target URL'); - return; - } - - try { - const response = await createReport({ - targetUrl: normalizedTargetUrl, - repoUrl: repoUrl.trim() || undefined, - }); - - setTargetUrl(''); - setRepoUrl(''); - setShowNewRunDialog(false); - toast.success('Penetration test queued successfully.'); - router.push(`/${orgId}/security/penetration-tests/${response.id}`); - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Could not queue a new report'); - } - }; - - return ( - - setShowNewRunDialog(true)}>Create Report - } - > - Run penetration tests with Maced and review generated reports.{' '} - - Manage subscription - - - - - - - Queue a penetration test - - Your subscription includes 3 penetration test runs per month. Additional runs are - charged as overage immediately. - - -
    -
    - - setTargetUrl(event.target.value)} - required - /> -
    -
    - - {githubConnected ? ( - - ) : ( - setRepoUrl(event.target.value)} - /> - )} - {githubConnected && ( - r.htmlUrl === repoUrl) ? '' : repoUrl} - placeholder="or paste URL manually" - onChange={(event) => setRepoUrl(event.target.value)} - /> - )} - {githubConnected ? ( -

    - Optional. Leave blank to run a black-box scan. -

    - ) : ( -
    -

    - Optional. Connect GitHub to select from your repos. -

    - -
    - )} -
    - - - - -
    -
    -
    - - - - Your reports ({reports.length}) - - {activeReports.length > 0 - ? `${activeReports.length} report${activeReports.length === 1 ? '' : 's'} in progress` - : completedReports.length > 0 - ? `${completedReports.length} completed report${completedReports.length === 1 ? '' : 's'}` - : 'No reports yet'} - - - - {isLoading ? ( -
    - -
    - ) : reports.length === 0 ? ( -
    - -

    No reports yet

    -

    - Create your first penetration test to get started. -

    -
    - -
    -
    - ) : ( - - - - Target - Repository - Status - Progress - Last update - Actions - - - - {reports.map((report) => ( - - {report.targetUrl} - {report.repoUrl || '—'} - - {statusLabel[report.status]} - - - {report.progress ? ( - - {`In progress${typeof report.progress.completedAgents === 'number' && typeof report.progress.totalAgents === 'number' ? ` (${report.progress.completedAgents}/${report.progress.totalAgents})` : ''}`} - - ) : isReportInProgress(report.status) ? ( - In queue - ) : report.status === 'failed' ? ( - - {report.failedReason ?? report.error ?? 'Run failed'} - - ) : ( - - )} - - {formatReportDate(report.updatedAt)} - - - {report.status === 'completed' ? ( - Ready - ) : ( - Pending - )} - - - ))} - -
    - )} -
    -
    -
    - ); +/** + * Penetration tests list route. Renders the split-view shell with no run + * selected — the right pane shows the Overview. Clicking a row in the left + * list navigates to `[reportId]` which re-renders the same shell with that + * run selected. + */ +export function PenetrationTestsPageClient({ + orgId, +}: PenetrationTestsPageClientProps) { + return ; } diff --git a/apps/app/src/app/(app)/[orgId]/settings/billing/billing-actions.tsx b/apps/app/src/app/(app)/[orgId]/settings/billing/billing-actions.tsx deleted file mode 100644 index 88832b090f..0000000000 --- a/apps/app/src/app/(app)/[orgId]/settings/billing/billing-actions.tsx +++ /dev/null @@ -1,69 +0,0 @@ -'use client'; - -import { api } from '@/lib/api-client'; -import { Button } from '@trycompai/design-system'; -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; - -interface BillingActionsProps { - orgId: string; - action: 'subscribe' | 'portal'; -} - -export function BillingActions({ orgId, action }: BillingActionsProps) { - const router = useRouter(); - const [isLoading, setIsLoading] = useState(false); - - const handleClick = async () => { - setIsLoading(true); - try { - const billingUrl = `${window.location.origin}/${orgId}/settings/billing`; - - if (action === 'subscribe') { - const res = await api.post<{ url: string }>( - '/v1/pentest-billing/subscribe', - { - successUrl: `${billingUrl}?success=true&session_id={CHECKOUT_SESSION_ID}`, - cancelUrl: billingUrl, - }, - orgId, - ); - if (res.data?.url) { - window.location.href = res.data.url; - return; - } - throw new Error(res.error ?? 'Failed to create checkout session'); - } - - if (action === 'portal') { - const res = await api.post<{ url: string }>( - '/v1/pentest-billing/portal', - { returnUrl: billingUrl }, - orgId, - ); - if (res.data?.url) { - window.location.href = res.data.url; - return; - } - throw new Error(res.error ?? 'Failed to create portal session'); - } - } catch (error) { - console.error('Billing action failed:', error); - setIsLoading(false); - } - }; - - return ( - - ); -} diff --git a/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx b/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx index 84d1861f8d..2cb0c60194 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx @@ -1,128 +1,26 @@ -import { serverApi } from '@/lib/api-server'; -import { Button } from '@trycompai/design-system'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@trycompai/ui/card'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@trycompai/ui/card'; import type { Metadata } from 'next'; -import { redirect } from 'next/navigation'; -import { BillingActions } from './billing-actions'; - -interface BillingPageProps { - params: Promise<{ orgId: string }>; - searchParams: Promise<{ success?: string; session_id?: string }>; -} - -interface SubscriptionStatus { - hasSubscription: boolean; - status?: string; - includedRunsPerPeriod?: number; - runsThisPeriod?: number; - currentPeriodEnd?: string; -} - -export default async function BillingPage({ params, searchParams }: BillingPageProps) { - const { orgId } = await params; - const { success, session_id } = await searchParams; - - let successMessage: string | null = null; - let errorMessage: string | null = null; - - if (success === 'true' && session_id) { - const res = await serverApi.post('/v1/pentest-billing/handle-success', { sessionId: session_id }); - if (res.error) { - errorMessage = res.error; - } else { - successMessage = 'Subscription activated! You can now create penetration test runs.'; - } - } - - const statusRes = await serverApi.get('/v1/pentest-billing/status'); - const subscription = statusRes.data; +export default async function BillingPage() { return (
    - {successMessage && ( -
    - {successMessage} -
    - )} - - {errorMessage && ( -
    - {errorMessage} -
    - )} - - - - Payment & Billing - - Manage your payment method for all app subscriptions. - - - - {subscription?.hasSubscription ? ( -
    -

    - Stripe customer connected. -

    - -
    - ) : ( -

    - Payment method will be set up when you subscribe to an app below. -

    - )} -
    -
    - Penetration Testing - $99/month — Includes 3 penetration test runs per period. Additional runs charged as - overage at $49/run. + Every organization gets a free trial run. Paid plans are coming soon. - - {subscription?.hasSubscription && subscription.status === 'active' ? ( -
    -
    - Status - {subscription.status} -
    -
    - Included runs / period - {subscription.includedRunsPerPeriod} -
    - {subscription.runsThisPeriod !== undefined && ( -
    - Runs used this period - - {subscription.runsThisPeriod} / {subscription.includedRunsPerPeriod} - -
    - )} - {subscription.currentPeriodEnd && ( -
    - Period ends - - {new Date(subscription.currentPeriodEnd).toLocaleDateString()} - -
    - )} -
    - ) : subscription?.hasSubscription && subscription.status === 'cancelled' ? ( -

    - Your subscription has been cancelled. Subscribe below to resume. -

    - ) : ( -

    - No active subscription. Subscribe to start running penetration tests. -

    - )} - - {(!subscription?.hasSubscription || subscription.status === 'cancelled') && ( - - )} + +

    + See your remaining trial runs on the Penetration Tests page. +

    diff --git a/apps/app/src/app/api/webhooks/stripe-pentest/route.ts b/apps/app/src/app/api/webhooks/stripe-pentest/route.ts deleted file mode 100644 index beee6c1c2d..0000000000 --- a/apps/app/src/app/api/webhooks/stripe-pentest/route.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { env } from '@/env.mjs'; -import { stripe } from '@/lib/stripe'; -import { db } from '@db/server'; -import { headers } from 'next/headers'; -import { NextResponse } from 'next/server'; -import type Stripe from 'stripe'; - -export async function POST(request: Request): Promise { - if (!stripe) { - return NextResponse.json({ error: 'Stripe not configured' }, { status: 500 }); - } - - const webhookSecret = env.STRIPE_PENTEST_WEBHOOK_SECRET; - if (!webhookSecret) { - return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 }); - } - - const body = await request.text(); - const headersList = await headers(); - const signature = headersList.get('stripe-signature'); - - if (!signature) { - return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 }); - } - - let event: Stripe.Event; - try { - event = stripe.webhooks.constructEvent(body, signature, webhookSecret); - } catch (err) { - const message = err instanceof Error ? err.message : 'Webhook signature verification failed'; - return NextResponse.json({ error: message }, { status: 400 }); - } - - try { - if (event.type === 'checkout.session.completed') { - const session = event.data.object as Stripe.Checkout.Session; - - // Only handle subscription checkouts - if (session.mode !== 'subscription') { - return NextResponse.json({ received: true }); - } - - const stripeCustomerId = - typeof session.customer === 'string' ? session.customer : session.customer?.id ?? ''; - const stripeSubscriptionId = - typeof session.subscription === 'string' - ? session.subscription - : session.subscription?.id ?? ''; - - if (!stripeCustomerId || !stripeSubscriptionId) { - return NextResponse.json({ received: true }); - } - - // Find the org from the OrganizationBilling record created at checkout start - const billing = await db.organizationBilling.findFirst({ - where: { stripeCustomerId }, - }); - - if (!billing) { - // Unknown customer — not one of ours - return NextResponse.json({ received: true }); - } - - // Retrieve full subscription to get price and period details - const subscription = await stripe.subscriptions.retrieve(stripeSubscriptionId); - // In Stripe SDK v20+ (API 2025-12-15.clover), period dates moved to SubscriptionItem - const item = subscription.items.data[0]; - const status = subscription.status === 'active' ? 'active' : subscription.status; - - await db.pentestSubscription.upsert({ - where: { organizationId: billing.organizationId }, - create: { - organizationId: billing.organizationId, - organizationBillingId: billing.id, - stripeSubscriptionId, - stripePriceId: item?.price.id ?? '', - status, - currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000), - currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000), - }, - update: { - stripeSubscriptionId, - stripePriceId: item?.price.id ?? '', - status, - currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000), - currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000), - }, - }); - } else if (event.type === 'customer.subscription.updated') { - const subscription = event.data.object as Stripe.Subscription; - // In Stripe SDK v20+ (API 2025-12-15.clover), period dates moved to SubscriptionItem - const item = subscription.items.data[0]; - await db.pentestSubscription.updateMany({ - where: { stripeSubscriptionId: subscription.id }, - data: { - status: subscription.status, - currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000), - currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000), - }, - }); - } else if (event.type === 'customer.subscription.deleted') { - const subscription = event.data.object as Stripe.Subscription; - await db.pentestSubscription.updateMany({ - where: { stripeSubscriptionId: subscription.id }, - data: { status: 'cancelled' }, - }); - } - } catch (err) { - console.error('Error handling stripe-pentest webhook event', event.type, err); - return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 }); - } - - return NextResponse.json({ received: true }); -} diff --git a/apps/app/src/env.mjs b/apps/app/src/env.mjs index ef5562b17e..efb8bb00d9 100644 --- a/apps/app/src/env.mjs +++ b/apps/app/src/env.mjs @@ -51,9 +51,6 @@ export const env = createEnv({ APP_AWS_ENDPOINT: z.string().optional(), BROWSERBASE_PROJECT_ID: z.string().optional(), INTERNAL_API_TOKEN: z.string().optional(), - STRIPE_PENTEST_SUBSCRIPTION_PRICE_ID: z.string().optional(), - STRIPE_PENTEST_OVERAGE_PRICE_ID: z.string().optional(), - STRIPE_PENTEST_WEBHOOK_SECRET: z.string().optional(), }, client: { @@ -130,9 +127,6 @@ export const env = createEnv({ APP_AWS_ENDPOINT: process.env.APP_AWS_ENDPOINT, BROWSERBASE_PROJECT_ID: process.env.BROWSERBASE_PROJECT_ID, INTERNAL_API_TOKEN: process.env.INTERNAL_API_TOKEN, - STRIPE_PENTEST_SUBSCRIPTION_PRICE_ID: process.env.STRIPE_PENTEST_SUBSCRIPTION_PRICE_ID, - STRIPE_PENTEST_OVERAGE_PRICE_ID: process.env.STRIPE_PENTEST_OVERAGE_PRICE_ID, - STRIPE_PENTEST_WEBHOOK_SECRET: process.env.STRIPE_PENTEST_WEBHOOK_SECRET, NEXT_PUBLIC_SELF_HOSTED: process.env.NEXT_PUBLIC_SELF_HOSTED, NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV, }, diff --git a/apps/app/src/lib/security/penetration-tests-client.ts b/apps/app/src/lib/security/penetration-tests-client.ts index 7630422055..9897feee87 100644 --- a/apps/app/src/lib/security/penetration-tests-client.ts +++ b/apps/app/src/lib/security/penetration-tests-client.ts @@ -26,11 +26,8 @@ export interface PentestRun { export interface PentestCreateRequest { targetUrl: string; repoUrl?: string; - githubToken?: string; - configYaml?: string; pipelineTesting?: boolean; testMode?: boolean; - workspace?: string; webhookUrl?: string; notificationEmail?: string; } @@ -39,3 +36,61 @@ export interface CreatePenetrationTestResponse { id: string; status?: PentestReportStatus; } + +export type IssueSeverity = + | 'critical' + | 'high' + | 'medium' + | 'low' + | 'info'; + +export type IssueStatus = + | 'open' + | 'acknowledged' + | 'resolved' + | 'false_positive' + | 'wont_fix'; + +// Mirrors @maced/api-client's Issue type. Kept as a lightweight frontend +// copy so we don't import server-only deps into the browser bundle. +export interface PentestIssue { + id: string; + runId?: string | null; + findingId?: string | null; + title: string; + summary?: string | null; + description?: string; + severity: IssueSeverity; + status: IssueStatus; + cve?: string | null; + cweId?: string | null; + cvssScore?: number | null; + affectedEndpoint?: string | null; + proofOfConcept?: string | null; + impact?: string | null; + remediation?: string | null; + createdAt: string; + updatedAt: string; +} + +// Agent-level event emitted during the run. Maced returns two shapes +// depending on kind: +// - tool_use: has `tool`, `summary` usually equals the tool name +// - tool_result: has `summary` with the tool output (starts with "→"), +// sometimes `emphasis: "critical"` for load-bearing results +// Older fields (`description`, `raw`, `category`) are kept optional as a +// fallback for older events or future shape changes. +export interface PentestAgentEvent { + id: string; + agent: string; + kind?: 'tool_use' | 'tool_result' | string; + tool?: string | null; + summary?: string | null; + emphasis?: 'critical' | 'warning' | string | null; + timestamp: number; + // Legacy / fallback fields + category?: string; + turn?: number; + description?: string | null; + raw?: string; +} diff --git a/bun.lock b/bun.lock index d3c4f52d4d..4b271c7edf 100644 --- a/bun.lock +++ b/bun.lock @@ -128,6 +128,7 @@ "@aws-sdk/s3-request-presigner": "3.1013.0", "@browserbasehq/sdk": "2.6.0", "@browserbasehq/stagehand": "^3.2.1", + "@maced/api-client": "^0.9.1", "@mendable/firecrawl-js": "^4.9.3", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", @@ -1662,6 +1663,8 @@ "@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="], + "@maced/api-client": ["@maced/api-client@0.9.1", "", { "dependencies": { "openapi-fetch": "^0.12.2" } }, "sha512-fcAZYfqm49ift9fxZ4ZEiGKPces68kkVeQggZTMFTbon40Sh7kCpAZuWN7n4fjxfBqaXCUw3mo8BRECFf5a+sQ=="], + "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], @@ -5210,6 +5213,10 @@ "openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="], + "openapi-fetch": ["openapi-fetch@0.12.5", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-FnAMWLt0MNL6ComcL4q/YbB1tUgyz5YnYtwA1+zlJ5xcucmK5RlWsgH1ynxmEeu8fGJkYjm8armU/HVpORc9lw=="], + + "openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.15", "", {}, "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw=="], + "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100644 index 0000000000..6c1631781a --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,172 @@ +# Pentest v1 — end-to-end rebuild (single PR) + +## Goal + +Ship pentest v1 in **one PR** with everything working end-to-end against real Maced: + +- Every org has 1 free trial run (single flag to change the number) +- Uses `@maced/api-client` SDK — no homegrown HTTP client or schemas +- Real-time status via Maced's signed webhooks (HMAC verification via SDK) +- Full audit trail via existing `AuditLog` +- Existing UI kept; only add credit-balance display + button gating +- All dead/outdated code removed in the same PR + +## Out of scope (v2 — separate plan) + +- Stripe subscriptions / Checkout / paid plans +- Top-up purchases, overage billing +- Stripe customer portal +- Credit-history ledger table (add if needed when top-ups land) + +## Guiding principles + +- **No new tables.** Extend `pentest_subscription`; reuse `AuditLog`. +- **Flexibility lives in one place per concern.** Trial grant amount = one flag. Trial grants on org-create = one hook. +- **SDK types are the source of truth** — delete every homegrown Maced type / Zod schema. +- **Every piece must work end-to-end against real Maced before PR is opened** — not a stack of hopes-to-work. + +--- + +## Work breakdown (ordered; build + verify each phase locally before moving on) + +### Phase 1 — Clean the old code we're replacing + +- [ ] Delete `apps/api/src/security-penetration-tests/maced-client.ts` (homegrown HTTP client + Zod schemas — replaced by SDK). +- [ ] Delete `apps/api/src/security-penetration-tests/pentest-billing.controller.ts`. +- [ ] Delete `apps/api/src/security-penetration-tests/pentest-billing.service.ts`. +- [ ] Delete `apps/app/src/app/api/webhooks/stripe-pentest/route.ts`. +- [ ] Delete the dead webhook-handshake code still in `security-penetration-tests.service.ts` (everything related to `verifyAndRecordWebhookHandshake`, `webhookHandshakeSecretName`, `parseWebhookHandshake`, `PersistedWebhookHandshake` — fully replaced in Phase 4). +- [ ] Remove `STRIPE_PENTEST_SUBSCRIPTION_PRICE_ID`, `STRIPE_PENTEST_OVERAGE_PRICE_ID`, `STRIPE_PENTEST_WEBHOOK_SECRET` references from `apps/api/.env.example` and `comp-private/apps/infra/index.ts`. +- [ ] Remove the frontend billing-actions Stripe CTAs from `apps/app/src/app/(app)/[orgId]/settings/billing/billing-actions.tsx` (or collapse the file to a "Trial" read-only display). +- [ ] Delete (or port to SDK) `apps/api/test/maced-contract.e2e-spec.ts` and re-enable `.github/workflows/maced-contract-canary.yml` if ported. + +**Verify:** project typechecks; tests that reference deleted files are removed or updated. UI loads (will have reduced functionality temporarily until Phase 3+). + +### Phase 2 — Swap to `@maced/api-client` + +- [ ] Initialize `createMacedClient({ apiKey: process.env.MACED_API_KEY! })` as a singleton in `SecurityPenetrationTestsService`. +- [ ] Replace every Maced call with SDK methods: + - `maced.pentests.create(...)` → `createReport()` + - `maced.pentests.list(...)` → `listReports()` + - `maced.pentests.retrieve(id)` → `getReport()` + - `maced.pentests.progress(id)` (verify SDK method name) → `getReportProgress()` + - Report markdown + PDF → SDK's equivalent (verify streaming API) +- [ ] Use SDK-inferred types everywhere (`z.infer<...>` imports go away). +- [ ] In `createReport()`, keep the backfill pattern (create response is lean by Maced's documented contract). +- [ ] Update `apps/api/src/security-penetration-tests/README.md` to reference the SDK as the source of truth. + +**Verify:** create a real pentest via the UI against dev-tier Maced → 201, run appears in list, detail page loads, polling works, markdown/PDF download works. + +### Phase 3 — Credit system (v1 trial gate) + +#### DB migration (one migration file, multiple changes) + +- [ ] Migration `add_pentest_trial_credits_and_pentest_audit_type`: + - `pentest_subscription.stripeSubscriptionId` → nullable + - `pentest_subscription.stripePriceId` → nullable + - `pentest_subscription.organizationBillingId` → nullable + - Add `pentest_subscription.runsRemaining Int @default(1)` + - Add `pentest_subscription.planType String @default("trial")` (values: `"trial"`, `"subscription"` — string, not enum, for easy extension) + - Add `pentest` to `AuditLogEntityType` enum +- [ ] Backfill step in the same migration: insert a `pentest_subscription` row for every existing `Organization` with no row, `{ runsRemaining: 1, planType: 'trial', status: 'active', currentPeriodStart: now(), currentPeriodEnd: now() + 100 years }`. + +#### Audit plumbing + +- [ ] Add `pentest: AuditLogEntityType.pentest` to `RESOURCE_TO_ENTITY_TYPE` in `apps/api/src/audit/audit-log.constants.ts`. + +#### Trial-grant config (single knob) + +- [ ] Helper `apps/api/src/security-penetration-tests/trial-grant.ts` → `getTrialGrantAmount(): Promise`. Reads PostHog numeric flag `pentest-trial-grant`, falls back to `process.env.PENTEST_TRIAL_GRANT`, final fallback `1`. +- [ ] Single call site for granting credits to a new org (see next section). + +#### Org-create hook + +- [ ] Audit where new organizations are created (`OrganizationsService.create()`, onboarding flow, or Better Auth post-registration hook). Add a single call: `creditsService.grantInitialTrial(orgId)`. + +#### Credits service + +- [ ] New `apps/api/src/security-penetration-tests/pentest-credits.service.ts`: + - `getBalance(orgId): Promise<{ runsRemaining, planType }>` + - `debit(orgId, runId): Promise` — atomic decrement inside a transaction; throws `HttpException(402)` if balance would go negative. + - `grant(orgId, amount, reason): Promise` — increment; logged via Nest logger. + - `grantInitialTrial(orgId): Promise` — idempotent; looks up the grant amount via `getTrialGrantAmount()` and upserts the row. Safe to call for existing orgs. + - `refund(orgId, runId, reason): Promise` — increment + Nest logger. Used by the webhook handler on `pentest.failed`. + +#### Credits controller + +- [ ] New `apps/api/src/security-penetration-tests/pentest-credits.controller.ts`: + - `GET /v1/pentest-credits/status` → `{ runsRemaining, planType }` — gated `@RequirePermission('pentest', 'read')`. + - No mutation endpoints in v1 (all grants are server-driven). + +#### Service integration (create path) + +- [ ] Rewrite `createReport()` to: + 1. Check balance → throw 402 if `runsRemaining === 0`. + 2. Call Maced SDK create → get `{ id, status }`. + 3. In a Prisma `$transaction`: `persistRunOwnership` + `creditsService.debit`. Rollback both on either failure. + 4. Return backfilled response. +- [ ] On the failure path, if Maced created the run but we failed to persist, log loudly (`logger.error`) with enough context to reconcile manually; don't automatically refund the user (they got lucky — run exists at Maced). + +#### Frontend wiring + +- [ ] New hook `usePentestCredits(orgId)` in the existing hooks dir → `useSWR('/v1/pentest-credits/status')`. +- [ ] `penetration-tests-page-client.tsx`: + - Show `"Your trial: X run(s) remaining"` near the page header or inside the dialog. + - Disable **Create Report** (and the dialog's "Start penetration test") when `runsRemaining === 0`. + - Empty-state copy swap: "You've used your trial. Paid plans coming soon." when balance is 0 and no runs exist. +- [ ] Remove or stub `/settings/billing` page's Stripe-dependent sections; replace with "Trial — 1 free run per org" read-only display for now. + +**Verify:** new org has a row; existing orgs get backfilled to 1; 402 fires before Maced is called when balance is 0; debit happens atomically on success; UI shows balance + disables button; `AuditLog` has entries for every create/read/delete with `entityType=pentest`. + +### Phase 4 — Webhook verification via SDK + +- [ ] On service boot (`onModuleInit`): fetch webhook secret via SDK (`maced.webhooks.secret()` or equivalent) → cache in a module-scoped variable. Log on failure; retry on next webhook receipt if the cache is empty. +- [ ] Rewrite `SecurityPenetrationTestsService.handleWebhook()`: + 1. Read the signature header (exact header name from SDK docs — probably `X-Maced-Signature`). + 2. Pass raw body + signature + secret into SDK's `verifyMacedWebhook` (or equivalent). **Need raw body** — configure NestJS to preserve raw body for this route (similar to the Stripe webhook pattern). + 3. On signature invalid → 401 Unauthorized. + 4. Parse validated payload for `{ runId, status, ... }`. + 5. Map `runId` → org via `security_penetration_test_runs`. If ownership not found → 404. + 6. Update run status (local cache / emit SSE, or just log for v1 — polling already covers the UI). + 7. If event is `pentest.failed` → call `creditsService.refund(orgId, runId, 'scan_failed')` so the user isn't charged for Maced's failure. + 8. Write an `AuditLog` entry for the event (system user convention — pick whatever the repo uses elsewhere for system-initiated audits). +- [ ] Delete everything related to the phantom handshake — `verifyAndRecordWebhookHandshake`, `webhookHandshakeSecretName`, `parseWebhookHandshake`, `PersistedWebhookHandshake`, the `secret`-table write/read for handshake tokens. +- [ ] Keep polling as the UX path for status — no frontend change needed in v1 beyond what Phase 3 added. + +**Verify:** tunnel a real Maced webhook into localhost (ngrok); signature verification passes for valid, fails for tampered; `pentest.failed` refunds; invalid run ID → 404. + +### Phase 5 — Tests + +- [ ] **Service unit tests** (Jest) — rewrite `security-penetration-tests.service.spec.ts` to: + - Mock `@maced/api-client` module. + - Cover: 402 when balance 0, debit on success, rollback on txn failure. + - Webhook: valid signature, invalid signature, tampered payload, unknown run, failed event refund. +- [ ] **Controller unit tests** — cover credits controller + existing controller. +- [ ] **Credits service tests** — balance read, atomic debit (simulate concurrent), grant, refund, idempotent trial grant. +- [ ] **Frontend tests** — Vitest for hooks + page: balance shown, button disabled at 0. + +### Phase 6 — Final verification (against dev Maced) + +- [ ] Clean local DB; run the migration; confirm backfill. +- [ ] Create a run via UI → 201, row in DB with `runsRemaining = 0` after debit. +- [ ] Try to create another → 402, "Create Report" disabled in UI. +- [ ] Tunnel Maced webhook via ngrok → completion event arrives, verified, logged. +- [ ] Force a failure scenario (target URL that should fail / testMode=true?) → `pentest.failed` webhook → credit refunded → UI shows `runsRemaining = 1` again. +- [ ] Check `AuditLog` rows: create, read, delete, refund, grant all present. + +--- + +## Risks & open items to answer during implementation + +- **Raw body for webhook verification.** NestJS's global `ValidationPipe` + JSON parser will have already consumed the body by the time we reach the handler. Need to register a raw-body middleware/interceptor for the webhook route *only* (similar to how Stripe webhooks are handled elsewhere in the codebase — reuse that pattern). +- **Org-create hook location.** Have to find the exact spot during implementation. Candidates: `OrganizationsService.create()`, the onboarding flow, a Better Auth post-registration hook. +- **PostHog numeric flag tier availability.** Confirm plan supports it; if not, env-var-only is fine for v1. +- **SDK method names.** Plan assumes `maced.pentests.create/list/retrieve/progress`, `maced.webhooks.secret`, `verifyMacedWebhook`. Must verify at Phase 2 kickoff by inspecting the installed package's types. +- **Transactional debit.** If Prisma `$transaction` fails after Maced succeeded, the run exists at Maced but not in our DB → the user sees nothing, we owe Maced. For v1: log loudly; reconcile manually. v1.5: background reconciliation job. +- **Webhook → UI live updates.** v1 relies on polling (which already works). If we want push updates, add SSE later. Not in scope for this PR. + +--- + +## Review (fill in after PR is opened) + +_Add: what surprised us about Maced's real responses, what had to change from this plan, what we should revisit in v2._ From f494299f957b1775909e2fd620ca00dbdc839726 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 29 Apr 2026 00:16:30 -0400 Subject: [PATCH 02/15] feat(pentest): credits wallet, admin grants, audit logging, UX polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backbone: - pentest_credits wallet table (balance + lifetime totals + last_grant_source), backfill of 1 trial credit per existing org, drop empty pentest_subscriptions / organization_billing legacy tables - credit_refunded_at column on security_penetration_test_runs for webhook refund idempotency - 'pentest' added to AuditLogEntityType enum Service: - PentestCreditsService with idempotent grantInitialTrial, grant, atomic debitOrThrow, refund (no-op on missing wallet row), audit-log entries on grant/refund attributed to org owner - createReport: debit-first ordering blocks concurrent fast-clicks before hitting Maced; refund-on-Maced-failure; refund-on-ownership-persist-failure; 402 attempts also written to audit log - handleWebhook: refund on pentest.failed / pentest.cancelled with claim- before-refund pattern (updateMany WHERE creditRefundedAt IS NULL); audit entry on pentest.completed with finding count, duration, agent count - persistRunOwnership upsert hardened to never overwrite organizationId on conflict (defensive — Maced generates unique IDs in practice) - @maced/api-client@0.9.1 retry wrapper disabled (maxAttempts: 1) to avoid the "Cannot construct a Request..." cryptic error on retriable 5xx; jest transformIgnorePatterns added so specs can load the ESM SDK Admin panel: - New "Pentest credits" tab on org detail page - POST /v1/admin/organizations/:orgId/pentest-credits/grant (PlatformAdminGuard, source=manual, capped at 1000/grant, audited via AdminAuditLogInterceptor + per-org audit log entry) Frontend UX: - Quota footer in scan sidebar (X scans remaining / trial used messaging) - "+ New scan" disabled at zero balance with explicit tooltip + create-form banner; no extra clutter in the header - Overview pane: onboarding state for 0 scans; posture stats (completed, coverage, avg duration, cadence), Latest Assessment card with download + view-detail, Recent Scans, Stale Coverage. Real data only — no fabricated severity rollup or trend charts. - Clean-state detail pane redesigned: neutral hero, no decorative checks, symmetric Markdown/PDF buttons, framework-agnostic CTA copy, suppressed misleading "<1m" duration when Maced doesn't bump updatedAt, suppressed "Last update" when equal to "Started", removed redundant right-column - Findings detail: severity left-bar accent on neutral card (no full-card tint that overwhelmed at any severity) - Sidebar: filter button + agent count removed (misleading at low data volumes); status pill stays "Completed" everywhere (clean variant dropped to keep sidebar/detail badges consistent) - Execution trace opens by default; events containing "maced" filtered out (white-label cleanup) plus TodoWrite noise - Tamed running pulse animation; elapsed time computed client-side from createdAt instead of trusting Maced progress.elapsedMs - Softer medium severity tokens; full-bleed split-view (negative margins around app-shell padding) - is-security-enabled feature flag check removed (RBAC pentest:read alone gates access; flag was breaking dev after PostHog/session restarts) Tenant isolation: - All per-run endpoints flow through assertRunOwnership returning 404 (not 403) so org B cannot enumerate org A's run ids - listReports filters via listOwnedRunIds before mapping - Audit log entries scoped to organizationId of the affected org Tests: - 17 unit tests for PentestCreditsService covering status, grant, debit (including concurrent-debit race), refund, audit-log writes, no-owner edge case, audit failure tolerance Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/package.json | 3 + .../admin-organizations.module.ts | 4 + .../admin-pentest-credits.controller.ts | 69 ++++ apps/api/src/audit/audit-log.constants.ts | 1 + .../pentest-credits.controller.ts | 44 +++ .../pentest-credits.service.spec.ts | 338 ++++++++++++++++++ .../pentest-credits.service.ts | 314 ++++++++++++++++ .../security-penetration-tests.module.ts | 7 +- ...security-penetration-tests.service.spec.ts | 38 +- .../security-penetration-tests.service.ts | 212 ++++++++++- .../[adminOrgId]/components/AdminOrgTabs.tsx | 5 + .../components/PentestCreditsTab.tsx | 150 ++++++++ apps/app/src/app/(app)/[orgId]/layout.tsx | 8 +- .../src/app/(app)/[orgId]/security/layout.tsx | 27 +- .../_components/AgentActivityLog.tsx | 39 +- .../_components/CleanReportLayout.tsx | 185 ++++++++++ .../_components/CompletedDetail.tsx | 94 ++--- .../_components/CreateRunPanel.tsx | 29 +- .../_components/EmptyState.tsx | 22 +- .../_components/FailedDetail.tsx | 2 +- .../_components/FindingDetail.tsx | 21 +- .../_components/OverviewPane.tsx | 321 +++++++++++------ .../penetration-tests/_components/RunList.tsx | 94 ++++- .../_components/RunningDetail.tsx | 34 +- .../_components/SplitView.tsx | 49 ++- .../_components/StatusPill.tsx | 27 +- .../_components/overview-internals.tsx | 266 ++++++++++++++ .../_components/pentest-tokens.css | 14 +- .../hooks/use-penetration-tests.ts | 40 +++ .../actions/create-organization-minimal.ts | 7 + .../setup/actions/create-organization.ts | 7 + .../migration.sql | 42 +++ .../migration.sql | 6 + .../prisma/schema/organization-billing.prisma | 12 - packages/db/prisma/schema/organization.prisma | 7 +- .../db/prisma/schema/pentest-credits.prisma | 37 ++ .../prisma/schema/pentest-subscription.prisma | 20 -- .../security-penetration-test-run.prisma | 6 + packages/db/prisma/schema/shared.prisma | 1 + 39 files changed, 2297 insertions(+), 305 deletions(-) create mode 100644 apps/api/src/admin-organizations/admin-pentest-credits.controller.ts create mode 100644 apps/api/src/security-penetration-tests/pentest-credits.controller.ts create mode 100644 apps/api/src/security-penetration-tests/pentest-credits.service.spec.ts create mode 100644 apps/api/src/security-penetration-tests/pentest-credits.service.ts create mode 100644 apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/PentestCreditsTab.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CleanReportLayout.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/overview-internals.tsx create mode 100644 packages/db/prisma/migrations/20260427000000_pentest_credits/migration.sql create mode 100644 packages/db/prisma/migrations/20260427120000_pentest_run_credit_refund/migration.sql delete mode 100644 packages/db/prisma/schema/organization-billing.prisma create mode 100644 packages/db/prisma/schema/pentest-credits.prisma delete mode 100644 packages/db/prisma/schema/pentest-subscription.prisma diff --git a/apps/api/package.json b/apps/api/package.json index b903372a76..3ad3b29b75 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -158,6 +158,9 @@ "transform": { "^.+\\.(t|j)sx?$": "ts-jest" }, + "transformIgnorePatterns": [ + "node_modules/(?!(@maced/api-client|better-auth)/)" + ], "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/apps/api/src/admin-organizations/admin-organizations.module.ts b/apps/api/src/admin-organizations/admin-organizations.module.ts index 752159622e..7b8d0b8235 100644 --- a/apps/api/src/admin-organizations/admin-organizations.module.ts +++ b/apps/api/src/admin-organizations/admin-organizations.module.ts @@ -7,6 +7,7 @@ import { EvidenceFormsModule } from '../evidence-forms/evidence-forms.module'; import { PoliciesModule } from '../policies/policies.module'; import { CommentsModule } from '../comments/comments.module'; import { AttachmentsModule } from '../attachments/attachments.module'; +import { SecurityPenetrationTestsModule } from '../security-penetration-tests/security-penetration-tests.module'; import { AdminOrganizationsController } from './admin-organizations.controller'; import { AdminOrganizationsService } from './admin-organizations.service'; import { AdminFindingsController } from './admin-findings.controller'; @@ -15,6 +16,7 @@ import { AdminTasksController } from './admin-tasks.controller'; import { AdminVendorsController } from './admin-vendors.controller'; import { AdminContextController } from './admin-context.controller'; import { AdminEvidenceController } from './admin-evidence.controller'; +import { AdminPentestCreditsController } from './admin-pentest-credits.controller'; @Module({ imports: [ @@ -26,6 +28,7 @@ import { AdminEvidenceController } from './admin-evidence.controller'; PoliciesModule, CommentsModule, AttachmentsModule, + SecurityPenetrationTestsModule, ], controllers: [ AdminOrganizationsController, @@ -35,6 +38,7 @@ import { AdminEvidenceController } from './admin-evidence.controller'; AdminVendorsController, AdminContextController, AdminEvidenceController, + AdminPentestCreditsController, ], providers: [AdminOrganizationsService], }) diff --git a/apps/api/src/admin-organizations/admin-pentest-credits.controller.ts b/apps/api/src/admin-organizations/admin-pentest-credits.controller.ts new file mode 100644 index 0000000000..feb6c03644 --- /dev/null +++ b/apps/api/src/admin-organizations/admin-pentest-credits.controller.ts @@ -0,0 +1,69 @@ +import { + BadRequestException, + Body, + Controller, + Get, + Param, + Post, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { PlatformAdminGuard } from '../auth/platform-admin.guard'; +import { PentestCreditsService } from '../security-penetration-tests/pentest-credits.service'; +import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor'; + +/** + * Request body for granting credits via the admin panel. `amount` is capped + * at 1000 to prevent typo-induced runaway grants — admins can submit + * multiple times if a larger pool is genuinely needed. + */ +class GrantPentestCreditsDto { + @IsInt() + @Min(1) + @Max(1000) + amount!: number; + + /** + * Free-form note. Persisted on the audit log entry as `data.note` so + * support / compliance can reconstruct *why* a grant happened. + */ + @IsOptional() + @IsString() + note?: string; +} + +@ApiExcludeController() +@ApiTags('Admin - Pentest Credits') +@Controller({ path: 'admin/organizations', version: '1' }) +@UseGuards(PlatformAdminGuard) +@UseInterceptors(AdminAuditLogInterceptor) +@Throttle({ default: { ttl: 60_000, limit: 30 } }) +export class AdminPentestCreditsController { + constructor(private readonly credits: PentestCreditsService) {} + + @Get(':orgId/pentest-credits') + @ApiOperation({ + summary: 'Get pentest credit wallet status for any organization', + }) + async getStatus(@Param('orgId') orgId: string) { + return this.credits.getStatus(orgId); + } + + @Post(':orgId/pentest-credits/grant') + @ApiOperation({ + summary: 'Grant pentest credits to an organization (platform admin)', + }) + async grant( + @Param('orgId') orgId: string, + @Body() body: GrantPentestCreditsDto, + ) { + if (!Number.isInteger(body.amount) || body.amount < 1) { + throw new BadRequestException('amount must be a positive integer'); + } + await this.credits.grant(orgId, body.amount, 'manual'); + return this.credits.getStatus(orgId); + } +} diff --git a/apps/api/src/audit/audit-log.constants.ts b/apps/api/src/audit/audit-log.constants.ts index ded7922095..ce69e5fcdb 100644 --- a/apps/api/src/audit/audit-log.constants.ts +++ b/apps/api/src/audit/audit-log.constants.ts @@ -39,6 +39,7 @@ export const RESOURCE_TO_ENTITY_TYPE: Record< trust: AuditLogEntityType.trust, app: AuditLogEntityType.organization, questionnaire: AuditLogEntityType.organization, + pentest: AuditLogEntityType.pentest, audit: null, }; diff --git a/apps/api/src/security-penetration-tests/pentest-credits.controller.ts b/apps/api/src/security-penetration-tests/pentest-credits.controller.ts new file mode 100644 index 0000000000..1067b1b9c0 --- /dev/null +++ b/apps/api/src/security-penetration-tests/pentest-credits.controller.ts @@ -0,0 +1,44 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { + ApiHeader, + ApiOperation, + 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 { PentestCreditsService } from './pentest-credits.service'; + +@ApiTags('Pentest Credits') +@Controller({ path: 'pentest-credits', 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 PentestCreditsController { + constructor(private readonly credits: PentestCreditsService) {} + + /** + * Returns the org's current pentest-credit wallet state. Frontend uses + * this to drive the "X runs remaining" badge and to disable the + * "+ New scan" button at zero balance. + */ + @Get('status') + @RequirePermission('pentest', 'read') + @ApiOperation({ + summary: 'Get pentest credit status', + description: + 'Current spendable balance plus lifetime granted/consumed totals.', + }) + @ApiResponse({ status: 200, description: 'Credits status' }) + async getStatus(@OrganizationId() organizationId: string) { + return this.credits.getStatus(organizationId); + } +} diff --git a/apps/api/src/security-penetration-tests/pentest-credits.service.spec.ts b/apps/api/src/security-penetration-tests/pentest-credits.service.spec.ts new file mode 100644 index 0000000000..b557623c7a --- /dev/null +++ b/apps/api/src/security-penetration-tests/pentest-credits.service.spec.ts @@ -0,0 +1,338 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { db } from '@db'; +import { PentestCreditsService } from './pentest-credits.service'; + +jest.mock('@db', () => ({ + AuditLogEntityType: { pentest: 'pentest' }, + Prisma: {}, + db: { + pentestCredits: { + findUnique: jest.fn(), + upsert: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + }, + member: { + findFirst: jest.fn(), + }, + auditLog: { + create: jest.fn(), + }, + }, +})); + +type MockDb = { + pentestCredits: { + findUnique: jest.Mock; + upsert: jest.Mock; + update: jest.Mock; + updateMany: jest.Mock; + }; + member: { + findFirst: jest.Mock; + }; + auditLog: { + create: jest.Mock; + }; +}; + +describe('PentestCreditsService', () => { + const mockedDb = db as unknown as MockDb; + let service: PentestCreditsService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new PentestCreditsService(); + // Default: every org has an owner so audit-log writes can attribute. + mockedDb.member.findFirst.mockResolvedValue({ + id: 'mem_owner', + userId: 'usr_owner', + }); + mockedDb.auditLog.create.mockResolvedValue({}); + }); + + describe('getStatus', () => { + it('returns zero balance when no row exists', async () => { + mockedDb.pentestCredits.findUnique.mockResolvedValue(null); + const status = await service.getStatus('org_1'); + expect(status).toEqual({ + balance: 0, + totalGranted: 0, + totalConsumed: 0, + lastGrantSource: 'none', + }); + }); + + it('returns the wallet row when it exists', async () => { + mockedDb.pentestCredits.findUnique.mockResolvedValue({ + balance: 3, + totalGranted: 5, + totalConsumed: 2, + lastGrantSource: 'subscription', + }); + const status = await service.getStatus('org_1'); + expect(status.balance).toBe(3); + expect(status.totalGranted).toBe(5); + expect(status.totalConsumed).toBe(2); + expect(status.lastGrantSource).toBe('subscription'); + }); + }); + + describe('grantInitialTrial', () => { + it('upserts with create-side defaults and update-side no-op', async () => { + mockedDb.pentestCredits.upsert.mockResolvedValue({}); + await service.grantInitialTrial('org_1'); + expect(mockedDb.pentestCredits.upsert).toHaveBeenCalledWith({ + where: { organizationId: 'org_1' }, + create: { + organizationId: 'org_1', + balance: 1, + totalGranted: 1, + lastGrantSource: 'trial', + }, + update: {}, + }); + }); + }); + + describe('grant', () => { + it('rejects non-positive amounts', async () => { + await expect(service.grant('org_1', 0, 'topup')).rejects.toThrow(); + await expect(service.grant('org_1', -3, 'topup')).rejects.toThrow(); + }); + + it('upserts incrementing balance and totalGranted', async () => { + mockedDb.pentestCredits.upsert.mockResolvedValue({}); + await service.grant('org_1', 5, 'subscription'); + expect(mockedDb.pentestCredits.upsert).toHaveBeenCalledWith({ + where: { organizationId: 'org_1' }, + create: expect.objectContaining({ + balance: 5, + totalGranted: 5, + lastGrantSource: 'subscription', + }), + update: { + balance: { increment: 5 }, + totalGranted: { increment: 5 }, + lastGrantSource: 'subscription', + }, + }); + }); + }); + + describe('debitOrThrow', () => { + it('atomically decrements when balance > 0 and returns new status', async () => { + mockedDb.pentestCredits.updateMany.mockResolvedValue({ count: 1 }); + mockedDb.pentestCredits.findUnique.mockResolvedValue({ + balance: 0, + totalGranted: 1, + totalConsumed: 1, + lastGrantSource: 'trial', + }); + + const status = await service.debitOrThrow('org_1', 'run_abc'); + expect(status.balance).toBe(0); + expect(mockedDb.pentestCredits.updateMany).toHaveBeenCalledWith({ + where: { organizationId: 'org_1', balance: { gt: 0 } }, + data: { + balance: { decrement: 1 }, + totalConsumed: { increment: 1 }, + }, + }); + }); + + it('throws 402 with pentest_credits_exhausted code when no row decrements', async () => { + mockedDb.pentestCredits.updateMany.mockResolvedValue({ count: 0 }); + await expect(service.debitOrThrow('org_1', 'run_x')).rejects.toMatchObject( + { + status: HttpStatus.PAYMENT_REQUIRED, + response: expect.objectContaining({ + code: 'pentest_credits_exhausted', + }), + }, + ); + }); + + it('works without a runId (debit-before-create flow)', async () => { + mockedDb.pentestCredits.updateMany.mockResolvedValue({ count: 1 }); + mockedDb.pentestCredits.findUnique.mockResolvedValue({ + balance: 4, + totalGranted: 5, + totalConsumed: 1, + lastGrantSource: 'trial', + }); + const status = await service.debitOrThrow('org_1'); + expect(status.balance).toBe(4); + }); + + it('blocks the second of two concurrent debits when only one credit remains', async () => { + // First call gets count: 1 (decrement succeeded). Second sees count: 0 + // because Postgres evaluated the WHERE condition after the first + // decrement landed. + mockedDb.pentestCredits.updateMany + .mockResolvedValueOnce({ count: 1 }) + .mockResolvedValueOnce({ count: 0 }); + mockedDb.pentestCredits.findUnique.mockResolvedValue({ + balance: 0, + totalGranted: 1, + totalConsumed: 1, + lastGrantSource: 'trial', + }); + + await expect(service.debitOrThrow('org_1')).resolves.toMatchObject({ + balance: 0, + }); + await expect(service.debitOrThrow('org_1')).rejects.toBeInstanceOf( + HttpException, + ); + }); + }); + + describe('refund', () => { + it('updates balance and totalConsumed, sets source to refund', async () => { + mockedDb.pentestCredits.updateMany.mockResolvedValue({ count: 1 }); + await service.refund('org_1', 'run_xyz', 'pentest.failed'); + expect(mockedDb.pentestCredits.updateMany).toHaveBeenCalledWith({ + where: { organizationId: 'org_1' }, + data: { + balance: { increment: 1 }, + totalConsumed: { decrement: 1 }, + lastGrantSource: 'refund', + }, + }); + }); + + it('is a clean no-op (no throw) when no wallet row exists', async () => { + mockedDb.pentestCredits.updateMany.mockResolvedValue({ count: 0 }); + await expect( + service.refund('org_1', 'run_xyz', 'pentest.failed'), + ).resolves.toBeUndefined(); + }); + + it('accepts an undefined runId for pre-create refund paths', async () => { + mockedDb.pentestCredits.updateMany.mockResolvedValue({ count: 1 }); + await expect( + service.refund('org_1', undefined, 'maced_create_failed'), + ).resolves.toBeUndefined(); + }); + }); + + describe('audit log entries', () => { + it('writes an audit entry on successful refund', async () => { + mockedDb.pentestCredits.updateMany.mockResolvedValue({ count: 1 }); + await service.refund('org_1', 'run_xyz', 'pentest.failed'); + expect(mockedDb.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + organizationId: 'org_1', + userId: 'usr_owner', + memberId: 'mem_owner', + entityType: 'pentest', + entityId: 'run_xyz', + description: expect.stringContaining('Refunded'), + data: expect.objectContaining({ + action: 'credit_refunded', + runId: 'run_xyz', + reason: 'pentest.failed', + }), + }), + }); + }); + + it('writes an audit entry on grant', async () => { + mockedDb.pentestCredits.upsert.mockResolvedValue({}); + await service.grant('org_1', 5, 'subscription'); + expect(mockedDb.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: 'usr_owner', + entityType: 'pentest', + description: expect.stringContaining('Granted 5 pentest credits'), + data: expect.objectContaining({ + action: 'credit_granted', + source: 'subscription', + amount: 5, + }), + }), + }); + }); + + it('does NOT write an audit entry when the refund was a no-op', async () => { + mockedDb.pentestCredits.updateMany.mockResolvedValue({ count: 0 }); + await service.refund('org_1', 'run_xyz', 'pentest.failed'); + expect(mockedDb.auditLog.create).not.toHaveBeenCalled(); + }); + + it('skips audit log gracefully when the org has no owner member', async () => { + mockedDb.pentestCredits.updateMany.mockResolvedValue({ count: 1 }); + mockedDb.member.findFirst.mockResolvedValue(null); + await expect( + service.refund('org_1', 'run_xyz', 'pentest.failed'), + ).resolves.toBeUndefined(); + expect(mockedDb.auditLog.create).not.toHaveBeenCalled(); + }); + + it('does not throw when audit-log write itself fails', async () => { + mockedDb.pentestCredits.updateMany.mockResolvedValue({ count: 1 }); + mockedDb.auditLog.create.mockRejectedValue(new Error('db is down')); + await expect( + service.refund('org_1', 'run_xyz', 'pentest.failed'), + ).resolves.toBeUndefined(); + }); + }); + + describe('writePentestAuditEntry (public)', () => { + it('writes a pentest_completed entry with completion metadata', async () => { + await service.writePentestAuditEntry({ + organizationId: 'org_1', + action: 'pentest_completed', + runId: 'run_done', + description: 'Pentest completed for https://x.com — 4 findings, 1h 12m', + metadata: { issueCount: 4, durationMs: 4_320_000 }, + }); + expect(mockedDb.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + entityType: 'pentest', + entityId: 'run_done', + description: expect.stringContaining('completed'), + data: expect.objectContaining({ + action: 'pentest_completed', + issueCount: 4, + durationMs: 4_320_000, + }), + }), + }); + }); + + it('writes a pentest_create_blocked entry with the reason', async () => { + await service.writePentestAuditEntry({ + organizationId: 'org_1', + action: 'pentest_create_blocked', + runId: null, + description: 'Pentest create blocked: no credits remaining', + metadata: { reason: 'pentest_credits_exhausted' }, + }); + expect(mockedDb.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + entityType: 'pentest', + entityId: null, + data: expect.objectContaining({ + action: 'pentest_create_blocked', + reason: 'pentest_credits_exhausted', + }), + }), + }); + }); + + it('does not throw when no owner is found', async () => { + mockedDb.member.findFirst.mockResolvedValue(null); + await expect( + service.writePentestAuditEntry({ + organizationId: 'org_1', + action: 'pentest_completed', + runId: 'run_done', + description: 'Pentest completed', + }), + ).resolves.toBeUndefined(); + expect(mockedDb.auditLog.create).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/security-penetration-tests/pentest-credits.service.ts b/apps/api/src/security-penetration-tests/pentest-credits.service.ts new file mode 100644 index 0000000000..740062f703 --- /dev/null +++ b/apps/api/src/security-penetration-tests/pentest-credits.service.ts @@ -0,0 +1,314 @@ +import { + HttpException, + HttpStatus, + Injectable, + Logger, +} from '@nestjs/common'; +import { AuditLogEntityType, db, Prisma } from '@db'; + +/** + * Source of credits — free-form so v2 can add new sources without a schema + * migration. v1 only mints `'trial'`. Future: `'subscription'`, `'topup'`, + * `'promo'`, `'refund'`, `'manual'`. + */ +export type CreditGrantSource = + | 'trial' + | 'subscription' + | 'topup' + | 'promo' + | 'refund' + | 'manual'; + +export interface PentestCreditsStatus { + balance: number; + totalGranted: number; + totalConsumed: number; + lastGrantSource: string; +} + +/** + * Audit-log action codes for pentest events. Stored in `audit_log.data.action` + * so existing audit-log UIs can filter on a stable enum. + */ +export type PentestAuditAction = + | 'credit_granted' + | 'credit_refunded' + | 'pentest_completed' + | 'pentest_create_blocked'; + +/** + * Pentest credit wallet. Encapsulates all balance reads/writes so the rest + * of the API doesn't poke at the `pentest_credits` table directly. All + * mutating ops are atomic via Prisma's interactive transactions or + * conditional updates so two concurrent creates can't double-spend. + */ +@Injectable() +export class PentestCreditsService { + private readonly logger = new Logger(PentestCreditsService.name); + + /** + * Default trial grant for new orgs. Static today; in v2 this can become a + * PostHog numeric flag (or env var) so the number can be bumped without + * a deploy. Single call site keeps the change cheap. + */ + private readonly initialTrialAmount = 1; + + async getStatus(organizationId: string): Promise { + const row = await db.pentestCredits.findUnique({ + where: { organizationId }, + }); + if (!row) { + // Org has no row yet. Return a zero balance — the client UI treats + // this as "no trial granted, paid plans coming soon." + return { + balance: 0, + totalGranted: 0, + totalConsumed: 0, + lastGrantSource: 'none', + }; + } + return { + balance: row.balance, + totalGranted: row.totalGranted, + totalConsumed: row.totalConsumed, + lastGrantSource: row.lastGrantSource, + }; + } + + /** + * Grant the initial trial credit when an org is created. Idempotent — + * safe to call repeatedly; subsequent calls no-op so we never accidentally + * top a user up just by re-running the org-create hook. + */ + async grantInitialTrial(organizationId: string): Promise { + const amount = this.initialTrialAmount; + await db.pentestCredits.upsert({ + where: { organizationId }, + create: { + organizationId, + balance: amount, + totalGranted: amount, + lastGrantSource: 'trial', + }, + // Already exists — leave it alone. Idempotent by design. + update: {}, + }); + this.logger.log( + `[Credits] grantInitialTrial org=${organizationId} amount=${amount}`, + ); + } + + /** + * Add credits from any source. Used for v2 Stripe webhooks (subscription + * renewals, top-ups), refunds, manual admin grants, etc. + */ + async grant( + organizationId: string, + amount: number, + source: CreditGrantSource, + ): Promise { + if (amount <= 0) { + throw new Error( + `grant amount must be positive (got ${amount} for org=${organizationId})`, + ); + } + await db.pentestCredits.upsert({ + where: { organizationId }, + create: { + organizationId, + balance: amount, + totalGranted: amount, + lastGrantSource: source, + }, + update: { + balance: { increment: amount }, + totalGranted: { increment: amount }, + lastGrantSource: source, + }, + }); + this.logger.log( + `[Credits] grant org=${organizationId} amount=+${amount} source=${source}`, + ); + await this.writePentestAuditEntry({ + organizationId, + action: 'credit_granted', + runId: null, + description: `Granted ${amount} pentest credit${amount === 1 ? '' : 's'} (source=${source})`, + metadata: { source, amount }, + }); + } + + /** + * Consume one credit. Atomic via a conditional update so two concurrent + * createReport calls cannot both succeed when only one credit remains. + * Returns the new status (with decremented balance) on success or throws + * 402 when the balance is empty. + * + * Designed to be called from inside the same Prisma transaction that + * persists the run's ownership row, so debit + ownership-insert succeed + * or fail together. + */ + async debitOrThrow( + organizationId: string, + runId?: string, + tx?: Pick, + ): Promise { + const client = tx ?? db; + const updated = await client.pentestCredits.updateMany({ + where: { organizationId, balance: { gt: 0 } }, + data: { + balance: { decrement: 1 }, + totalConsumed: { increment: 1 }, + }, + }); + if (updated.count === 0) { + // Either no row, or balance is zero — both look identical to the + // user. Surface the balance=0 message either way. + this.logger.warn( + `[Credits] debit blocked: out of balance org=${organizationId} run=${runId ?? 'pending'}`, + ); + throw new HttpException( + { + error: + 'No pentest runs remaining. Paid plans coming soon — contact support if you need access today.', + code: 'pentest_credits_exhausted', + }, + HttpStatus.PAYMENT_REQUIRED, + ); + } + const status = await this.getStatus(organizationId); + this.logger.log( + `[Credits] debit org=${organizationId} run=${runId ?? 'pending'} balance=${status.balance}`, + ); + return status; + } + + /** + * Refund a previously-debited credit. Used when Maced reports a failed + * scan via webhook — the customer shouldn't pay for a run that never + * actually ran. Not idempotent on its own (refunding twice for the + * same `pentest.failed` event would over-credit), so webhook callers + * dedupe via the run-row's `creditRefundedAt` column before invoking. + * + * On success, writes an audit-log entry (best-effort) so the refund + * appears in the `audit_log` table alongside other pentest activity — + * support and compliance can answer "why does this org's balance + * differ from the trial grant?" without reading server logs. + */ + async refund( + organizationId: string, + runId: string | undefined, + reason: string, + ): Promise { + // updateMany so a missing wallet row is a clean no-op rather than a + // P2025 throw — refund failures must never crash the caller (they + // already logged the original failure). + const updated = await db.pentestCredits.updateMany({ + where: { organizationId }, + data: { + balance: { increment: 1 }, + totalConsumed: { decrement: 1 }, + lastGrantSource: 'refund', + }, + }); + if (updated.count === 0) { + this.logger.warn( + `[Credits] refund skipped: no wallet row for org=${organizationId} run=${runId ?? 'pending'} reason=${reason}`, + ); + return; + } + this.logger.log( + `[Credits] refund org=${organizationId} run=${runId ?? 'pending'} reason=${reason}`, + ); + await this.writePentestAuditEntry({ + organizationId, + action: 'credit_refunded', + runId: runId ?? null, + description: `Refunded 1 pentest credit (run=${runId ?? 'pending'}, reason=${reason})`, + metadata: { reason }, + }); + } + + /** + * Write a pentest-related audit log entry. Owned by this service + * because it already has the `resolveOrgOwnerActor` helper that other + * pentest paths (webhook refunds, completed-event logging, + * create-blocked logging) need to attribute system-driven events to a + * real `userId`. + * + * Best-effort: the audit-log write is wrapped in try/catch so a write + * failure never escalates into a credit-flow failure. Public so other + * services in the pentest module (createReport / handleWebhook) can + * record their own pentest events without re-implementing actor + * resolution. + */ + async writePentestAuditEntry(params: { + organizationId: string; + action: PentestAuditAction; + runId: string | null; + description: string; + metadata?: Record; + }): Promise { + try { + const actor = await this.resolveOrgOwnerActor(params.organizationId); + if (!actor) { + this.logger.warn( + `[Audit] No owner found for org=${params.organizationId} — skipping audit log for ${params.action}`, + ); + return; + } + + const data: Prisma.InputJsonValue = { + action: params.action, + resource: 'pentest', + permission: 'create', + runId: params.runId, + ...(params.metadata ?? {}), + }; + + await db.auditLog.create({ + data: { + organizationId: params.organizationId, + userId: actor.userId, + memberId: actor.memberId, + entityType: AuditLogEntityType.pentest, + entityId: params.runId, + description: params.description, + data, + }, + }); + } catch (error) { + // Audit failure must never break the credit flow — log and move on. + this.logger.error( + `[Audit] Failed to write ${params.action} entry for org=${params.organizationId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + /** + * Find an actor user/member to attribute system-driven audit-log + * entries to. Picks the oldest active 'owner' member of the org. Used + * when the audit event has no human-driven request context (webhook + * refund, scheduled job). + */ + private async resolveOrgOwnerActor( + organizationId: string, + ): Promise<{ userId: string; memberId: string } | null> { + const owner = await db.member.findFirst({ + where: { + organizationId, + isActive: true, + role: { contains: 'owner' }, + }, + select: { id: true, userId: true }, + orderBy: { createdAt: 'asc' }, + }); + + if (!owner) { + return null; + } + return { userId: owner.userId, memberId: owner.id }; + } +} 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 a0770b22fd..1560d1c85e 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 @@ -1,11 +1,14 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { PentestCreditsController } from './pentest-credits.controller'; +import { PentestCreditsService } from './pentest-credits.service'; import { SecurityPenetrationTestsController } from './security-penetration-tests.controller'; import { SecurityPenetrationTestsService } from './security-penetration-tests.service'; @Module({ imports: [AuthModule], - controllers: [SecurityPenetrationTestsController], - providers: [SecurityPenetrationTestsService], + controllers: [SecurityPenetrationTestsController, PentestCreditsController], + providers: [SecurityPenetrationTestsService, PentestCreditsService], + 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 4f326e1449..2afda1b79c 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 @@ -3,6 +3,7 @@ import { db } from '@db'; import { createHash } from 'node:crypto'; import type { CredentialVaultService } from '../integration-platform/services/credential-vault.service'; import type { CreatePenetrationTestDto } from './dto/create-penetration-test.dto'; +import type { PentestCreditsService } from './pentest-credits.service'; import { SecurityPenetrationTestsService } from './security-penetration-tests.service'; const mockCredentialVaultService: jest.Mocked< @@ -11,6 +12,16 @@ const mockCredentialVaultService: jest.Mocked< getDecryptedCredentials: jest.fn(), }; +// All createReport tests assume an org with credits. Tests that exercise the +// 0-balance path override `getStatus` to return balance: 0. +const mockPentestCreditsService: jest.Mocked< + Pick +> = { + getStatus: jest.fn(), + debitOrThrow: jest.fn(), + refund: jest.fn(), +}; + jest.mock('@db', () => ({ db: { securityPenetrationTestRun: { @@ -65,7 +76,7 @@ describe('SecurityPenetrationTestsService', () => { let service: SecurityPenetrationTestsService; beforeAll(() => { - process.env.MACED_API_KEY = 'test-maced-api-key'; + process.env.MACED_API_KEY = 'mc_dev_test_maced_api_key'; }); afterAll(() => { @@ -77,8 +88,23 @@ describe('SecurityPenetrationTestsService', () => { }); beforeEach(() => { - process.env.MACED_API_KEY = 'test-maced-api-key'; - service = new SecurityPenetrationTestsService(); + process.env.MACED_API_KEY = 'mc_dev_test_maced_api_key'; + mockPentestCreditsService.getStatus.mockResolvedValue({ + balance: 5, + totalGranted: 5, + totalConsumed: 0, + lastGrantSource: 'trial', + }); + mockPentestCreditsService.debitOrThrow.mockResolvedValue({ + balance: 4, + totalGranted: 5, + totalConsumed: 1, + lastGrantSource: 'trial', + }); + mockPentestCreditsService.refund.mockResolvedValue(); + service = new SecurityPenetrationTestsService( + mockPentestCreditsService as unknown as PentestCreditsService, + ); fetchMock.mockReset(); global.fetch = fetchMock as unknown as typeof fetch; mockedDb.securityPenetrationTestRun.upsert.mockResolvedValue({}); @@ -123,7 +149,7 @@ describe('SecurityPenetrationTestsService', () => { expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ - 'x-api-key': 'test-maced-api-key', + 'x-api-key': 'mc_dev_test_maced_api_key', }), }), ); @@ -620,7 +646,7 @@ describe('SecurityPenetrationTestsService', () => { expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ - 'x-api-key': 'test-maced-api-key', + 'x-api-key': 'mc_dev_test_maced_api_key', }), }), ); @@ -672,7 +698,7 @@ describe('SecurityPenetrationTestsService', () => { expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ - 'x-api-key': 'test-maced-api-key', + 'x-api-key': 'mc_dev_test_maced_api_key', }), }), ); 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 11f582e667..f490d383be 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 @@ -23,6 +23,7 @@ import { } from '@maced/api-client'; import type { CreatePenetrationTestDto } from './dto/create-penetration-test.dto'; +import { PentestCreditsService } from './pentest-credits.service'; export type PentestReportStatus = | 'provisioning' @@ -86,7 +87,7 @@ export class SecurityPenetrationTestsService { private readonly logger = new Logger(SecurityPenetrationTestsService.name); private readonly macedClient: MacedClient; - constructor() { + constructor(private readonly credits: PentestCreditsService) { const apiKey = process.env.MACED_API_KEY; if (!apiKey) { // Throw at construction so the app fails loudly on boot, not on first request. @@ -96,6 +97,14 @@ export class SecurityPenetrationTestsService { apiKey, baseUrl: process.env.MACED_API_BASE_URL, userAgent: 'comp-api', + // Disable SDK-level retries. The 0.9.1 retry wrapper reuses the same + // Request object across attempts, which throws "Cannot construct a + // Request with a Request object that has already been used" on any + // retriable status (408/425/429/500/502/503/504) when the request + // has a body. The cryptic error masks the real upstream failure. + // We retry where it matters at the application layer (ownership + // persistence in createReport). + retry: { maxAttempts: 1 }, }); } @@ -189,6 +198,38 @@ export class SecurityPenetrationTestsService { ): Promise { const resolvedWebhookUrl = this.resolveWebhookUrl(payload.webhookUrl); + // Debit FIRST so concurrent fast-clicks block at the cheap DB + // conditional update before any of them reach Maced. Without this, + // double-clicks would race past the balance check, all hit Maced + // (creating multiple paid runs), and only one would win the debit — + // we'd burn money on orphaned provider-side runs. Atomic + // `updateMany WHERE balance > 0` guarantees only one decrement + // succeeds. + try { + await this.credits.debitOrThrow(organizationId); + } catch (error) { + if ( + error instanceof HttpException && + error.getStatus() === HttpStatus.PAYMENT_REQUIRED + ) { + // Record the blocked attempt so support / compliance can answer + // "did the user try to scan after their trial was used?". Best- + // effort — never let an audit-log failure hide the 402 from the + // user. + await this.credits.writePentestAuditEntry({ + organizationId, + action: 'pentest_create_blocked', + runId: null, + description: 'Pentest create blocked: no credits remaining', + metadata: { + reason: 'pentest_credits_exhausted', + targetUrl: payload.targetUrl, + }, + }); + } + throw error; + } + // Public repos only. We deliberately do NOT auto-attach the org's // GitHub OAuth token — that would silently share Comp customer creds // with a third-party vendor. Private-repo support belongs behind an @@ -214,13 +255,26 @@ export class SecurityPenetrationTestsService { }, }; - const createdReport = await this.callMaced( - () => this.macedClient.pentests.create(body), - 'creating penetration test', - ); + let createdReport: PentestCreated; + try { + createdReport = await this.callMaced( + () => this.macedClient.pentests.create(body), + 'creating penetration test', + ); + } catch (error) { + // Provider call failed after we debited. Refund so the user isn't + // charged for a run that never started. + await this.refundQuietly(organizationId, 'pending', 'maced_create_failed'); + throw error; + } const providerRunId = createdReport.id; if (!providerRunId) { + await this.refundQuietly( + organizationId, + 'pending', + 'maced_missing_run_id', + ); throw new HttpException( { error: 'Create response missing report identifier' }, HttpStatus.BAD_GATEWAY, @@ -232,6 +286,16 @@ export class SecurityPenetrationTestsService { providerRunId, ); if (!ownershipPersisted) { + // We debited and Maced created the run, but our DB rejected the + // ownership row 3x. Refund — the user can't see the run, so they + // shouldn't pay for it. The Maced run is orphaned (no + // ownership) but Maced has the `compOrganizationId` metadata if + // support ever needs to clean it up. + await this.refundQuietly( + organizationId, + providerRunId, + 'ownership_persist_failed', + ); throw new HttpException( { error: @@ -395,6 +459,23 @@ export class SecurityPenetrationTestsService { (issueId ? ` issue=${issueId}` : ''), ); + // Refund the credit on terminal failure events. The user paid for a + // run that didn't deliver value, so they shouldn't lose the credit. + // Idempotent via the run row's `creditRefundedAt` column — webhook + // redelivery cannot double-credit. + if (event.type === 'pentest.failed' || event.type === 'pentest.cancelled') { + await this.refundOnTerminalFailure(event.data.pentestId, event.type); + } + + // Successful completion deserves its own audit-log row so the + // run's lifecycle is durably recorded ("scan completed for X with N + // findings"). Without this the audit log shows the create but not + // the result, and the only completion record is in NestJS logs / + // Maced. + if (event.type === 'pentest.completed') { + await this.auditPentestCompleted(event.data); + } + return { success: true, eventType: event.type, @@ -402,6 +483,93 @@ export class SecurityPenetrationTestsService { }; } + /** + * Look up the run's owning org and write a `pentest_completed` audit + * row. Quiet on orphan runs (no ownership row → can't attribute) — + * those are rare race-condition artifacts and don't represent + * customer-visible state. + */ + private async auditPentestCompleted( + data: { + pentestId: string; + targetUrl: string; + issueCount: number; + durationMs: number; + agentCount: number; + }, + ): Promise { + const run = await db.securityPenetrationTestRun.findUnique({ + where: { providerRunId: data.pentestId }, + select: { organizationId: true }, + }); + if (!run) { + this.logger.log( + `[Webhook] pentest.completed for unowned run ${data.pentestId} — skipping audit`, + ); + return; + } + await this.credits.writePentestAuditEntry({ + organizationId: run.organizationId, + action: 'pentest_completed', + runId: data.pentestId, + description: `Pentest completed for ${data.targetUrl} — ${data.issueCount} finding${data.issueCount === 1 ? '' : 's'}, ${this.formatDurationMs(data.durationMs)}`, + metadata: { + targetUrl: data.targetUrl, + issueCount: data.issueCount, + durationMs: data.durationMs, + agentCount: data.agentCount, + }, + }); + } + + /** + * Atomically marks the run as refunded and credits the org's wallet. + * The conditional `where: { creditRefundedAt: null }` ensures the + * second delivery of the same event sees the marker and short-circuits + * — the wallet stays correct even if Maced retries the webhook. + */ + private async refundOnTerminalFailure( + providerRunId: string, + eventType: 'pentest.failed' | 'pentest.cancelled', + ): Promise { + const claimed = await db.securityPenetrationTestRun.updateMany({ + where: { providerRunId, creditRefundedAt: null }, + data: { creditRefundedAt: new Date() }, + }); + + if (claimed.count === 0) { + // Either we don't own this run (orphan from a fast-click race — + // ownership row never persisted) OR the credit has already been + // refunded. Either way: do nothing further. + this.logger.log( + `[Webhook] ${eventType} refund skipped run=${providerRunId} (no ownership row or already refunded)`, + ); + return; + } + + const run = await db.securityPenetrationTestRun.findUnique({ + where: { providerRunId }, + select: { organizationId: true }, + }); + if (!run) { + // Race: the row was deleted between updateMany and findUnique. + // Vanishingly rare; log and bail. + this.logger.warn( + `[Webhook] ${eventType} run row vanished after claim run=${providerRunId}`, + ); + return; + } + + await this.refundQuietly(run.organizationId, providerRunId, eventType); + } + + private formatDurationMs(ms: number): string { + const totalMin = Math.max(Math.round(ms / 60_000), 0); + const hours = Math.floor(totalMin / 60); + const minutes = totalMin % 60; + return hours > 0 ? `${hours}h ${minutes}m` : `${totalMin}m`; + } + private trimTrailingSlashes(value: string): string { let end = value.length; while (end > 1 && value.charCodeAt(end - 1) === 47) { @@ -599,10 +767,40 @@ export class SecurityPenetrationTestsService { }; } + /** + * Refund a credit, swallowing any error so the caller's primary failure + * path remains intact. The original error has already been logged by + * the caller — losing the refund would be unfortunate but should never + * promote into a different failure mode for the user. + */ + private async refundQuietly( + organizationId: string, + runId: string, + reason: string, + ): Promise { + try { + await this.credits.refund(organizationId, runId, reason); + } catch (error) { + this.logger.error( + `Refund failed for org=${organizationId} run=${runId} reason=${reason}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + private async persistRunOwnership( organizationId: string, reportId: string, ): Promise { + // Defensive: if a row already exists for this providerRunId, do NOT + // overwrite its organizationId. Maced generates unique providerRunIds + // per create, so in normal operation the create branch is the only + // one that fires. But if any future bug or replay attempted to "take + // over" an existing run by submitting it with a different orgId, the + // empty `update: {}` ensures the original owner stays intact. The + // upsert pattern (vs. plain create) is kept to make `createReport` + // idempotent against retry-style transient errors. await db.securityPenetrationTestRun.upsert({ where: { providerRunId: reportId, @@ -611,9 +809,7 @@ export class SecurityPenetrationTestsService { organizationId, providerRunId: reportId, }, - update: { - organizationId, - }, + update: {}, }); } diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx index 7389f61bd4..b43bd90698 100644 --- a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx @@ -33,6 +33,7 @@ import { EvidenceTab } from './EvidenceTab'; import { PoliciesTab } from './PoliciesTab'; import { TimelineTab } from './TimelineTab'; import { FeatureFlagsTab } from './FeatureFlagsTab'; +import { PentestCreditsTab } from './PentestCreditsTab'; interface OrgMember { id: string; @@ -134,6 +135,7 @@ export function AdminOrgTabs({ Context Evidence Timeline + Pentest credits Feature Flags } @@ -184,6 +186,9 @@ export function AdminOrgTabs({ + + + diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/PentestCreditsTab.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/PentestCreditsTab.tsx new file mode 100644 index 0000000000..830ebab33d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/PentestCreditsTab.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@trycompai/design-system'; +import { Input } from '@trycompai/ui/input'; +import { Label } from '@trycompai/ui/label'; +import { toast } from 'sonner'; +import useSWR from 'swr'; +import { api } from '@/lib/api-client'; + +interface PentestCreditsStatus { + balance: number; + totalGranted: number; + totalConsumed: number; + lastGrantSource: string; +} + +const fetcher = async ([url, orgId]: [string, string]) => { + const res = await api.get(url, orgId); + if (res.status < 200 || res.status >= 300) { + throw new Error(res.error ?? `Failed (${res.status})`); + } + return res.data as PentestCreditsStatus; +}; + +export function PentestCreditsTab({ + orgId, + currentOrgId, +}: { + orgId: string; + currentOrgId: string; +}) { + const endpoint = `/v1/admin/organizations/${orgId}/pentest-credits`; + + const { data, isLoading, error, mutate } = useSWR( + [endpoint, currentOrgId], + fetcher, + { revalidateOnFocus: false }, + ); + + const [amount, setAmount] = useState('1'); + const [submitting, setSubmitting] = useState(false); + + const handleGrant = async () => { + const parsed = Number.parseInt(amount, 10); + if (!Number.isFinite(parsed) || parsed < 1) { + toast.error('Amount must be a positive integer.'); + return; + } + setSubmitting(true); + const res = await api.post( + `${endpoint}/grant`, + { amount: parsed }, + currentOrgId, + ); + setSubmitting(false); + if (res.error) { + toast.error(res.error); + return; + } + toast.success(`Granted ${parsed} credit${parsed === 1 ? '' : 's'}.`); + setAmount('1'); + await mutate(); + }; + + return ( +
    +
    +

    + Wallet status +

    + + {error ? ( +

    + Failed to load: {error.message} +

    + ) : isLoading || !data ? ( +

    Loading…

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

    + Grant credits +

    +

    + Adds credits to this organization's balance with{' '} + source=manual. Logged in their + audit trail and the platform-admin audit log. +

    + +
    +
    + + setAmount(e.target.value)} + className="w-32" + /> +
    + +
    +
    +
    + ); +} + +function Stat({ + label, + value, + mono, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
    +
    + {label} +
    +
    + {value} +
    +
    + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index 7ab3994d90..d85c2e2314 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -143,11 +143,13 @@ export default async function Layout({ ); } - // Check feature flags for menu items + // Check feature flags for menu items. Security (penetration tests) is + // always enabled now — the nav rail entry is gated solely by the + // `pentest:read` permission downstream, matching `security/layout.tsx`. let isQuestionnaireEnabled = false; let isTrustNdaEnabled = false; let isWebAutomationsEnabled = false; - let isSecurityEnabled = false; + const isSecurityEnabled = true; if (session?.user?.id) { const flags = await getFeatureFlags(session.user.id, { groups: { organization: organization.id }, @@ -158,8 +160,6 @@ export default async function Layout({ isWebAutomationsEnabled = flags['is-web-automations-enabled'] === true || flags['is-web-automations-enabled'] === 'true'; - isSecurityEnabled = - flags['is-security-enabled'] === true || flags['is-security-enabled'] === 'true'; } // Check auditor role diff --git a/apps/app/src/app/(app)/[orgId]/security/layout.tsx b/apps/app/src/app/(app)/[orgId]/security/layout.tsx index 24c6853264..112f8c3177 100644 --- a/apps/app/src/app/(app)/[orgId]/security/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/layout.tsx @@ -1,8 +1,4 @@ -import { getFeatureFlags } from '@/app/posthog'; import { requireRoutePermission } from '@/lib/permissions.server'; -import { auth } from '@/utils/auth'; -import { headers } from 'next/headers'; -import { notFound } from 'next/navigation'; export default async function SecurityLayout({ children, @@ -13,24 +9,13 @@ export default async function SecurityLayout({ }) { const { orgId } = await params; + // Access is gated solely by the `pentest:read` permission + // (via `requireRoutePermission`), which already redirects + // unauthenticated/unauthorized users. The previous + // `is-security-enabled` PostHog flag was kept from a staged-rollout + // era and added a second 404 path that broke local dev whenever + // PostHog couldn't return the flag. await requireRoutePermission('penetration-tests', orgId); - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session?.user?.id) { - return notFound(); - } - - const flags = await getFeatureFlags(session.user.id); - const isSecurityEnabled = - flags['is-security-enabled'] === true || - flags['is-security-enabled'] === 'true'; - - if (!isSecurityEnabled) { - return notFound(); - } - return <>{children}; } diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AgentActivityLog.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AgentActivityLog.tsx index cf6f5639eb..f13eef2a17 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AgentActivityLog.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AgentActivityLog.tsx @@ -9,14 +9,45 @@ interface AgentActivityLogProps { } /** - * Collapsible under-the-hood activity log. Collapsed by default per the - * design handoff — findings are the hero, agent events are secondary. + * Drop events that reference our infrastructure provider in any string + * field. Customers see Comp.ai end-to-end — exposing Maced-internal tool + * names (`mcp__maced-helper__*`) or branded mentions in agent prose + * leaks the supplier and looks like internal dev tooling. This is purely + * a customer-facing UI filter; the events still exist in the API + * response and our logs. + * + * Also drops `TodoWrite` rows — agent self-bookkeeping that has no + * informational value for customers. + */ +function isCustomerVisible(event: PentestAgentEvent): boolean { + if (event.tool === 'TodoWrite') return false; + const fields: (string | null | undefined)[] = [ + event.agent, + event.tool, + event.summary, + event.description, + event.raw, + ]; + for (const field of fields) { + if (typeof field === 'string' && field.toLowerCase().includes('maced')) { + return false; + } + } + return true; +} + +/** + * Collapsible execution trace. Open by default — surfaces the agent + * activity stream as proof-of-work without forcing a click. The section + * is still wrapped in `
    ` so users can collapse it once they've + * seen what they need. */ export function AgentActivityLog({ events, - defaultOpen = false, + defaultOpen = true, }: AgentActivityLogProps) { const recent = [...events] + .filter(isCustomerVisible) .sort((a, b) => b.timestamp - a.timestamp) .slice(0, 200); @@ -28,7 +59,7 @@ export function AgentActivityLog({
    - Agent activity + Execution trace {events.length} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CleanReportLayout.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CleanReportLayout.tsx new file mode 100644 index 0000000000..98b59b1e0a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CleanReportLayout.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { Button } from '@trycompai/design-system'; +import { Document, Download } from '@trycompai/design-system/icons'; +import type { + PentestAgentEvent, + PentestRun, +} from '@/lib/security/penetration-tests-client'; + +interface CleanReportLayoutProps { + run: PentestRun; + events: PentestAgentEvent[]; + onDownloadMarkdown: () => void; + onDownloadPdf: () => void; + onReRun?: () => void; +} + +/** + * Audit-grade attestation layout for clean (zero findings) runs. + * + * Deliberately minimal. We only render what we can prove from `run`: + * - Target URL ✅ + * - Duration ✅ (updatedAt - createdAt) + * - Completion timestamp ✅ + * - Run id ✅ + * - Severity counts (all 0) ✅ + * + * Re-add — only when the data is real, not when it looks plausible: + * - Per-run coverage matrix (needs Maced endpoint listing the agents + * / categories that actually ran for THIS + * run; the standard-suite list is too + * weak a claim for audit attestation) + * - Agent grid (event stream is a partial subset of + * agents — "X / 22" reads like an + * incomplete scan even on success) + * - Streak history (needs per-run issue counts in the list + * endpoint — backend aggregation) + * - sha256 attestation hash (Maced doesn't expose one) + * - Scheduled-scan footer (no scheduling feature exists) + */ +export function CleanReportLayout({ + run, + events: _events, + onDownloadMarkdown, + onDownloadPdf, + onReRun, +}: CleanReportLayoutProps) { + const durationMs = computeDurationMs(run.createdAt, run.updatedAt); + + return ( +
    + + + + + + + {onReRun ? ( +
    + + Re-run for an updated attestation when your stack changes. + + +
    + ) : null} +
    + ); +} + +interface HeroRowProps { + run: PentestRun; + durationMs: number; +} + +function HeroRow({ run, durationMs }: HeroRowProps) { + return ( +
    + {/* Single-column hero. Hierarchy comes from size (32px headline + vs 11–12px metadata). The status pill + run id in the page + header above already carry the "completed" cue and run-id + reference, so a separate right-side attestation column was + rendering only duplicates of those values once Maced's + stale-`updatedAt` data forced us to hide the timestamp. */} +

    + No findings reported in this scan +

    +

    + {run.targetUrl} + {/* Maced doesn't always bump `updatedAt` on completion, so the + computed duration can collapse to ~0 even for a 3-hour scan. + Suppress the line entirely when that happens — better to say + nothing than to print "< 1m" for a multi-hour assessment. + The real duration is in the `pentest.completed` webhook + payload; persisting it is a follow-up. */} + {durationMs >= 60_000 + ? ` · Completed in ${formatDurationLabel(durationMs)}` + : ''} +

    +

    + The downloaded report is the complete assessment record — + always reference it for full context. +

    +
    + ); +} + +function SeveritySummaryLine() { + return ( +
    + 0 critical · 0 high · 0 medium · 0 low · 0 info +
    + ); +} + +interface AttachToAuditCtaProps { + onDownloadMarkdown: () => void; + onDownloadPdf: () => void; +} + +function AttachToAuditCta({ + onDownloadMarkdown, + onDownloadPdf, +}: AttachToAuditCtaProps) { + return ( +
    +
    +
    +

    Attach to audit

    +

    + Timestamped, evidence-grade output for audits and security reviews. +

    +
    +
    + {/* Both buttons render as outline — they're parallel options + (PDF for auditors, Markdown for tooling/automation), neither + is universally primary. Treating one as filled would steer + users toward a format that may not match their workflow. */} + + +
    +
    +
    + ); +} + +function computeDurationMs(start: string, end: string): number { + const startMs = new Date(start).getTime(); + const endMs = new Date(end).getTime(); + if ( + !Number.isFinite(startMs) || + !Number.isFinite(endMs) || + endMs < startMs + ) { + return 0; + } + return endMs - startMs; +} + +function formatDurationLabel(ms: number): string { + const totalMin = Math.max(Math.round(ms / 60_000), 0); + const hours = Math.floor(totalMin / 60); + const minutes = totalMin % 60; + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + if (totalMin === 0) { + return '< 1m'; + } + return `${totalMin}m`; +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CompletedDetail.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CompletedDetail.tsx index 90af2191c0..4abd99ed18 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CompletedDetail.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CompletedDetail.tsx @@ -2,7 +2,6 @@ import { Button } from '@trycompai/design-system'; import { - CheckmarkFilled, Document, Download, Renew, @@ -14,6 +13,7 @@ import type { } from '@/lib/security/penetration-tests-client'; import { formatReportDate } from '../lib'; import { AgentActivityLog } from './AgentActivityLog'; +import { CleanReportLayout } from './CleanReportLayout'; import { FindingsTable } from './FindingsTable'; import { SevTally } from './SevTally'; import { StatusPill } from './StatusPill'; @@ -44,7 +44,7 @@ export function CompletedDetail({ return (
    -
    +
    @@ -56,14 +56,26 @@ export function CompletedDetail({ {run.targetUrl}
    - - + {/* Markdown/PDF only show in the header when there are + findings — for clean runs the "Attach to audit" CTA in + CleanReportLayout already surfaces them as the primary + action, so a second pair here is duplicative. */} + {!isClean ? ( + <> + + + + ) : null} {onReRun ? (
    Started {formatReportDate(run.createdAt)} - Last update {formatReportDate(run.updatedAt)} + {/* Suppress "Last update" when it matches "Started". Maced + doesn't always bump `updatedAt` on completion, so showing + two identical timestamps is just noise. We'll re-show this + line once we either (a) Maced fixes the bug, or (b) we + persist completion time from the webhook payload. */} + {run.updatedAt && run.updatedAt !== run.createdAt ? ( + Last update {formatReportDate(run.updatedAt)} + ) : null} {run.repoUrl ? Repo: {run.repoUrl} : null} {run.testMode ? ( @@ -84,43 +103,28 @@ export function CompletedDetail({
    - - -
    -

    - Findings ({issues.length}) -

    - {isClean ? ( - - ) : ( - - )} -
    + {isClean ? ( + + ) : ( + <> + +
    +

    + Findings ({issues.length}) +

    + +
    + + )}
    ); } - -function CleanFindingsEmpty() { - return ( -
    - -
    -
    No issues found
    -
    - The scan completed without discovering any vulnerabilities. If the - markdown report describes findings anyway, they weren't persisted to - the structured issue list — that's a provider-side inconsistency. -
    -
    -
    - ); -} 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 07c009906e..5ddc3a846f 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 @@ -11,6 +11,10 @@ interface CreateRunPanelProps { orgId: string; onSubmit: (payload: PentestCreateRequest) => Promise<{ id: string }>; isSubmitting?: boolean; + /** Spendable credit balance — disables submit when 0. */ + balance?: number; + /** True when the trial has already been used (paid plans coming soon). */ + trialUsed?: boolean; } /** @@ -22,10 +26,13 @@ export function CreateRunPanel({ orgId, onSubmit, isSubmitting, + balance, + trialUsed, }: CreateRunPanelProps) { const router = useRouter(); const [targetUrl, setTargetUrl] = useState(''); const [repoUrl, setRepoUrl] = useState(''); + const canCreate = balance === undefined ? true : balance > 0; const handleCancel = () => { router.push(`/${orgId}/security/penetration-tests`); @@ -33,6 +40,14 @@ export function CreateRunPanel({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (!canCreate) { + toast.error( + trialUsed + ? "You've used your trial run. Paid plans coming soon." + : 'No pentest runs remaining.', + ); + return; + } const normalized = normalizeUrl(targetUrl); if (!normalized) { toast.error('Target URL is required.'); @@ -69,6 +84,14 @@ export function CreateRunPanel({ discovered — you don't need to keep this page open.

    + {!canCreate && ( +
    + {trialUsed + ? "You've used your trial run. Paid plans are coming soon — contact support if you need access today." + : 'No pentest runs remaining.'} +
    + )} +