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 f3f51de438..b36942bebc 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", @@ -157,6 +158,9 @@ "transform": { "^.+\\.(t|j)sx?$": "ts-jest" }, + "transformIgnorePatterns": [ + "node_modules/(?!(@maced/api-client|better-auth)/)" + ], "collectCoverageFrom": [ "**/*.(t|j)s" ], @@ -197,7 +201,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/admin-organizations/admin-audit-log.interceptor.ts b/apps/api/src/admin-organizations/admin-audit-log.interceptor.ts index eac9368b96..121f1caf76 100644 --- a/apps/api/src/admin-organizations/admin-audit-log.interceptor.ts +++ b/apps/api/src/admin-organizations/admin-audit-log.interceptor.ts @@ -20,6 +20,10 @@ const SEGMENT_TO_RESOURCE: Record< tasks: { entity: AuditLogEntityType.task, singular: 'task' }, vendors: { entity: AuditLogEntityType.vendor, singular: 'vendor' }, context: { entity: AuditLogEntityType.organization, singular: 'context' }, + 'pentest-credits': { + entity: AuditLogEntityType.pentest, + singular: 'pentest credits', + }, }; const SPECIAL_ACTION_DESCRIPTIONS: Record = { diff --git a/apps/api/src/admin-organizations/admin-organizations.module.ts b/apps/api/src/admin-organizations/admin-organizations.module.ts index 1f63faa775..938dcc6668 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 { PurgeOrganizationService } from './purge-organization.service'; @@ -18,6 +19,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: [ @@ -29,6 +31,7 @@ import { AdminEvidenceController } from './admin-evidence.controller'; PoliciesModule, CommentsModule, AttachmentsModule, + SecurityPenetrationTestsModule, ], controllers: [ AdminOrganizationsController, @@ -38,6 +41,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..7b6ac2273e --- /dev/null +++ b/apps/api/src/admin-organizations/admin-pentest-credits.controller.ts @@ -0,0 +1,75 @@ +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 (no `/grant` suffix). The + // AdminAuditLogInterceptor's URL parser treats the segment after the + // resource as an entity id; if we used `/grant`, the audit log + // would record `entityId: "grant"` which is meaningless and breaks + // the admin audit trail. Keeping the route shape standard + // (`:orgId/`) lets the interceptor produce correct metadata. + @Post(':orgId/pentest-credits') + @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/admin-organizations/purge-organization-snapshot.service.ts b/apps/api/src/admin-organizations/purge-organization-snapshot.service.ts index 34e304c702..c29db09a64 100644 --- a/apps/api/src/admin-organizations/purge-organization-snapshot.service.ts +++ b/apps/api/src/admin-organizations/purge-organization-snapshot.service.ts @@ -22,8 +22,6 @@ export class PurgeOrganizationSnapshotService { } const [ - billing, - pentest, trustResources, trustNdas, trustDocs, @@ -36,14 +34,13 @@ export class PurgeOrganizationSnapshotService { integrations, counts, ] = await Promise.all([ - db.organizationBilling.findUnique({ - where: { organizationId }, - select: { stripeCustomerId: true }, - }), - db.pentestSubscription.findUnique({ - where: { organizationId }, - select: { stripeSubscriptionId: true }, - }), + // The legacy `organization_billing` and `pentest_subscriptions` + // tables were dropped in migration 20260427000000_pentest_credits; + // they were Stripe-coupled records that never had production data + // and have been superseded by the `pentest_credits` wallet model. + // The snapshot intentionally omits them — there's nothing to + // capture. If/when v2 introduces real Stripe billing, the new + // tables get added here at that point. db.trustResource.findMany({ where: { organizationId }, select: { s3Key: true }, @@ -130,9 +127,13 @@ export class PurgeOrganizationSnapshotService { return { organization: { id: org.id, name: org.name, slug: org.slug }, counts, + // Stripe IDs intentionally null — the source tables were dropped + // in 20260427000000_pentest_credits. The shape is preserved so + // downstream consumers (purge orchestrator) don't need to change + // until v2 billing replaces these. stripe: { - customerId: billing?.stripeCustomerId ?? null, - subscriptionId: pentest?.stripeSubscriptionId ?? null, + customerId: null, + subscriptionId: null, }, s3KeysByBucket, knowledgeBaseDocumentIds: kbDocs.map((d) => d.id), 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/main.ts b/apps/api/src/main.ts index 75585eb752..bde25faf86 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,6 +84,23 @@ 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. + // Routes that need the exact request bytes for HMAC signature verification. + // Anything matched here gets `req.rawBody` populated; everything else uses + // the standard parser which discards the buffer to avoid keeping a 150MB + // copy of every JSON payload alive on the heap. + const RAW_BODY_PATHS = [ + '/v1/security-penetration-tests/webhook', + '/security-penetration-tests/webhook', + ]; + const needsRawBody = (req: express.Request): boolean => + RAW_BODY_PATHS.some((p) => req.path.endsWith(p)); + + const jsonParserWithRaw = express.json({ + limit: '150mb', + verify: (req, _res, buf) => { + (req as express.Request).rawBody = buf; + }, + }); const jsonParser = express.json({ limit: '150mb' }); const urlencodedParser = express.urlencoded({ limit: '150mb', @@ -92,7 +115,8 @@ async function bootstrap(): Promise { if (req.path.startsWith('/api/auth')) { return next(); } - jsonParser(req, res, (err?: unknown) => { + const parser = needsRawBody(req) ? jsonParserWithRaw : jsonParser; + parser(req, res, (err?: unknown) => { if (err) return next(err); urlencodedParser(req, res, next); }); diff --git a/apps/api/src/security-penetration-tests/README.md b/apps/api/src/security-penetration-tests/README.md deleted file mode 100644 index db8ed06519..0000000000 --- a/apps/api/src/security-penetration-tests/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# Security Penetration Tests (Maced Integration) - -This module exposes Comp API endpoints under `/v1/security-penetration-tests` and orchestrates report generation with Maced (`/v1/pentests`). - -## Endpoints - -- `GET /v1/security-penetration-tests` -- `POST /v1/security-penetration-tests` -- `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 - -- `MACED_API_KEY`: Maced API key used by Nest API when calling provider endpoints. - -## Optional environment variables - -- `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 - -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. - -## 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) - -Use this e2e canary to detect Maced API contract drift against the live provider without creating new paid runs. - -- 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`. 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/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..63710af0f6 --- /dev/null +++ b/apps/api/src/security-penetration-tests/pentest-credits.service.spec.ts @@ -0,0 +1,321 @@ +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('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..d2afc75bad --- /dev/null +++ b/apps/api/src/security-penetration-tests/pentest-credits.service.ts @@ -0,0 +1,321 @@ +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, + }; + } + + // Initial trial credits are granted via Prisma's nested-create on + // `Organization` in the org-create server actions + // (apps/app/src/app/(app)/setup/actions/create-organization*.ts) so + // the credit row is inserted in the same transaction as the + // organization itself — no chance of an org existing without its + // trial credit. Existing orgs were backfilled by migration + // `20260427000000_pentest_credits`. There is intentionally no + // service-side `grantInitialTrial` method; if a manual re-grant is + // ever needed, it goes through the platform-admin grant flow. + + /** + * 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, + ); + } + // Read the post-debit balance through the same client so we see + // the just-decremented value when running inside a transaction + // (otherwise the read would use a fresh `db` connection and return + // a stale, pre-debit balance). + const row = await client.pentestCredits.findUnique({ + where: { organizationId }, + }); + const status: PentestCreditsStatus = row + ? { + balance: row.balance, + totalGranted: row.totalGranted, + totalConsumed: row.totalConsumed, + lastGrantSource: row.lastGrantSource, + } + : { balance: 0, totalGranted: 0, totalConsumed: 0, lastGrantSource: 'none' }; + 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, + tx?: Pick, + ): Promise { + // Use the optional tx client when provided so the caller can wrap + // claim+refund in a single transaction (webhook idempotency: if the + // refund DB write fails, the claim rolls back and a redelivered + // webhook can retry). + const client = tx ?? db; + // 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 client.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.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..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,14 +1,14 @@ 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 { 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, IntegrationPlatformModule], - controllers: [SecurityPenetrationTestsController, PentestBillingController], - providers: [SecurityPenetrationTestsService, PentestBillingService], + imports: [AuthModule], + 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 d9bf2aba90..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,9 +88,22 @@ describe('SecurityPenetrationTestsService', () => { }); beforeEach(() => { - process.env.MACED_API_KEY = 'test-maced-api-key'; + 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( - mockCredentialVaultService as unknown as CredentialVaultService, + mockPentestCreditsService as unknown as PentestCreditsService, ); fetchMock.mockReset(); global.fetch = fetchMock as unknown as typeof fetch; @@ -125,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', }), }), ); @@ -585,201 +609,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'; @@ -812,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', }), }), ); @@ -864,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', }), }), ); @@ -929,48 +763,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..8217a2e420 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,23 +7,47 @@ 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'; +import { PentestCreditsService } from './pentest-credits.service'; + +/** + * Drops events that mention our infrastructure provider in any string + * field. Matches the same predicate as the frontend's + * `isCustomerVisible`, but applied at the API layer so the filter + * cannot be bypassed by a non-browser client (curl, DevTools, custom + * SDK consumer). The events still exist in our internal logs. + */ +function isCustomerVisibleEvent(event: PentestEvent): boolean { + const e = event as PentestEvent & { + agent?: unknown; + tool?: unknown; + summary?: unknown; + description?: unknown; + raw?: unknown; + }; + if (typeof e.tool === 'string' && e.tool === 'TodoWrite') return false; + const fields: unknown[] = [e.agent, e.tool, e.summary, e.description, e.raw]; + for (const field of fields) { + if (typeof field === 'string' && field.toLowerCase().includes('maced')) { + return false; + } + } + return true; } export type PentestReportStatus = @@ -34,6 +58,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 +107,81 @@ 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(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. + 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', + // 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 }, + }); + } - 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 +207,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,87 +223,104 @@ 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; - } = { + // 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 + // 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; + 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 createdReport = - await this.macedClient.createPentest(sanitizedPayload); - const providerRunId = createdReport.id; - if (!providerRunId) { - throw new HttpException( - { error: 'Create response missing report identifier' }, - HttpStatus.BAD_GATEWAY, + await this.refundQuietly( + organizationId, + 'pending', + 'maced_missing_run_id', ); - } - 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', - }, + { error: 'Create response missing report identifier' }, 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, 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: @@ -227,52 +330,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 +356,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 +368,39 @@ 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); + const events = await this.callMaced( + () => this.macedClient.pentests.events(id), + `fetching penetration test events ${id}`, + ); + // Filter at the API layer (defense in depth) — a UI-only filter + // would leave Maced-internal tool names (`mcp__maced-helper__*`) + // and any "Maced" prose mentions visible in the raw HTTP response, + // i.e. accessible via DevTools / curl / a custom client. By + // dropping these rows before they leave our server, the customer- + // facing surface stays white-labeled regardless of consumer. + return events.filter(isCustomerVisibleEvent); } async getReportOutput( @@ -298,13 +409,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,149 +427,224 @@ 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}` : ''), ); + // 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 the refund transaction fails + // (e.g. transient DB blip), the error propagates so this handler + // returns 5xx and Maced redelivers the webhook — without that, the + // customer would silently lose their 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, - 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 }, - }); + /** + * 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 { + // Atomic claim — only the first webhook delivery for this run gets + // count: 1 back. Subsequent redeliveries see `completed_audit_at` + // already set and bail out before writing a duplicate audit row. + const claimed = await db.securityPenetrationTestRun.updateMany({ + where: { + providerRunId: data.pentestId, + completedAuditAt: null, + }, + data: { completedAuditAt: new Date() }, + }); + if (claimed.count === 0) { + // Either no ownership row (orphan) or this completion event has + // already been audited. Either way: silent no-op. + this.logger.log( + `[Webhook] pentest.completed audit skipped run=${data.pentestId} (no ownership row or already audited)`, + ); + return; + } - if (!provider) { - return null; - } + const run = await db.securityPenetrationTestRun.findUnique({ + where: { providerRunId: data.pentestId }, + select: { organizationId: true }, + }); + if (!run) { + // Race: the row vanished between updateMany and findUnique. + // Vanishingly rare; log and bail. + this.logger.warn( + `[Webhook] pentest.completed run row vanished after claim run=${data.pentestId}`, + ); + 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, + }, + }); + } - const connection = await db.integrationConnection.findFirst({ - where: { - providerId: provider.id, - organizationId, - status: 'active', - }, - select: { id: true }, + /** + * 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 { + // Wrap claim + refund in a single transaction so a refund failure + // rolls back the claim. Without this, a transient DB blip on the + // wallet write would leave `creditRefundedAt` set with no actual + // refund, and webhook redelivery would short-circuit forever — the + // customer never gets their credit back. + // + // Errors are NOT swallowed here — they propagate to handleWebhook + // → Maced sees 5xx → redelivers the webhook. On the redelivery + // the rolled-back `creditRefundedAt` is null again, so the claim + // re-fires and the refund is retried. + await db.$transaction(async (tx) => { + const claimed = await tx.securityPenetrationTestRun.updateMany({ + where: { providerRunId, creditRefundedAt: null }, + data: { creditRefundedAt: new Date() }, }); - if (!connection) { - return null; + 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 credentials = - await this.credentialVaultService.getDecryptedCredentials( - connection.id, + const run = await tx.securityPenetrationTestRun.findUnique({ + where: { providerRunId }, + select: { organizationId: true }, + }); + if (!run) { + // Vanishingly rare race; abort the transaction so the claim + // rolls back. Webhook redelivery will retry. + throw new Error( + `Run row vanished after claim for ${providerRunId}`, ); + } - 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), + // Pass the tx client through so the wallet write happens in + // the same transaction as the claim. If this throws, the claim + // is undone and the error propagates up to handleWebhook → Maced + // sees 5xx and redelivers, allowing retry. + await this.credits.refund( + run.organizationId, + providerRunId, + eventType, + tx, ); - return null; - } + }); + } + + 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 { @@ -469,13 +657,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,29 +844,40 @@ 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; + /** + * 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) + }`, + ); } - - return timingSafeEqual(aBuffer, bBuffer); - } - - private webhookHandshakeSecretName(reportId: string): string { - return `security_penetration_test_webhook_${reportId}`; } 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, @@ -658,9 +886,7 @@ export class SecurityPenetrationTestsService { organizationId, providerRunId: reportId, }, - update: { - organizationId, - }, + update: {}, }); } @@ -777,167 +1003,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/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index 1f6fb8f217..29ec796fce 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -1911,6 +1911,7 @@ export class TrustAccessService { | 'iso42001' | 'gdpr' | 'hipaa' + | 'soc3' | 'soc2type1' | 'soc2type2' | 'pci_dss' @@ -1923,6 +1924,7 @@ export class TrustAccessService { [TrustFramework.hipaa]: 'hipaa', [TrustFramework.soc2_type1]: 'soc2type1', [TrustFramework.soc2_type2]: 'soc2type2', + [TrustFramework.soc3]: 'soc3', [TrustFramework.pci_dss]: 'pci_dss', [TrustFramework.nen_7510]: 'nen7510', [TrustFramework.iso_9001]: 'iso9001', @@ -2594,6 +2596,8 @@ export class TrustAccessService { const CERT_MAP: Record = { soc2: 'soc2', 'soc 2': 'soc2', + soc3: 'soc3', + 'soc 3': 'soc3', iso27001: 'iso27001', 'iso 27001': 'iso27001', iso42001: 'iso42001', @@ -2647,6 +2651,7 @@ export class TrustAccessService { const LABEL_MAP: Record = { soc2: 'SOC 2', + soc3: 'SOC 3', iso27001: 'ISO 27001', iso42001: 'ISO 42001', iso9001: 'ISO 9001', diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts index 22952031a6..4d5e0d4fde 100644 --- a/apps/api/src/trust-portal/trust-portal.service.ts +++ b/apps/api/src/trust-portal/trust-portal.service.ts @@ -123,6 +123,7 @@ export class TrustPortalService { | 'iso42001_status' | 'gdpr_status' | 'hipaa_status' + | 'soc3_status' | 'soc2type1_status' | 'soc2type2_status' | 'pci_dss_status' @@ -133,6 +134,7 @@ export class TrustPortalService { | 'iso42001' | 'gdpr' | 'hipaa' + | 'soc3' | 'soc2type1' | 'soc2type2' | 'pci_dss' @@ -186,6 +188,11 @@ export class TrustPortalService { enabledField: 'iso9001', slug: 'iso_9001', }, + [TrustFramework.soc3]: { + statusField: 'soc3_status', + enabledField: 'soc3', + slug: 'soc3', + }, }; async getDomainStatus( @@ -612,6 +619,7 @@ export class TrustPortalService { soc2: 'soc2', soc2type1: 'soc2type1', soc2type2: 'soc2type2', + soc3: 'soc3', iso27001: 'iso27001', iso42001: 'iso42001', gdpr: 'gdpr', @@ -626,6 +634,7 @@ export class TrustPortalService { const statusFieldMap: Record = { soc2type1Status: 'soc2type1_status', soc2type2Status: 'soc2type2_status', + soc3Status: 'soc3_status', iso27001Status: 'iso27001_status', iso42001Status: 'iso42001_status', gdprStatus: 'gdpr_status', @@ -636,6 +645,7 @@ export class TrustPortalService { // Also support snake_case input (from other callers) soc2type1_status: 'soc2type1_status', soc2type2_status: 'soc2type2_status', + soc3_status: 'soc3_status', iso27001_status: 'iso27001_status', iso42001_status: 'iso42001_status', gdpr_status: 'gdpr_status', @@ -1515,6 +1525,7 @@ export class TrustPortalService { // Framework flags soc2type1: trust.soc2type1 ?? false, soc2type2: trust.soc2type2 || trust.soc2 || false, + soc3: trust.soc3 ?? false, iso27001: trust.iso27001 ?? false, iso42001: trust.iso42001 ?? false, gdpr: trust.gdpr ?? false, @@ -1528,6 +1539,7 @@ export class TrustPortalService { !trust.soc2type2 && trust.soc2 ? (trust.soc2_status ?? 'started') : (trust.soc2type2_status ?? 'started'), + soc3Status: trust.soc3_status ?? 'started', iso27001Status: trust.iso27001_status ?? 'started', iso42001Status: trust.iso42001_status ?? 'started', gdprStatus: trust.gdpr_status ?? 'started', 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]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx index f8b348004c..5faab00b25 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 @@ -35,6 +35,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; @@ -171,6 +172,7 @@ export function AdminOrgTabs({ Context Evidence Timeline + Pentest credits Feature Flags } @@ -221,6 +223,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..8c4ac0e8c9 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/PentestCreditsTab.tsx @@ -0,0 +1,162 @@ +'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 () => { + // Use `Number()` instead of `parseInt()` — parseInt silently + // truncates "1.5" → 1 and accepts "1e3" as 1000, both of which + // would grant an unintended amount. `Number()` rejects them as NaN + // / non-integer respectively. + const parsed = Number(amount); + if ( + !Number.isFinite(parsed) || + !Number.isInteger(parsed) || + parsed < 1 || + parsed > 1000 + ) { + toast.error('Amount must be a whole number between 1 and 1000.'); + return; + } + setSubmitting(true); + // POST to the same endpoint as the GET — the API treats POST on + // the resource as "grant N credits", which keeps the URL parser- + // friendly for the AdminAuditLogInterceptor. + const res = await api.post( + endpoint, + { 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]/documents/components/CompanyFormPageClient.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyFormPageClient.tsx index 7d82ce9f2d..2ee76d67a8 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyFormPageClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyFormPageClient.tsx @@ -594,7 +594,7 @@ export function CompanyFormPageClient({ Upload Evidence - Upload a PDF or image as evidence for this document. + Upload a PDF, image, Markdown, or CSV file as evidence for this document.
@@ -630,7 +630,7 @@ export function CompanyFormPageClient({ setSelectedFile(e.target.files?.[0] ?? null)} className="block w-full text-sm text-foreground file:mr-4 file:rounded-md file:border-0 file:bg-muted file:px-4 file:py-2 file:text-sm file:font-medium file:text-foreground hover:file:bg-muted/80 file:cursor-pointer cursor-pointer" /> diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkOverview.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkOverview.tsx index 30f499848e..d0f25cd09a 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkOverview.tsx @@ -16,18 +16,15 @@ import { } from '@trycompai/ui/dropdown-menu'; import { useState } from 'react'; import { usePermissions } from '@/hooks/use-permissions'; -import { getControlStatus } from '@/lib/control-compliance'; +import { + type EvidenceSubmissionInfo, + getControlStatus, +} from '@/lib/control-compliance'; import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; import { FrameworkDeleteDialog } from './FrameworkDeleteDialog'; import { AddCustomRequirementSheet } from './AddCustomRequirementSheet'; import { LinkRequirementSheet } from './LinkRequirementSheet'; -interface EvidenceSubmissionInfo { - id: string; - formType: string; - createdAt: Date | string; -} - interface FrameworkOverviewProps { frameworkInstanceWithControls: FrameworkInstanceWithControls; tasks: (Task & { controls: Control[] })[]; diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkProgress.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkProgress.tsx index bff198f325..192a4763f0 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkProgress.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkProgress.tsx @@ -2,15 +2,13 @@ import type { Control, Task } from '@db'; import { Badge, Text } from '@trycompai/design-system'; -import { getControlStatus } from '@/lib/control-compliance'; +import { + type EvidenceSubmissionInfo, + getControlStatus, + getFrameworkAggregatePercent, +} from '@/lib/control-compliance'; import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; -interface EvidenceSubmissionInfo { - id: string; - formType: string; - createdAt: Date | string; -} - interface Props { framework: FrameworkInstanceWithControls; tasks: (Task & { controls: Control[] })[]; @@ -32,7 +30,7 @@ export function FrameworkProgress({ framework, tasks, evidenceSubmissions }: Pro ) === 'completed', ).length; - const percent = totalControls > 0 ? Math.round((compliantControls / totalControls) * 100) : 0; + const percent = getFrameworkAggregatePercent(allControls, tasks, evidenceSubmissions); const remaining = totalControls - compliantControls; const variant: 'default' | 'secondary' | 'destructive' = diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx index e77682983b..ab13e7d36f 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx @@ -17,30 +17,27 @@ import { } from '@trycompai/design-system'; import { Search } from '@trycompai/design-system/icons'; import { useParams, useRouter } from 'next/navigation'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; + +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; -import { getControlStatus } from '@/lib/control-compliance'; +import { + type EvidenceSubmissionInfo, + type RequirementArtifactCounts, + getControlProgressPercent, + getControlStatus, + getRequirementArtifactCounts, + getRequirementCompliancePercent, + getRequirementStatus, +} from '@/lib/control-compliance'; +import type { StatusType } from '@/components/status-indicator'; interface RequirementItem extends FrameworkEditorRequirement { mappedControlsCount: number; satisfiedControlsCount: number; compliancePercent: number; -} - -function getRequirementStatus( - satisfiedCount: number, - totalCount: number, -): { label: string; variant: 'default' | 'secondary' | 'destructive' } { - if (totalCount === 0) return { label: 'No Controls', variant: 'secondary' }; - if (satisfiedCount === totalCount) return { label: 'Satisfied', variant: 'default' }; - if (satisfiedCount > 0) return { label: 'In Progress', variant: 'secondary' }; - return { label: 'Not Started', variant: 'destructive' }; -} - -interface EvidenceSubmissionInfo { - id: string; - formType: string; - createdAt: Date | string; + controlStatuses: StatusType[]; + artifactCounts: RequirementArtifactCounts; } export function FrameworkRequirements({ @@ -60,6 +57,8 @@ export function FrameworkRequirements({ frameworkInstanceId: string; }>(); const [searchTerm, setSearchTerm] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); const items = useMemo(() => { return requirementDefinitions.map((def) => { @@ -68,26 +67,43 @@ export function FrameworkRequirements({ control.requirementsMapped?.some((reqMap) => reqMap.requirementId === def.id) ?? false, ); - const satisfiedControlsCount = mappedControls.filter( - (control) => getControlStatus( + const controlStatuses = mappedControls.map((control) => + getControlStatus( control.policies, tasks ?? [], control.id, control.controlDocumentTypes, evidenceSubmissions, - ) === 'completed', + ), + ); + const satisfiedControlsCount = controlStatuses.filter( + (status) => status === 'completed', ).length; - const compliancePercent = - mappedControls.length > 0 - ? Math.round((satisfiedControlsCount / mappedControls.length) * 100) - : 0; + const controlProgressPercents = mappedControls.map((control) => + getControlProgressPercent( + control.policies, + tasks ?? [], + control.id, + control.controlDocumentTypes, + evidenceSubmissions, + ), + ); + const compliancePercent = getRequirementCompliancePercent(controlProgressPercents); + + const artifactCounts = getRequirementArtifactCounts( + mappedControls, + tasks ?? [], + evidenceSubmissions, + ); return { ...def, mappedControlsCount: mappedControls.length, satisfiedControlsCount, compliancePercent, + controlStatuses, + artifactCounts, }; }); }, [requirementDefinitions, frameworkInstanceWithControls.controls, tasks, evidenceSubmissions]); @@ -103,6 +119,17 @@ export function FrameworkRequirements({ ); }, [items, searchTerm]); + const pageCount = Math.max(1, Math.ceil(filteredItems.length / pageSize)); + const paginatedItems = useMemo( + () => filteredItems.slice((page - 1) * pageSize, page * pageSize), + [filteredItems, page, pageSize], + ); + + // Snap back to page 1 when filtering or page-size changes shrink the result set. + useEffect(() => { + if (page > pageCount) setPage(1); + }, [page, pageCount]); + const handleRowClick = (requirementId: string) => { router.push(`/${orgId}/frameworks/${frameworkInstanceId}/requirements/${requirementId}`); }; @@ -122,29 +149,45 @@ export function FrameworkRequirements({ />
- +
{ + setPageSize(size); + setPage(1); + }, + }} + > Identifier Name Description - Controls Compliance Status + Controls + Policies + Tasks + Documents - {filteredItems.length === 0 ? ( + {paginatedItems.length === 0 ? ( - + No requirements found. ) : ( - filteredItems.map((item) => { - const status = getRequirementStatus(item.satisfiedControlsCount, item.mappedControlsCount); + paginatedItems.map((item) => { + const status = getRequirementStatus(item.controlStatuses); const identifier = item.identifier?.trim(); return ( @@ -174,19 +217,12 @@ export function FrameworkRequirements({ {item.description || '—'} - -
- - {item.satisfiedControlsCount}/{item.mappedControlsCount} - -
-
@@ -205,6 +241,34 @@ export function FrameworkRequirements({ {status.label} + +
+ + {item.satisfiedControlsCount}/{item.mappedControlsCount} + +
+
+ +
+ + {item.artifactCounts.policies.completed}/{item.artifactCounts.policies.total} + +
+
+ +
+ + {item.artifactCounts.tasks.completed}/{item.artifactCounts.tasks.total} + +
+
+ +
+ + {item.artifactCounts.documents.completed}/{item.artifactCounts.documents.total} + +
+
); }) diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/RequirementControls.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/RequirementControls.tsx index cdfa99f8b7..a4770fed3f 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/RequirementControls.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/RequirementControls.tsx @@ -1,18 +1,22 @@ 'use client'; import type { Control, RequirementMap, Task } from '@db'; -import { Heading } from '@trycompai/design-system'; +import { Badge, Heading, Text } from '@trycompai/design-system'; +import { + type EvidenceSubmissionInfo, + getControlStatus, + getFrameworkAggregatePercent, +} from '@/lib/control-compliance'; import { RequirementControlsTable } from './table/RequirementControlsTable'; -interface EvidenceSubmissionInfo { - id: string; - formType: string; - createdAt: Date | string; -} +type ControlWithRelations = Control & { + policies?: Array<{ id: string; name: string; status: string }>; + controlDocumentTypes?: Array<{ formType: string }>; +}; interface RequirementControlsProps { tasks: (Task & { controls: Control[] })[]; - relatedControls: (RequirementMap & { control: Control & { policies: Array<{ id: string; name: string; status: string }> } } )[]; + relatedControls: (RequirementMap & { control: ControlWithRelations })[]; evidenceSubmissions?: EvidenceSubmissionInfo[]; frameworkInstanceId: string; } @@ -23,17 +27,57 @@ export function RequirementControls({ evidenceSubmissions = [], frameworkInstanceId, }: RequirementControlsProps) { + const controls = relatedControls.map((rc) => rc.control); + const totalControls = controls.length; + const compliantControls = controls.filter( + (control) => + getControlStatus( + control.policies ?? [], + tasks, + control.id, + control.controlDocumentTypes, + evidenceSubmissions, + ) === 'completed', + ).length; + const remaining = totalControls - compliantControls; + const percent = getFrameworkAggregatePercent(controls, tasks, evidenceSubmissions); + const variant: 'default' | 'secondary' | 'destructive' = + percent >= 80 ? 'default' : percent >= 60 ? 'secondary' : 'destructive'; + return (
+ {totalControls > 0 && ( +
+
+ {percent}% compliant + + {compliantControls} completed + + + {remaining} remaining + + + {totalControls} total controls + +
+
+
+
+
+ )} +
Controls - {relatedControls.length} + {totalControls}
rc.control)} + controls={controls} tasks={tasks} evidenceSubmissions={evidenceSubmissions} frameworkInstanceId={frameworkInstanceId} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/table/RequirementControlsTable.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/table/RequirementControlsTable.tsx index fe8da1cba8..c1ee13cf09 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/table/RequirementControlsTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/table/RequirementControlsTable.tsx @@ -17,20 +17,21 @@ import { import { Launch, Search } from '@trycompai/design-system/icons'; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; -import { useMemo, useState } from 'react'; -import { getControlStatus } from '@/lib/control-compliance'; +import { useEffect, useMemo, useState } from 'react'; +import { + type EvidenceSubmissionInfo, + getControlProgressPercent, + getControlStatus, + getRequirementArtifactCounts, +} from '@/lib/control-compliance'; + +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; type ControlWithPolicies = Control & { policies?: Array<{ id: string; name: string; status: string }>; controlDocumentTypes?: Array<{ formType: string }>; }; -interface EvidenceSubmissionInfo { - id: string; - formType: string; - createdAt: Date | string; -} - interface RequirementControlsTableProps { controls: ControlWithPolicies[]; tasks: (Task & { controls: Control[] })[]; @@ -61,6 +62,8 @@ export function RequirementControlsTable({ const { orgId } = useParams<{ orgId: string }>(); const router = useRouter(); const [searchTerm, setSearchTerm] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); const filteredControls = useMemo(() => { if (!controls?.length) return []; @@ -74,6 +77,17 @@ export function RequirementControlsTable({ ); }, [controls, searchTerm]); + const pageCount = Math.max(1, Math.ceil(filteredControls.length / pageSize)); + const paginatedControls = useMemo( + () => filteredControls.slice((page - 1) * pageSize, page * pageSize), + [filteredControls, page, pageSize], + ); + + // Snap back to page 1 when filtering or page-size changes shrink the result set. + useEffect(() => { + if (page > pageCount) setPage(1); + }, [page, pageCount]); + const getControlHref = (controlId: string) => `/${orgId}/frameworks/${frameworkInstanceId}/controls/${controlId}`; @@ -104,42 +118,69 @@ export function RequirementControlsTable({
-
+
{ + setPageSize(size); + setPage(1); + }, + }} + > Name Description + Compliance + Status Policies Tasks - Status + Documents - {filteredControls.length === 0 ? ( + {paginatedControls.length === 0 ? ( - + No controls found. ) : ( - filteredControls.map((control) => { - const controlTasks = tasks.filter((t) => t.controls.some((c) => c.id === control.id)); + paginatedControls.map((control) => { const policies = control.policies ?? []; - const publishedCount = policies.filter((p) => p.status === 'published').length; - const doneTasks = controlTasks.filter( - (t) => t.status === 'done' || t.status === 'not_relevant', - ).length; + const documentTypes = control.controlDocumentTypes ?? []; + + // Use the shared aggregator so per-control counts (especially + // documents) honour the same 6-month freshness rule as + // getControlStatus / getControlProgressPercent below. + const counts = getRequirementArtifactCounts( + [control], + tasks, + evidenceSubmissions, + ); const status = getControlStatus( policies, tasks, control.id, - control.controlDocumentTypes, + documentTypes, evidenceSubmissions, ); const badge = getStatusBadge(status); + const compliancePercent = getControlProgressPercent( + policies, + tasks, + control.id, + documentTypes, + evidenceSubmissions, + ); return ( {control.description || '—'} + +
+
+
+
+
+ + {compliancePercent}% + +
+
+ + + {badge.label} +
- {publishedCount}/{policies.length} + {counts.policies.completed}/{counts.policies.total}
- {doneTasks}/{controlTasks.length} + {counts.tasks.completed}/{counts.tasks.total}
- {badge.label} +
+ + {counts.documents.completed}/{counts.documents.total} + +
); 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/[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..b32d23a282 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AgentActivityLog.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useState } from 'react'; +import { cn } from '@trycompai/design-system/cn'; +import type { PentestAgentEvent } from '@/lib/security/penetration-tests-client'; + +interface AgentActivityLogProps { + events: PentestAgentEvent[]; + defaultOpen?: boolean; +} + +/** + * 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 = true, +}: AgentActivityLogProps) { + // Track open state ourselves so the user can collapse the details + // panel without React re-forcing it open on the next render. With + // `open={defaultOpen}` as a controlled prop the user's collapse + // would be undone on every parent re-render (e.g. SWR poll). + const [open, setOpen] = useState(defaultOpen); + const recent = [...events] + .filter(isCustomerVisible) + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, 200); + + return ( +
setOpen((e.currentTarget as HTMLDetailsElement).open)} + className="overflow-hidden rounded-[var(--radius)] border border-border" + > + +
+ + Execution trace + + + {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..a8b54c1c23 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AgentProgressGrid.tsx @@ -0,0 +1,53 @@ +'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); + // Clamp `done` to the grid size — if Maced ever reports done > total + // (rare, but possible when their progress payload is briefly out of + // sync), we'd otherwise render extra cells past the configured columns + // and break the visual width. + const doneClamped = Math.min(Math.max(done, 0), count); + const running = doneClamped < count ? 1 : 0; + const pending = Math.max(count - doneClamped - running, 0); + + return ( +
+ {Array.from({ length: doneClamped }).map((_, i) => ( + + ))} + {running > 0 ? ( + + ) : null} + {Array.from({ length: pending }).map((_, i) => ( + + ))} +
+ ); +} 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 new file mode 100644 index 0000000000..4abd99ed18 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CompletedDetail.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { Button } from '@trycompai/design-system'; +import { + 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 { CleanReportLayout } from './CleanReportLayout'; +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} +

+
+ {/* 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 ? ( + + ) : null} +
+
+
+ Started {formatReportDate(run.createdAt)} + {/* 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 ? ( + + Test mode + + ) : null} +
+
+ + {isClean ? ( + + ) : ( + <> + +
+

+ Findings ({issues.length}) +

+ +
+ + )} + + +
+
+ ); +} 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..5e78e7e73d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.tsx @@ -0,0 +1,203 @@ +'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; + /** Spendable credit balance — disables submit when 0. */ + balance?: number; + /** True when the trial has already been used (paid plans coming soon). */ + trialUsed?: 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, + 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`); + }; + + 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.'); + 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. +

+ + {!canCreate && ( +
+ {trialUsed + ? "You've used your trial run. Paid plans are coming soon — contact support if you need access today." + : 'No pentest runs remaining.'} +
+ )} + +
+ +
+ + https:// + + setTargetUrl(e.target.value)} + placeholder="your.staging.app" + 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..3a83908c54 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/EmptyState.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { Button } from '@trycompai/design-system'; +import { + Link, + Settings, + Rocket, +} from '@trycompai/design-system/icons'; + +interface EmptyStateProps { + onCreateClick: () => void; + /** Spendable balance — when 0 the CTA is disabled. */ + balance?: number; + /** True if the trial has already been used (paid plans coming soon copy). */ + trialUsed?: boolean; +} + +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 public repository for deeper, code-aware coverage.', + Icon: Settings, + }, + { + title: 'Scan runs automatically', + description: + 'Probing typically takes 1–3 hours. You get a compliance-grade report when it finishes.', + Icon: Rocket, + }, +]; + +export function EmptyState({ + onCreateClick, + balance, + trialUsed, +}: EmptyStateProps) { + const canCreate = balance === undefined ? true : balance > 0; + const tagline = trialUsed + ? "You've used your trial run. Paid plans are coming soon — contact support if you need access today." + : 'Automated black-box pen testing. Start a scan to see findings here.'; + return ( +
+
+
+

+ Penetration Tests +

+ + New + +
+

{tagline}

+
+ + + +
+
+

+ 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..046dd6a384 --- /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..785f0df71a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingDetail.tsx @@ -0,0 +1,240 @@ +'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_BAR_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 accentBar = SEVERITY_BAR_VAR[issue.severity]; + const eyebrowFg = SEVERITY_FG_VAR[issue.severity]; + + return ( +
+
+
+ +
+ + {/* Hero — neutral card surface so the severity wash doesn't + dominate the page; severity is still instantly readable via + the colored left accent bar and the eyebrow label. */} +
+
+ {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} + + ))} + + + +
+ +
+
+ + +
+ {/* Use truthiness (`||`) rather than `??` so empty-string + values from upstream still render the fallback message. */} + +
+
+ + +
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+ + +

+ 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..7ff82a7fc4 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingsTable.tsx @@ -0,0 +1,110 @@ +'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)} + onKeyDown={(e) => { + if (!onRowClick) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + 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..5aa3d849ec --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/OverviewPane.tsx @@ -0,0 +1,259 @@ +'use client'; + +import { Button } from '@trycompai/design-system'; +import { Add } from '@trycompai/design-system/icons'; +import type { PentestRun } from '@/lib/security/penetration-tests-client'; +import { + LatestAssessment, + RecentScansSection, + StaleCoverageSection, + avgDurationMs, + cadenceLabel, + computeStaleTargets, + countWithin, + formatDurationLabel, + mostRecent, + relativeTime, + sortByUpdatedDesc, + uniqueTargets, +} from './overview-internals'; + +interface OverviewPaneProps { + orgId: string; + runs: PentestRun[]; + onCreateClick: () => void; + /** False when the org is out of pentest credits — disables the CTA. */ + canCreate: boolean; + onDownloadMarkdown: (runId: string) => void; + onDownloadPdf: (runId: string) => void; +} + +/** + * Right pane shown when no scan is selected. Two real states: + * + * - 0 completed scans → onboarding card with primary CTA + * - 1+ completed scans → posture overview: real counts, recent scans, + * stale-coverage list. NO cross-scan severity aggregation, NO trend + * chart, NO "open findings" queue — those need per-run issue counts + * in the list endpoint (backend aggregation), which we don't have + * yet. Surfacing fabricated severity numbers in a posture dashboard + * would actively mislead in an audit context. + */ +export function OverviewPane({ + orgId, + runs, + onCreateClick, + canCreate, + onDownloadMarkdown, + onDownloadPdf, +}: OverviewPaneProps) { + const completed = runs.filter((r) => r.status === 'completed'); + + if (completed.length === 0) { + return ; + } + + return ( + + ); +} + +function OnboardingState({ + onCreateClick, + canCreate, +}: { + onCreateClick: () => void; + canCreate: boolean; +}) { + return ( +
+
+
+ Penetration tests · Overview +
+

+ No scans yet. Start with your most exposed target. +

+ +
    + {[ + 'Pick a target URL', + 'Agents run for ~1–3 hours', + 'Get a signed report — clean or with findings', + ].map((step, i) => ( +
  1. + + {String(i + 1).padStart(2, '0')} + + {step} +
  2. + ))} +
+ +
+ +
+
+
+ ); +} + +interface PostureOverviewProps { + orgId: string; + runs: PentestRun[]; + completed: PentestRun[]; + onCreateClick: () => void; + canCreate: boolean; + onDownloadMarkdown: (runId: string) => void; + onDownloadPdf: (runId: string) => void; +} + +function PostureOverview({ + orgId, + runs, + completed, + onCreateClick, + canCreate, + onDownloadMarkdown, + onDownloadPdf, +}: PostureOverviewProps) { + // Coverage and stale-target stats use ONLY completed runs — a target + // that's only ever had failed/cancelled scans isn't truly "covered," + // and a target whose latest scan failed shouldn't reset the staleness + // clock. The full `runs` list is only used for the recent activity + // sidebar elsewhere. + const targets = uniqueTargets(completed); + const lastScan = mostRecent(completed); + const avgDuration = avgDurationMs(completed); + const scansLast30d = countWithin(completed, 30 * 24 * 60 * 60 * 1000); + const recentScans = sortByUpdatedDesc(completed).slice(0, 6); + const staleTargets = computeStaleTargets(completed, targets); + + return ( +
+
+
+
+

+ Overview +

+

+ {completed.length}{' '} + completed scan{completed.length === 1 ? '' : 's'} + {' · '} + {targets.length} target + {targets.length === 1 ? '' : 's'} covered + {lastScan + ? ` · last sweep ${relativeTime(lastScan.updatedAt)}` + : ''} +

+
+ +
+ + {lastScan ? ( + + ) : null} + + + + + + +
+
+ ); +} + + +interface StatBandProps { + completed: number; + targets: number; + avgDurationMs: number; + scansLast30d: number; +} + +function StatBand({ + completed, + targets, + avgDurationMs: avgMs, + scansLast30d, +}: StatBandProps) { + return ( +
+
+ + + 0 ? formatDurationLabel(avgMs) : '—'} + subline="per completed scan" + divider + /> + +
+
+ ); +} + +function StatCell({ + label, + value, + subline, + divider, +}: { + label: string; + value: string; + subline: string; + divider?: boolean; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
{subline}
+
+ ); +} 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..3cb7023000 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunList.tsx @@ -0,0 +1,249 @@ +'use client'; + +import { cn } from '@trycompai/design-system/cn'; +import { Progress } from '@trycompai/design-system'; +import { 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; + /** Spendable credit balance — drives the "X runs left" badge. */ + balance?: number; + /** True when the user has used their initial trial (balance 0, totalGranted > 0). */ + trialUsed?: boolean; +} + +export function RunList({ + orgId, + runs, + selectedRunId, + onCreateClick, + balance, + trialUsed, +}: RunListProps) { + const router = useRouter(); + const canCreate = balance === undefined ? true : balance > 0; + const newButtonTitle = !canCreate + ? trialUsed + ? "You've used your trial run. Paid plans coming soon." + : 'No pentest runs remaining.' + : 'Start a new scan'; + return ( + + ); +} + +interface QuotaFooterProps { + balance: number; + trialUsed: boolean; +} + +/** + * Sidebar footer showing scan-quota status. Persistent across overview / + * detail / create routes — always visible without crowding the header + * actions. Falls back to a "Contact support" mailto when the user is at + * zero so they have a clear next step. + */ +function QuotaFooter({ balance, trialUsed }: QuotaFooterProps) { + if (balance > 0) { + return ( +
+
+ + + {balance} + {' '} + scan{balance === 1 ? '' : 's'} remaining + + Trial +
+
+ ); + } + + return ( +
+
+ {trialUsed ? "You've used your trial scan" : 'No scans available'} +
+
+ Paid plans coming soon —{' '} + + contact support + + . +
+
+ ); +} + +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 ? ( +
    +
    + +
    + {(elapsedLabel || etaLabel) && ( +
    + {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..6f5de1e69b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunningDetail.tsx @@ -0,0 +1,177 @@ +'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); + // Pass the run id so the highlights hook resets its `seenRef` when + // the user switches to a different scan — otherwise IDs from the + // previous run linger and ALL of the new run's findings flash as + // "newly arrived" on first render. + const highlighted = useNewFindingHighlights(run.id, issues); + + const progress = run.progress; + const completedAgents = progress?.completedAgents ?? 0; + const totalAgents = progress?.totalAgents ?? 22; + // Compute elapsed client-side from `createdAt` rather than trusting + // `progress.elapsedMs` from Maced — that field isn't always populated, + // which would otherwise show "0m" hours into a real scan. Updates on + // each SWR poll (~4s cadence), which is fine granularity for a + // multi-hour run. + const startedMs = new Date(run.createdAt).getTime(); + const elapsedMs = + Number.isFinite(startedMs) && startedMs > 0 + ? Math.max(0, Date.now() - startedMs) + : 0; + const elapsedLabel = formatElapsed(elapsedMs); + + return ( +
    +
    +
    +
    + + + {run.id} + +
    +

    + {run.targetUrl} +

    +
    + Started {formatReportDate(run.createdAt)} + {run.updatedAt && run.updatedAt !== run.createdAt ? ( + Last update {formatReportDate(run.updatedAt)} + ) : null} + {run.repoUrl ? Repo: {run.repoUrl} : null} +
    +
    + +
    +

    + Scan progress +

    + +
    + Running · {elapsedLabel} elapsed +
    +
    + +
    +

    + Live severity tally +

    + +
    + +
    +

    + Findings ({issues.length}) +

    + + Scanning. New findings will appear here as agents discover them. +
    + } + /> + + + +
    + + ); +} + +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. + * + * Keyed on `runId` — when the user navigates between scans, the seen-set + * resets so we don't carry over IDs from the previous run. + */ +function useNewFindingHighlights( + runId: string, + issues: PentestIssue[], +): Set { + const seenRef = useRef>(new Set()); + const lastRunIdRef = useRef(null); + const [highlighted, setHighlighted] = useState>(new Set()); + + // On run change: prime `seenRef` with the issues already present so + // they don't all flash as newly-arrived. Bypass the next "newly + // landed" pass entirely for this run change. + if (lastRunIdRef.current !== runId) { + seenRef.current = new Set(issues.map((i) => i.id)); + lastRunIdRef.current = runId; + } + + 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; + }); + + // Schedule per-batch removal independently — fire and forget. + // Don't `clearTimeout` on cleanup: if `issues` changes in <2s + // (very common during a live scan polling at 3s), the cleanup + // would cancel the pending removal and the highlight class would + // stick to those rows forever. Each scheduled removal targets only + // the IDs from its batch, so multiple in-flight timers can't + // step on each other. + window.setTimeout(() => { + setHighlighted((prev) => { + const next = new Set(prev); + for (const id of newlyLanded) next.delete(id); + return next; + }); + }, 2000); + }, [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..fac21a5e7d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SplitView.tsx @@ -0,0 +1,255 @@ +'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, + usePentestCredits, +} 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 { credits } = usePentestCredits(orgId); + // Keep `balance` undefined while credits are loading. Coalescing to 0 + // would prematurely disable "+ New scan" before we know the user's + // real balance — child props treat `undefined` as "loading, allow + // optimistic UI" and a real `0` as "confirmed empty, block create." + const balance = credits?.balance; + const trialUsed = + credits !== undefined && credits.balance === 0 && credits.totalGranted > 0; + + 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`, + }); + }; + + // Parameterized variants for surfaces that aren't tied to `selectedRun` + // (the overview pane needs to download the latest run's report without + // making the user click into it first). + const handleDownloadMarkdownById = (runId: string) => + downloadArtifact({ + orgId, + path: `/v1/security-penetration-tests/${encodeURIComponent(runId)}/report`, + filename: `penetration-test-${runId}.md`, + }); + + const handleDownloadPdfById = (runId: string) => + downloadArtifact({ + orgId, + path: `/v1/security-penetration-tests/${encodeURIComponent(runId)}/pdf`, + filename: `penetration-test-${runId}.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 ( + // Negative margins to escape the app-shell's `p-4 md:p-6` so the + // pentest split-view renders edge-to-edge like an IDE rather than + // as a padded card. The h-calc subtracts only the global topbar + // (4rem); the outer shell padding is undone by `-m-*`. +
    + +
    + ); + } + + return ( +
    +
    + +
    +
    + {isCreateMode ? ( + + ) : selectedRunId === null ? ( + 0} + onDownloadMarkdown={handleDownloadMarkdownById} + onDownloadPdf={handleDownloadPdfById} + /> + ) : ( + setSelectedFinding(null)} + onDownloadMarkdown={() => void handleDownloadMarkdown()} + onDownloadPdf={() => void handleDownloadPdf()} + /> + )} +
    +
    + ); +} + +async function downloadArtifact({ + orgId, + path, + filename, +}: { + orgId: string; + path: string; + filename?: string; +}) { + // Derive the Accept header from the filename's extension, not from + // whether `filename` is set — both Markdown and PDF callers pass a + // filename, so the previous `filename ? pdf : md` check requested + // application/pdf for both formats. + const accept = filename?.toLowerCase().endsWith('.pdf') + ? 'application/pdf' + : 'text/markdown'; + try { + const response = await api.raw(path, { + method: 'GET', + organizationId: orgId, + headers: { Accept: accept }, + }); + 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..f9879aaa63 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/StatusPill.tsx @@ -0,0 +1,88 @@ +import { cn } from '@trycompai/design-system/cn'; + +type StatusKind = + | 'provisioning' + | 'cloning' + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +interface StatusPillProps { + status: StatusKind | string; + /** + * @deprecated retained for prop compatibility. Was used to promote + * `completed` runs to a "clean" pill, but the sidebar list can't compute + * the same value (no per-run issue counts in the list endpoint), which + * led to the detail view saying "CLEAN" while the sidebar said + * "COMPLETED" for the same run. The promotion is dropped — the hero + * headline ("No findings reported in this scan") already carries the + * success cue. + */ + 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', + }, + failed: { + label: 'Failed', + dotClass: 'bg-destructive', + textClass: 'text-destructive', + }, + cancelled: { + label: 'Cancelled', + dotClass: 'bg-muted-foreground', + textClass: 'text-muted-foreground', + }, +}; + +// Fallback for status values we don't know how to render. Better to +// show "Unknown" than to silently render an unrelated status (e.g. +// previously this defaulted to "Provisioning", which would mislead +// users into thinking the scan was still starting up). +const CONFIG_UNKNOWN: { label: string; dotClass: string; textClass: string } = { + label: 'Unknown', + dotClass: 'bg-muted-foreground', + textClass: 'text-muted-foreground', +}; + +export function StatusPill({ status, className }: StatusPillProps) { + const config = STATUS_CONFIG[status as StatusKind] ?? CONFIG_UNKNOWN; + + return ( + + + {config.label} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/overview-internals.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/overview-internals.tsx new file mode 100644 index 0000000000..5f79fbc5c0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/overview-internals.tsx @@ -0,0 +1,266 @@ +'use client'; + +import { Button } from '@trycompai/design-system'; +import { + ArrowRight, + Checkmark, + Document, + Download, +} 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'; + +const STALE_THRESHOLD_MS = 14 * 24 * 60 * 60 * 1000; + +interface LatestAssessmentProps { + orgId: string; + run: PentestRun; + onDownloadMarkdown: (runId: string) => void; + onDownloadPdf: (runId: string) => void; +} + +export function LatestAssessment({ + orgId, + run, + onDownloadMarkdown, + onDownloadPdf, +}: LatestAssessmentProps) { + const router = useRouter(); + const durationMs = + new Date(run.updatedAt).getTime() - new Date(run.createdAt).getTime(); + return ( +
    +
    + Latest assessment +
    +
    {run.targetUrl}
    +

    + {formatReportDate(run.updatedAt)} + {durationMs > 0 ? ` · ran in ${formatDurationLabel(durationMs)}` : ''} + {' · '} + {run.id} +

    +
    + + + +
    +
    + ); +} + +export function RecentScansSection({ + orgId, + runs, +}: { + orgId: string; + runs: PentestRun[]; +}) { + const router = useRouter(); + if (runs.length === 0) return null; + return ( +
    +
    + Recent scans · {runs.length} +
    +
    + {runs.map((run) => ( + + ))} +
    +
    + ); +} + +export function StaleCoverageSection({ + orgId, + stale, +}: { + orgId: string; + stale: { targetUrl: string; lastScanAt: string | null }[]; +}) { + const router = useRouter(); + return ( +
    +
    + Stale coverage · {stale.length} +
    + {stale.length === 0 ? ( +
    + + All targets scanned in the last 14 days. +
    + ) : ( +
    + {stale.map(({ targetUrl, lastScanAt }) => ( +
    + + {displayHost(targetUrl)} + + + {lastScanAt ? `last ${relativeTime(lastScanAt)}` : 'never'} + + +
    + ))} +
    + )} +
    + ); +} + +// --- helpers --------------------------------------------------------------- + +export function uniqueTargets(runs: readonly PentestRun[]): string[] { + return Array.from(new Set(runs.map((r) => r.targetUrl).filter(Boolean))); +} + +export function mostRecent(runs: readonly PentestRun[]): PentestRun | null { + if (runs.length === 0) return null; + return [...runs].sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + )[0]!; +} + +export function sortByUpdatedDesc(runs: readonly PentestRun[]): PentestRun[] { + return [...runs].sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); +} + +export function countWithin( + runs: readonly PentestRun[], + windowMs: number, +): number { + const cutoff = Date.now() - windowMs; + return runs.filter((r) => new Date(r.updatedAt).getTime() >= cutoff).length; +} + +export function computeStaleTargets( + runs: readonly PentestRun[], + targets: string[], +): { targetUrl: string; lastScanAt: string | null }[] { + const now = Date.now(); + return targets + .map((targetUrl) => { + const targetRuns = runs.filter((r) => r.targetUrl === targetUrl); + const lastScan = mostRecent(targetRuns); + const ageMs = lastScan + ? now - new Date(lastScan.updatedAt).getTime() + : Infinity; + return { + targetUrl, + lastScanAt: lastScan?.updatedAt ?? null, + ageMs, + }; + }) + .filter(({ ageMs }) => ageMs > STALE_THRESHOLD_MS) + .map(({ targetUrl, lastScanAt }) => ({ targetUrl, lastScanAt })); +} + +/** + * Average run duration in milliseconds, derived from `updatedAt - createdAt` + * across the supplied runs. Filters out non-positive durations (clock skew, + * rows that completed before they were stored, etc.). Returns 0 when there's + * no usable data so callers can render a neutral placeholder. + */ +export function avgDurationMs(runs: readonly PentestRun[]): number { + const durations = runs + .map((r) => new Date(r.updatedAt).getTime() - new Date(r.createdAt).getTime()) + .filter((ms) => Number.isFinite(ms) && ms > 0); + if (durations.length === 0) return 0; + const total = durations.reduce((sum, ms) => sum + ms, 0); + return Math.round(total / durations.length); +} + +export 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`; +} + +export function cadenceLabel(scansLast30d: number): string { + if (scansLast30d === 0) return 'no scans in the last 30 days'; + if (scansLast30d >= 30) return 'multiple scans/day'; + const intervalDays = Math.round(30 / scansLast30d); + if (intervalDays === 1) return 'about 1 scan/day'; + return `about 1 scan every ${intervalDays} days`; +} + +export function relativeTime(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + if (!Number.isFinite(ms) || ms < 0) return '—'; + const minutes = Math.floor(ms / 60_000); + if (minutes < 60) return minutes < 1 ? 'just now' : `${minutes} min ago`; + const hours = Math.floor(ms / 3_600_000); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(ms / 86_400_000); + if (days < 7) return `${days}d ago`; + const weeks = Math.floor(days / 7); + if (weeks < 5) return `${weeks}w ago`; + const months = Math.floor(days / 30); + return `${months}mo ago`; +} + +export function displayHost(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} 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..4fe6aa0d8f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/pentest-tokens.css @@ -0,0 +1,97 @@ +/* + * 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.97 0.022 80); + --pt-sev-medium-fg: oklch(0.48 0.13 70); + --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.24 0.04 80); + --pt-sev-medium-fg: oklch(0.85 0.12 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; + } + 50% { + opacity: 0.6; + } +} + +.pt-agent-cell--running { + background: var(--pt-pulse); + animation: pt-pulse 2.4s ease-in-out infinite; +} + +/* Respect OS-level reduced-motion preference. Users with vestibular + disorders or motion sensitivity get a static colored cell instead of + the heartbeat — they still see WHICH cell is running, just without + the animation. */ +@media (prefers-reduced-motion: reduce) { + .pt-agent-cell--running { + animation: none; + opacity: 0.85; + } +} + +/* 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..168890cace 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,22 +3,28 @@ import { api } from '@/lib/api-client'; import type { CreatePenetrationTestResponse, + PentestAgentEvent, PentestCreateRequest, + PentestIssue, PentestProgress, PentestReportStatus, PentestRun, } from '@/lib/security/penetration-tests-client'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSWRConfig } from 'swr'; import useSWR from 'swr'; import { isReportInProgress, sortReportsByUpdatedAtDesc } from '../lib'; const reportListEndpoint = '/v1/security-penetration-tests'; -const githubReposEndpoint = '/v1/security-penetration-tests/github/repos'; +const creditsStatusEndpoint = '/v1/pentest-credits/status'; 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 +41,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 +75,8 @@ const resolveCreateStatus = ( interface CreatePayload { targetUrl: string; repoUrl?: string; - githubToken?: string; - configYaml?: string; pipelineTesting?: boolean; testMode?: boolean; - workspace?: string; } type CreateReportApiPayload = PentestCreateRequest; @@ -199,34 +200,136 @@ 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)); + + const { data, error, mutate } = useSWR( + shouldFetch ? reportIssuesKey(organizationId, reportId) : null, + fetchApiJson, + { + refreshInterval: inProgress ? 3000 : 0, + revalidateOnFocus: true, + }, + ); + + // Trigger one final fetch the moment the run transitions out of an + // in-progress state. Without this, the very last batch of findings + // Maced writes during the completion phase can be missed: we stop + // polling immediately and don't refresh again until the user + // refocuses the tab. + const wasInProgressRef = useRef(inProgress); + useEffect(() => { + if (wasInProgressRef.current && !inProgress && shouldFetch) { + void mutate(); + } + wasInProgressRef.current = inProgress; + }, [inProgress, shouldFetch, mutate]); + + return { + issues: data ?? [], + isLoading: shouldFetch && data === undefined && !error, + error: error as Error | undefined, + mutate, + }; +} + +// 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 && reportId); + const inProgress = Boolean(status && isReportInProgress(status)); + + const { data, error, mutate } = useSWR( + shouldFetch ? reportEventsKey(organizationId, reportId) : null, + fetchApiJson, + { + refreshInterval: inProgress ? 5000 : 0, + revalidateOnFocus: true, + }, + ); + + // Same final-refresh pattern as usePenetrationTestIssues — when the + // run transitions out of in-progress we poll one more time so the + // last batch of events Maced wrote during the completion phase isn't + // missed (otherwise we'd stop polling immediately and never see them + // unless the user refocuses the tab). + const wasInProgressRef = useRef(inProgress); + useEffect(() => { + if (wasInProgressRef.current && !inProgress && shouldFetch) { + void mutate(); + } + wasInProgressRef.current = inProgress; + }, [inProgress, shouldFetch, mutate]); + + return { + events: data ?? [], + isLoading: shouldFetch && data === undefined && !error, + }; } -interface GithubReposResponse { - repos: GithubRepo[]; - connected: boolean; +export interface PentestCreditsStatus { + balance: number; + totalGranted: number; + totalConsumed: number; + lastGrantSource: string; } -export function useGithubRepos(organizationId: string): { - repos: GithubRepo[]; - connected: boolean; +const creditsKey = (organizationId: string): ReportsSWRKey => + [creditsStatusEndpoint, organizationId] as const; + +/** + * Wallet status for the org. Drives the "X runs remaining" badge in the + * RunList header and gates the "+ New" button. Re-fetched after each + * successful create via SWR's `mutate(creditsKey(...))`. + */ +export function usePentestCredits(organizationId: string): { + credits: PentestCreditsStatus | undefined; isLoading: boolean; + error: Error | undefined; + mutate: () => Promise; } { const shouldFetch = Boolean(organizationId); - const { data } = useSWR( - shouldFetch ? githubReposKey(organizationId) : null, + const { data, error, mutate } = useSWR( + shouldFetch ? creditsKey(organizationId) : null, fetchApiJson, + { revalidateOnFocus: true }, ); return { - repos: data?.repos ?? [], - connected: data?.connected ?? false, - isLoading: shouldFetch && data === undefined, + credits: data, + isLoading: shouldFetch && data === undefined && !error, + error: error as Error | undefined, + mutate, }; } @@ -250,11 +353,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 +368,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, @@ -321,6 +412,9 @@ export function useCreatePenetrationTest( } void mutate(reportListKey(organizationId)); void mutate(reportKey(organizationId, reportId)); + // Balance changed — kick the credits cache so the badge and the + // "+ New" gating reflect the new balance immediately. + void mutate(creditsKey(organizationId)); return data; } catch (reportError) { const message = diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/lib.test.ts b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/lib.test.ts deleted file mode 100644 index e42647934c..0000000000 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/lib.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { formatReportDate, isReportInProgress, sortReportsByUpdatedAtDesc, statusLabel } from './lib'; - -describe('penetration test lib helpers', () => { - it('sortReportsByUpdatedAtDesc orders newest first', () => { - const sorted = sortReportsByUpdatedAtDesc([ - { updatedAt: '2023-01-01T00:00:00Z', id: 'old', targetUrl: '', repoUrl: '', status: 'completed', createdAt: '', error: null, temporalUiUrl: null, webhookUrl: null, userId: '', organizationId: '', sandboxId: '', workflowId: '', sessionId: '' }, - { updatedAt: '2024-01-02T00:00:00Z', id: 'new', targetUrl: '', repoUrl: '', status: 'completed', createdAt: '', error: null, temporalUiUrl: null, webhookUrl: null, userId: '', organizationId: '', sandboxId: '', workflowId: '', sessionId: '' }, - { updatedAt: 'invalid-date', id: 'bad', targetUrl: '', repoUrl: '', status: 'completed', createdAt: '', error: null, temporalUiUrl: null, webhookUrl: null, userId: '', organizationId: '', sandboxId: '', workflowId: '', sessionId: '' }, - ]); - - expect(sorted.map((report) => report.id)).toEqual(['new', 'old', 'bad']); - }); - - it('isReportInProgress returns true only for active lifecycle states', () => { - expect(isReportInProgress('provisioning')).toBe(true); - expect(isReportInProgress('cloning')).toBe(true); - expect(isReportInProgress('running')).toBe(true); - expect(isReportInProgress('completed')).toBe(false); - expect(isReportInProgress('failed')).toBe(false); - expect(isReportInProgress('cancelled')).toBe(false); - }); - - it('provides stable human labels for all known states', () => { - expect(statusLabel).toMatchObject({ - provisioning: 'Queued', - cloning: 'Preparing', - running: 'Running', - completed: 'Completed', - failed: 'Failed', - cancelled: 'Cancelled', - }); - }); - - it('falls back to raw value when date formatting fails', () => { - expect(formatReportDate('not-a-date')).toBe('not-a-date'); - }); - - it('sorts as equal when both timestamps are invalid', () => { - const sorted = sortReportsByUpdatedAtDesc([ - { - updatedAt: 'invalid-date', - id: 'first', - targetUrl: '', - repoUrl: '', - status: 'completed', - createdAt: '', - error: null, - temporalUiUrl: null, - webhookUrl: null, - userId: '', - organizationId: '', - sandboxId: '', - workflowId: '', - sessionId: '', - }, - { - updatedAt: 'also-invalid', - id: 'second', - targetUrl: '', - repoUrl: '', - status: 'completed', - createdAt: '', - error: null, - temporalUiUrl: null, - webhookUrl: null, - userId: '', - organizationId: '', - sandboxId: '', - workflowId: '', - sessionId: '', - }, - ]); - - expect(sorted.map((report) => report.id)).toEqual(['first', 'second']); - }); - - it('sorts invalid timestamps as oldest', () => { - const sorted = sortReportsByUpdatedAtDesc([ - { - updatedAt: 'invalid-date', - id: 'invalid', - targetUrl: '', - repoUrl: '', - status: 'completed', - createdAt: '', - error: null, - temporalUiUrl: null, - webhookUrl: null, - userId: '', - organizationId: '', - sandboxId: '', - workflowId: '', - sessionId: '', - }, - { - updatedAt: '2025-02-01T10:00:00Z', - id: 'valid', - targetUrl: '', - repoUrl: '', - status: 'completed', - createdAt: '', - error: null, - temporalUiUrl: null, - webhookUrl: null, - userId: '', - organizationId: '', - sandboxId: '', - workflowId: '', - sessionId: '', - }, - ]); - - expect(sorted.map((report) => report.id)).toEqual(['valid', 'invalid']); - }); -}); 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.test.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx deleted file mode 100644 index f683ee0e85..0000000000 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx +++ /dev/null @@ -1,605 +0,0 @@ -import { cloneElement, isValidElement, type ComponentProps, type ReactNode } from 'react'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { PentestRun } from '@/lib/security/penetration-tests-client'; -import * as integrationPlatform from '@/hooks/use-integration-platform'; -import * as pentestHooks from './hooks/use-penetration-tests'; -import { PenetrationTestsPageClient } from './penetration-tests-page-client'; - -const { pushMock, reportHookMock, createHookMock, createReportMock, toastSuccessMock, toastErrorMock, startOAuthMock } = vi.hoisted(() => ({ - pushMock: vi.fn(), - reportHookMock: vi.fn(), - createHookMock: vi.fn(), - createReportMock: vi.fn(), - toastSuccessMock: vi.fn(), - toastErrorMock: vi.fn(), - startOAuthMock: vi.fn().mockResolvedValue({ success: false, error: 'Not configured' }), -})); - -vi.mock('next/link', () => ({ - default: ({ href, children, ...props }: { href: string; children: ReactNode }) => ( - - {children} - - ), -})); - -vi.mock('next/navigation', () => ({ - useSearchParams: () => new URLSearchParams(), - useRouter: () => ({ - replace: vi.fn(), - push: pushMock, - refresh: vi.fn(), - back: vi.fn(), - }), -})); - -vi.mock('sonner', () => ({ - toast: { - success: (...args: unknown[]) => toastSuccessMock(...args), - error: (...args: unknown[]) => toastErrorMock(...args), - }, -})); - -vi.mock('./hooks/use-penetration-tests', () => ({ - usePenetrationTests: (...args: never[]) => reportHookMock(...args), - useCreatePenetrationTest: (...args: never[]) => createHookMock(...args), - usePenetrationTest: vi.fn(), - usePenetrationTestProgress: vi.fn(), - useGithubRepos: vi.fn().mockReturnValue({ repos: [], isLoading: false }), -})); - -vi.mock('@/hooks/use-integration-platform', () => ({ - useIntegrationConnections: vi.fn().mockReturnValue({ connections: [], isLoading: false }), - useIntegrationMutations: vi.fn().mockReturnValue({ startOAuth: startOAuthMock }), -})); - -vi.mock('@trycompai/ui/select', () => ({ - Select: ({ children, onValueChange }: { children: ReactNode; onValueChange?: (value: string) => void }) => ( -
    {children}
    - ), - SelectContent: ({ children }: { children: ReactNode }) =>
    {children}
    , - SelectItem: ({ children, value }: { children: ReactNode; value: string }) => ( - - ), - SelectTrigger: ({ children, id }: { children: ReactNode; id?: string }) =>
    {children}
    , - SelectValue: ({ placeholder }: { placeholder?: string }) => {placeholder}, -})); - -vi.mock('@trycompai/ui/input', () => ({ - Input: (props: React.ComponentProps<'input'>) => , -})); - -vi.mock('@trycompai/ui/label', () => ({ - Label: ({ children, ...props }: React.ComponentProps<'label'>) => , -})); - -vi.mock('@trycompai/ui/table', () => ({ - Table: ({ children }: { children: ReactNode }) => {children}
    , - TableBody: ({ children }: { children: ReactNode }) => {children}, - TableCell: ({ children }: { children: ReactNode }) => {children}, - TableHead: ({ children }: { children: ReactNode }) => {children}, - TableHeader: ({ children }: { children: ReactNode }) => {children}, - TableRow: ({ children }: { children: ReactNode }) => {children}, -})); - -vi.mock('@trycompai/ui/dialog', () => ({ - Dialog: ({ children, open }: { children: ReactNode; open: boolean }) => ( -
    {children}
    - ), - DialogContent: ({ children }: { children: ReactNode }) =>
    {children}
    , - DialogDescription: ({ children }: { children: ReactNode }) =>

    {children}

    , - DialogFooter: ({ children }: { children: ReactNode }) =>
    {children}
    , - DialogHeader: ({ children }: { children: ReactNode }) =>
    {children}
    , - DialogTitle: ({ children }: { children: ReactNode }) =>

    {children}

    , -})); - -vi.mock('@trycompai/ui/card', () => ({ - Card: ({ children }: { children: ReactNode }) =>
    {children}
    , - CardContent: ({ children }: { children: ReactNode }) =>
    {children}
    , - CardDescription: ({ children }: { children: ReactNode }) =>

    {children}

    , - CardHeader: ({ children }: { children: ReactNode }) =>
    {children}
    , - CardTitle: ({ children }: { children: ReactNode }) =>

    {children}

    , -})); - -vi.mock('@trycompai/ui/badge', () => ({ - Badge: ({ children }: { children: ReactNode }) => {children}, -})); - -vi.mock('@trycompai/design-system', () => ({ - Button: ({ asChild, children, ...props }: ComponentProps<'button'> & { asChild?: boolean }) => { - if (asChild && isValidElement(children)) { - return cloneElement(children, { ...props }); - } - return ( - - ); - }, - PageHeader: ({ title, actions, children }: { title: string; actions?: ReactNode; children?: ReactNode }) => ( -
    -

    {title}

    - {actions} - {children} -
    - ), - PageLayout: ({ children }: { children: ReactNode }) =>
    {children}
    , -})); - -const reportRows: PentestRun[] = [ - { - id: 'run_running', - targetUrl: 'https://running.example.com', - repoUrl: 'https://github.com/org/running', - status: 'running', - createdAt: '2026-02-26T12:00:00Z', - updatedAt: '2026-02-26T13:00:00Z', - error: null, - temporalUiUrl: null, - webhookUrl: null, - }, - { - id: 'run_completed', - targetUrl: 'https://completed.example.com', - repoUrl: 'https://github.com/org/completed', - status: 'completed', - createdAt: '2026-02-25T12:00:00Z', - updatedAt: '2026-02-25T13:00:00Z', - error: null, - temporalUiUrl: null, - webhookUrl: null, - }, -]; - -describe('PenetrationTestsPageClient', () => { - beforeEach(() => { - vi.clearAllMocks(); - - reportHookMock.mockReturnValue({ - reports: [], - isLoading: false, - error: undefined, - mutate: vi.fn(), - activeReports: [], - completedReports: [], - }); - - createReportMock.mockResolvedValue({ - id: 'run_new', - status: 'provisioning', - }); - - createHookMock.mockReturnValue({ - createReport: createReportMock, - isCreating: false, - error: null, - resetError: vi.fn(), - }); - - startOAuthMock.mockResolvedValue({ success: false, error: 'Not configured' }); - - vi.mocked(integrationPlatform.useIntegrationConnections).mockReturnValue({ - connections: [], - isLoading: false, - error: undefined, - refresh: vi.fn(), - }); - vi.mocked(integrationPlatform.useIntegrationMutations).mockReturnValue({ - startOAuth: startOAuthMock, - } as ReturnType); - - vi.mocked(pentestHooks.useGithubRepos).mockReturnValue({ - repos: [], - isLoading: false, - } as ReturnType); - }); - - it('renders an empty state and call-to-action when no reports exist', () => { - render(); - - expect(screen.getAllByText('No reports yet')).toHaveLength(2); - expect(screen.getByRole('button', { name: 'Create your first report' })).toBeInTheDocument(); - }); - - it('renders loading state for submit button while run creation is in progress', () => { - createHookMock.mockReturnValue({ - createReport: createReportMock, - isCreating: true, - error: null, - resetError: vi.fn(), - }); - - const { getByText } = render(); - - fireEvent.click(getByText('Create your first report')); - - expect(screen.getByText('Starting...')).toBeInTheDocument(); - expect(screen.getByText('Starting...').closest('button')).toBeTruthy(); - }); - - it('displays completed report summary when there are no active reports', () => { - reportHookMock.mockReturnValue({ - reports: [reportRows[1]], - isLoading: false, - error: undefined, - mutate: vi.fn(), - activeReports: [], - completedReports: [reportRows[1]], - }); - - render(); - - expect(screen.getByText('1 completed report')).toBeInTheDocument(); - }); - - it('uses pluralized summary copy for multiple active and completed report counts', () => { - reportHookMock.mockReturnValue({ - reports: reportRows, - isLoading: false, - error: undefined, - mutate: vi.fn(), - activeReports: reportRows, - completedReports: [reportRows[1], { ...reportRows[1], id: 'run_completed_2' }], - }); - - render(); - - expect(screen.getByText('2 reports in progress')).toBeInTheDocument(); - }); - - it('uses pluralized summary copy for multiple completed reports when none are active', () => { - reportHookMock.mockReturnValue({ - reports: [{ ...reportRows[1], id: 'run_completed_2' }, reportRows[1]], - isLoading: false, - error: undefined, - mutate: vi.fn(), - activeReports: [], - completedReports: [{ ...reportRows[1], id: 'run_completed_2' }, reportRows[1]], - }); - - render(); - - expect(screen.getByText('2 completed reports')).toBeInTheDocument(); - }); - - it('shows in-progress text with agent counts for a running report', () => { - const runningWithProgress: PentestRun = { - id: 'run_with_progress', - targetUrl: 'https://running.example.com', - repoUrl: 'https://github.com/org/running', - status: 'running', - createdAt: '2026-02-26T14:00:00Z', - updatedAt: '2026-02-26T14:30:00Z', - error: null, - temporalUiUrl: null, - webhookUrl: null, - progress: { - status: 'running', - completedAgents: 0, - totalAgents: 2, - elapsedMs: 500, - }, - }; - - reportHookMock.mockReturnValue({ - reports: [runningWithProgress], - isLoading: false, - error: undefined, - mutate: vi.fn(), - activeReports: [runningWithProgress], - completedReports: [], - }); - - render(); - - expect(screen.getByText('In progress (0/2)')).toBeInTheDocument(); - }); - - it('renders running and completed reports in the table', () => { - reportHookMock.mockReturnValue({ - reports: reportRows, - isLoading: false, - error: undefined, - mutate: vi.fn(), - activeReports: [reportRows[0]], - completedReports: [reportRows[1]], - }); - - render(); - - expect(screen.getByText('https://running.example.com')).toBeInTheDocument(); - expect(screen.getByText('https://completed.example.com')).toBeInTheDocument(); - expect(screen.getByText('1 report in progress')).toBeInTheDocument(); - expect(screen.getAllByRole('button', { name: 'View output' })).toHaveLength(2); - }); - - it('renders repository fallback when repoUrl is not available', () => { - const noRepoRun: PentestRun = { - id: 'run_no_repo', - targetUrl: 'https://no-repo.example.com', - repoUrl: null, - status: 'running', - createdAt: '2026-02-26T14:00:00Z', - updatedAt: '2026-02-26T14:30:00Z', - error: null, - temporalUiUrl: null, - webhookUrl: null, - progress: { - status: 'running', - completedAgents: 1, - totalAgents: 2, - elapsedMs: 1000, - }, - }; - - reportHookMock.mockReturnValue({ - reports: [noRepoRun], - isLoading: false, - error: undefined, - mutate: vi.fn(), - activeReports: [noRepoRun], - completedReports: [], - }); - - render(); - - expect(screen.getByText('https://no-repo.example.com')).toBeInTheDocument(); - expect(screen.getByText('—')).toBeInTheDocument(); - }); - - it('shows a loading state while list data is loading', () => { - reportHookMock.mockReturnValue({ - reports: [], - isLoading: true, - error: undefined, - mutate: vi.fn(), - activeReports: [], - completedReports: [], - }); - - const { container } = render(); - - expect(container.querySelector('.animate-spin')).toBeTruthy(); - }); - - it('renders progress for running report rows with agent counts', () => { - const inProgressRun: PentestRun = { - id: 'run_in_progress', - targetUrl: 'https://running-progress.example.com', - repoUrl: 'https://github.com/org/running-progress', - status: 'running', - createdAt: '2026-02-26T14:00:00Z', - updatedAt: '2026-02-26T14:30:00Z', - error: null, - temporalUiUrl: null, - webhookUrl: null, - progress: { - status: 'running', - completedAgents: 1, - totalAgents: 2, - elapsedMs: 1500, - }, - }; - - reportHookMock.mockReturnValue({ - reports: [inProgressRun], - isLoading: false, - error: undefined, - mutate: vi.fn(), - activeReports: [inProgressRun], - completedReports: [], - }); - - render(); - - expect(screen.getByText('In progress (1/2)')).toBeInTheDocument(); - }); - - it('renders progress row without counts when agent count values are unavailable', () => { - const noCounts: PentestRun = { - id: 'run_without_counts', - targetUrl: 'https://running-progress.example.com', - repoUrl: 'https://github.com/org/running-progress', - status: 'running', - createdAt: '2026-02-26T14:00:00Z', - updatedAt: '2026-02-26T14:30:00Z', - error: null, - temporalUiUrl: null, - webhookUrl: null, - progress: { - status: 'running', - completedAgents: 'n/a' as unknown as number, - totalAgents: 'n/a' as unknown as number, - elapsedMs: 0, - }, - }; - - reportHookMock.mockReturnValue({ - reports: [noCounts], - isLoading: false, - error: undefined, - mutate: vi.fn(), - activeReports: [noCounts], - completedReports: [], - }); - - render(); - - expect(screen.getByText('In progress')).toBeInTheDocument(); - expect(screen.queryByText('(n/a/n/a)')).toBeNull(); - }); - - it('creates a report and navigates to the report detail page', async () => { - const { getByText, getByLabelText } = render(); - - await act(async () => { - fireEvent.click(getByText('Create Report')); - }); - - await act(async () => { - fireEvent.change(getByLabelText('Target URL'), { - target: { - value: 'https://example.com', - }, - }); - fireEvent.change(getByLabelText('Repository URL'), { - target: { - value: 'https://github.com/org/repo', - }, - }); - fireEvent.click(getByText('Start penetration test')); - }); - - await waitFor(() => { - expect(createReportMock).toHaveBeenCalledWith({ - targetUrl: 'https://example.com', - repoUrl: 'https://github.com/org/repo', - }); - expect(toastSuccessMock).toHaveBeenCalledWith('Penetration test queued successfully.'); - expect(pushMock).toHaveBeenCalledWith('/org_123/security/penetration-tests/run_new'); - }); - }); - - it('requires target URL before submitting report request', async () => { - render(); - const submitForm = screen.getByText('Start penetration test').closest('form'); - - await act(async () => { - fireEvent.submit(submitForm as HTMLFormElement); - }); - - await waitFor(() => { - expect(createReportMock).not.toHaveBeenCalled(); - expect(toastErrorMock).toHaveBeenCalledWith('Target URL is required'); - }); - }); - - it('creates a report without repository URL when only target is provided', async () => { - const { getByText, getByLabelText } = render(); - - await act(async () => { - fireEvent.click(getByText('Create Report')); - }); - - await act(async () => { - fireEvent.change(getByLabelText('Target URL'), { - target: { - value: 'jungle.ai', - }, - }); - fireEvent.click(getByText('Start penetration test')); - }); - - await waitFor(() => { - expect(createReportMock).toHaveBeenCalledWith({ - targetUrl: 'https://jungle.ai', - repoUrl: undefined, - }); - }); - }); - - it('surfaces errors when run creation fails', async () => { - createReportMock.mockRejectedValue(new Error('No active pentest subscription.')); - - const { getByText, getByLabelText } = render(); - - await act(async () => { - fireEvent.click(getByText('Create Report')); - }); - - await act(async () => { - fireEvent.change(getByLabelText('Target URL'), { - target: { - value: 'https://example.com', - }, - }); - fireEvent.change(getByLabelText('Repository URL'), { - target: { - value: 'https://github.com/org/repo', - }, - }); - fireEvent.click(getByText('Start penetration test')); - }); - - await waitFor(() => { - expect(toastErrorMock).toHaveBeenCalledWith('No active pentest subscription.'); - }); - }); - - it('surfaces a generic error message when run creation fails with non-error value', async () => { - createReportMock.mockRejectedValue('service-down'); - - const { getByText, getByLabelText } = render(); - - await act(async () => { - fireEvent.click(getByText('Create Report')); - }); - - await act(async () => { - fireEvent.change(getByLabelText('Target URL'), { - target: { - value: 'https://example.com', - }, - }); - fireEvent.change(getByLabelText('Repository URL'), { - target: { - value: 'https://github.com/org/repo', - }, - }); - fireEvent.click(getByText('Start penetration test')); - }); - - await waitFor(() => { - expect(toastErrorMock).toHaveBeenCalledWith('Could not queue a new report'); - }); - }); - - it('shows a Connect GitHub button when GitHub is not connected', async () => { - render(); - - await act(async () => { - fireEvent.click(screen.getByText('Create Report')); - }); - - expect(screen.getByRole('button', { name: 'Connect GitHub' })).toBeInTheDocument(); - expect(screen.queryByTestId('github-repo-select')).toBeNull(); - }); - - it('shows the repo selector dropdown when GitHub is connected', async () => { - vi.mocked(integrationPlatform.useIntegrationConnections).mockReturnValue({ - connections: [{ id: 'conn_1', providerSlug: 'github', status: 'active', variables: null, errorMessage: null }] as never, - isLoading: false, - error: undefined, - refresh: vi.fn(), - }); - vi.mocked(pentestHooks.useGithubRepos).mockReturnValue({ - repos: [{ id: 1, name: 'repo', fullName: 'org/repo', private: false, htmlUrl: 'https://github.com/org/repo' }], - isLoading: false, - } as ReturnType); - - render(); - - await act(async () => { - fireEvent.click(screen.getByText('Create Report')); - }); - - expect(screen.getByTestId('github-repo-select')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Connect GitHub' })).toBeNull(); - }); - - it('starts GitHub OAuth when Connect GitHub button is clicked', async () => { - render(); - - await act(async () => { - fireEvent.click(screen.getByText('Create Report')); - }); - - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Connect GitHub' })); - }); - - expect(startOAuthMock).toHaveBeenCalledWith('github', expect.any(String)); - }); -}); 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/(app)/[orgId]/trust/page.tsx b/apps/app/src/app/(app)/[orgId]/trust/page.tsx index 32ea9e2e5d..32b1f80c42 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/page.tsx @@ -43,6 +43,7 @@ export default async function TrustPage({ hipaa: 'hipaaFileName', soc2_type1: 'soc2type1FileName', soc2_type2: 'soc2type2FileName', + soc3: 'soc3FileName', pci_dss: 'pcidssFileName', nen_7510: 'nen7510FileName', iso_9001: 'iso9001FileName', @@ -55,6 +56,7 @@ export default async function TrustPage({ hipaaFileName: null, soc2type1FileName: null, soc2type2FileName: null, + soc3FileName: null, pcidssFileName: null, nen7510FileName: null, iso9001FileName: null, @@ -100,6 +102,7 @@ export default async function TrustPage({ primaryColor={settings?.primaryColor ?? null} soc2type1={settings?.soc2type1 ?? false} soc2type2={settings?.soc2type2 ?? false} + soc3={settings?.soc3 ?? false} iso27001={settings?.iso27001 ?? false} iso42001={settings?.iso42001 ?? false} gdpr={settings?.gdpr ?? false} @@ -109,6 +112,7 @@ export default async function TrustPage({ iso9001={settings?.iso9001 ?? false} soc2type1Status={settings?.soc2type1Status ?? 'started'} soc2type2Status={settings?.soc2type2Status ?? 'started'} + soc3Status={settings?.soc3Status ?? 'started'} iso27001Status={settings?.iso27001Status ?? 'started'} iso42001Status={settings?.iso42001Status ?? 'started'} gdprStatus={settings?.gdprStatus ?? 'started'} @@ -123,6 +127,7 @@ export default async function TrustPage({ hipaaFileName={certificateFiles.hipaaFileName} soc2type1FileName={certificateFiles.soc2type1FileName} soc2type2FileName={certificateFiles.soc2type2FileName} + soc3FileName={certificateFiles.soc3FileName} pcidssFileName={certificateFiles.pcidssFileName} nen7510FileName={certificateFiles.nen7510FileName} iso9001FileName={certificateFiles.iso9001FileName} diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.test.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.test.tsx index 0031f74b5e..56a1be66ba 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.test.tsx @@ -200,6 +200,7 @@ describe('TrustPortalSwitch permission gating', () => { orgId: 'org-1', soc2type1: false, soc2type2: false, + soc3: false, iso27001: true, iso42001: false, gdpr: false, @@ -208,6 +209,7 @@ describe('TrustPortalSwitch permission gating', () => { nen7510: false, soc2type1Status: 'started' as const, soc2type2Status: 'started' as const, + soc3Status: 'started' as const, iso27001Status: 'compliant' as const, iso42001Status: 'started' as const, gdprStatus: 'started' as const, diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx index 19fcc8cc7f..57340223bf 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx @@ -61,6 +61,7 @@ const trustPortalSwitchSchema = z.object({ primaryColor: z.string().optional(), soc2type1: z.boolean(), soc2type2: z.boolean(), + soc3: z.boolean(), iso27001: z.boolean(), iso42001: z.boolean(), gdpr: z.boolean(), @@ -70,6 +71,7 @@ const trustPortalSwitchSchema = z.object({ iso9001: z.boolean(), soc2type1Status: z.enum(['started', 'in_progress', 'compliant']), soc2type2Status: z.enum(['started', 'in_progress', 'compliant']), + soc3Status: z.enum(['started', 'in_progress', 'compliant']), iso27001Status: z.enum(['started', 'in_progress', 'compliant']), iso42001Status: z.enum(['started', 'in_progress', 'compliant']), gdprStatus: z.enum(['started', 'in_progress', 'compliant']), @@ -93,6 +95,7 @@ const FRAMEWORK_KEY_TO_API_SLUG: Record = { hipaa: 'hipaa', soc2type1: 'soc2_type1', soc2type2: 'soc2_type2', + soc3: 'soc3', pcidss: 'pci_dss', nen7510: 'nen_7510', iso9001: 'iso_9001', @@ -151,6 +154,7 @@ export function TrustPortalSwitch({ orgId, soc2type1, soc2type2, + soc3, iso27001, iso42001, gdpr, @@ -158,6 +162,7 @@ export function TrustPortalSwitch({ pcidss, soc2type1Status, soc2type2Status, + soc3Status, iso27001Status, iso42001Status, gdprStatus, @@ -174,6 +179,7 @@ export function TrustPortalSwitch({ hipaaFileName, soc2type1FileName, soc2type2FileName, + soc3FileName, pcidssFileName, nen7510FileName, iso9001FileName, @@ -192,6 +198,7 @@ export function TrustPortalSwitch({ orgId: string; soc2type1: boolean; soc2type2: boolean; + soc3: boolean; iso27001: boolean; iso42001: boolean; gdpr: boolean; @@ -200,6 +207,7 @@ export function TrustPortalSwitch({ nen7510: boolean; soc2type1Status: 'started' | 'in_progress' | 'compliant'; soc2type2Status: 'started' | 'in_progress' | 'compliant'; + soc3Status: 'started' | 'in_progress' | 'compliant'; iso27001Status: 'started' | 'in_progress' | 'compliant'; iso42001Status: 'started' | 'in_progress' | 'compliant'; gdprStatus: 'started' | 'in_progress' | 'compliant'; @@ -215,6 +223,7 @@ export function TrustPortalSwitch({ hipaaFileName?: string | null; soc2type1FileName?: string | null; soc2type2FileName?: string | null; + soc3FileName?: string | null; pcidssFileName?: string | null; nen7510FileName?: string | null; iso9001FileName?: string | null; @@ -240,6 +249,7 @@ export function TrustPortalSwitch({ hipaa: hipaaFileName ?? null, soc2type1: soc2type1FileName ?? null, soc2type2: soc2type2FileName ?? null, + soc3: soc3FileName ?? null, pcidss: pcidssFileName ?? null, nen7510: nen7510FileName ?? null, iso9001: iso9001FileName ?? null, @@ -253,6 +263,7 @@ export function TrustPortalSwitch({ hipaa: hipaaFileName ?? null, soc2type1: soc2type1FileName ?? null, soc2type2: soc2type2FileName ?? null, + soc3: soc3FileName ?? null, pcidss: pcidssFileName ?? null, nen7510: nen7510FileName ?? null, iso9001: iso9001FileName ?? null, @@ -264,6 +275,7 @@ export function TrustPortalSwitch({ hipaaFileName, soc2type1FileName, soc2type2FileName, + soc3FileName, pcidssFileName, nen7510FileName, iso9001FileName, @@ -323,6 +335,7 @@ export function TrustPortalSwitch({ primaryColor: primaryColor ?? undefined, soc2type1: soc2type1 ?? false, soc2type2: soc2type2 ?? false, + soc3: soc3 ?? false, iso27001: iso27001 ?? false, iso42001: iso42001 ?? false, gdpr: gdpr ?? false, @@ -332,6 +345,7 @@ export function TrustPortalSwitch({ iso9001: iso9001 ?? false, soc2type1Status: soc2type1Status ?? 'started', soc2type2Status: soc2type2Status ?? 'started', + soc3Status: soc3Status ?? 'started', iso27001Status: iso27001Status ?? 'started', iso42001Status: iso42001Status ?? 'started', gdprStatus: gdprStatus ?? 'started', @@ -685,6 +699,39 @@ export function TrustPortalSwitch({ orgId={orgId} disabled={!canUpdate} /> + {/* SOC 3 */} + { + try { + await updateFrameworkSettings({ + soc3Status: value as 'started' | 'in_progress' | 'compliant', + }); + toast.success('SOC 3 status updated'); + } catch (error) { + toast.error('Failed to update SOC 3 status'); + } + }} + onToggle={async (checked) => { + try { + await updateFrameworkSettings({ + soc3: checked, + }); + toast.success('SOC 3 status updated'); + } catch (error) { + toast.error('Failed to update SOC 3 status'); + } + }} + fileName={certificateFiles.soc3} + onFileUpload={handleFileUpload} + onFilePreview={handleFilePreview} + frameworkKey="soc3" + orgId={orgId} + disabled={!canUpdate} + /> {/* PCI DSS */} step.key !== 'organizationName' && step.key !== 'website') 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/control-compliance.test.ts b/apps/app/src/lib/control-compliance.test.ts new file mode 100644 index 0000000000..ea3277b78a --- /dev/null +++ b/apps/app/src/lib/control-compliance.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from 'vitest'; +import { + getControlProgressPercent, + getControlStatus, + getFrameworkAggregatePercent, + getRequirementArtifactCounts, + getRequirementCompliancePercent, + getRequirementStatus, + type EvidenceSubmissionInfo, +} from './control-compliance'; +import type { Control, Task } from '@db'; + +const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000; + +function makeTask(overrides: Partial = {}) { + return { + id: overrides.id ?? 't1', + title: 'Task', + status: 'todo', + controls: overrides.controls ?? [{ id: 'c1' } as Control], + ...overrides, + } as Task & { controls: Control[] }; +} + +describe('getControlStatus', () => { + it('returns not_started when policies are draft, tasks are todo, and no documents submitted', () => { + const status = getControlStatus( + [{ status: 'draft' }], + [makeTask({ status: 'todo' })], + 'c1', + [], + [], + ); + expect(status).toBe('not_started'); + }); + + it('returns completed when all policies published, tasks done, and no document types required', () => { + const status = getControlStatus( + [{ status: 'published' }], + [makeTask({ status: 'done' })], + 'c1', + [], + [], + ); + expect(status).toBe('completed'); + }); + + it('returns in_progress when policies/tasks complete but a required document type has no submission', () => { + const status = getControlStatus( + [{ status: 'published' }], + [makeTask({ status: 'done' })], + 'c1', + [{ formType: 'access_control_policy' }], + [], + ); + expect(status).toBe('in_progress'); + }); + + it('returns completed when policies/tasks complete and a required document was submitted within 6 months', () => { + const recent = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const submissions: EvidenceSubmissionInfo[] = [ + { id: 'evs1', formType: 'access_control_policy', submittedAt: recent }, + ]; + const status = getControlStatus( + [{ status: 'published' }], + [makeTask({ status: 'done' })], + 'c1', + [{ formType: 'access_control_policy' }], + submissions, + ); + expect(status).toBe('completed'); + }); + + it('returns in_progress when policies/tasks complete but the only document submission is older than 6 months', () => { + const stale = new Date(Date.now() - SIX_MONTHS_MS - 24 * 60 * 60 * 1000); + const submissions: EvidenceSubmissionInfo[] = [ + { id: 'evs1', formType: 'access_control_policy', submittedAt: stale }, + ]; + const status = getControlStatus( + [{ status: 'published' }], + [makeTask({ status: 'done' })], + 'c1', + [{ formType: 'access_control_policy' }], + submissions, + ); + expect(status).toBe('in_progress'); + }); +}); + +describe('getRequirementStatus', () => { + it('returns "No Controls" when there are no controls mapped to the requirement', () => { + expect(getRequirementStatus([])).toEqual({ + label: 'No Controls', + variant: 'secondary', + }); + }); + + it('returns "Satisfied" when every control is completed', () => { + expect(getRequirementStatus(['completed', 'completed'])).toEqual({ + label: 'Satisfied', + variant: 'default', + }); + }); + + it('returns "Not Started" when every control is not_started', () => { + expect(getRequirementStatus(['not_started', 'not_started'])).toEqual({ + label: 'Not Started', + variant: 'destructive', + }); + }); + + it('returns "In Progress" when at least one control is in_progress (even if none are completed)', () => { + expect(getRequirementStatus(['in_progress', 'not_started'])).toEqual({ + label: 'In Progress', + variant: 'secondary', + }); + }); + + it('returns "In Progress" when some controls are completed but not all', () => { + expect(getRequirementStatus(['completed', 'not_started'])).toEqual({ + label: 'In Progress', + variant: 'secondary', + }); + }); +}); + +describe('getControlProgressPercent', () => { + it('returns 0 when the control has no policies, tasks, or document types', () => { + expect(getControlProgressPercent([], [], 'c1', [], [])).toBe(0); + }); + + it('returns 100 when every linked policy and task is complete', () => { + const percent = getControlProgressPercent( + [{ status: 'published' }], + [makeTask({ status: 'done' })], + 'c1', + [], + [], + ); + expect(percent).toBe(100); + }); + + it('returns 67 when 2 of 3 artifacts are complete (1 policy published, 1 of 2 tasks done)', () => { + const percent = getControlProgressPercent( + [{ status: 'published' }], + [ + makeTask({ id: 't1', status: 'done' }), + makeTask({ id: 't2', status: 'todo' }), + ], + 'c1', + [], + [], + ); + expect(percent).toBe(67); + }); + + it('counts a recent document submission as a completed artifact', () => { + const recent = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const percent = getControlProgressPercent( + [], + [], + 'c1', + [{ formType: 'access_control_policy' }], + [{ id: 'evs1', formType: 'access_control_policy', submittedAt: recent }], + ); + expect(percent).toBe(100); + }); + + it('does not count a document submission older than 6 months as complete', () => { + const stale = new Date(Date.now() - SIX_MONTHS_MS - 24 * 60 * 60 * 1000); + const percent = getControlProgressPercent( + [], + [], + 'c1', + [{ formType: 'access_control_policy' }], + [{ id: 'evs1', formType: 'access_control_policy', submittedAt: stale }], + ); + expect(percent).toBe(0); + }); + + it('only counts tasks linked to the given control', () => { + // The unrelated task is `todo` so a missing filter would drop the percent + // from 100 (correct: only the linked, done task counts) to 50 (broken). + const percent = getControlProgressPercent( + [], + [ + makeTask({ id: 't1', status: 'done', controls: [{ id: 'c1' } as Control] }), + makeTask({ id: 't2', status: 'todo', controls: [{ id: 'other' } as Control] }), + ], + 'c1', + [], + [], + ); + expect(percent).toBe(100); + }); +}); + +describe('getRequirementCompliancePercent', () => { + it('returns 0 when no controls are mapped', () => { + expect(getRequirementCompliancePercent([])).toBe(0); + }); + + it('returns the average of the underlying control progress percents', () => { + expect(getRequirementCompliancePercent([0, 100])).toBe(50); + expect(getRequirementCompliancePercent([67, 33])).toBe(50); + }); + + it('returns the bubbled-up percent for a requirement with a single in-progress control', () => { + expect(getRequirementCompliancePercent([67])).toBe(67); + }); +}); + +describe('getRequirementArtifactCounts', () => { + it('returns zero counts when there are no controls', () => { + const counts = getRequirementArtifactCounts([], [], []); + expect(counts).toEqual({ + policies: { total: 0, completed: 0 }, + tasks: { total: 0, completed: 0 }, + documents: { total: 0, completed: 0 }, + }); + }); + + it('aggregates counts across multiple controls', () => { + const controls = [ + { + id: 'c1', + policies: [{ id: 'p1', status: 'published' }], + controlDocumentTypes: [{ formType: 'access_control_policy' }], + }, + { + id: 'c2', + policies: [{ id: 'p2', status: 'draft' }], + controlDocumentTypes: [], + }, + ]; + const tasks = [ + makeTask({ id: 't1', status: 'done', controls: [{ id: 'c1' } as Control] }), + makeTask({ id: 't2', status: 'todo', controls: [{ id: 'c2' } as Control] }), + ]; + const counts = getRequirementArtifactCounts(controls, tasks, []); + expect(counts).toEqual({ + policies: { total: 2, completed: 1 }, + tasks: { total: 2, completed: 1 }, + documents: { total: 1, completed: 0 }, + }); + }); + + it('deduplicates policies, tasks, and document types shared across controls', () => { + const controls = [ + { + id: 'c1', + policies: [{ id: 'p1', status: 'published' }], + controlDocumentTypes: [{ formType: 'access_control_policy' }], + }, + { + id: 'c2', + policies: [{ id: 'p1', status: 'published' }], + controlDocumentTypes: [{ formType: 'access_control_policy' }], + }, + ]; + const sharedTask = makeTask({ + id: 't1', + status: 'done', + controls: [{ id: 'c1' } as Control, { id: 'c2' } as Control], + }); + const counts = getRequirementArtifactCounts(controls, [sharedTask], []); + expect(counts).toEqual({ + policies: { total: 1, completed: 1 }, + tasks: { total: 1, completed: 1 }, + documents: { total: 1, completed: 0 }, + }); + }); + + it('counts a document type as completed when there is a recent submission', () => { + const recent = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const stale = new Date(Date.now() - SIX_MONTHS_MS - 24 * 60 * 60 * 1000); + const controls = [ + { + id: 'c1', + policies: [], + controlDocumentTypes: [ + { formType: 'access_control_policy' }, + { formType: 'incident_response_plan' }, + ], + }, + ]; + const submissions: EvidenceSubmissionInfo[] = [ + { id: 'evs1', formType: 'access_control_policy', submittedAt: recent }, + { id: 'evs2', formType: 'incident_response_plan', submittedAt: stale }, + ]; + const counts = getRequirementArtifactCounts(controls, [], submissions); + expect(counts.documents).toEqual({ total: 2, completed: 1 }); + }); +}); + +describe('getFrameworkAggregatePercent', () => { + it('returns 0 when the framework has no artifacts', () => { + expect(getFrameworkAggregatePercent([], [], [])).toBe(0); + }); + + it('returns 100 when every artifact across the framework is complete', () => { + const controls = [ + { + id: 'c1', + policies: [{ id: 'p1', status: 'published' }], + controlDocumentTypes: [], + }, + ]; + const tasks = [ + makeTask({ id: 't1', status: 'done', controls: [{ id: 'c1' } as Control] }), + ]; + expect(getFrameworkAggregatePercent(controls, tasks, [])).toBe(100); + }); + + it('weights every policy, task, and document equally across the framework', () => { + const controls = [ + { + id: 'c1', + policies: [{ id: 'p1', status: 'published' }], + controlDocumentTypes: [{ formType: 'access_control_policy' }], + }, + { + id: 'c2', + policies: [{ id: 'p2', status: 'draft' }], + controlDocumentTypes: [], + }, + ]; + const tasks = [ + makeTask({ id: 't1', status: 'done', controls: [{ id: 'c1' } as Control] }), + makeTask({ id: 't2', status: 'todo', controls: [{ id: 'c2' } as Control] }), + ]; + // 5 total artifacts (2 policies, 2 tasks, 1 doc), 2 completed → 40% + expect(getFrameworkAggregatePercent(controls, tasks, [])).toBe(40); + }); +}); diff --git a/apps/app/src/lib/control-compliance.ts b/apps/app/src/lib/control-compliance.ts index 0844432d52..8f903051b0 100644 --- a/apps/app/src/lib/control-compliance.ts +++ b/apps/app/src/lib/control-compliance.ts @@ -13,7 +13,7 @@ export interface DocumentType { export interface EvidenceSubmissionInfo { id: string; formType: string; - createdAt: Date | string; + submittedAt: Date | string; } const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000; @@ -47,7 +47,7 @@ export function getControlStatus( const now = Date.now(); const sorted = [...evidenceSubmissions].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + (a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime(), ); for (const dt of documentTypes) { @@ -57,7 +57,7 @@ export function getControlStatus( continue; } anyDocumentSubmitted = true; - if (now - new Date(latestSubmission.createdAt).getTime() > SIX_MONTHS_MS) { + if (now - new Date(latestSubmission.submittedAt).getTime() > SIX_MONTHS_MS) { allDocumentsFresh = false; } } @@ -76,6 +76,164 @@ export function getControlStatus( return 'in_progress'; } +export type RequirementStatusVariant = 'default' | 'secondary' | 'destructive'; + +export interface RequirementStatusBadge { + label: string; + variant: RequirementStatusVariant; +} + +export function getRequirementStatus( + controlStatuses: StatusType[], +): RequirementStatusBadge { + if (controlStatuses.length === 0) { + return { label: 'No Controls', variant: 'secondary' }; + } + + const allCompleted = controlStatuses.every((s) => s === 'completed'); + if (allCompleted) { + return { label: 'Satisfied', variant: 'default' }; + } + + const allNotStarted = controlStatuses.every((s) => s === 'not_started'); + if (allNotStarted) { + return { label: 'Not Started', variant: 'destructive' }; + } + + return { label: 'In Progress', variant: 'secondary' }; +} + +export function getControlProgressPercent( + policies: SelectedPolicy[], + tasks: (Task & { controls: Control[] })[], + controlId: string, + documentTypes?: DocumentType[], + evidenceSubmissions?: EvidenceSubmissionInfo[], +): number { + const controlTasks = tasks.filter((task) => task.controls.some((c) => c.id === controlId)); + + let totalItems = policies.length + controlTasks.length; + let completedItems = 0; + + for (const policy of policies) { + if (policy.status === 'published') completedItems++; + } + for (const task of controlTasks) { + if (task.status === 'done' || task.status === 'not_relevant') completedItems++; + } + + if (documentTypes?.length) { + totalItems += documentTypes.length; + if (evidenceSubmissions?.length) { + const now = Date.now(); + const sorted = [...evidenceSubmissions].sort( + (a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime(), + ); + for (const dt of documentTypes) { + const latest = sorted.find((es) => es.formType === dt.formType); + if ( + latest && + now - new Date(latest.submittedAt).getTime() <= SIX_MONTHS_MS + ) { + completedItems++; + } + } + } + } + + return totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0; +} + +export function getRequirementCompliancePercent( + controlProgressPercents: number[], +): number { + if (controlProgressPercents.length === 0) return 0; + const sum = controlProgressPercents.reduce((a, b) => a + b, 0); + return Math.round(sum / controlProgressPercents.length); +} + +export interface ControlForRequirementCounts { + id: string; + policies?: Array<{ id: string; status: string | null }> | null; + controlDocumentTypes?: Array<{ formType: string }> | null; +} + +export interface RequirementArtifactCounts { + policies: { total: number; completed: number }; + tasks: { total: number; completed: number }; + documents: { total: number; completed: number }; +} + +export function getRequirementArtifactCounts( + controls: ControlForRequirementCounts[], + tasks: (Task & { controls: Control[] })[], + evidenceSubmissions?: EvidenceSubmissionInfo[], +): RequirementArtifactCounts { + const controlIds = new Set(controls.map((c) => c.id)); + + const policiesById = new Map(); + for (const control of controls) { + for (const policy of control.policies ?? []) { + policiesById.set(policy.id, policy); + } + } + + const tasksById = new Map(); + for (const task of tasks) { + if (task.controls.some((c) => controlIds.has(c.id))) { + tasksById.set(task.id, task); + } + } + + const documentFormTypes = new Set(); + for (const control of controls) { + for (const dt of control.controlDocumentTypes ?? []) { + documentFormTypes.add(dt.formType); + } + } + + const policiesCompleted = Array.from(policiesById.values()).filter( + (p) => p.status === 'published', + ).length; + const tasksCompleted = Array.from(tasksById.values()).filter( + (t) => t.status === 'done' || t.status === 'not_relevant', + ).length; + + let documentsCompleted = 0; + if (documentFormTypes.size > 0 && evidenceSubmissions?.length) { + const now = Date.now(); + const sorted = [...evidenceSubmissions].sort( + (a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime(), + ); + for (const formType of documentFormTypes) { + const latest = sorted.find((es) => es.formType === formType); + if (latest && now - new Date(latest.submittedAt).getTime() <= SIX_MONTHS_MS) { + documentsCompleted++; + } + } + } + + return { + policies: { total: policiesById.size, completed: policiesCompleted }, + tasks: { total: tasksById.size, completed: tasksCompleted }, + documents: { total: documentFormTypes.size, completed: documentsCompleted }, + }; +} + +export function getFrameworkAggregatePercent( + controls: ControlForRequirementCounts[], + tasks: (Task & { controls: Control[] })[], + evidenceSubmissions?: EvidenceSubmissionInfo[], +): number { + const counts = getRequirementArtifactCounts(controls, tasks, evidenceSubmissions); + const total = + counts.policies.total + counts.tasks.total + counts.documents.total; + if (total === 0) return 0; + const completed = + counts.policies.completed + counts.tasks.completed + counts.documents.completed; + return Math.round((completed / total) * 100); +} + export function isPolicyCompleted(policy: SelectedPolicy): boolean { if (!policy) return false; return policy.status === 'published'; 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 ad5b423ad8..252a7c39b7 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", @@ -1664,6 +1665,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=="], @@ -5214,6 +5217,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/integrations-catalog/README.md b/integrations-catalog/README.md index de84c3afac..b64c12d771 100644 --- a/integrations-catalog/README.md +++ b/integrations-catalog/README.md @@ -2,9 +2,9 @@ Public catalog of all compliance integrations available in the [CompAI](https://trycomp.ai) platform. -**540 integrations** across 9 categories. +**559 integrations** across 9 categories. -> Last updated: 2026-04-20 +> Last updated: 2026-04-29 ## What's in this catalog @@ -33,25 +33,25 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ ## Summary by category -- **Security** — 120 integrations -- **Productivity** — 111 integrations +- **Security** — 129 integrations +- **Productivity** — 115 integrations - **HR & People** — 61 integrations -- **Cloud** — 53 integrations +- **Monitoring** — 53 integrations +- **Cloud** — 52 integrations - **Development** — 52 integrations -- **Monitoring** — 48 integrations - **Communication** — 46 integrations -- **Infrastructure** — 29 integrations +- **Infrastructure** — 31 integrations - **Identity & Access** — 20 integrations ## Full catalog -### Cloud (53) +### Cloud (52) | Integration | Slug | Auth | Checks | Sync | |-------------|------|------|--------|------| | [Airbyte](integrations/airbyte.json) | `airbyte` | api_key | 2 | | | [Aiven](integrations/aiven.json) | `aiven` | api_key | 2 | | -| [Anthropic](integrations/anthropic.json) | `anthropic` | api_key | 2 | | +| [Anthropic](integrations/anthropic.json) | `anthropic` | custom | 2 | | | [Box](integrations/box.json) | `box` | oauth2 | 2 | | | [Brex](integrations/brex.json) | `brex` | api_key | 1 | | | [Civo](integrations/civo.json) | `civo` | api_key | 2 | | @@ -61,18 +61,17 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Cohere](integrations/cohere.json) | `cohere` | api_key | 2 | | | [Convex](integrations/convex.json) | `convex` | custom | 3 | | | [Databricks](integrations/databricks.json) | `databricks` | api_key | 2 | | -| [Datadog](integrations/datadog.json) | `datadog` | custom | 3 | | +| [Datadog](integrations/datadog.json) | `datadog` | custom | 5 | | | [dbt Cloud](integrations/dbt-cloud.json) | `dbt-cloud` | api_key | 2 | | | [Deepgram](integrations/deepgram.json) | `deepgram` | api_key | 2 | | | [DigitalOcean](integrations/digitalocean.json) | `digitalocean` | api_key | 2 | | -| [Doppler](integrations/doppler.json) | `doppler` | api_key | 2 | | +| [Doppler](integrations/doppler.json) | `doppler` | custom | 2 | | | [Egnyte](integrations/egnyte.json) | `egnyte` | custom | 3 | ✓ | | [Elastic Cloud](integrations/elastic-cloud.json) | `elastic-cloud` | custom | 2 | | -| [Firebase](integrations/firebase.json) | `firebase` | oauth2 | 3 | | +| [Firebase](integrations/firebase.json) | `firebase` | oauth2 | 2 | | | [Fireworks AI](integrations/fireworks-ai.json) | `fireworks-ai` | api_key | 2 | | | [Fivetran](integrations/fivetran.json) | `fivetran` | custom | 2 | | | [Fly.io](integrations/fly.json) | `fly` | api_key | 2 | | -| [Google Cloud](integrations/google-cloud.json) | `google-cloud` | oauth2 | 2 | | | [Groq](integrations/groq.json) | `groq` | api_key | 2 | | | [Heroku](integrations/heroku.json) | `heroku` | custom | 2 | | | [Hetzner Cloud](integrations/hetzner.json) | `hetzner` | api_key | 2 | | @@ -162,7 +161,7 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Azure DevOps](integrations/azure-devops.json) | `azure-devops` | custom | 3 | | | [Baseten](integrations/baseten.json) | `baseten` | custom | 2 | | | [Bitbucket](integrations/bitbucket.json) | `bitbucket` | custom | 2 | | -| [BrowserStack](integrations/browserstack.json) | `browserstack` | custom | 2 | | +| [BrowserStack](integrations/browserstack.json) | `browserstack` | basic | 2 | | | [Buddy](integrations/buddy.json) | `buddy` | api_key | 2 | | | [Buildkite](integrations/buildkite.json) | `buildkite` | api_key | 2 | | | [Census](integrations/census.json) | `census` | api_key | 2 | | @@ -292,7 +291,7 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [FusionAuth](integrations/fusionauth.json) | `fusionauth` | custom | 4 | ✓ | | [JumpCloud](integrations/jumpcloud.json) | `jumpcloud` | custom | 3 | | | [Microsoft Entra ID](integrations/entra-id.json) | `entra-id` | custom | 4 | ✓ | -| [Okta](integrations/okta.json) | `okta` | custom | 3 | | +| [Okta](integrations/okta.json) | `okta` | custom | 5 | | | [OneLogin](integrations/onelogin.json) | `onelogin` | custom | 2 | | | [Permit.io](integrations/permit-io.json) | `permit-io` | custom | 2 | | | [Persona](integrations/persona.json) | `persona` | api_key | 2 | | @@ -302,7 +301,7 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Stytch](integrations/stytch.json) | `stytch` | custom | 2 | | | [WorkOS](integrations/workos.json) | `workos` | api_key | 2 | | -### Infrastructure (29) +### Infrastructure (31) | Integration | Slug | Auth | Checks | Sync | |-------------|------|------|--------|------| @@ -321,6 +320,7 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [KeyCDN](integrations/keycdn.json) | `keycdn` | custom | 2 | | | [Kong Konnect](integrations/kong.json) | `kong` | api_key | 2 | | | [Koyeb](integrations/koyeb.json) | `koyeb` | api_key | 2 | | +| [Miradore](integrations/miradore.json) | `miradore` | custom | 5 | | | [ngrok](integrations/ngrok.json) | `ngrok` | api_key | 2 | | | [Northflank](integrations/northflank.json) | `northflank` | api_key | 2 | | | [NS1](integrations/ns1.json) | `ns1` | custom | 2 | | @@ -333,19 +333,20 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Tailscale](integrations/tailscale.json) | `tailscale` | api_key | 2 | | | [Teleport](integrations/teleport.json) | `teleport` | api_key | 2 | | | [Terraform Cloud](integrations/terraform-cloud.json) | `terraform-cloud` | custom | 2 | | +| [UniFi](integrations/unifi.json) | `unifi` | custom | 5 | | | [Veeam Backup & Replication](integrations/veeam.json) | `veeam` | custom | 3 | | | [ZeroTier](integrations/zerotier.json) | `zerotier` | custom | 2 | | -### Monitoring (48) +### Monitoring (53) | Integration | Slug | Auth | Checks | Sync | |-------------|------|------|--------|------| +| [ActivTrak](integrations/activtrak.json) | `activtrak` | custom | 3 | | | [Amplitude](integrations/amplitude.json) | `amplitude` | api_key | 2 | | | [Anodot](integrations/anodot.json) | `anodot` | custom | 2 | | | [Auvik](integrations/auvik.json) | `auvik` | basic | 2 | | | [Axiom](integrations/axiom.json) | `axiom` | custom | 2 | | -| [Better Stack](integrations/better-uptime.json) | `better-uptime` | api_key | 2 | | -| [BetterStack](integrations/betterstack.json) | `betterstack` | api_key | 2 | | +| [Better Stack](integrations/better-stack.json) | `better-stack` | custom | 3 | | | [Bugsnag](integrations/bugsnag.json) | `bugsnag` | api_key | 2 | | | [Checkly](integrations/checkly.json) | `checkly` | api_key | 2 | | | [Coralogix](integrations/coralogix.json) | `coralogix` | custom | 2 | | @@ -356,17 +357,20 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [FireHydrant](integrations/firehydrant.json) | `firehydrant` | api_key | 2 | | | [FullStory](integrations/fullstory.json) | `fullstory` | api_key | 2 | | | [Grafana Cloud](integrations/grafana-cloud.json) | `grafana-cloud` | custom | 4 | | +| [Heap](integrations/heap.json) | `heap` | custom | 2 | | | [Hex](integrations/hex.json) | `hex` | api_key | 2 | | | [Highlight](integrations/highlight-io.json) | `highlight-io` | api_key | 2 | | | [Honeybadger](integrations/honeybadger.json) | `honeybadger` | custom | 2 | | | [Honeycomb](integrations/honeycomb.json) | `honeycomb` | api_key | 2 | | | [Incident.io](integrations/incident-io.json) | `incident-io` | api_key | 2 | | | [Instatus](integrations/instatus.json) | `instatus` | custom | 2 | | +| [LogicMonitor](integrations/logicmonitor.json) | `logicmonitor` | custom | 5 | | | [LogRocket](integrations/logrocket.json) | `logrocket` | api_key | 2 | | | [Logz.io](integrations/logzio.json) | `logzio` | api_key | 2 | | | [Lumigo](integrations/lumigo.json) | `lumigo` | custom | 1 | | | [Mezmo](integrations/mezmo.json) | `mezmo` | custom | 2 | | | [Mezmo (LogDNA)](integrations/logdna.json) | `logdna` | api_key | 2 | | +| [Microsoft Sentinel](integrations/microsoft-sentinel.json) | `microsoft-sentinel` | oauth2 | 3 | | | [Mixpanel](integrations/mixpanel.json) | `mixpanel` | custom | 2 | | | [Monte Carlo](integrations/monte-carlo.json) | `monte-carlo` | custom | 2 | | | [New Relic](integrations/new-relic.json) | `new-relic` | custom | 3 | | @@ -380,6 +384,7 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [RudderStack](integrations/rudderstack.json) | `rudderstack` | api_key | 2 | | | [Sentry](integrations/sentry.json) | `sentry` | custom | 2 | | | [SigNoz](integrations/signoz.json) | `signoz` | custom | 2 | | +| [Site24x7](integrations/site24x7.json) | `site24x7` | custom | 5 | | | [Splunk](integrations/splunk.json) | `splunk` | custom | 2 | | | [Splunk On-Call](integrations/victorops.json) | `victorops` | custom | 2 | | | [Statsig](integrations/statsig.json) | `statsig` | custom | 2 | | @@ -388,8 +393,9 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Sumo Logic](integrations/sumo-logic.json) | `sumo-logic` | basic | 2 | | | [Sumo Logic](integrations/sumologic.json) | `sumologic` | custom | 2 | | | [Updown.io](integrations/updown.json) | `updown` | custom | 2 | | +| [Uptime Robot](integrations/uptime-robot.json) | `uptime-robot` | api_key | 2 | | -### Productivity (111) +### Productivity (115) | Integration | Slug | Auth | Checks | Sync | |-------------|------|------|--------|------| @@ -428,8 +434,8 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [DocuSign](integrations/docusign.json) | `docusign` | oauth2 | 2 | | | [Domo](integrations/domo.json) | `domo` | custom | 2 | | | [Dropbox Business](integrations/dropbox-business.json) | `dropbox-business` | oauth2 | 3 | ✓ | -| [Dropbox Sign](integrations/dropbox-sign.json) | `dropbox-sign` | custom | 1 | | | [Dropbox Sign](integrations/hellosign.json) | `hellosign` | api_key | 2 | | +| [Dropbox Sign](integrations/dropbox-sign.json) | `dropbox-sign` | custom | 1 | | | [Dub.co](integrations/dub.json) | `dub` | api_key | 2 | | | [Dynamics 365](integrations/dynamics-365.json) | `dynamics-365` | custom | 3 | | | [Expensify](integrations/expensify.json) | `expensify` | custom | 1 | | @@ -441,6 +447,7 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Freshsales](integrations/freshsales.json) | `freshsales` | custom | 2 | ✓ | | [Freshservice](integrations/freshservice.json) | `freshservice` | custom | 2 | | | [Google Workspace](integrations/google-workspace-admin.json) | `google-workspace-admin` | oauth2 | 2 | | +| [Grain](integrations/grain.json) | `grain` | custom | 3 | | | [Guru](integrations/guru.json) | `guru` | custom | 1 | | | [Harvest](integrations/harvest.json) | `harvest` | custom | 2 | | | [Hive](integrations/hive.json) | `hive` | custom | 2 | ✓ | @@ -453,6 +460,7 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Lago](integrations/lago.json) | `lago` | api_key | 2 | | | [Litmos](integrations/litmos.json) | `litmos` | custom | 2 | ✓ | | [Lob](integrations/lob.json) | `lob` | custom | 2 | | +| [Looker](integrations/looker.json) | `looker` | custom | 2 | | | [Lucid](integrations/lucid.json) | `lucid` | api_key | 2 | | | [Lucidchart](integrations/lucidchart.json) | `lucidchart` | custom | 2 | | | [Mailchimp](integrations/mailchimp.json) | `mailchimp` | custom | 2 | | @@ -460,6 +468,7 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Mercury](integrations/mercury.json) | `mercury` | api_key | 1 | | | [Metronome](integrations/metronome.json) | `metronome` | api_key | 2 | | | [Microsoft 365](integrations/microsoft-365.json) | `microsoft-365` | oauth2 | 3 | | +| [Microsoft Power BI](integrations/power-bi.json) | `power-bi` | custom | 2 | | | [Miro](integrations/miro.json) | `miro` | api_key | 2 | | | [Monday.com](integrations/monday.json) | `monday` | custom | 2 | | | [MURAL](integrations/mural.json) | `mural` | custom | 2 | | @@ -490,6 +499,7 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Square](integrations/square.json) | `square` | api_key | 2 | | | [Storyblok](integrations/storyblok.json) | `storyblok` | custom | 2 | | | [Stripe](integrations/stripe.json) | `stripe` | custom | 1 | | +| [Tableau](integrations/tableau.json) | `tableau` | custom | 2 | | | [Teamwork](integrations/teamwork.json) | `teamwork` | api_key | 2 | | | [Toggl Track](integrations/toggl.json) | `toggl` | basic | 2 | | | [Totango](integrations/totango.json) | `totango` | custom | 2 | | @@ -505,7 +515,7 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Zoho CRM](integrations/zoho-crm.json) | `zoho-crm` | custom | 3 | ✓ | | [Zuora](integrations/zuora.json) | `zuora` | custom | 2 | | -### Security (120) +### Security (129) | Integration | Slug | Auth | Checks | Sync | |-------------|------|------|--------|------| @@ -519,6 +529,7 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Apiiro](integrations/apiiro.json) | `apiiro` | custom | 2 | | | [Apple Business Manager](integrations/apple-business-manager.json) | `apple-business-manager` | custom | 3 | | | [Aqua Security](integrations/aqua-security.json) | `aqua-security` | custom | 2 | | +| [Arctic Wolf](integrations/arctic-wolf.json) | `arctic-wolf` | custom | 4 | | | [Atera](integrations/atera.json) | `atera` | custom | 3 | | | [Automox](integrations/automox.json) | `automox` | api_key | 2 | | | [Axonius](integrations/axonius.json) | `axonius` | custom | 5 | ✓ | @@ -527,10 +538,12 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Bitdefender GravityZone](integrations/bitdefender-gravityzone.json) | `bitdefender-gravityzone` | custom | 5 | | | [Bitsight](integrations/bitsight.json) | `bitsight` | custom | 5 | | | [Bitwarden](integrations/bitwarden.json) | `bitwarden` | custom | 3 | | +| [Bugcrowd](integrations/bugcrowd.json) | `bugcrowd` | custom | 3 | | | [Carbon Black](integrations/carbon-black.json) | `carbon-black` | api_key | 2 | | | [Cato Networks](integrations/cato-networks.json) | `cato-networks` | custom | 2 | | | [Certn](integrations/certn.json) | `certn` | custom | 2 | | | [Chainguard](integrations/chainguard.json) | `chainguard` | custom | 2 | | +| [Check Point](integrations/checkpoint.json) | `checkpoint` | custom | 6 | | | [Checkmarx](integrations/checkmarx.json) | `checkmarx` | custom | 2 | | | [Cisco Secure Endpoint](integrations/cisco-secure-endpoint.json) | `cisco-secure-endpoint` | custom | 3 | | | [Cisco Umbrella](integrations/cisco-umbrella.json) | `cisco-umbrella` | custom | 2 | | @@ -541,8 +554,9 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Contrast Security](integrations/contrast-security.json) | `contrast-security` | custom | 2 | | | [Cortex XDR](integrations/cortex-xdr.json) | `cortex-xdr` | custom | 3 | | | [Coursera for Business](integrations/coursera-business.json) | `coursera-business` | custom | 2 | ✓ | -| [CrowdStrike Falcon](integrations/crowdstrike.json) | `crowdstrike` | custom | 2 | | +| [CrowdStrike Falcon](integrations/crowdstrike.json) | `crowdstrike` | custom | 5 | | | [CyberArk Identity](integrations/cyberark-identity.json) | `cyberark-identity` | custom | 3 | ✓ | +| [Cybereason](integrations/cybereason.json) | `cybereason` | custom | 4 | | | [Cycode](integrations/cycode.json) | `cycode` | custom | 2 | | | [Darktrace](integrations/darktrace.json) | `darktrace` | custom | 3 | | | [Datto RMM](integrations/datto-rmm.json) | `datto-rmm` | custom | 3 | | @@ -551,6 +565,7 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Endor Labs](integrations/endor-labs.json) | `endor-labs` | custom | 1 | | | [Envoy](integrations/envoy.json) | `envoy` | api_key | 3 | ✓ | | [ESET Protect](integrations/eset-protect.json) | `eset-protect` | custom | 3 | | +| [Expel](integrations/expel.json) | `expel` | api_key | 5 | | | [FleetDM](integrations/fleetdm.json) | `fleetdm` | custom | 3 | | | [Forescout](integrations/forescout.json) | `forescout` | custom | 3 | | | [Fortinet FortiGate](integrations/fortinet-fortigate.json) | `fortinet-fortigate` | custom | 4 | | @@ -564,13 +579,15 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Hoxhunt](integrations/hoxhunt.json) | `hoxhunt` | custom | 4 | | | [Huntress](integrations/huntress.json) | `huntress` | basic | 3 | | | [Hyperproof](integrations/hyperproof.json) | `hyperproof` | api_key | 2 | | +| [Illumio](integrations/illumio.json) | `illumio` | custom | 7 | | | [Infisical](integrations/infisical.json) | `infisical` | api_key | 2 | | +| [Invicti](integrations/invicti.json) | `invicti` | custom | 6 | | +| [Iru (formerly Kandji)](integrations/kandji.json) | `kandji` | api_key | 2 | | | [Ivanti Neurons](integrations/ivanti.json) | `ivanti` | custom | 3 | | | [Jamf Pro](integrations/jamf.json) | `jamf` | custom | 3 | | -| [Kandji](integrations/kandji.json) | `kandji` | api_key | 2 | | | [Kaseya VSA](integrations/kaseya-vsa.json) | `kaseya-vsa` | custom | 3 | | | [Keeper Security](integrations/keeper-security.json) | `keeper-security` | custom | 4 | | -| [KnowBe4](integrations/knowbe4.json) | `knowbe4` | custom | 2 | | +| [KnowBe4](integrations/knowbe4.json) | `knowbe4` | custom | 5 | | | [Kolide](integrations/kolide.json) | `kolide` | custom | 2 | | | [Lacework](integrations/lacework.json) | `lacework` | custom | 2 | | | [LastPass Business](integrations/lastpass.json) | `lastpass` | custom | 3 | | @@ -602,8 +619,8 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Secureframe](integrations/secureframe.json) | `secureframe` | api_key | 2 | | | [SecurityScorecard](integrations/securityscorecard.json) | `securityscorecard` | custom | 5 | | | [Semgrep](integrations/semgrep.json) | `semgrep` | api_key | 2 | | -| [SentinelOne](integrations/sentinelone.json) | `sentinelone` | api_key | 2 | | -| [Snyk](integrations/snyk.json) | `snyk` | custom | 2 | | +| [SentinelOne](integrations/sentinelone.json) | `sentinelone` | api_key | 5 | | +| [Snyk](integrations/snyk.json) | `snyk` | custom | 5 | | | [Socket](integrations/socket-dev.json) | `socket-dev` | api_key | 2 | | | [Socket Security](integrations/socket.json) | `socket` | custom | 2 | | | [SonarCloud](integrations/sonarcloud.json) | `sonarcloud` | custom | 2 | | @@ -616,6 +633,7 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Tenable Cloud](integrations/tenable-cloud.json) | `tenable-cloud` | custom | 2 | | | [Tenable.io](integrations/tenable.json) | `tenable` | custom | 2 | | | [ThreatDown (Malwarebytes)](integrations/threatdown.json) | `threatdown` | custom | 5 | | +| [ThreatLocker](integrations/threatlocker.json) | `threatlocker` | custom | 6 | | | [Tines](integrations/tines.json) | `tines` | custom | 2 | | | [Torq](integrations/torq.json) | `torq` | custom | 2 | | | [Transcend](integrations/transcend.json) | `transcend` | api_key | 2 | | @@ -623,6 +641,7 @@ curl https://raw.githubusercontent.com/trycompai/comp/main/integrations-catalog/ | [Trend Micro Vision One](integrations/trend-micro-vision-one.json) | `trend-micro-vision-one` | custom | 4 | | | [Twingate](integrations/twingate.json) | `twingate` | custom | 4 | ✓ | | [Veracode](integrations/veracode.json) | `veracode` | custom | 2 | | +| [Verkada](integrations/verkada.json) | `verkada` | custom | 3 | | | [VMware Workspace ONE](integrations/vmware-workspace-one.json) | `vmware-workspace-one` | custom | 3 | | | [VulnCheck](integrations/vulncheck.json) | `vulncheck` | custom | 2 | | | [WatchGuard](integrations/watchguard.json) | `watchguard` | custom | 3 | | diff --git a/integrations-catalog/index.json b/integrations-catalog/index.json index 52fdb6e8a7..b94cb1ec46 100644 --- a/integrations-catalog/index.json +++ b/integrations-catalog/index.json @@ -1,17 +1,17 @@ { - "generatedAt": "2026-04-20T21:22:12.175Z", - "total": 540, - "sourceCount": 540, - "uniqueSlugs": 540, + "generatedAt": "2026-04-29T16:30:41.116Z", + "total": 559, + "sourceCount": 559, + "uniqueSlugs": 559, "byCategory": { "HR & People": 61, - "Security": 120, - "Productivity": 111, - "Infrastructure": 29, + "Security": 129, + "Productivity": 115, + "Infrastructure": 31, "Communication": 46, - "Cloud": 53, + "Monitoring": 53, + "Cloud": 52, "Development": 52, - "Monitoring": 48, "Identity & Access": 20 }, "integrations": [ @@ -96,6 +96,15 @@ "syncSupported": false, "file": "integrations/activecampaign.json" }, + { + "slug": "activtrak", + "name": "ActivTrak", + "category": "Monitoring", + "authType": "custom", + "checkCount": 3, + "syncSupported": false, + "file": "integrations/activtrak.json" + }, { "slug": "addigy", "name": "Addigy", @@ -235,7 +244,7 @@ "slug": "anthropic", "name": "Anthropic", "category": "Cloud", - "authType": "api_key", + "authType": "custom", "checkCount": 2, "syncSupported": false, "file": "integrations/anthropic.json" @@ -276,6 +285,15 @@ "syncSupported": false, "file": "integrations/aqua-security.json" }, + { + "slug": "arctic-wolf", + "name": "Arctic Wolf", + "category": "Security", + "authType": "custom", + "checkCount": 4, + "syncSupported": false, + "file": "integrations/arctic-wolf.json" + }, { "slug": "asana", "name": "Asana", @@ -439,22 +457,13 @@ "file": "integrations/beehiiv.json" }, { - "slug": "better-uptime", + "slug": "better-stack", "name": "Better Stack", "category": "Monitoring", - "authType": "api_key", - "checkCount": 2, - "syncSupported": false, - "file": "integrations/better-uptime.json" - }, - { - "slug": "betterstack", - "name": "BetterStack", - "category": "Monitoring", - "authType": "api_key", - "checkCount": 2, + "authType": "custom", + "checkCount": 3, "syncSupported": false, - "file": "integrations/betterstack.json" + "file": "integrations/better-stack.json" }, { "slug": "beyond-trust", @@ -577,7 +586,7 @@ "slug": "browserstack", "name": "BrowserStack", "category": "Development", - "authType": "custom", + "authType": "basic", "checkCount": 2, "syncSupported": false, "file": "integrations/browserstack.json" @@ -591,6 +600,15 @@ "syncSupported": false, "file": "integrations/buddy.json" }, + { + "slug": "bugcrowd", + "name": "Bugcrowd", + "category": "Security", + "authType": "custom", + "checkCount": 3, + "syncSupported": false, + "file": "integrations/bugcrowd.json" + }, { "slug": "bugsnag", "name": "Bugsnag", @@ -744,6 +762,15 @@ "syncSupported": false, "file": "integrations/chartmogul.json" }, + { + "slug": "checkpoint", + "name": "Check Point", + "category": "Security", + "authType": "custom", + "checkCount": 6, + "syncSupported": false, + "file": "integrations/checkpoint.json" + }, { "slug": "checkly", "name": "Checkly", @@ -1154,7 +1181,7 @@ "name": "CrowdStrike Falcon", "category": "Security", "authType": "custom", - "checkCount": 2, + "checkCount": 5, "syncSupported": false, "file": "integrations/crowdstrike.json" }, @@ -1194,6 +1221,15 @@ "syncSupported": true, "file": "integrations/cyberark-identity.json" }, + { + "slug": "cybereason", + "name": "Cybereason", + "category": "Security", + "authType": "custom", + "checkCount": 4, + "syncSupported": false, + "file": "integrations/cybereason.json" + }, { "slug": "cycode", "name": "Cycode", @@ -1244,7 +1280,7 @@ "name": "Datadog", "category": "Cloud", "authType": "custom", - "checkCount": 3, + "checkCount": 5, "syncSupported": false, "file": "integrations/datadog.json" }, @@ -1405,7 +1441,7 @@ "slug": "doppler", "name": "Doppler", "category": "Cloud", - "authType": "api_key", + "authType": "custom", "checkCount": 2, "syncSupported": false, "file": "integrations/doppler.json" @@ -1438,22 +1474,22 @@ "file": "integrations/dropbox-business.json" }, { - "slug": "dropbox-sign", + "slug": "hellosign", "name": "Dropbox Sign", "category": "Productivity", - "authType": "custom", - "checkCount": 1, + "authType": "api_key", + "checkCount": 2, "syncSupported": false, - "file": "integrations/dropbox-sign.json" + "file": "integrations/hellosign.json" }, { - "slug": "hellosign", + "slug": "dropbox-sign", "name": "Dropbox Sign", "category": "Productivity", - "authType": "api_key", - "checkCount": 2, + "authType": "custom", + "checkCount": 1, "syncSupported": false, - "file": "integrations/hellosign.json" + "file": "integrations/dropbox-sign.json" }, { "slug": "druva", @@ -1519,22 +1555,22 @@ "file": "integrations/egnyte.json" }, { - "slug": "elastic-cloud", + "slug": "elastic", "name": "Elastic Cloud", - "category": "Cloud", + "category": "Monitoring", "authType": "custom", "checkCount": 2, "syncSupported": false, - "file": "integrations/elastic-cloud.json" + "file": "integrations/elastic.json" }, { - "slug": "elastic", + "slug": "elastic-cloud", "name": "Elastic Cloud", - "category": "Monitoring", + "category": "Cloud", "authType": "custom", "checkCount": 2, "syncSupported": false, - "file": "integrations/elastic.json" + "file": "integrations/elastic-cloud.json" }, { "slug": "employment-hero", @@ -1581,6 +1617,15 @@ "syncSupported": false, "file": "integrations/eset-protect.json" }, + { + "slug": "expel", + "name": "Expel", + "category": "Security", + "authType": "api_key", + "checkCount": 5, + "syncSupported": false, + "file": "integrations/expel.json" + }, { "slug": "expensify", "name": "Expensify", @@ -1631,7 +1676,7 @@ "name": "Firebase", "category": "Cloud", "authType": "oauth2", - "checkCount": 3, + "checkCount": 2, "syncSupported": false, "file": "integrations/firebase.json" }, @@ -1869,15 +1914,6 @@ "syncSupported": false, "file": "integrations/goodhire.json" }, - { - "slug": "google-cloud", - "name": "Google Cloud", - "category": "Cloud", - "authType": "oauth2", - "checkCount": 2, - "syncSupported": false, - "file": "integrations/google-cloud.json" - }, { "slug": "google-workspace-admin", "name": "Google Workspace", @@ -1905,6 +1941,15 @@ "syncSupported": false, "file": "integrations/grafana-cloud.json" }, + { + "slug": "grain", + "name": "Grain", + "category": "Productivity", + "authType": "custom", + "checkCount": 3, + "syncSupported": false, + "file": "integrations/grain.json" + }, { "slug": "greenhouse", "name": "Greenhouse", @@ -1977,6 +2022,15 @@ "syncSupported": false, "file": "integrations/hashicorp-vault.json" }, + { + "slug": "heap", + "name": "Heap", + "category": "Monitoring", + "authType": "custom", + "checkCount": 2, + "syncSupported": false, + "file": "integrations/heap.json" + }, { "slug": "helpscout", "name": "Help Scout", @@ -2157,6 +2211,15 @@ "syncSupported": false, "file": "integrations/hyperproof.json" }, + { + "slug": "illumio", + "name": "Illumio", + "category": "Security", + "authType": "custom", + "checkCount": 7, + "syncSupported": false, + "file": "integrations/illumio.json" + }, { "slug": "incident-io", "name": "Incident.io", @@ -2202,6 +2265,15 @@ "syncSupported": false, "file": "integrations/intercom.json" }, + { + "slug": "invicti", + "name": "Invicti", + "category": "Security", + "authType": "custom", + "checkCount": 6, + "syncSupported": false, + "file": "integrations/invicti.json" + }, { "slug": "ionos", "name": "IONOS Cloud", @@ -2220,6 +2292,15 @@ "syncSupported": false, "file": "integrations/ironclad.json" }, + { + "slug": "kandji", + "name": "Iru (formerly Kandji)", + "category": "Security", + "authType": "api_key", + "checkCount": 2, + "syncSupported": false, + "file": "integrations/kandji.json" + }, { "slug": "it-glue", "name": "IT Glue", @@ -2319,15 +2400,6 @@ "syncSupported": true, "file": "integrations/justworks.json" }, - { - "slug": "kandji", - "name": "Kandji", - "category": "Security", - "authType": "api_key", - "checkCount": 2, - "syncSupported": false, - "file": "integrations/kandji.json" - }, { "slug": "kaseya-vsa", "name": "Kaseya VSA", @@ -2405,7 +2477,7 @@ "name": "KnowBe4", "category": "Security", "authType": "custom", - "checkCount": 2, + "checkCount": 5, "syncSupported": false, "file": "integrations/knowbe4.json" }, @@ -2562,6 +2634,15 @@ "syncSupported": false, "file": "integrations/lob.json" }, + { + "slug": "logicmonitor", + "name": "LogicMonitor", + "category": "Monitoring", + "authType": "custom", + "checkCount": 5, + "syncSupported": false, + "file": "integrations/logicmonitor.json" + }, { "slug": "logrocket", "name": "LogRocket", @@ -2580,6 +2661,15 @@ "syncSupported": false, "file": "integrations/logzio.json" }, + { + "slug": "looker", + "name": "Looker", + "category": "Productivity", + "authType": "custom", + "checkCount": 2, + "syncSupported": false, + "file": "integrations/looker.json" + }, { "slug": "loops", "name": "Loops", @@ -2778,6 +2868,24 @@ "syncSupported": false, "file": "integrations/intune.json" }, + { + "slug": "power-bi", + "name": "Microsoft Power BI", + "category": "Productivity", + "authType": "custom", + "checkCount": 2, + "syncSupported": false, + "file": "integrations/power-bi.json" + }, + { + "slug": "microsoft-sentinel", + "name": "Microsoft Sentinel", + "category": "Monitoring", + "authType": "oauth2", + "checkCount": 3, + "syncSupported": false, + "file": "integrations/microsoft-sentinel.json" + }, { "slug": "microsoft-teams", "name": "Microsoft Teams", @@ -2805,6 +2913,15 @@ "syncSupported": false, "file": "integrations/mintlify.json" }, + { + "slug": "miradore", + "name": "Miradore", + "category": "Infrastructure", + "authType": "custom", + "checkCount": 5, + "syncSupported": false, + "file": "integrations/miradore.json" + }, { "slug": "miro", "name": "Miro", @@ -3053,7 +3170,7 @@ "name": "Okta", "category": "Identity & Access", "authType": "custom", - "checkCount": 3, + "checkCount": 5, "syncSupported": false, "file": "integrations/okta.json" }, @@ -3872,7 +3989,7 @@ "name": "SentinelOne", "category": "Security", "authType": "api_key", - "checkCount": 2, + "checkCount": 5, "syncSupported": false, "file": "integrations/sentinelone.json" }, @@ -3939,6 +4056,15 @@ "syncSupported": false, "file": "integrations/signoz.json" }, + { + "slug": "site24x7", + "name": "Site24x7", + "category": "Monitoring", + "authType": "custom", + "checkCount": 5, + "syncSupported": false, + "file": "integrations/site24x7.json" + }, { "slug": "slack", "name": "Slack", @@ -3980,7 +4106,7 @@ "name": "Snyk", "category": "Security", "authType": "custom", - "checkCount": 2, + "checkCount": 5, "syncSupported": false, "file": "integrations/snyk.json" }, @@ -4003,22 +4129,22 @@ "file": "integrations/socket.json" }, { - "slug": "sonarqube-cloud", + "slug": "sonarcloud", "name": "SonarCloud", - "category": "Development", - "authType": "api_key", + "category": "Security", + "authType": "custom", "checkCount": 2, "syncSupported": false, - "file": "integrations/sonarqube-cloud.json" + "file": "integrations/sonarcloud.json" }, { - "slug": "sonarcloud", + "slug": "sonarqube-cloud", "name": "SonarCloud", - "category": "Security", - "authType": "custom", + "category": "Development", + "authType": "api_key", "checkCount": 2, "syncSupported": false, - "file": "integrations/sonarcloud.json" + "file": "integrations/sonarqube-cloud.json" }, { "slug": "sonarqube-server", @@ -4263,6 +4389,15 @@ "syncSupported": false, "file": "integrations/sysdig.json" }, + { + "slug": "tableau", + "name": "Tableau", + "category": "Productivity", + "authType": "custom", + "checkCount": 2, + "syncSupported": false, + "file": "integrations/tableau.json" + }, { "slug": "tailscale", "name": "Tailscale", @@ -4380,6 +4515,15 @@ "syncSupported": false, "file": "integrations/threatdown.json" }, + { + "slug": "threatlocker", + "name": "ThreatLocker", + "category": "Security", + "authType": "custom", + "checkCount": 6, + "syncSupported": false, + "file": "integrations/threatlocker.json" + }, { "slug": "tines", "name": "Tines", @@ -4533,6 +4677,15 @@ "syncSupported": false, "file": "integrations/ukg-ready.json" }, + { + "slug": "unifi", + "name": "UniFi", + "category": "Infrastructure", + "authType": "custom", + "checkCount": 5, + "syncSupported": false, + "file": "integrations/unifi.json" + }, { "slug": "upcloud", "name": "UpCloud", @@ -4560,6 +4713,15 @@ "syncSupported": false, "file": "integrations/upstash.json" }, + { + "slug": "uptime-robot", + "name": "Uptime Robot", + "category": "Monitoring", + "authType": "api_key", + "checkCount": 2, + "syncSupported": false, + "file": "integrations/uptime-robot.json" + }, { "slug": "userflow", "name": "Userflow", @@ -4596,6 +4758,15 @@ "syncSupported": false, "file": "integrations/vercel.json" }, + { + "slug": "verkada", + "name": "Verkada", + "category": "Security", + "authType": "custom", + "checkCount": 3, + "syncSupported": false, + "file": "integrations/verkada.json" + }, { "slug": "vitally", "name": "Vitally", diff --git a/integrations-catalog/integrations/action1.json b/integrations-catalog/integrations/action1.json index 845783407b..c0ed1b2f90 100644 --- a/integrations-catalog/integrations/action1.json +++ b/integrations-catalog/integrations/action1.json @@ -4,23 +4,35 @@ "description": "Patch management and endpoint management platform. Monitor vulnerability status, patch compliance, and device inventory.", "category": "Security", "docsUrl": "https://www.action1.com/api-documentation/", - "baseUrl": "https://app.action1.com", + "baseUrl": "https://app.action1.com/api/3.0", "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Log in to Action1 at https://app.action1.com\n2. Go to Settings → API Credentials\n3. Click 'Add' to create new credentials\n4. Copy the Client ID and Client Secret\n5. Paste both values below", + "setupInstructions": "Setup steps:\n\n1. Log in to your Action1 Console\n - North America: https://app.action1.com\n - Europe: https://app.eu.action1.com\n - Australia: https://app.au.action1.com\n\n2. Create API Credentials:\n - Click the gear icon (Settings) in the top-right\n - Go to API Credentials\n - Click Add to create new credentials\n - Important: the credential must have Administrator role\n - Copy the Client ID and Client Secret (secret is only shown once)\n\n3. Find your Organization ID:\n - After logging in, look at the URL in your browser\n - Find the ?org= parameter (e.g., https://app.action1.com/console/dashboard?org=12345)\n - The number after ?org= is your Organization ID\n\n4. Select your Region:\n - us - North America\n - eu - Europe\n - au - Australia", "credentialFields": [ { - "label": "Client ID", + "label": "Region", "type": "text", "required": true, - "helpText": "Found in Action1 Console → Settings → API Credentials." + "helpText": "Enter your Action1 region: us (North America), eu (Europe), or au (Australia)." }, { - "label": "Client Secret", + "label": "API Client ID", + "type": "text", + "required": true, + "helpText": "From Action1 Console: Settings (gear icon) > API Credentials > Add. Copy the Client ID shown after creation." + }, + { + "label": "API Client Secret", "type": "password", "required": true, - "helpText": "Generated when you create API credentials in Action1." + "helpText": "The Client Secret generated alongside the Client ID. This is only shown once at creation time." + }, + { + "label": "Organization ID", + "type": "text", + "required": false, + "helpText": "Optional. Your Action1 Organization ID from the ?org= parameter in the URL (e.g., ?org=12345). Leave blank to auto-detect from your account." } ] } @@ -41,28 +53,28 @@ { "slug": "action1_asset_inventory", "name": "Action1 Asset Inventory", - "description": "Verifies endpoint inventory in Action1.", + "description": "Lists all managed endpoints in your Action1 organization to verify endpoint inventory is actively tracked.", "defaultSeverity": "medium", "enabled": true }, { "slug": "action1_vulnerability_management", "name": "Action1 Vulnerability Management", - "description": "Verifies vulnerability scanning and tracking in Action1.", + "description": "Checks vulnerability scan data in Action1 to verify that vulnerability management is active and scanning endpoints.", "defaultSeverity": "medium", "enabled": true }, { "slug": "action1_patch_validation", "name": "Action1 Patch Validation", - "description": "Checks for missing patches across managed endpoints.", + "description": "Checks for missing updates across managed endpoints to verify patch management is active.", "defaultSeverity": "medium", "enabled": true }, { "slug": "action1_device_configuration", "name": "Action1 Device Configuration", - "description": "Verifies device configuration policies are defined in Action1.", + "description": "Checks that device configuration policies are defined and actively applied to managed endpoints.", "defaultSeverity": "medium", "enabled": true } diff --git a/integrations-catalog/integrations/activtrak.json b/integrations-catalog/integrations/activtrak.json new file mode 100644 index 0000000000..70c0286558 --- /dev/null +++ b/integrations-catalog/integrations/activtrak.json @@ -0,0 +1,58 @@ +{ + "slug": "activtrak", + "name": "ActivTrak", + "description": "Monitor ActivTrak workforce analytics platform for user activity monitoring, alerting, and agent deployment compliance", + "category": "Monitoring", + "docsUrl": "https://developers.activtrak.com/introduction", + "baseUrl": "https://{{credentials.region}}/", + "authConfig": { + "type": "custom", + "config": { + "setupInstructions": "1. Log in to ActivTrak at https://app.activtrak.com\n2. Navigate to API & Integrations > API Keys\n3. Click 'Create API Key' and follow the prompts\n4. Copy the API key (it can only be viewed once)\n5. Determine your region from your ActivTrak login URL\n6. Enter the API key and region below", + "credentialFields": [ + { + "label": "API Key", + "type": "password", + "required": true, + "helpText": "Generate at ActivTrak > API & Integrations > API Keys. Requires Admin role." + }, + { + "label": "API Region", + "type": "text", + "required": true, + "helpText": "Your ActivTrak API base domain. US: api.activtrak.com, EU: api-eu.activtrak.com, UK: api-uk.activtrak.com, AU: api-au.activtrak.com, CA: api-ca.activtrak.com" + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "activtrak_monitoring_alerting", + "name": "Monitoring & Alerting", + "description": "Verifies ActivTrak activity monitoring is active by checking for recent activity data collected by deployed agents", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "activtrak_employee_access", + "name": "Employee Access", + "description": "Lists all ActivTrak console users (consumers) with their roles and group access permissions", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "activtrak_device_list", + "name": "Device List", + "description": "Lists all monitored devices (user agents/clients) registered in ActivTrak", + "defaultSeverity": "medium", + "enabled": true + } + ], + "checkCount": 3, + "isActive": true +} diff --git a/integrations-catalog/integrations/affinity.json b/integrations-catalog/integrations/affinity.json index ff03f9f650..3654e650a1 100644 --- a/integrations-catalog/integrations/affinity.json +++ b/integrations-catalog/integrations/affinity.json @@ -8,13 +8,13 @@ "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Log in to Affinity at https://affinity.co\n2. Go to Settings → API\n3. Copy your API Key\n4. Base64 encode 'api_key:' (with colon): echo -n 'apikey:' | base64\n5. Enter the encoded value below", + "setupInstructions": "1. Log in to Affinity at https://affinity.co\n2. Go to Settings > API\n3. Copy your API Key\n4. Paste it below", "credentialFields": [ { - "label": "Encoded API Key", + "label": "API Key", "type": "password", "required": true, - "helpText": "Base64 encode 'api_key:' (with trailing colon). Found in Affinity Settings → API. Command: echo -n 'apikey:' | base64" + "helpText": "Found in Affinity Settings > API" } ] } diff --git a/integrations-catalog/integrations/anthropic.json b/integrations-catalog/integrations/anthropic.json index 865dae2a92..1aa9d62e7f 100644 --- a/integrations-catalog/integrations/anthropic.json +++ b/integrations-catalog/integrations/anthropic.json @@ -6,8 +6,17 @@ "docsUrl": "https://docs.anthropic.com/en/api/", "baseUrl": "https://api.anthropic.com", "authConfig": { - "type": "api_key", - "config": {} + "type": "custom", + "config": { + "setupInstructions": "1. Go to console.anthropic.com and sign in\n2. Navigate to Settings > Admin API Keys (requires Organization Admin role)\n3. Create a new Admin API key\n4. Copy the key and enter it above\n5. Note: Admin API keys are required for organization-level checks. Regular API keys only work for model access checks.", + "credentialFields": [ + { + "label": "API Key", + "type": "password", + "required": true + } + ] + } }, "capabilities": [ "checks" @@ -25,7 +34,7 @@ { "slug": "anthropic_employee_access", "name": "Anthropic Employee Access", - "description": "Verifies employee access to Anthropic via API.", + "description": "Review Anthropic organization access by listing API keys, pending invitations, and workspaces", "defaultSeverity": "medium", "enabled": true } diff --git a/integrations-catalog/integrations/arctic-wolf.json b/integrations-catalog/integrations/arctic-wolf.json new file mode 100644 index 0000000000..83ca4c9d9c --- /dev/null +++ b/integrations-catalog/integrations/arctic-wolf.json @@ -0,0 +1,71 @@ +{ + "slug": "arctic-wolf", + "name": "Arctic Wolf", + "description": "Monitor Arctic Wolf managed detection and response (MDR) platform for security ticket management, incident tracking, and threat response compliance", + "category": "Security", + "docsUrl": "https://docs.arcticwolf.com/en/developer-and-oem/ticket-api/arctic-wolf-ticket-api", + "baseUrl": "https://eloc.global-prod.arcticwolf.net", + "authConfig": { + "type": "custom", + "config": { + "setupInstructions": "1. Sign in to the Arctic Wolf Unified Portal at https://dashboard.arcticwolf.com\n2. Go to Organization Profile > Personal API Keys\n3. Click Create an API Key, give it a name, and select an expiry period\n4. Copy the API key token (you will not be able to view it again)\n5. Find your POD and Organization UUID by calling the Organizations API:\n curl https://eloc.global-prod.arcticwolf.net/api/v1/organizations -H \"Authorization: Bearer YOUR_API_KEY\"\n6. Note the 'pod' value (e.g., us001) and the 'id' value (your Organization UUID) from the response", + "credentialFields": [ + { + "label": "Personal API Key", + "type": "password", + "required": true, + "helpText": "Generate in Arctic Wolf Unified Portal: Organization Profile > Personal API Keys > Create an API Key" + }, + { + "label": "POD (Region)", + "type": "text", + "required": true, + "helpText": "Your Arctic Wolf POD name (e.g., us001, us002). Find it via the Organizations API or ask your Concierge Security Team." + }, + { + "label": "Organization UUID", + "type": "text", + "required": true, + "helpText": "Your Arctic Wolf organization UUID. Find it via the Organizations API or in the Unified Portal." + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "arctic_wolf_api_connectivity", + "name": "API Connectivity", + "description": "Verifies connectivity to the Arctic Wolf API and that the Personal API Key is valid by retrieving the organization list", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "arctic_wolf_incident_response", + "name": "Incident Response Monitoring", + "description": "Checks for open and recent incident tickets in Arctic Wolf to verify active incident response monitoring", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "arctic_wolf_ticket_management", + "name": "Ticket Management", + "description": "Reviews all Arctic Wolf security tickets to ensure active ticket management and timely resolution", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "arctic_wolf_threat_detection", + "name": "Threat Detection Coverage", + "description": "Verifies Arctic Wolf MDR is actively monitoring by checking for recent tickets that indicate threat detection activity", + "defaultSeverity": "medium", + "enabled": true + } + ], + "checkCount": 4, + "isActive": true +} diff --git a/integrations-catalog/integrations/ashby.json b/integrations-catalog/integrations/ashby.json index 5a383014fc..e99e57fbc6 100644 --- a/integrations-catalog/integrations/ashby.json +++ b/integrations-catalog/integrations/ashby.json @@ -8,13 +8,13 @@ "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Log in to Ashby\n2. Go to Settings → Integrations → API Keys\n3. Create a new API key\n4. Run: echo -n 'YOUR_API_KEY:' | base64\n5. Paste the output below", + "setupInstructions": "1. Log in to Ashby\n2. Go to Settings > Integrations > API Keys\n3. Create a new API key\n4. Paste the API key below", "credentialFields": [ { - "label": "Basic Auth Token", + "label": "API Key", "type": "password", "required": true, - "helpText": "Base64-encode your API key with a colon: echo -n 'your_api_key:' | base64" + "helpText": "Found in Ashby Settings > Integrations > API Keys" } ] } diff --git a/integrations-catalog/integrations/backblaze.json b/integrations-catalog/integrations/backblaze.json index b2f64a364a..39ba13426b 100644 --- a/integrations-catalog/integrations/backblaze.json +++ b/integrations-catalog/integrations/backblaze.json @@ -8,19 +8,19 @@ "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Log in to Backblaze\n2. Go to Account → App Keys\n3. Note your Account ID and Application Key\n4. Encode them: echo -n 'accountId:applicationKey' | base64\n5. Paste the encoded value below", + "setupInstructions": "1. Log in to Backblaze\n2. Go to Account > App Keys\n3. Copy your Account ID and Application Key\n4. Paste both values below", "credentialFields": [ { "label": "Account ID", "type": "text", "required": true, - "helpText": "Backblaze → Account → App Keys → Account ID" + "helpText": "Found in Backblaze > Account > App Keys" }, { - "label": "Basic Auth Token", + "label": "Application Key", "type": "password", "required": true, - "helpText": "Run: echo -n 'accountId:applicationKey' | base64" + "helpText": "Found in Backblaze > Account > App Keys" } ] } diff --git a/integrations-catalog/integrations/bamboohr.json b/integrations-catalog/integrations/bamboohr.json index 940cb554b6..74e4d5eca6 100644 --- a/integrations-catalog/integrations/bamboohr.json +++ b/integrations-catalog/integrations/bamboohr.json @@ -8,13 +8,13 @@ "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Go to BambooHR > profile icon > API Keys > Add New Key\n2. Copy the API key\n3. Encode it: run echo -n YOUR_API_KEY:x | base64\n4. Enter the encoded result and your subdomain below", + "setupInstructions": "1. Go to BambooHR > profile icon > API Keys > Add New Key\n2. Copy the API key\n3. Paste it below along with your BambooHR subdomain", "credentialFields": [ { - "label": "Encoded API Token", + "label": "API Key", "type": "password", "required": true, - "helpText": "Base64 encode your API key: run echo -n YOUR_API_KEY:x | base64" + "helpText": "Found in BambooHR > profile icon > API Keys" } ] } diff --git a/integrations-catalog/integrations/better-stack.json b/integrations-catalog/integrations/better-stack.json new file mode 100644 index 0000000000..3e0dd536b3 --- /dev/null +++ b/integrations-catalog/integrations/better-stack.json @@ -0,0 +1,52 @@ +{ + "slug": "better-stack", + "name": "Better Stack", + "description": "Uptime monitoring, incident management, and on-call scheduling platform. Monitor uptime status, track incidents, and review team member access controls.", + "category": "Monitoring", + "docsUrl": "https://betterstack.com/docs/uptime/api/getting-started-with-uptime-api/", + "baseUrl": "https://uptime.betterstack.com", + "authConfig": { + "type": "custom", + "config": { + "setupInstructions": "1. Log in to Better Stack at https://betterstack.com\n2. Go to Settings > API tokens (https://betterstack.com/settings/global-api-tokens)\n3. Copy an existing Global API token or create a new one\n4. Paste the token below", + "credentialFields": [ + { + "label": "API Token", + "type": "password", + "required": true, + "helpText": "Better Stack API token. Get it from Better Stack Dashboard > Settings > API tokens. You can use either a Global API token or an Uptime API token." + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "better_stack_uptime_monitors", + "name": "Uptime Monitors", + "description": "Lists all uptime monitors and their current status. Monitors with 'down' status are flagged as findings.", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "better_stack_incident_response", + "name": "Incident Response", + "description": "Lists recent incidents and their resolution status. Unresolved incidents that have not been acknowledged are flagged.", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "better_stack_employee_access", + "name": "Employee Access", + "description": "Lists all team members with their roles and invitation status. Verifies employee access controls.", + "defaultSeverity": "medium", + "enabled": true + } + ], + "checkCount": 3, + "isActive": true +} diff --git a/integrations-catalog/integrations/better-uptime.json b/integrations-catalog/integrations/better-uptime.json deleted file mode 100644 index 69109d7084..0000000000 --- a/integrations-catalog/integrations/better-uptime.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "slug": "better-uptime", - "name": "Better Stack", - "description": "Uptime monitoring and incident management platform.", - "category": "Monitoring", - "docsUrl": "https://betterstack.com/docs/uptime/api/", - "baseUrl": "https://uptime.betterstack.com", - "authConfig": { - "type": "api_key", - "config": {} - }, - "capabilities": [ - "checks" - ], - "supportsMultipleConnections": false, - "syncSupported": false, - "checks": [ - { - "slug": "better_uptime_app_availability", - "name": "Better Stack App Availability", - "description": "Checks Better Stack app availability.", - "defaultSeverity": "medium", - "enabled": true - }, - { - "slug": "better_uptime_employee_access", - "name": "Better Stack Employee Access", - "description": "Checks Better Stack employee access.", - "defaultSeverity": "medium", - "enabled": true - } - ], - "checkCount": 2, - "isActive": true -} diff --git a/integrations-catalog/integrations/betterstack.json b/integrations-catalog/integrations/betterstack.json deleted file mode 100644 index 2c0758b0bb..0000000000 --- a/integrations-catalog/integrations/betterstack.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "slug": "betterstack", - "name": "BetterStack", - "description": "Monitor BetterStack uptime monitors and incident alerts for availability compliance", - "category": "Monitoring", - "docsUrl": "https://betterstack.com/docs/uptime/api/getting-started-with-uptime-api/", - "baseUrl": "https://uptime.betterstack.com/", - "authConfig": { - "type": "api_key", - "config": {} - }, - "capabilities": [ - "checks" - ], - "supportsMultipleConnections": false, - "syncSupported": false, - "checks": [ - { - "slug": "betterstack_app_availability", - "name": "App Availability", - "description": "Verifies BetterStack monitors are configured and services are up", - "defaultSeverity": "high", - "enabled": true - }, - { - "slug": "betterstack_incident_response", - "name": "Incident Response", - "description": "Checks BetterStack for open incidents requiring response", - "defaultSeverity": "high", - "enabled": true - } - ], - "checkCount": 2, - "isActive": true -} diff --git a/integrations-catalog/integrations/bitbucket.json b/integrations-catalog/integrations/bitbucket.json index 5242b143f1..d316d7c8c5 100644 --- a/integrations-catalog/integrations/bitbucket.json +++ b/integrations-catalog/integrations/bitbucket.json @@ -8,13 +8,19 @@ "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Log in to Bitbucket\n2. Go to Personal Settings > App passwords\n3. Create an App Password with Repositories:Read and Account:Read permissions\n4. Base64-encode 'your_username:app_password'\n5. Paste the encoded string below", + "setupInstructions": "1. Log in to Bitbucket\n2. Go to Personal Settings > App passwords\n3. Create an App Password with Repositories:Read and Account:Read permissions\n4. Enter your Bitbucket username and the App Password below", "credentialFields": [ { - "label": "Base64 Encoded Token", + "label": "Bitbucket Username", + "type": "text", + "required": true, + "helpText": "Your Bitbucket username (not email)" + }, + { + "label": "App Password", "type": "password", "required": true, - "helpText": "Base64-encode 'username:app_password'. Create an App Password in Bitbucket > Personal Settings > App passwords" + "helpText": "Created in Bitbucket > Personal Settings > App passwords" } ] } diff --git a/integrations-catalog/integrations/browserstack.json b/integrations-catalog/integrations/browserstack.json index 7b75fbf12f..6d5ed0865e 100644 --- a/integrations-catalog/integrations/browserstack.json +++ b/integrations-catalog/integrations/browserstack.json @@ -6,17 +6,11 @@ "docsUrl": "https://www.browserstack.com/docs/automate/api-reference/selenium/introduction", "baseUrl": "https://api.browserstack.com/", "authConfig": { - "type": "custom", + "type": "basic", "config": { - "setupInstructions": "1. Log in to BrowserStack at https://browserstack.com\n2. Go to Account → Summary to find your Username and Access Key\n3. Encode them: base64('username:access_key')\n4. Enter the encoded value below", - "credentialFields": [ - { - "label": "Encoded Credentials", - "type": "password", - "required": true, - "helpText": "Base64 encode 'username:access_key'. Command: echo -n 'user@email.com:access_key' | base64" - } - ] + "setupInstructions": "1. Log in to BrowserStack at https://browserstack.com\n2. Go to Account > Summary to find your Username and Access Key\n3. Enter your Username and Access Key below", + "usernameField": "username", + "passwordField": "access_key" } }, "capabilities": [ diff --git a/integrations-catalog/integrations/bugcrowd.json b/integrations-catalog/integrations/bugcrowd.json new file mode 100644 index 0000000000..fab7feb23b --- /dev/null +++ b/integrations-catalog/integrations/bugcrowd.json @@ -0,0 +1,52 @@ +{ + "slug": "bugcrowd", + "name": "Bugcrowd", + "description": "Monitor bug bounty programs, vulnerability submissions, and reward configurations on the Bugcrowd platform for security compliance", + "category": "Security", + "docsUrl": "https://docs.bugcrowd.com/api/getting-started", + "baseUrl": "https://api.bugcrowd.com", + "authConfig": { + "type": "custom", + "config": { + "setupInstructions": "Prerequisites: You must have an Organization Owner or Admin role on your Bugcrowd organization to generate API credentials with access to all programs and submissions.\n\n1. Log into Bugcrowd at https://tracker.bugcrowd.com\n2. Click your profile picture in the top right\n3. Select 'API Credentials' from the dropdown\n4. Enter a descriptive name (e.g., 'CompAI Integration') and click 'Create credentials'\n5. Copy the full token (username:password format) before leaving the page - it won't be shown again\n6. Paste the token here\n\nNote: The API token format is username:password (colon-separated). Ensure you copy both parts.", + "credentialFields": [ + { + "label": "API Token", + "type": "password", + "required": true, + "helpText": "Your Bugcrowd API token in the format username:password (e.g., myuser:abc123def456). Generate it from your Bugcrowd profile > API Credentials." + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "bugcrowd_active_programs", + "name": "Active Bug Bounty Programs", + "description": "Verifies that the organization has active bug bounty programs on Bugcrowd, indicating an ongoing commitment to secure code through external vulnerability testing", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "bugcrowd_submission_triage", + "name": "Vulnerability Submission Triage & Resolution", + "description": "Checks that vulnerability submissions on Bugcrowd are being actively triaged and resolved, demonstrating effective incident response processes", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "bugcrowd_program_config", + "name": "Program Reward & Scope Configuration", + "description": "Verifies that Bugcrowd programs have properly configured reward ranges and target scopes, ensuring effective monitoring and alerting for the bug bounty program", + "defaultSeverity": "medium", + "enabled": true + } + ], + "checkCount": 3, + "isActive": true +} diff --git a/integrations-catalog/integrations/chargebee.json b/integrations-catalog/integrations/chargebee.json index df69a19c1f..edb5a45d83 100644 --- a/integrations-catalog/integrations/chargebee.json +++ b/integrations-catalog/integrations/chargebee.json @@ -8,19 +8,19 @@ "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Log in to Chargebee at https://app.chargebee.com\n2. Go to Settings → Configure Chargebee → API Keys\n3. Copy your Full-Access or Read-Only API key\n4. Base64 encode 'api_key:' (with trailing colon, empty password)\n5. Enter your site name and encoded key below", + "setupInstructions": "1. Log in to Chargebee at https://app.chargebee.com\n2. Go to Settings > Configure Chargebee > API Keys\n3. Copy your Full-Access or Read-Only API key\n4. Enter your site name and API key below", "credentialFields": [ { "label": "Site Name", "type": "text", "required": true, - "helpText": "Your Chargebee site name (e.g. mycompany — the subdomain of your Chargebee URL)" + "helpText": "Your Chargebee site name (the subdomain in your Chargebee URL)" }, { - "label": "Encoded API Key", + "label": "API Key", "type": "password", "required": true, - "helpText": "Base64 encode 'api_key:' (with colon, empty password). Command: echo -n 'your_api_key:' | base64" + "helpText": "Found in Settings > Configure Chargebee > API Keys" } ] } diff --git a/integrations-catalog/integrations/checkpoint.json b/integrations-catalog/integrations/checkpoint.json new file mode 100644 index 0000000000..45bdc42830 --- /dev/null +++ b/integrations-catalog/integrations/checkpoint.json @@ -0,0 +1,85 @@ +{ + "slug": "checkpoint", + "name": "Check Point", + "description": "Check Point network and cloud security integration for compliance monitoring. Connects to the Check Point Management API to verify firewall policies, access control rules, threat prevention, network segmentation, HTTPS inspection, and gateway health.", + "category": "Security", + "docsUrl": "https://sc1.checkpoint.com/documents/latest/APIs/", + "baseUrl": null, + "authConfig": { + "type": "custom", + "config": { + "setupInstructions": "1. Log in to your Check Point SmartConsole\n2. Go to Manage & Settings > Blades > Management API\n3. Ensure the Management API is enabled\n4. Under API Settings, set 'Accepted API calls from' to allow your CompAI server IP or 'All IP addresses'\n5. Publish the session, then run 'api restart' on the Management Server CLI (or reboot) so the setting takes effect\n6. Create an API key: Manage & Settings > Permissions & Administrators > create a new administrator with read-only permissions and an API key\n7. Copy the Management Server URL (the IP/hostname where SmartConsole connects)\n8. Paste the Management Server URL and API Key below\n\nNetwork prerequisites: TCP 443 from CompAI to your Management Server must be allowed. Ensure the server presents a trusted TLS certificate.\n\nNote: The API key must have at least read-only access. For Multi-Domain Server deployments, also specify the domain name.", + "credentialFields": [ + { + "label": "Management Server URL", + "type": "text", + "required": true, + "helpText": "The base URL of your Check Point Management Server (e.g. https://192.168.1.100 or https://mgmt.example.com). Default port is 443." + }, + { + "label": "API Key", + "type": "password", + "required": true, + "helpText": "API key for authentication. Generate in SmartConsole: Manage & Settings > Blades > Management API > API Keys. The API key must have read-only permissions." + }, + { + "label": "Domain (optional)", + "type": "text", + "required": false, + "helpText": "For Multi-Domain Server deployments, specify the domain name or UID. Leave empty for single-domain setups." + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "checkpoint_app_availability", + "name": "Check Point App Availability", + "description": "Verifies connectivity to the Check Point Management API by authenticating and confirming session establishment.", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "checkpoint_access_control_policy", + "name": "Check Point Access Control Policy", + "description": "Verifies that access control rules are configured with a default deny policy and proper rule structure.", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "checkpoint_threat_prevention", + "name": "Check Point Threat Prevention", + "description": "Verifies that threat prevention profiles (IPS, Anti-Bot, Anti-Virus, Threat Emulation) are configured and active.", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "checkpoint_gateway_health", + "name": "Check Point Gateway Health", + "description": "Verifies that security gateways are configured and reporting to the management server.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "checkpoint_https_inspection", + "name": "Check Point HTTPS Inspection", + "description": "Verifies that HTTPS inspection is configured for encrypted traffic visibility and threat detection.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "checkpoint_admin_permissions", + "name": "Check Point Administrator Permissions", + "description": "Reviews administrator accounts to verify proper role-based access control (RBAC) and identify any overprivileged accounts.", + "defaultSeverity": "medium", + "enabled": true + } + ], + "checkCount": 6, + "isActive": true +} diff --git a/integrations-catalog/integrations/close-crm.json b/integrations-catalog/integrations/close-crm.json index cbaa5348eb..dcd74150e1 100644 --- a/integrations-catalog/integrations/close-crm.json +++ b/integrations-catalog/integrations/close-crm.json @@ -8,13 +8,13 @@ "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Log in to Close at https://app.close.com\n2. Go to Settings → API Keys → Create API Key\n3. Base64 encode 'api_key:' (with colon): echo -n 'apikey:' | base64\n4. Enter the encoded value below", + "setupInstructions": "1. Log in to Close at https://app.close.com\n2. Go to Settings > API Keys > Create API Key\n3. Copy the API key and paste it below", "credentialFields": [ { - "label": "Encoded API Key", + "label": "API Key", "type": "password", "required": true, - "helpText": "Base64 encode 'api_key:' (with trailing colon). Found in Close Settings → API Keys. Command: echo -n 'apikey:' | base64" + "helpText": "Found in Close Settings > API Keys" } ] } diff --git a/integrations-catalog/integrations/cloudinary.json b/integrations-catalog/integrations/cloudinary.json index 892a766139..78b5eb55cc 100644 --- a/integrations-catalog/integrations/cloudinary.json +++ b/integrations-catalog/integrations/cloudinary.json @@ -8,7 +8,7 @@ "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Log in to Cloudinary at https://cloudinary.com/console\n2. Go to Settings → Access Keys\n3. Copy your API Key and API Secret\n4. Base64 encode 'api_key:api_secret'\n5. Enter your cloud name and encoded credentials below", + "setupInstructions": "1. Log in to Cloudinary at https://cloudinary.com/console\n2. Go to Settings > Access Keys\n3. Copy your Cloud Name, API Key, and API Secret\n4. Enter them below", "credentialFields": [ { "label": "Cloud Name", @@ -17,10 +17,16 @@ "helpText": "Your Cloudinary cloud name (found in Dashboard)" }, { - "label": "Encoded API Credentials", + "label": "API Key", + "type": "text", + "required": true, + "helpText": "Found in Settings > Access Keys" + }, + { + "label": "API Secret", "type": "password", "required": true, - "helpText": "Base64 encode 'api_key:api_secret'. Found in Cloudinary Dashboard → Settings → Access Keys. Command: echo -n 'api_key:api_secret' | base64" + "helpText": "Found in Settings > Access Keys (click to reveal)" } ] } diff --git a/integrations-catalog/integrations/confluence.json b/integrations-catalog/integrations/confluence.json index 56aa3e97d0..2f90bce680 100644 --- a/integrations-catalog/integrations/confluence.json +++ b/integrations-catalog/integrations/confluence.json @@ -8,13 +8,27 @@ "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Go to id.atlassian.com/manage-profile/security/api-tokens\n2. Click 'Create API token'\n3. Base64-encode 'your_email:api_token'\n4. Paste the encoded string below", + "setupInstructions": "1. Log in to your Atlassian account at id.atlassian.com\n2. Go to Security > API tokens (id.atlassian.com/manage-profile/security/api-tokens)\n3. Click 'Create API token', give it a name, and copy the token\n4. Enter the fields above:\n - Site Domain: your Atlassian site (e.g. yourcompany.atlassian.net)\n - Email: the email of the account that created the token\n - API Token: the token you just copied\n - Group Name (optional): leave empty for the default 'confluence-users' group, or enter your custom group name\n5. Tip: We recommend using an Atlassian service account rather than a personal account. Create one at admin.atlassian.com > Directory > Users", "credentialFields": [ { - "label": "Base64 Encoded Token", + "label": "Atlassian Site Domain", + "type": "text", + "required": true + }, + { + "label": "Email", + "type": "text", + "required": true + }, + { + "label": "API Token", "type": "password", - "required": true, - "helpText": "Base64-encode 'email@example.com:api_token'. Create an API token at id.atlassian.com/manage-profile/security/api-tokens" + "required": true + }, + { + "label": "Confluence Group Name", + "type": "text", + "required": false } ] } diff --git a/integrations-catalog/integrations/crowdstrike.json b/integrations-catalog/integrations/crowdstrike.json index 53bbf1290f..c60fed3844 100644 --- a/integrations-catalog/integrations/crowdstrike.json +++ b/integrations-catalog/integrations/crowdstrike.json @@ -3,12 +3,12 @@ "name": "CrowdStrike Falcon", "description": "Monitor CrowdStrike Falcon endpoint security platform for device protection and threat detection compliance", "category": "Security", - "docsUrl": "https://developer.crowdstrike.com/crowdstrike/docs/welcome", + "docsUrl": "https://developer.crowdstrike.com/", "baseUrl": "https://api.crowdstrike.com/", "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Log in to Falcon Console at https://falcon.crowdstrike.com\n2. Go to Support & Resources → API Clients and Keys\n3. Click Add new API client\n4. Enable scopes: Hosts (Read), User Management (Read)\n5. Copy the Client ID and Secret", + "setupInstructions": "1. Log in to Falcon Console at https://falcon.crowdstrike.com\n2. Go to Support & Resources → API Clients and Keys\n3. Click Add new API client\n4. Enable scopes: Hosts (Read), User Management (Read), Prevention Policies (Read), Spotlight Vulnerabilities (Read)\n5. Copy the Client ID and Secret", "credentialFields": [ { "label": "Client ID", @@ -44,8 +44,29 @@ "description": "Reviews CrowdStrike Falcon users with access to the endpoint security platform", "defaultSeverity": "medium", "enabled": true + }, + { + "slug": "crowdstrike_prevention_policy", + "name": "Prevention Policy Enforcement", + "description": "Verifies that CrowdStrike Falcon prevention policies are configured and enabled", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "crowdstrike_vulnerability_management", + "name": "Vulnerability Management", + "description": "Monitors CrowdStrike Spotlight for open vulnerabilities across managed endpoints", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "crowdstrike_sensor_coverage", + "name": "Sensor Coverage", + "description": "Assesses CrowdStrike Falcon sensor deployment coverage and health across endpoints", + "defaultSeverity": "medium", + "enabled": true } ], - "checkCount": 2, + "checkCount": 5, "isActive": true } diff --git a/integrations-catalog/integrations/cybereason.json b/integrations-catalog/integrations/cybereason.json new file mode 100644 index 0000000000..72390635cb --- /dev/null +++ b/integrations-catalog/integrations/cybereason.json @@ -0,0 +1,71 @@ +{ + "slug": "cybereason", + "name": "Cybereason", + "description": "Cybereason is an endpoint detection and response (EDR) platform that provides real-time threat detection, MalOp (Malicious Operation) investigation, and automated incident response across enterprise endpoints.", + "category": "Security", + "docsUrl": "https://nest.cybereason.com/documentation/api-documentation", + "baseUrl": null, + "authConfig": { + "type": "custom", + "config": { + "setupInstructions": "Prerequisites:\n- Cybereason management console access with admin privileges\n- The user MUST be created as type 'API' (not 'GUI/Console' user). GUI users with 2FA enabled cannot be used for this integration.\n\nSteps:\n1. Log in to your Cybereason management console as an admin\n2. Navigate to the admin area (typically System > Users or Settings > Users depending on your version)\n3. Create a new user of type 'API' (not GUI/Console user)\n4. Assign the API user the 'System Admin' role for full API access\n5. Enter your Cybereason server URL below (e.g., https://yourcompany.cybereason.net)\n6. Enter the API user credentials\n\nNote: The integration authenticates via session cookies. API-type users are designed for programmatic access and do not require 2FA, unlike GUI users which require interactive two-factor authentication.", + "credentialFields": [ + { + "label": "Server URL", + "type": "text", + "required": true, + "helpText": "Your Cybereason server URL (e.g., https://yourcompany.cybereason.net). This is the base URL of your Cybereason management console." + }, + { + "label": "API Username", + "type": "text", + "required": true, + "helpText": "The username for your Cybereason API user. Must be an API-type user (not a GUI/Console user)." + }, + { + "label": "Password", + "type": "password", + "required": true, + "helpText": "The password for your Cybereason API user." + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "cybereason_secure_devices", + "name": "Secure Devices", + "description": "Verifies that endpoint protection sensors are actively running on monitored devices. Checks the status of all sensors and flags any that are disconnected, stale, or have outdated protection versions.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "cybereason_device_list", + "name": "Device List", + "description": "Lists all monitored endpoints and sensors in the Cybereason environment. Provides a comprehensive inventory of all devices under endpoint protection management.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "cybereason_monitoring_alerting", + "name": "Monitoring and Alerting", + "description": "Checks that Cybereason is actively detecting and generating MalOps (Malicious Operations) and security alerts. Verifies that the detection engine is operational and threats are being identified.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "cybereason_incident_response", + "name": "Incident Response", + "description": "Verifies that security incidents (MalOps) are being tracked, investigated, and remediated. Checks for unresolved incidents and validates that response workflows are in place.", + "defaultSeverity": "medium", + "enabled": true + } + ], + "checkCount": 4, + "isActive": true +} diff --git a/integrations-catalog/integrations/datadog.json b/integrations-catalog/integrations/datadog.json index d0c65bb3ae..926bc58c21 100644 --- a/integrations-catalog/integrations/datadog.json +++ b/integrations-catalog/integrations/datadog.json @@ -51,8 +51,22 @@ "description": "Verifies that Datadog monitors have notification channels configured for alerting", "defaultSeverity": "high", "enabled": true + }, + { + "slug": "datadog_audit_logging", + "name": "Audit Logging", + "description": "Verifies that Datadog Audit Trail is enabled and recording events for compliance tracking", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "datadog_user_management", + "name": "User Management", + "description": "Lists Datadog users, checks for inactive or disabled accounts, and verifies proper access management", + "defaultSeverity": "medium", + "enabled": true } ], - "checkCount": 3, + "checkCount": 5, "isActive": true } diff --git a/integrations-catalog/integrations/discord.json b/integrations-catalog/integrations/discord.json index b5dbc0990a..e47d60a3f3 100644 --- a/integrations-catalog/integrations/discord.json +++ b/integrations-catalog/integrations/discord.json @@ -8,19 +8,19 @@ "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Go to https://discord.com/developers/applications\n2. Create a new application → Bot → Reset Token\n3. Enable Server Members Intent under Privileged Gateway Intents\n4. Invite the bot to your server with 'View Channels' + 'View Members' permissions\n5. Enable Developer Mode in Discord settings, right-click server → Copy ID\n6. Enter bot token and server ID below", + "setupInstructions": "1. Go to https://discord.com/developers/applications and create a new application\n2. Go to Bot section, click Reset Token, and copy the token\n3. Under Privileged Gateway Intents, enable Server Members Intent\n4. Go to OAuth2 > URL Generator, check \"bot\" scope, then check \"View Channels\" permission\n5. Copy the generated URL, open it in your browser, and select your server\n6. Enable Developer Mode in Discord (User Settings > Advanced), right-click your server name, Copy Server ID\n7. Enter the bot token and server ID below", "credentialFields": [ { "label": "Bot Token", "type": "password", "required": true, - "helpText": "Found in Discord Developer Portal → Your App → Bot → Token" + "helpText": "Found in Discord Developer Portal > Your App > Bot > Token" }, { "label": "Server (Guild) ID", "type": "text", "required": true, - "helpText": "Right-click your Discord server name → Copy Server ID (enable Developer Mode first)" + "helpText": "Right-click your Discord server name > Copy Server ID (enable Developer Mode first)" } ] } diff --git a/integrations-catalog/integrations/doppler.json b/integrations-catalog/integrations/doppler.json index 0345535dda..93575c6f88 100644 --- a/integrations-catalog/integrations/doppler.json +++ b/integrations-catalog/integrations/doppler.json @@ -1,13 +1,23 @@ { "slug": "doppler", "name": "Doppler", - "description": "Monitor Doppler secrets management — verify project configurations and team member access for secrets hygiene compliance", + "description": "Monitor Doppler secrets management - verify project configurations and team member access for secrets hygiene compliance", "category": "Cloud", "docsUrl": "https://docs.doppler.com/reference/api", "baseUrl": "https://api.doppler.com/", "authConfig": { - "type": "api_key", - "config": {} + "type": "custom", + "config": { + "setupInstructions": "Option 1 - Service Account Token (recommended, requires Team or Enterprise plan):\n1. In the Doppler dashboard, go to Team in the left navigation\n2. Select the Service Accounts tab\n3. Click the + button to create a new service account\n4. Assign a workplace role with read access\n5. Generate a token for the service account and copy it (starts with dp.sa.)\n\nOption 2 - Personal Token:\n1. In the Doppler dashboard, go to the Tokens page, then select Personal\n2. Generate a Personal Token and copy it (starts with dp.pt.)\n\nNote: Service Tokens (dp.st.*) will NOT work - they are project-scoped and cannot access workplace-level endpoints needed for compliance checks.", + "credentialFields": [ + { + "label": "Doppler Token", + "type": "password", + "required": true, + "helpText": "A Personal Token (dp.pt.*) or Service Account Token (dp.sa.*) with workplace-level access. Service Tokens (dp.st.*) will NOT work." + } + ] + } }, "capabilities": [ "checks" diff --git a/integrations-catalog/integrations/dropbox-sign.json b/integrations-catalog/integrations/dropbox-sign.json index 46fad8dddd..d9cdb6eabc 100644 --- a/integrations-catalog/integrations/dropbox-sign.json +++ b/integrations-catalog/integrations/dropbox-sign.json @@ -8,13 +8,13 @@ "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Log in to Dropbox Sign\n2. Go to Settings → Integrations → API\n3. Copy your API key\n4. Run: echo -n 'YOUR_API_KEY:' | base64\n5. Paste the output below", + "setupInstructions": "1. Log in to Dropbox Sign\n2. Go to Settings > Integrations > API\n3. Copy your API key and paste it below", "credentialFields": [ { - "label": "Basic Auth Token", + "label": "API Key", "type": "password", "required": true, - "helpText": "Base64-encode your API key with a colon: echo -n 'your_api_key:' | base64" + "helpText": "Found in Dropbox Sign Settings > Integrations > API" } ] } diff --git a/integrations-catalog/integrations/duo-security.json b/integrations-catalog/integrations/duo-security.json index e8d43cda72..7e11e2d244 100644 --- a/integrations-catalog/integrations/duo-security.json +++ b/integrations-catalog/integrations/duo-security.json @@ -8,13 +8,19 @@ "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Log in to Duo Admin Panel at https://admin.duosecurity.com\n2. Go to Applications → Protect an Application\n3. Find Admin API → click Protect\n4. Copy the Integration Key, Secret Key, and API Hostname\n5. Base64 encode 'integration_key:secret_key': echo -n 'ikey:skey' | base64\n6. Enter both below", + "setupInstructions": "1. Log in to Duo Admin Panel at https://admin.duosecurity.com\n2. Go to Applications > Protect an Application\n3. Find Admin API > click Protect\n4. Copy the Integration Key, Secret Key, and API Hostname\n5. Paste them into the fields below", "credentialFields": [ { - "label": "Encoded Credentials", + "label": "Integration Key", + "type": "text", + "required": true, + "helpText": "Found in Duo Admin Panel > Applications > Admin API" + }, + { + "label": "Secret Key", "type": "password", "required": true, - "helpText": "Base64 encode 'integration_key:secret_key'. Found in Duo Admin Panel → Applications → Admin API. Command: echo -n 'DIXXXXXXXX:secret' | base64" + "helpText": "Found in Duo Admin Panel > Applications > Admin API" }, { "label": "API Hostname", diff --git a/integrations-catalog/integrations/elastic.json b/integrations-catalog/integrations/elastic.json index 86973609ba..346825cf16 100644 --- a/integrations-catalog/integrations/elastic.json +++ b/integrations-catalog/integrations/elastic.json @@ -8,13 +8,19 @@ "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Log in to Kibana\n2. Go to Stack Management > Security > API keys\n3. Create an API key with cluster:monitor and security permissions\n4. The key is provided as id:value — base64-encode it\n5. Paste the encoded value below", + "setupInstructions": "1. Log in to Kibana\n2. Go to Stack Management > Security > API keys\n3. Create an API key with cluster:monitor and security permissions\n4. Copy the API Key ID and the API Key secret value\n5. Paste them into the fields below", "credentialFields": [ { - "label": "API Key", + "label": "API Key ID", + "type": "text", + "required": true, + "helpText": "The ID portion of your Elasticsearch API key (shown when the key is created)" + }, + { + "label": "API Key Secret", "type": "password", "required": true, - "helpText": "Create an API key in Kibana > Stack Management > Security > API keys. Use the base64-encoded 'id:value' format" + "helpText": "The secret value of your Elasticsearch API key (shown once at creation time)" } ] } diff --git a/integrations-catalog/integrations/expel.json b/integrations-catalog/integrations/expel.json new file mode 100644 index 0000000000..aa041ecf7e --- /dev/null +++ b/integrations-catalog/integrations/expel.json @@ -0,0 +1,58 @@ +{ + "slug": "expel", + "name": "Expel", + "description": "Monitor Expel Workbench MDR platform for security alerts, investigations, device health, and user access management", + "category": "Security", + "docsUrl": "https://workbench.expel.io/api/v2/docs/", + "baseUrl": "https://workbench.expel.io/api/v2", + "authConfig": { + "type": "api_key", + "config": { + "setupInstructions": "1. Log in to Expel Workbench at https://workbench.expel.io\n2. Go to Organization Settings > Service Accounts\n3. Create a new service account with the appropriate role (admin or analyst)\n4. Copy the generated API key\n5. Paste the API key in the field below" + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "expel_app_availability", + "name": "Expel App Availability", + "description": "Verifies connectivity to the Expel Workbench API and validates API key authentication", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "expel_alert_management", + "name": "Expel Alert Management", + "description": "Checks for open critical and high severity alerts in Expel Workbench that may require immediate attention", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "expel_incident_response", + "name": "Expel Incident Response", + "description": "Reviews recent investigations and flags open incidents that need attention", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "expel_security_device_health", + "name": "Expel Security Device Health", + "description": "Verifies that connected security devices in Expel Workbench are healthy and operational", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "expel_employee_access", + "name": "Expel Employee Access", + "description": "Lists user accounts and their roles in Expel Workbench for access review", + "defaultSeverity": "medium", + "enabled": true + } + ], + "checkCount": 5, + "isActive": true +} diff --git a/integrations-catalog/integrations/firebase.json b/integrations-catalog/integrations/firebase.json index 52191a5e69..f4931de747 100644 --- a/integrations-catalog/integrations/firebase.json +++ b/integrations-catalog/integrations/firebase.json @@ -24,13 +24,6 @@ "supportsMultipleConnections": false, "syncSupported": false, "checks": [ - { - "slug": "mfa_config", - "name": "2FA", - "description": "Verifies that multi-factor authentication is enabled at the Firebase project level", - "defaultSeverity": "high", - "enabled": true - }, { "slug": "user_access", "name": "Employee Access", @@ -46,6 +39,6 @@ "enabled": true } ], - "checkCount": 3, + "checkCount": 2, "isActive": true } diff --git a/integrations-catalog/integrations/freshservice.json b/integrations-catalog/integrations/freshservice.json index 11a0b69ef2..2e51bdd238 100644 --- a/integrations-catalog/integrations/freshservice.json +++ b/integrations-catalog/integrations/freshservice.json @@ -8,13 +8,13 @@ "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Go to Freshservice Profile Settings (top-right avatar)\n2. Copy your API Key\n3. Encode: base64('your_api_key:X')\n4. Enter the encoded value below along with your subdomain", + "setupInstructions": "1. Log in to Freshservice\n2. Go to Profile Settings (top-right avatar)\n3. Copy your API Key\n4. Enter the API key below along with your subdomain in each check", "credentialFields": [ { - "label": "Encoded API Key", + "label": "API Key", "type": "password", "required": true, - "helpText": "Base64 encode your API key: base64('your_api_key:X'). Use https://www.base64encode.org/ — note the literal ':X' suffix" + "helpText": "Found in Freshservice Profile Settings (top-right avatar > Profile Settings)" } ] } diff --git a/integrations-catalog/integrations/google-cloud.json b/integrations-catalog/integrations/google-cloud.json deleted file mode 100644 index 359738e420..0000000000 --- a/integrations-catalog/integrations/google-cloud.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "slug": "google-cloud", - "name": "Google Cloud", - "description": "Monitor GCP projects and infrastructure for cloud compliance", - "category": "Cloud", - "docsUrl": "https://cloud.google.com/apis/docs/overview", - "baseUrl": "https://cloudresourcemanager.googleapis.com/", - "authConfig": { - "type": "oauth2", - "config": { - "setupInstructions": "1. Go to Google Cloud Console > APIs & Services > Credentials\n2. Create an OAuth 2.0 Client ID (Web application)\n3. Add redirect URI: https://api.trycomp.ai/v1/integrations/oauth/callback\n4. Enable Cloud Resource Manager API\n5. Copy Client ID and Client Secret", - "scopes": [ - "https://www.googleapis.com/auth/cloudplatformprojects.readonly" - ], - "clientAuthMethod": "body", - "supportsRefreshToken": true - } - }, - "capabilities": [ - "checks" - ], - "supportsMultipleConnections": false, - "syncSupported": false, - "checks": [ - { - "slug": "gcp_infrastructure_inventory", - "name": "Infrastructure Inventory", - "description": "Lists GCP projects for cloud infrastructure inventory", - "defaultSeverity": "medium", - "enabled": true - }, - { - "slug": "gcp_separation_of_envs", - "name": "Separation of Environments", - "description": "Verifies GCP has separate projects per environment", - "defaultSeverity": "medium", - "enabled": true - } - ], - "checkCount": 2, - "isActive": true -} diff --git a/integrations-catalog/integrations/grain.json b/integrations-catalog/integrations/grain.json new file mode 100644 index 0000000000..6845910380 --- /dev/null +++ b/integrations-catalog/integrations/grain.json @@ -0,0 +1,51 @@ +{ + "slug": "grain", + "name": "Grain", + "description": "Grain meeting recording and intelligence platform integration for monitoring meeting recordings, team access, and compliance", + "category": "Productivity", + "docsUrl": "https://developers.grain.com/", + "baseUrl": null, + "authConfig": { + "type": "custom", + "config": { + "setupInstructions": "1. Log in to Grain at grain.com\n2. Go to Settings > Integrations > API tab\n3. Generate a Workspace Access Token (requires admin access)\n4. Copy the token and enter it above\n5. More details: https://developers.grain.com/", + "credentialFields": [ + { + "label": "Workspace Access Token", + "type": "password", + "required": true + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "grain_recordings", + "name": "App Availability", + "description": "Verify Grain is active and meeting recordings are being captured", + "defaultSeverity": "info", + "enabled": true + }, + { + "slug": "grain_user_access", + "name": "Employee Access", + "description": "Verify user access and team membership in Grain workspace", + "defaultSeverity": "info", + "enabled": true + }, + { + "slug": "grain_webhooks", + "name": "Monitoring & Alerting", + "description": "Verify webhook integrations are configured for meeting event notifications", + "defaultSeverity": "medium", + "enabled": true + } + ], + "checkCount": 3, + "isActive": true +} diff --git a/integrations-catalog/integrations/heap.json b/integrations-catalog/integrations/heap.json new file mode 100644 index 0000000000..f2edea3a60 --- /dev/null +++ b/integrations-catalog/integrations/heap.json @@ -0,0 +1,57 @@ +{ + "slug": "heap", + "name": "Heap", + "description": "Monitor Heap product analytics platform for account access and availability compliance. Heap (now part of Contentsquare) provides digital analytics to understand user behavior.", + "category": "Monitoring", + "docsUrl": "https://developers.heap.io/reference", + "baseUrl": null, + "authConfig": { + "type": "custom", + "config": { + "setupInstructions": "1. Log in to Heap at https://heapanalytics.com/app\n2. Go to Account > Manage > Privacy & Security\n3. Note your Environment ID (App ID)\n4. Generate an API key (or copy existing one)\n5. Enter both values below", + "credentialFields": [ + { + "label": "Environment ID (App ID)", + "type": "text", + "required": true, + "helpText": "Your Heap Main Production environment ID. Found in Account > Manage > Privacy & Security." + }, + { + "label": "API Key", + "type": "password", + "required": true, + "helpText": "Generate in Account > Manage > Privacy & Security. Admin access required." + }, + { + "label": "Data Center Region", + "type": "select", + "required": true, + "helpText": "Select the region where your Heap data is stored. US is the default. EU is at eu.heapanalytics.com." + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "heap_employee_access", + "name": "Employee Access", + "description": "Verifies Heap account credentials are valid by authenticating with the API. Heap does not expose a team member listing API, so this check validates that the configured credentials have admin-level access.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "heap_app_availability", + "name": "App Availability", + "description": "Verifies the Heap platform is accessible by authenticating against the Heap API endpoint", + "defaultSeverity": "high", + "enabled": true + } + ], + "checkCount": 2, + "isActive": true +} diff --git a/integrations-catalog/integrations/hexnode.json b/integrations-catalog/integrations/hexnode.json index 14cd5be571..91f25921eb 100644 --- a/integrations-catalog/integrations/hexnode.json +++ b/integrations-catalog/integrations/hexnode.json @@ -4,7 +4,7 @@ "description": "Hexnode UEM integration for device management, compliance monitoring, and policy enforcement", "category": "Security", "docsUrl": "https://www.hexnode.com/mobile-device-management/developers/", - "baseUrl": null, + "baseUrl": "https://placeholder.hexnodemdm.com", "authConfig": { "type": "custom", "config": { diff --git a/integrations-catalog/integrations/illumio.json b/integrations-catalog/integrations/illumio.json new file mode 100644 index 0000000000..3d9e8dee19 --- /dev/null +++ b/integrations-catalog/integrations/illumio.json @@ -0,0 +1,97 @@ +{ + "slug": "illumio", + "name": "Illumio", + "description": "Illumio is a Zero Trust Segmentation platform that provides microsegmentation, workload visibility, policy enforcement, and ransomware protection for data centers and cloud environments.", + "category": "Security", + "docsUrl": null, + "baseUrl": "https://illumio.placeholder.invalid", + "authConfig": { + "type": "custom", + "config": { + "credentialFields": [ + { + "label": "PCE URL", + "type": "text", + "required": true, + "helpText": "Your Illumio PCE base URL including port, e.g. https://pce.example.com:8443. Do not include a trailing slash." + }, + { + "label": "Organization ID", + "type": "text", + "required": true, + "helpText": "Your Illumio organization ID (typically 1 for single-org deployments). Found in PCE URL paths as /orgs/N." + }, + { + "label": "API Key ID", + "type": "text", + "required": true, + "helpText": "The API Key ID (username) generated in PCE Settings > API Keys." + }, + { + "label": "API Secret", + "type": "password", + "required": true, + "helpText": "The API Secret (password) generated alongside the API Key." + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "illumio_workload_visibility", + "name": "Workload Visibility", + "description": "Verifies that workloads are managed by Illumio VEN agents and have visibility enabled, ensuring network traffic is being monitored for microsegmentation policy enforcement.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "illumio_enforcement_mode", + "name": "Enforcement Mode", + "description": "Checks that workloads are running in full enforcement mode rather than idle or visibility-only, ensuring segmentation policies are actively blocking unauthorized traffic.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "illumio_segmentation_rulesets", + "name": "Segmentation Rulesets", + "description": "Verifies that segmentation rulesets are defined and provisioned in Illumio, confirming that microsegmentation policies exist to control lateral movement between workloads.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "illumio_label_coverage", + "name": "Label Coverage", + "description": "Checks that workloads have Illumio labels assigned (Role, Application, Environment, Location), essential for policy-driven microsegmentation and proper workload classification.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "illumio_password_policy", + "name": "Password Policy", + "description": "Verifies that a strong password policy is configured in the Illumio PCE, checking minimum length and complexity requirements for local user accounts.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "illumio_events_monitoring", + "name": "Events Monitoring", + "description": "Verifies that audit events are being generated in the Illumio PCE, confirming that the events framework is active and security-relevant activities are being logged for compliance.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "illumio_enforcement_boundaries", + "name": "Enforcement Boundaries", + "description": "Checks whether enforcement boundaries are defined in Illumio, providing deny-list based segmentation to block specific traffic flows without requiring full workload enforcement.", + "defaultSeverity": "medium", + "enabled": true + } + ], + "checkCount": 7, + "isActive": true +} diff --git a/integrations-catalog/integrations/invicti.json b/integrations-catalog/integrations/invicti.json new file mode 100644 index 0000000000..d35ece079a --- /dev/null +++ b/integrations-catalog/integrations/invicti.json @@ -0,0 +1,79 @@ +{ + "slug": "invicti", + "name": "Invicti", + "description": "Web application security scanner (formerly Netsparker). Monitor vulnerability scanning, application security posture, scan coverage, and security policy compliance.", + "category": "Security", + "docsUrl": "https://docs.invicti.com/ip/start-invicti-api", + "baseUrl": "https://{{credentials.instance_url}}", + "authConfig": { + "type": "custom", + "config": { + "setupInstructions": "1. Log in to your Invicti Platform\n2. Click your initials at the top right, then select User Settings\n3. Click API Key in the left menu\n4. Click Generate New API Key (or Copy if you already have one)\n5. Enter your Instance URL (e.g. platform.invicti.com for US region, platform-eu.invicti.com for EU, platform-ca.invicti.com for Canada)\n6. Paste your API key below", + "credentialFields": [ + { + "label": "Instance URL", + "type": "text", + "required": true, + "helpText": "Your Invicti platform URL without https:// (e.g. platform.invicti.com for US, platform-eu.invicti.com for EU, platform-ca.invicti.com for Canada, or your on-premises URL)." + }, + { + "label": "API Key", + "type": "password", + "required": true, + "helpText": "Your Invicti API key (JWT format). Found in User Settings > API Key." + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "invicti_app_availability", + "name": "Invicti App Availability", + "description": "Verifies the Invicti Platform API is accessible and the API key is valid.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "invicti_critical_vulnerabilities", + "name": "Invicti Critical Vulnerability Monitoring", + "description": "Checks for open critical-severity vulnerabilities across all scanned applications.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "invicti_scan_coverage", + "name": "Invicti Scan Coverage", + "description": "Verifies that all configured targets have been scanned and checks for stale scan coverage.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "invicti_scan_health", + "name": "Invicti Scan Health", + "description": "Monitors recent scan status for failed or aborted scans that may indicate configuration issues.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "invicti_sso_configuration", + "name": "Invicti SSO Configuration", + "description": "Verifies that SAML SSO is configured for the Invicti Platform to enforce centralized authentication.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "invicti_audit_logging", + "name": "Invicti Audit Logging", + "description": "Verifies that audit logging is active and capturing user and system activities.", + "defaultSeverity": "medium", + "enabled": true + } + ], + "checkCount": 6, + "isActive": true +} diff --git a/integrations-catalog/integrations/jira.json b/integrations-catalog/integrations/jira.json index 8a5305d6ce..e5961cc958 100644 --- a/integrations-catalog/integrations/jira.json +++ b/integrations-catalog/integrations/jira.json @@ -8,13 +8,19 @@ "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Go to https://id.atlassian.com/manage-profile/security/api-tokens\n2. Create an API token\n3. Encode: echo -n your@email.com:YOUR_API_TOKEN | base64\n4. Enter the result and your subdomain below", + "setupInstructions": "1. Go to https://id.atlassian.com/manage-profile/security/api-tokens\n2. Create an API token\n3. Enter your Atlassian email and the API token below, along with your Jira subdomain in each check", "credentialFields": [ { - "label": "Encoded API Token", + "label": "Email Address", + "type": "text", + "required": true, + "helpText": "The email address associated with your Atlassian account" + }, + { + "label": "API Token", "type": "password", "required": true, - "helpText": "Base64 encode your credentials: run echo -n your@email.com:YOUR_API_TOKEN | base64" + "helpText": "Create one at https://id.atlassian.com/manage-profile/security/api-tokens" } ] } diff --git a/integrations-catalog/integrations/kandji.json b/integrations-catalog/integrations/kandji.json index f2b07cc530..550c698cbb 100644 --- a/integrations-catalog/integrations/kandji.json +++ b/integrations-catalog/integrations/kandji.json @@ -1,13 +1,15 @@ { "slug": "kandji", - "name": "Kandji", - "description": "Monitor Kandji MDM device compliance and management for endpoint security", + "name": "Iru (formerly Kandji)", + "description": "Monitor Iru (formerly Kandji) MDM device compliance and management for endpoint security", "category": "Security", - "docsUrl": "https://support.kandji.io/support/solutions/articles/72000560541", + "docsUrl": "https://api-docs.kandji.io/", "baseUrl": "https://yoursubdomain.api.kandji.io/", "authConfig": { "type": "api_key", - "config": {} + "config": { + "setupInstructions": "## Setup Iru (formerly Kandji) Integration\n\n1. Sign in to your Iru (formerly Kandji) admin console\n2. Go to **Settings** then **Access** then **API Token**\n3. Click **Add token** and give it a name like \"CompAI Integration\"\n4. Grant the token the **Device List** permission\n5. Copy the API token (it is only shown once)\n6. Note your Iru (formerly Kandji) subdomain from your URL (e.g. \"mycompany\" from mycompany.clients.us-1.kandji.io)\n7. Paste the API token and subdomain into CompAI" + } }, "capabilities": [ "checks" @@ -18,14 +20,14 @@ { "slug": "kandji_secure_devices", "name": "Secure Devices", - "description": "Verifies Kandji-managed devices are compliant and have agents installed", + "description": "Verifies Iru-managed devices are compliant and have agents installed", "defaultSeverity": "high", "enabled": true }, { "slug": "kandji_device_list", "name": "Device List", - "description": "Lists all Kandji-managed devices for device inventory", + "description": "Lists all Iru-managed devices for device inventory", "defaultSeverity": "medium", "enabled": true } diff --git a/integrations-catalog/integrations/knowbe4.json b/integrations-catalog/integrations/knowbe4.json index d897ad78bf..2029eed415 100644 --- a/integrations-catalog/integrations/knowbe4.json +++ b/integrations-catalog/integrations/knowbe4.json @@ -44,8 +44,29 @@ "description": "Verifies employee access to KnowBe4 by checking account info and user count.", "defaultSeverity": "medium", "enabled": true + }, + { + "slug": "knowbe4_security_training", + "name": "KnowBe4 Security Awareness Training", + "description": "Checks training campaign completion rates across all active campaigns. Fails if overall completion rate is below 80%.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "knowbe4_phishing_simulation", + "name": "KnowBe4 Phishing Simulation", + "description": "Checks phishing simulation campaign results and reports the organization's phish-prone percentage.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "knowbe4_user_groups", + "name": "KnowBe4 User Group Management", + "description": "Verifies that users are organized into groups for targeted security awareness training.", + "defaultSeverity": "medium", + "enabled": true } ], - "checkCount": 2, + "checkCount": 5, "isActive": true } diff --git a/integrations-catalog/integrations/logicmonitor.json b/integrations-catalog/integrations/logicmonitor.json new file mode 100644 index 0000000000..e590c8ad1e --- /dev/null +++ b/integrations-catalog/integrations/logicmonitor.json @@ -0,0 +1,72 @@ +{ + "slug": "logicmonitor", + "name": "LogicMonitor", + "description": "LogicMonitor is an enterprise infrastructure monitoring platform that provides unified visibility across cloud, on-premises, and hybrid environments with automated monitoring, alerting, and AIOps capabilities.", + "category": "Monitoring", + "docsUrl": "https://www.logicmonitor.com/support/rest-api-developers-guide/overview/using-logicmonitors-rest-api", + "baseUrl": null, + "authConfig": { + "type": "custom", + "config": { + "setupInstructions": "1. Log in to your LogicMonitor portal.\n2. Go to Settings > Users and Roles.\n3. Select your user or create a new API-only user.\n4. Under the API Tokens tab, click Generate to create a new Bearer Token.\n5. Copy the Bearer token value and enter it above.\n6. Enter your LogicMonitor account name (the subdomain from your LogicMonitor URL).", + "credentialFields": [ + { + "label": "Account Name", + "type": "text", + "required": true, + "helpText": "Your LogicMonitor account name (the subdomain in your LogicMonitor URL, e.g. 'mycompany' from mycompany.logicmonitor.com)." + }, + { + "label": "API Bearer Token", + "type": "password", + "required": true, + "helpText": "Generate a Bearer token in LogicMonitor: Settings > Users and Roles > API Tokens. Copy the token value." + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "logicmonitor_app_availability", + "name": "LogicMonitor App Availability", + "description": "Verifies that the LogicMonitor API is reachable and the Bearer token is valid by making a lightweight API call.", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "logicmonitor_device_monitoring", + "name": "LogicMonitor Device Monitoring", + "description": "Lists monitored devices and verifies they are actively monitored with healthy status.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "logicmonitor_alert_management", + "name": "LogicMonitor Alert Management", + "description": "Checks for active critical and error alerts in LogicMonitor to assess current alert health.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "logicmonitor_collector_health", + "name": "LogicMonitor Collector Health", + "description": "Verifies that LogicMonitor collectors are registered and running, ensuring continuous monitoring data collection.", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "logicmonitor_alert_rules", + "name": "LogicMonitor Alert Rules", + "description": "Verifies that alert rules are configured in LogicMonitor to ensure proper alert routing and escalation.", + "defaultSeverity": "medium", + "enabled": true + } + ], + "checkCount": 5, + "isActive": true +} diff --git a/integrations-catalog/integrations/looker.json b/integrations-catalog/integrations/looker.json new file mode 100644 index 0000000000..d4af6970d2 --- /dev/null +++ b/integrations-catalog/integrations/looker.json @@ -0,0 +1,57 @@ +{ + "slug": "looker", + "name": "Looker", + "description": "Monitor Looker (Google Cloud) business intelligence platform for user access and role assignments. Verify instance availability and employee access controls.", + "category": "Productivity", + "docsUrl": "https://cloud.google.com/looker/docs/reference/looker-api/latest", + "baseUrl": "https://looker.com", + "authConfig": { + "type": "custom", + "config": { + "setupInstructions": "Setup steps:\n\n1. Log in to your Looker instance as an Admin\n2. Go to Admin > Users\n3. Select the user account to use for API access (or create a dedicated service account)\n4. Click Edit > API Keys > New API Key\n5. Copy the Client ID and Client Secret\n6. Enter your Looker instance URL (e.g., https://mycompany.looker.com)\n\nNote: The API user needs the 'Admin' role or at minimum 'see_users' and 'see_roles' permissions to list users and roles.", + "credentialFields": [ + { + "label": "Looker Instance URL", + "type": "text", + "required": true, + "helpText": "Your Looker instance URL (e.g., https://mycompany.looker.com or https://mycompany.cloud.looker.com). Do not include a trailing slash." + }, + { + "label": "API Client ID", + "type": "text", + "required": true, + "helpText": "The client_id from your Looker API3 credentials. Create API keys in Admin > Users > Edit User > API Keys." + }, + { + "label": "API Client Secret", + "type": "password", + "required": true, + "helpText": "The client_secret from your Looker API3 credentials." + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "looker_employee_access", + "name": "Employee Access", + "description": "Lists all Looker users and their assigned roles to verify access controls and role-based permissions.", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "looker_app_availability", + "name": "App Availability", + "description": "Verifies the Looker instance is accessible and API credentials are valid by authenticating and retrieving the current user profile.", + "defaultSeverity": "high", + "enabled": true + } + ], + "checkCount": 2, + "isActive": true +} diff --git a/integrations-catalog/integrations/microsoft-sentinel.json b/integrations-catalog/integrations/microsoft-sentinel.json new file mode 100644 index 0000000000..063c4d280a --- /dev/null +++ b/integrations-catalog/integrations/microsoft-sentinel.json @@ -0,0 +1,53 @@ +{ + "slug": "microsoft-sentinel", + "name": "Microsoft Sentinel", + "description": "Monitor analytics rules, incidents, and data connectors in Microsoft Sentinel (Azure's cloud-native SIEM/SOAR) to ensure threat detection and incident response are properly configured", + "category": "Monitoring", + "docsUrl": "https://learn.microsoft.com/en-us/rest/api/securityinsights/", + "baseUrl": "https://management.azure.com", + "authConfig": { + "type": "oauth2", + "config": { + "setupInstructions": "1. Register an app in Microsoft Entra ID (Azure Portal > App registrations)\n2. Under API permissions, add Azure Service Management > user_impersonation (delegated)\n3. Grant admin consent for the tenant\n4. Add a redirect URI matching your CompAI instance\n5. Note: The user authorizing must have at least Reader access to the Sentinel workspace", + "createAppUrl": "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade", + "scopes": [ + "https://management.azure.com/user_impersonation", + "offline_access", + "openid", + "profile" + ], + "clientAuthMethod": "body", + "supportsRefreshToken": true + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "sentinel_analytics_rules", + "name": "Analytics Rules Configuration", + "description": "Verifies that Microsoft Sentinel has analytics rules configured and enabled to detect threats and generate alerts", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "sentinel_incident_response", + "name": "Incident Tracking and Response", + "description": "Checks that security incidents in Microsoft Sentinel are being tracked, assigned to analysts, and resolved in a timely manner", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "sentinel_data_connectors", + "name": "Data Connector Status", + "description": "Verifies that Microsoft Sentinel data connectors are configured and actively ingesting security data from your environment", + "defaultSeverity": "high", + "enabled": true + } + ], + "checkCount": 3, + "isActive": true +} diff --git a/integrations-catalog/integrations/miradore.json b/integrations-catalog/integrations/miradore.json new file mode 100644 index 0000000000..539cac2bf8 --- /dev/null +++ b/integrations-catalog/integrations/miradore.json @@ -0,0 +1,71 @@ +{ + "slug": "miradore", + "name": "Miradore", + "description": "Miradore is a cross-platform Mobile Device Management (MDM) solution for managing Android, iOS, macOS, and Windows devices. This integration checks device inventory, compliance status, encryption, and OS update status.", + "category": "Infrastructure", + "docsUrl": "https://www.miradore.com/knowledge/integrations/miradore-api/", + "baseUrl": "https://online.miradore.com", + "authConfig": { + "type": "custom", + "config": { + "credentialFields": [ + { + "label": "Site Name", + "type": "text", + "required": true, + "helpText": "Your Miradore site name (the subdomain part of your Miradore URL, e.g., if your URL is https://online.miradore.com/mycompany, enter 'mycompany')" + }, + { + "label": "API Key", + "type": "password", + "required": true, + "helpText": "API authentication key generated from Miradore web console (System > Infrastructure diagram > Create key)" + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "miradore_app_availability", + "name": "App Availability", + "description": "Verifies connectivity to the Miradore API by fetching device data with a minimal query", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "miradore_device_list", + "name": "Device List", + "description": "Enumerates all managed devices in Miradore with platform, status, and model information", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "miradore_device_compliance", + "name": "Device Compliance", + "description": "Checks device security compliance across all platforms by verifying security configurations such as passcode presence, rooting/jailbreak status", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "miradore_encryption_status", + "name": "Encryption Status", + "description": "Checks disk encryption status on managed devices by examining storage and security encryption properties", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "miradore_os_update_status", + "name": "OS Update Status", + "description": "Checks whether managed devices have current operating system versions by examining OS inventory data", + "defaultSeverity": "medium", + "enabled": true + } + ], + "checkCount": 5, + "isActive": true +} diff --git a/integrations-catalog/integrations/okta.json b/integrations-catalog/integrations/okta.json index 70d32c8b8d..3cdb25ed74 100644 --- a/integrations-catalog/integrations/okta.json +++ b/integrations-catalog/integrations/okta.json @@ -51,8 +51,22 @@ "description": "Verifies that Okta groups are configured for role-based access management", "defaultSeverity": "medium", "enabled": true + }, + { + "slug": "okta_password_policy", + "name": "Password Policy", + "description": "Verifies that Okta password policies meet security requirements for length, complexity, and account lockout", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "okta_admin_audit", + "name": "Admin Role Audit", + "description": "Lists users with admin roles assigned and flags excessive admin privilege concentration", + "defaultSeverity": "medium", + "enabled": true } ], - "checkCount": 3, + "checkCount": 5, "isActive": true } diff --git a/integrations-catalog/integrations/power-bi.json b/integrations-catalog/integrations/power-bi.json new file mode 100644 index 0000000000..4090ef36d5 --- /dev/null +++ b/integrations-catalog/integrations/power-bi.json @@ -0,0 +1,57 @@ +{ + "slug": "power-bi", + "name": "Microsoft Power BI", + "description": "Business intelligence and analytics platform from Microsoft. Monitors workspace access, user roles, and service availability.", + "category": "Productivity", + "docsUrl": null, + "baseUrl": null, + "authConfig": { + "type": "custom", + "config": { + "setupInstructions": "Setup steps:\n\n1. Register an Azure AD application:\n - Go to Azure Portal > Microsoft Entra ID > App registrations > New registration\n - Name it (e.g., 'CompAI Power BI Integration')\n - Set supported account type to 'Accounts in this organizational directory only'\n - Click Register\n\n2. Create a Client Secret:\n - In your app, go to Certificates & secrets > New client secret\n - Copy the secret value immediately (it won't be shown again)\n\n3. Enable Service Principal for Power BI:\n - Go to Power BI Admin Portal > Tenant settings\n - Under 'Developer settings', enable 'Allow service principals to use Power BI APIs'\n - Add the service principal (or its security group) to the allowed group\n - Under 'Admin API settings', enable 'Allow service principals to use read-only admin APIs'\n - Add the service principal (or its security group) to the allowed group\n\n4. Copy the Tenant ID and Application (Client) ID from the app's Overview page.", + "credentialFields": [ + { + "label": "Tenant ID", + "type": "text", + "required": true, + "helpText": "Your Azure AD / Microsoft Entra ID tenant ID. Find it in Azure Portal > Microsoft Entra ID > Overview." + }, + { + "label": "Application (Client) ID", + "type": "text", + "required": true, + "helpText": "The Application (Client) ID from your registered Azure AD app." + }, + { + "label": "Client Secret", + "type": "password", + "required": true, + "helpText": "A client secret generated under Certificates & secrets for your Azure AD app." + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "power_bi_employee_access", + "name": "Employee Access", + "description": "Lists all users across Power BI workspaces with their access roles. Retrieves workspaces (groups) with expanded user membership to identify who has access and at what permission level (Admin, Member, Contributor, Viewer).", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "power_bi_app_availability", + "name": "App Availability", + "description": "Verifies the Power BI service is accessible by authenticating with the service principal and listing workspaces. Confirms the service is reachable and returning data.", + "defaultSeverity": "high", + "enabled": true + } + ], + "checkCount": 2, + "isActive": true +} diff --git a/integrations-catalog/integrations/sentinelone.json b/integrations-catalog/integrations/sentinelone.json index 4825c6a351..e814ea36cb 100644 --- a/integrations-catalog/integrations/sentinelone.json +++ b/integrations-catalog/integrations/sentinelone.json @@ -7,7 +7,9 @@ "baseUrl": "https://usea1.sentinelone.net/", "authConfig": { "type": "api_key", - "config": {} + "config": { + "setupInstructions": "1. Log in to your SentinelOne management console as an Admin.\n2. Navigate to Settings > Users, click your user name.\n3. In the API Token section, click Generate API Token.\n4. Copy the token.\n5. Enter your console URL (e.g. https://usea1.sentinelone.net) and the API token in CompAI." + } }, "capabilities": [ "checks" @@ -28,8 +30,29 @@ "description": "Lists all endpoints managed by SentinelOne for device inventory", "defaultSeverity": "medium", "enabled": true + }, + { + "slug": "s1_threat_detection", + "name": "Threat Detection", + "description": "Checks for unresolved threats in SentinelOne and flags critical or malicious incidents", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "s1_policy_enforcement", + "name": "Policy Enforcement", + "description": "Verifies that endpoint protection policies are configured across SentinelOne groups", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "s1_agent_updates", + "name": "Agent Version Currency", + "description": "Checks for outdated SentinelOne agent versions across the fleet and compares against available GA releases", + "defaultSeverity": "medium", + "enabled": true } ], - "checkCount": 2, + "checkCount": 5, "isActive": true } diff --git a/integrations-catalog/integrations/shopify.json b/integrations-catalog/integrations/shopify.json index 58e2aeade8..6b32e24ec3 100644 --- a/integrations-catalog/integrations/shopify.json +++ b/integrations-catalog/integrations/shopify.json @@ -4,23 +4,29 @@ "description": "Monitor Shopify e-commerce platform for store security, staff access, and payment compliance", "category": "Productivity", "docsUrl": "https://shopify.dev/docs/api/admin-rest", - "baseUrl": "https://myshopify.com/", + "baseUrl": "https://placeholder.myshopify.com", "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Log in to Shopify Admin at https://yourstore.myshopify.com/admin\n2. Go to Settings → Apps → Develop apps\n3. Create a custom app and enable required Admin API scopes\n4. Install the app to reveal the access token\n5. Enter your store URL and access token below", + "setupInstructions": "1. Go to the Shopify Dev Dashboard at https://dev.shopify.com/dashboard\n2. Click 'Create app' and select 'Start from Dev Dashboard'\n3. Name the app (e.g. 'CompAI') and click Create\n4. Go to the Versions tab, add the required scopes: read_products, read_orders, read_customers, read_themes, read_content (add read_users if you have Shopify Plus for staff access checks)\n5. Set your Webhooks API version and click Release\n6. Go to Home, scroll down, click 'Install app', select your store, and click Install\n7. Go to Settings and copy the Client ID and Client Secret\n8. Enter your store subdomain, Client ID, and Client Secret below\n\nNote: The Employee Access check requires Shopify Plus and the read_users scope (contact Shopify Plus Support to enable it). Non-Plus stores will still get the App Availability check.", "credentialFields": [ { - "label": "Store URL", + "label": "Store Subdomain", "type": "text", "required": true, - "helpText": "Your Shopify store URL (e.g. mystore.myshopify.com)" + "helpText": "Your Shopify store subdomain (e.g. 'mystore' from mystore.myshopify.com)" }, { - "label": "Admin API Access Token", + "label": "Client ID", + "type": "text", + "required": true, + "helpText": "Found in Dev Dashboard > Your App > Settings" + }, + { + "label": "Client Secret", "type": "password", "required": true, - "helpText": "Create a Custom App in Shopify Admin → Settings → Apps → Develop Apps, then install it to get the token" + "helpText": "Found in Dev Dashboard > Your App > Settings" } ] } @@ -34,14 +40,14 @@ { "slug": "shopify_employee_access", "name": "Employee Access", - "description": "Reviews Shopify staff accounts and their store access permissions", + "description": "Reviews Shopify staff accounts and their store access permissions (requires Shopify Plus with read_users scope)", "defaultSeverity": "high", "enabled": true }, { "slug": "shopify_app_availability", "name": "App Availability", - "description": "Verifies Shopify platform is operational", + "description": "Verifies Shopify platform is operational and SSL is enforced", "defaultSeverity": "low", "enabled": true } diff --git a/integrations-catalog/integrations/site24x7.json b/integrations-catalog/integrations/site24x7.json new file mode 100644 index 0000000000..75a33b7a8e --- /dev/null +++ b/integrations-catalog/integrations/site24x7.json @@ -0,0 +1,72 @@ +{ + "slug": "site24x7", + "name": "Site24x7", + "description": "Monitor Site24x7 infrastructure monitoring configuration including monitor health, alerts, SSL certificate monitoring, and notification profiles for compliance evidence", + "category": "Monitoring", + "docsUrl": "https://www.site24x7.com/help/api/", + "baseUrl": "https://www.site24x7.com", + "authConfig": { + "type": "custom", + "config": { + "setupInstructions": "1. Go to Zoho Developer Console (https://api-console.zoho.com for US, or your regional equivalent) and create a Self Client.\n2. Generate a grant token with scopes: Site24x7.Reports.Read, Site24x7.Admin.Read, Site24x7.Operations.Read.\n3. Exchange the grant token for an access token and refresh token via POST to https://accounts.zoho.com/oauth/v2/token.\n4. Enter the access token above.\n5. Enter your data center domain (e.g. site24x7.com for US, site24x7.eu for Europe).", + "credentialFields": [ + { + "label": "Zoho OAuth Access Token", + "type": "password", + "required": true, + "helpText": "Generate via Zoho Developer Console (Self Client). Requires scopes: Site24x7.Reports.Read, Site24x7.Admin.Read, Site24x7.Operations.Read. See https://www.site24x7.com/help/api/#authentication" + }, + { + "label": "Data Center Domain", + "type": "text", + "required": true, + "helpText": "Your Site24x7 data center domain. Options: site24x7.com (US), site24x7.eu (EU), site24x7.in (India), site24x7.cn (China), site24x7.net.au (Australia), site24x7.ca (Canada), site24x7.sa (Saudi Arabia). For Japan use app.site24x7.jp, UK use app.site24x7.uk, UAE use app.site24x7.ae." + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "site24x7_app_availability", + "name": "App Availability", + "description": "Verify that the Site24x7 API is reachable by retrieving the current status of all monitors", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "site24x7_monitor_health", + "name": "Monitoring & Alerting", + "description": "List all monitors and check their health status to ensure infrastructure monitoring is active", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "site24x7_alert_management", + "name": "Incident Response", + "description": "Check recent alert logs to verify that alerting is active and incidents are being tracked", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "site24x7_ssl_monitoring", + "name": "TLS / HTTPS", + "description": "Check that SSL/TLS certificate monitors are configured to track certificate expiry and validity", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "site24x7_notification_profiles", + "name": "Monitoring & Alerting", + "description": "Verify notification profiles are configured to ensure alerts are properly routed to the right contacts", + "defaultSeverity": "medium", + "enabled": true + } + ], + "checkCount": 5, + "isActive": true +} diff --git a/integrations-catalog/integrations/snyk.json b/integrations-catalog/integrations/snyk.json index 3b91c92fb4..70e1652d5d 100644 --- a/integrations-catalog/integrations/snyk.json +++ b/integrations-catalog/integrations/snyk.json @@ -38,8 +38,29 @@ "description": "Checks for critical and high severity open source vulnerabilities across monitored projects", "defaultSeverity": "high", "enabled": true + }, + { + "slug": "snyk_license_compliance", + "name": "License Compliance", + "description": "Checks for license policy violations across monitored open source dependencies", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "snyk_dependency_vulnerabilities", + "name": "Dependency Vulnerabilities", + "description": "Counts open vulnerabilities by severity and fails if any critical vulnerabilities exist", + "defaultSeverity": "critical", + "enabled": true + }, + { + "slug": "snyk_project_monitoring", + "name": "Project Monitoring", + "description": "Verifies that all Snyk projects have active monitoring enabled with recurring tests configured", + "defaultSeverity": "high", + "enabled": true } ], - "checkCount": 2, + "checkCount": 5, "isActive": true } diff --git a/integrations-catalog/integrations/tableau.json b/integrations-catalog/integrations/tableau.json new file mode 100644 index 0000000000..f7ab1248a8 --- /dev/null +++ b/integrations-catalog/integrations/tableau.json @@ -0,0 +1,63 @@ +{ + "slug": "tableau", + "name": "Tableau", + "description": "Tableau Cloud / Tableau Server - business intelligence and analytics platform by Salesforce. Monitors user access, site roles, and platform availability.", + "category": "Productivity", + "docsUrl": "https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api.htm", + "baseUrl": null, + "authConfig": { + "type": "custom", + "config": { + "setupInstructions": "Setup steps:\n\n1. Log in to your Tableau Cloud or Tableau Server site\n\n2. Create a Personal Access Token (PAT):\n - Click your profile icon in the top-right corner\n - Select 'Account Settings' (or 'My Account Settings')\n - Scroll to 'Personal Access Tokens'\n - Click 'Create a new token'\n - Enter a token name and click 'Create'\n - Important: Copy the token secret immediately - it is only shown once\n\n3. Find your Site URL:\n - Tableau Cloud: The URL in your browser, including the pod name (e.g., https://10ax.online.tableau.com)\n - Tableau Server: Your server address (e.g., https://tableau.yourcompany.com)\n\n4. Find your Site Name (Content URL):\n - Look at your browser URL after logging in\n - The site name appears after /#/site/ in the URL path\n - Example: For https://10ax.online.tableau.com/#/site/MySite, enter 'MySite'\n - For Tableau Server default site, leave this field empty\n\nNote: The PAT must belong to a user with Site Administrator or Server Administrator role to list all users.", + "credentialFields": [ + { + "label": "Site URL", + "type": "text", + "required": true, + "helpText": "Your Tableau Cloud or Tableau Server URL (e.g., https://10ax.online.tableau.com or https://tableau.yourcompany.com). Include the pod name for Tableau Cloud." + }, + { + "label": "Site Name (Content URL)", + "type": "text", + "required": true, + "helpText": "The content URL of your site. Found in your browser address bar after /#/site/. For example, if your URL is https://10ax.online.tableau.com/#/site/MySite, enter 'MySite'. For Tableau Server default site, leave empty." + }, + { + "label": "Personal Access Token Name", + "type": "text", + "required": true, + "helpText": "The name of your Personal Access Token (PAT). Generate one from Account Settings in Tableau Cloud or Tableau Server." + }, + { + "label": "Personal Access Token Secret", + "type": "password", + "required": true, + "helpText": "The secret value of your Personal Access Token. This is only shown once when you create the token - copy it immediately." + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "tableau_app_availability", + "name": "Tableau App Availability", + "description": "Verifies that the Tableau site is accessible by signing in with Personal Access Token credentials and querying site information.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "tableau_employee_access", + "name": "Tableau Employee Access", + "description": "Lists all site users and their site roles (Creator, Explorer, Viewer, Site Administrator, etc.) for access review and compliance monitoring.", + "defaultSeverity": "medium", + "enabled": true + } + ], + "checkCount": 2, + "isActive": true +} diff --git a/integrations-catalog/integrations/threatlocker.json b/integrations-catalog/integrations/threatlocker.json new file mode 100644 index 0000000000..88a39d44e5 --- /dev/null +++ b/integrations-catalog/integrations/threatlocker.json @@ -0,0 +1,79 @@ +{ + "slug": "threatlocker", + "name": "ThreatLocker", + "description": "ThreatLocker is a Zero Trust Endpoint Security platform providing application whitelisting, ringfencing, storage control, elevation control, and network access management.", + "category": "Security", + "docsUrl": "https://threatlocker.kb.help/api-documentation/", + "baseUrl": null, + "authConfig": { + "type": "custom", + "config": { + "setupInstructions": "1. Log in to the ThreatLocker Portal.\n2. Find your instance identifier: click the Help button (top right) and note the letter in parentheses next to 'ThreatLocker Access' (e.g., 'E').\n3. Generate an API Key: navigate to Users > API Keys, create a new key with at least 'View' permissions for Computers, Policies, Approvals, System Audit, and User Roles.\n4. Enter the instance identifier and API key in the fields above.", + "credentialFields": [ + { + "label": "Portal Instance", + "type": "text", + "required": true, + "helpText": "Your ThreatLocker portal instance identifier (e.g., 'e', 'a', 'b'). Find it in the ThreatLocker Portal under Help > ThreatLocker Access, shown in parentheses." + }, + { + "label": "API Key", + "type": "password", + "required": true, + "helpText": "Your ThreatLocker API Key. Generate one in the ThreatLocker Portal under Users > API Keys." + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "threatlocker_endpoint_protection", + "name": "Endpoint Protection Status", + "description": "Verifies that ThreatLocker-managed endpoints are operating in Secure mode with application control actively enforcing policies, not in Learning or Monitor-Only modes.", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "threatlocker_application_control", + "name": "Application Control Policies", + "description": "Verifies that application control (allowlisting) policies are configured and actively enforcing software restrictions across the organization.", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "threatlocker_approval_requests", + "name": "Approval Request Management", + "description": "Monitors pending approval requests to ensure blocked application requests are being reviewed and addressed in a timely manner.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "threatlocker_system_audit", + "name": "System Audit Logging", + "description": "Verifies that system audit logging is active and capturing administrative actions for compliance and security monitoring.", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "threatlocker_admin_roles", + "name": "Admin Access Control", + "description": "Verifies that administrative roles are configured in ThreatLocker, supporting role-based access control for portal management.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "threatlocker_login_security", + "name": "Portal Login Security", + "description": "Monitors portal login attempts to detect denied access attempts and ensure administrative access is secure.", + "defaultSeverity": "medium", + "enabled": true + } + ], + "checkCount": 6, + "isActive": true +} diff --git a/integrations-catalog/integrations/twilio.json b/integrations-catalog/integrations/twilio.json index 6d48afa4ff..2f5ad7b932 100644 --- a/integrations-catalog/integrations/twilio.json +++ b/integrations-catalog/integrations/twilio.json @@ -8,19 +8,19 @@ "authConfig": { "type": "custom", "config": { - "setupInstructions": "1. Go to https://console.twilio.com — copy your Account SID and Auth Token\n2. Encode credentials: base64('AccountSID:AuthToken')\n3. Enter both the encoded value and raw Account SID below", + "setupInstructions": "1. Go to https://console.twilio.com\n2. Copy your Account SID and Auth Token from the dashboard\n3. Paste both values below", "credentialFields": [ - { - "label": "Encoded Credentials", - "type": "password", - "required": true, - "helpText": "Base64 encode your Account SID and Auth Token: base64('AccountSID:AuthToken'). Use https://www.base64encode.org/" - }, { "label": "Account SID", "type": "text", "required": true, "helpText": "Your Twilio Account SID from the console dashboard" + }, + { + "label": "Auth Token", + "type": "password", + "required": true, + "helpText": "Your Twilio Auth Token from the console dashboard" } ] } diff --git a/integrations-catalog/integrations/unifi.json b/integrations-catalog/integrations/unifi.json new file mode 100644 index 0000000000..8f061af927 --- /dev/null +++ b/integrations-catalog/integrations/unifi.json @@ -0,0 +1,78 @@ +{ + "slug": "unifi", + "name": "UniFi", + "description": "Ubiquiti UniFi network management platform. Monitors hosts, sites, devices, firewall policies, and ISP connectivity through the official UniFi Site Manager and Network APIs.", + "category": "Infrastructure", + "docsUrl": "https://developer.ui.com/site-manager/v1.0.0/gettingstarted", + "baseUrl": null, + "authConfig": { + "type": "custom", + "config": { + "setupInstructions": "1. Sign in to the UniFi Site Manager at https://unifi.ui.com\n2. Go to the API section (or Settings > API Keys in EA)\n3. Click 'Create New API Key' and copy it\n4. For firewall checks: note your Console ID from the hosts list and Site ID from the sites list\n5. Paste the API key and optional IDs below", + "credentialFields": [ + { + "label": "API Key", + "type": "password", + "required": true, + "helpText": "Sign in to unifi.ui.com, navigate to API section, and create a new API key." + }, + { + "label": "Console ID (Host ID)", + "type": "text", + "required": false, + "helpText": "The ID of the UniFi console/host for firewall checks. Find it via the Site Manager API (GET /v1/hosts) or from the UniFi Site Manager URL." + }, + { + "label": "Site ID", + "type": "text", + "required": false, + "helpText": "The ID of the site for firewall checks. Find it via the Site Manager API (GET /v1/sites) or from the UniFi Site Manager URL." + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "unifi_app_availability", + "name": "App Availability", + "description": "Verifies that the UniFi Site Manager API is reachable and the API key is valid by listing hosts.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "unifi_device_list", + "name": "Device List", + "description": "Retrieves all UniFi devices managed by the account and verifies each device is online and adopted.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "unifi_production_firewall", + "name": "Production Firewall", + "description": "Checks that firewall policies are configured on the specified UniFi site via the Network API Cloud Connector.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "unifi_tls_https", + "name": "TLS/HTTPS", + "description": "Verifies that UniFi hosts are running with secure HTTPS connections by checking host reported state.", + "defaultSeverity": "medium", + "enabled": true + }, + { + "slug": "unifi_monitoring_alerting", + "name": "Monitoring and Alerting", + "description": "Verifies that ISP monitoring is active by checking that ISP metrics data exists for the account's sites.", + "defaultSeverity": "medium", + "enabled": true + } + ], + "checkCount": 5, + "isActive": true +} diff --git a/integrations-catalog/integrations/uptime-robot.json b/integrations-catalog/integrations/uptime-robot.json new file mode 100644 index 0000000000..1762a72c0c --- /dev/null +++ b/integrations-catalog/integrations/uptime-robot.json @@ -0,0 +1,37 @@ +{ + "slug": "uptime-robot", + "name": "Uptime Robot", + "description": "Monitor Uptime Robot uptime monitors and account accessibility for availability compliance", + "category": "Monitoring", + "docsUrl": "https://uptimerobot.com/api/v3/", + "baseUrl": "https://api.uptimerobot.com/v3", + "authConfig": { + "type": "api_key", + "config": { + "setupInstructions": "1. Log in to UptimeRobot at https://dashboard.uptimerobot.com\n2. Navigate to Integrations & API in the left sidebar\n3. Under the API section, create or copy your Main API Key or Read-Only API Key\n4. Paste the API key below" + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "uptime_robot_uptime_monitors", + "name": "Uptime Monitors", + "description": "Lists all Uptime Robot monitors and reports their current status (up/down/paused)", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "uptime_robot_app_availability", + "name": "App Availability", + "description": "Verifies Uptime Robot account is active and accessible via the API", + "defaultSeverity": "low", + "enabled": true + } + ], + "checkCount": 2, + "isActive": true +} diff --git a/integrations-catalog/integrations/verkada.json b/integrations-catalog/integrations/verkada.json new file mode 100644 index 0000000000..e8c09e77d0 --- /dev/null +++ b/integrations-catalog/integrations/verkada.json @@ -0,0 +1,58 @@ +{ + "slug": "verkada", + "name": "Verkada", + "description": "Monitor Verkada cloud-managed physical security platform for camera device inventory, firmware compliance, and alert configuration", + "category": "Security", + "docsUrl": "https://apidocs.verkada.com/reference/quick-start-guide", + "baseUrl": "https://api.verkada.com", + "authConfig": { + "type": "custom", + "config": { + "setupInstructions": "1. Sign in to Verkada Command at https://command.verkada.com\n2. Go to Organization Settings > API Keys (Organization Admin required)\n3. Click '+ New API Key' and name it (e.g. 'CompAI Integration')\n4. Select Read-Only permissions for Camera, Access Control, and Sensors\n5. Set an expiration date and click 'Generate Key'\n6. Copy the API Key immediately - it is only shown once\n7. Enter the API Key and your region's API Base URL below\n\nRegion URLs:\n- US (default): https://api.verkada.com\n- Europe: https://api.eu.verkada.com\n- Australia: https://api.au.verkada.com\n- GovCloud: https://api.verkadagov.com", + "credentialFields": [ + { + "label": "API Key", + "type": "password", + "required": true, + "helpText": "Found in Verkada Command > Organization Settings > API Keys. Requires Organization Admin permissions." + }, + { + "label": "API Base URL", + "type": "text", + "required": true, + "helpText": "US: https://api.verkada.com | EU: https://api.eu.verkada.com | AU: https://api.au.verkada.com | GovCloud: https://api.verkadagov.com" + } + ] + } + }, + "capabilities": [ + "checks" + ], + "supportsMultipleConnections": false, + "syncSupported": false, + "checks": [ + { + "slug": "verkada_device_list", + "name": "Device List", + "description": "Lists all cameras and devices in the Verkada organization and verifies they are online", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "verkada_secure_devices", + "name": "Secure Devices", + "description": "Checks that all Verkada camera firmware is up to date", + "defaultSeverity": "high", + "enabled": true + }, + { + "slug": "verkada_monitoring_alerting", + "name": "Monitoring and Alerting", + "description": "Verifies that Verkada camera alerts and notifications are being generated, indicating active monitoring", + "defaultSeverity": "high", + "enabled": true + } + ], + "checkCount": 3, + "isActive": true +} diff --git a/packages/db/prisma/migrations/20260421000000_add_soc3_to_trust/migration.sql b/packages/db/prisma/migrations/20260421000000_add_soc3_to_trust/migration.sql new file mode 100644 index 0000000000..f4f68b4ebf --- /dev/null +++ b/packages/db/prisma/migrations/20260421000000_add_soc3_to_trust/migration.sql @@ -0,0 +1,12 @@ +-- Add SOC 3 support to the trust portal: +-- * new `soc3` value on the `TrustFramework` enum (used by TrustResource) +-- * new `soc3` and `soc3_status` columns on the `Trust` model +-- +-- Safe to combine these in a single migration because `soc3_status` uses the +-- pre-existing `FrameworkStatus` enum — the newly-added `TrustFramework.soc3` +-- value is not referenced in any statement in this file. +ALTER TYPE "TrustFramework" ADD VALUE IF NOT EXISTS 'soc3'; + +ALTER TABLE "Trust" + ADD COLUMN "soc3" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN "soc3_status" "FrameworkStatus" NOT NULL DEFAULT 'started'; diff --git a/packages/db/prisma/migrations/20260427000000_pentest_credits/migration.sql b/packages/db/prisma/migrations/20260427000000_pentest_credits/migration.sql new file mode 100644 index 0000000000..a1efde66c7 --- /dev/null +++ b/packages/db/prisma/migrations/20260427000000_pentest_credits/migration.sql @@ -0,0 +1,42 @@ +-- Drop the legacy Stripe-coupled pentest tables. They are empty in +-- production and conflated three concerns (Stripe billing state, quota, +-- and org link). Replaced by `pentest_credits` (quota only) — Stripe +-- billing tables will be designed cross-product when v2 lands. +DROP TABLE IF EXISTS "pentest_subscriptions"; +DROP TABLE IF EXISTS "organization_billing"; + +-- Add `pentest` to the audit-log entity type enum so the existing +-- AuditLogInterceptor can classify pentest mutations (create / read / +-- delete) under their own entity type. +ALTER TYPE "AuditLogEntityType" ADD VALUE 'pentest'; + +-- Pentest credit wallet — one row per organization. +CREATE TABLE "pentest_credits" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('pcr'::text), + "organization_id" TEXT NOT NULL, + "balance" INTEGER NOT NULL DEFAULT 0, + "total_granted" INTEGER NOT NULL DEFAULT 0, + "total_consumed" INTEGER NOT NULL DEFAULT 0, + "last_grant_source" TEXT NOT NULL DEFAULT 'trial', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "pentest_credits_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "pentest_credits_organization_id_key" ON "pentest_credits"("organization_id"); +CREATE INDEX "pentest_credits_organization_id_idx" ON "pentest_credits"("organization_id"); + +ALTER TABLE "pentest_credits" + ADD CONSTRAINT "pentest_credits_organization_id_fkey" + FOREIGN KEY ("organization_id") REFERENCES "Organization"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- Backfill: every existing organization gets one trial credit. Generous +-- migration policy — orgs that already created pentests still get a fresh +-- credit, on the theory that "trial mode launching now" is the right +-- moment to grant universally. +INSERT INTO "pentest_credits" ("organization_id", "balance", "total_granted", "last_grant_source", "updated_at") +SELECT "id", 1, 1, 'trial', NOW() +FROM "Organization" +ON CONFLICT ("organization_id") DO NOTHING; diff --git a/packages/db/prisma/migrations/20260427120000_pentest_run_credit_refund/migration.sql b/packages/db/prisma/migrations/20260427120000_pentest_run_credit_refund/migration.sql new file mode 100644 index 0000000000..04a06a13c5 --- /dev/null +++ b/packages/db/prisma/migrations/20260427120000_pentest_run_credit_refund/migration.sql @@ -0,0 +1,6 @@ +-- Track when a run's credit has been refunded (e.g. on `pentest.failed` +-- or `pentest.cancelled` webhooks) so webhook redelivery cannot +-- double-credit the org. Nullable: a non-null value means "already +-- refunded for this run". +ALTER TABLE "security_penetration_test_runs" + ADD COLUMN "credit_refunded_at" TIMESTAMP(3); diff --git a/packages/db/prisma/migrations/20260429120000_pentest_credits_balance_check/migration.sql b/packages/db/prisma/migrations/20260429120000_pentest_credits_balance_check/migration.sql new file mode 100644 index 0000000000..501edeaace --- /dev/null +++ b/packages/db/prisma/migrations/20260429120000_pentest_credits_balance_check/migration.sql @@ -0,0 +1,19 @@ +-- Defense in depth. The atomic `updateMany WHERE balance > 0` in +-- PentestCreditsService.debitOrThrow already prevents negative balances +-- in normal operation, but a CHECK constraint guarantees the invariant +-- regardless of any future code path that mutates the wallet directly. +ALTER TABLE "pentest_credits" + ADD CONSTRAINT "pentest_credits_balance_nonneg" CHECK ("balance" >= 0); + +-- Drop the redundant single-column index on organization_id. The +-- `@unique` constraint on the same column already provides a btree +-- index — an additional index just consumes disk and adds write +-- amplification with no read benefit. +DROP INDEX IF EXISTS "pentest_credits_organization_id_idx"; + +-- Idempotency marker for `pentest.completed` webhook handling. Without +-- this, a redelivered completion event would write a duplicate +-- audit_log row each time. The atomic claim `updateMany WHERE +-- completed_audit_at IS NULL` short-circuits subsequent deliveries. +ALTER TABLE "security_penetration_test_runs" + ADD COLUMN "completed_audit_at" TIMESTAMP(3); diff --git a/packages/db/prisma/schema/organization-billing.prisma b/packages/db/prisma/schema/organization-billing.prisma deleted file mode 100644 index b6c610ae6a..0000000000 --- a/packages/db/prisma/schema/organization-billing.prisma +++ /dev/null @@ -1,12 +0,0 @@ -model OrganizationBilling { - id String @id @default(dbgenerated("generate_prefixed_cuid('obil'::text)")) - organizationId String @unique @map("organization_id") - stripeCustomerId String @map("stripe_customer_id") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - pentestSubscription PentestSubscription? - - @@map("organization_billing") -} diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma index ceadcd5655..a81b951fdd 100644 --- a/packages/db/prisma/schema/organization.prisma +++ b/packages/db/prisma/schema/organization.prisma @@ -60,9 +60,10 @@ model Organization { integrationOAuthApps IntegrationOAuthApp[] integrationSyncLogs IntegrationSyncLog[] - // Pentest Subscription - pentestSubscription PentestSubscription? - billing OrganizationBilling? + // Pentest credits — wallet of run-credits an org can spend. + // Source of credits (trial / future Stripe subscription / top-up) + // is metadata on the row; balance is unified. + pentestCredits PentestCredits? // Browser Automation browserbaseContext BrowserbaseContext? diff --git a/packages/db/prisma/schema/pentest-credits.prisma b/packages/db/prisma/schema/pentest-credits.prisma new file mode 100644 index 0000000000..9ee9fce98b --- /dev/null +++ b/packages/db/prisma/schema/pentest-credits.prisma @@ -0,0 +1,45 @@ +/// Pentest credit wallet — one row per organization, holding the org's +/// current quota for penetration test runs. +/// +/// `balance` is the operative number: decremented atomically when a run is +/// created, granted by trial bootstrap, future Stripe subscription renewals, +/// future top-up purchases, etc. The wallet does not differentiate by +/// source — credits are credits. The most recent grant source is recorded +/// for support visibility, not for spend logic. +/// +/// For a full audit trail of every grant/consume, a future +/// `pentest_credit_entries` ledger table can be layered in. v1 sticks with +/// running totals (`totalGranted` / `totalConsumed`) for simplicity. +model PentestCredits { + id String @id @default(dbgenerated("generate_prefixed_cuid('pcr'::text)")) + organizationId String @unique @map("organization_id") + + /// Spendable balance. Never negative. + /// Enforced both in code (atomic `updateMany WHERE balance > 0` in + /// PentestCreditsService.debitOrThrow) AND at the DB level via a + /// CHECK constraint added in migration + /// `20260429120000_pentest_credits_balance_check`. Prisma's schema + /// DSL doesn't currently support CHECK constraints, hence the + /// SQL-only migration. + balance Int @default(0) + + /// Lifetime totals — useful for analytics and "why do I have N credits?" + /// support questions without needing a full ledger. + totalGranted Int @default(0) @map("total_granted") + totalConsumed Int @default(0) @map("total_consumed") + + /// Where the most recent grant came from. Free-form string so v2 can add + /// new sources (`subscription`, `topup`, `promo`, `refund`, …) without a + /// schema change. `trial` is the v1 default. + lastGrantSource String @default("trial") @map("last_grant_source") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + // No explicit @@index([organizationId]) — `@unique` on organizationId + // already creates a btree index, and a duplicate would just consume + // disk + write amplification with no read benefit. + @@map("pentest_credits") +} diff --git a/packages/db/prisma/schema/pentest-subscription.prisma b/packages/db/prisma/schema/pentest-subscription.prisma deleted file mode 100644 index 791632998b..0000000000 --- a/packages/db/prisma/schema/pentest-subscription.prisma +++ /dev/null @@ -1,20 +0,0 @@ -model PentestSubscription { - id String @id @default(dbgenerated("generate_prefixed_cuid('psub'::text)")) - organizationId String @unique @map("organization_id") - organizationBillingId String @unique @map("organization_billing_id") - stripeSubscriptionId String @map("stripe_subscription_id") - stripePriceId String @map("stripe_price_id") - stripeOveragePriceId String? @map("stripe_overage_price_id") - status String @default("active") // active | cancelled | past_due - includedRunsPerPeriod Int @default(3) @map("included_runs_per_period") - currentPeriodStart DateTime @map("current_period_start") - currentPeriodEnd DateTime @map("current_period_end") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - organizationBilling OrganizationBilling @relation(fields: [organizationBillingId], references: [id]) - - @@index([organizationId]) - @@map("pentest_subscriptions") -} diff --git a/packages/db/prisma/schema/security-penetration-test-run.prisma b/packages/db/prisma/schema/security-penetration-test-run.prisma index 8a2ee79548..a4cfa455b6 100644 --- a/packages/db/prisma/schema/security-penetration-test-run.prisma +++ b/packages/db/prisma/schema/security-penetration-test-run.prisma @@ -5,6 +5,19 @@ model SecurityPenetrationTestRun { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + /// Set the first time we refund this run's credit (e.g. on + /// `pentest.failed` / `pentest.cancelled` webhooks). Used to make the + /// refund idempotent — webhook redelivery cannot double-credit because + /// the second attempt sees a non-null value here. + creditRefundedAt DateTime? @map("credit_refunded_at") + + /// Set the first time we write a `pentest_completed` audit-log entry + /// for this run. Webhook redelivery would otherwise create duplicate + /// rows in `audit_log` because Maced retries `pentest.completed` on + /// transient delivery failures. The atomic claim on this column + /// guarantees one audit row per run regardless of retry count. + completedAuditAt DateTime? @map("completed_audit_at") + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) @@unique([providerRunId]) diff --git a/packages/db/prisma/schema/shared.prisma b/packages/db/prisma/schema/shared.prisma index 75a5c0e14e..279e6a5b6d 100644 --- a/packages/db/prisma/schema/shared.prisma +++ b/packages/db/prisma/schema/shared.prisma @@ -53,6 +53,7 @@ enum AuditLogEntityType { integration trust finding + pentest } enum EvidenceFormType { diff --git a/packages/db/prisma/schema/trust.prisma b/packages/db/prisma/schema/trust.prisma index 1bc5dc82ef..5edcb4075a 100644 --- a/packages/db/prisma/schema/trust.prisma +++ b/packages/db/prisma/schema/trust.prisma @@ -17,6 +17,7 @@ model Trust { soc2 Boolean @default(false) soc2type1 Boolean @default(false) soc2type2 Boolean @default(false) + soc3 Boolean @default(false) iso27001 Boolean @default(false) iso42001 Boolean @default(false) nen7510 Boolean @default(false) @@ -28,6 +29,7 @@ model Trust { soc2_status FrameworkStatus @default(started) soc2type1_status FrameworkStatus @default(started) soc2type2_status FrameworkStatus @default(started) + soc3_status FrameworkStatus @default(started) iso27001_status FrameworkStatus @default(started) iso42001_status FrameworkStatus @default(started) nen7510_status FrameworkStatus @default(started) @@ -68,6 +70,7 @@ enum TrustFramework { hipaa soc2_type1 soc2_type2 + soc3 pci_dss nen_7510 iso_9001 diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 3f5dab459c..c637943536 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -12022,6 +12022,7 @@ "hipaa", "soc2_type1", "soc2_type2", + "soc3", "pci_dss", "nen_7510", "iso_9001" @@ -23568,6 +23569,7 @@ "hipaa", "soc2_type1", "soc2_type2", + "soc3", "pci_dss", "nen_7510", "iso_9001" @@ -23609,6 +23611,7 @@ "hipaa", "soc2_type1", "soc2_type2", + "soc3", "pci_dss", "nen_7510", "iso_9001" @@ -23651,6 +23654,7 @@ "hipaa", "soc2_type1", "soc2_type2", + "soc3", "pci_dss", "nen_7510", "iso_9001"