diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AuthorizationConsentField.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AuthorizationConsentField.tsx new file mode 100644 index 0000000000..3733d2963e --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/AuthorizationConsentField.tsx @@ -0,0 +1,54 @@ +import { Checkbox } from '@trycompai/design-system'; + +interface AuthorizationConsentFieldProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + errorMessage?: string; +} + +export function AuthorizationConsentField({ + checked, + onCheckedChange, + errorMessage, +}: AuthorizationConsentFieldProps) { + const hasError = Boolean(errorMessage); + const describedBy = hasError + ? 'pt-authorized-help pt-authorized-error' + : 'pt-authorized-help'; + + return ( + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.test.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.test.tsx index 519a2d3355..cb92aa75aa 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.test.tsx @@ -16,6 +16,16 @@ vi.mock('sonner', () => ({ toast: { error: vi.fn() }, })); +async function confirmAuthorization(user: ReturnType) { + await user.click( + screen.getByText('I own this target or have written authorization to test it.'), + ); +} + +function checkInput(label: RegExp) { + return screen.getByLabelText(label, { selector: 'input' }); +} + describe('CreateRunPanel', () => { beforeEach(() => { vi.clearAllMocks(); @@ -48,6 +58,7 @@ describe('CreateRunPanel', () => { render(); await user.type(screen.getByLabelText(/target url/i), 'app.example.com'); + await confirmAuthorization(user); await user.click(screen.getByRole('button', { name: /start scan/i })); await waitFor(() => { @@ -85,8 +96,7 @@ describe('CreateRunPanel', () => { render(); await user.click(screen.getByText('Quick')); - await user.click(screen.getByRole('button', { name: /customize scan/i })); - await user.click(screen.getByLabelText(/^xss$/i)); + await user.click(checkInput(/^xss$/i)); expect(screen.getByText('Custom · 11 min-25 min')).toBeInTheDocument(); expect(screen.queryByText(/based on/i)).not.toBeInTheDocument(); @@ -103,20 +113,20 @@ describe('CreateRunPanel', () => { render(); await user.click(screen.getByText('Quick')); - await user.click(screen.getByRole('button', { name: /customize scan/i })); - await user.click(validationLevel().getByLabelText(/safe proof/i)); - await user.click(screen.getByLabelText(/^xss$/i)); - await user.click(screen.getByLabelText(/^injection$/i)); - await user.click(screen.getByLabelText(/^authentication$/i)); - await user.click(screen.getByLabelText(/^authorization$/i)); - await user.click(screen.getByLabelText(/idor \/ bola/i)); - await user.click(screen.getByLabelText(/ssrf \/ xxe/i)); - await user.click(screen.getByLabelText(/^csrf$/i)); + await user.click(validationLevel().getByLabelText(/safe proof/i, { selector: 'input' })); + await user.click(checkInput(/^xss$/i)); + await user.click(checkInput(/^injection$/i)); + await user.click(checkInput(/^authentication$/i)); + await user.click(checkInput(/^authorization$/i)); + await user.click(checkInput(/^idor \/ bola$/i)); + await user.click(checkInput(/^ssrf \/ xxe$/i)); + await user.click(checkInput(/^csrf$/i)); expect(screen.getByText('Standard · 30-90 min')).toBeInTheDocument(); expect(screen.queryByText(/based on/i)).not.toBeInTheDocument(); await user.type(screen.getByLabelText(/target url/i), 'app.example.com'); + await confirmAuthorization(user); await user.click(screen.getByRole('button', { name: /start scan/i })); await waitFor(() => { @@ -142,12 +152,11 @@ describe('CreateRunPanel', () => { render(); await user.click(screen.getByText('Quick')); - await user.click(screen.getByRole('button', { name: /customize scan/i })); - await user.click(screen.getByLabelText(/^xss$/i)); + await user.click(checkInput(/^xss$/i)); expect(screen.getByText('Custom · 11 min-25 min')).toBeInTheDocument(); - await user.click(validationLevel().getByLabelText(/safe proof/i)); + await user.click(validationLevel().getByLabelText(/safe proof/i, { selector: 'input' })); expect(screen.getByText('Custom · 17 min-38 min')).toBeInTheDocument(); }); @@ -158,10 +167,9 @@ describe('CreateRunPanel', () => { render(); await user.click(screen.getByText('Quick')); - await user.click(screen.getByRole('button', { name: /customize scan/i })); - await user.click(screen.getByLabelText(/secrets & info disclosure/i)); - await user.click(screen.getByLabelText(/technology config/i)); - await user.click(screen.getByLabelText(/^discovery$/i)); + await user.click(checkInput(/^secrets & info disclosure$/i)); + await user.click(checkInput(/^technology config$/i)); + await user.click(checkInput(/^discovery$/i)); expect(screen.getByText('Custom · 5 min-5 min')).toBeInTheDocument(); }); @@ -172,13 +180,10 @@ describe('CreateRunPanel', () => { expect(screen.getByLabelText(/repository/i)).toBeInTheDocument(); }); - it('uses design-system radio styling for evidence options', async () => { - const user = userEvent.setup(); + it('uses design-system radio styling for evidence options', () => { render(); - await user.click(screen.getByRole('button', { name: /customize scan/i })); - - expect(validationLevel().getByLabelText(/safe proof/i).closest('label')).toHaveClass( + expect(validationLevel().getByLabelText(/safe proof/i, { selector: 'input' }).closest('label')).toHaveClass( 'has-[[data-checked]]:border-primary', 'has-[[data-checked]]:bg-primary/5', 'has-[[data-checked]]:text-primary', @@ -191,50 +196,51 @@ describe('CreateRunPanel', () => { render(); await user.click(screen.getByText('Quick')); - await user.click(screen.getByRole('button', { name: /customize scan/i })); - await user.click(screen.getByLabelText(/secrets & info disclosure/i)); - await user.click(screen.getByLabelText(/technology config/i)); - await user.click(screen.getByLabelText(/^discovery$/i)); - await user.click(screen.getByLabelText(/^xss$/i)); + await user.click(checkInput(/^secrets & info disclosure$/i)); + await user.click(checkInput(/^technology config$/i)); + await user.click(checkInput(/^discovery$/i)); + await user.click(checkInput(/^xss$/i)); - expect(screen.getByLabelText(/^discovery$/i)).toBeChecked(); + expect(checkInput(/^discovery$/i)).toBeChecked(); }); - it('shows report-only helper text', async () => { + it('shows report-only helper text when report_only is selected', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByText('Quick')); - await user.click(screen.getByRole('button', { name: /customize scan/i })); expect( - screen.getByText(/fastest and lowest risk/i), + screen.getByText(/findings are reported without exploitation/i), ).toBeInTheDocument(); }); - it('shows validation level choices and selected helper copy', async () => { + it('renders the Scan coverage panel open by default and exposes validation level options', async () => { const user = userEvent.setup(); render(); - expect(screen.getByText('Customize scan')).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: /customize scan/i })); - + expect(screen.getByText('Scan coverage')).toBeInTheDocument(); expect(screen.getByText('Validation level')).toBeInTheDocument(); - expect(validationLevel().getByLabelText(/report only/i)).toBeInTheDocument(); - expect(validationLevel().getByLabelText(/safe proof/i)).toBeInTheDocument(); - expect(validationLevel().getByLabelText(/impact proof/i)).toBeInTheDocument(); + expect(validationLevel().getByLabelText(/report only/i, { selector: 'input' })).toBeInTheDocument(); + expect(validationLevel().getByLabelText(/safe proof/i, { selector: 'input' })).toBeInTheDocument(); + expect(validationLevel().getByLabelText(/impact proof/i, { selector: 'input' })).toBeInTheDocument(); - await user.click(validationLevel().getByLabelText(/report only/i)); - expect(screen.getByText(/fastest and lowest risk/i)).toBeInTheDocument(); + await user.click(validationLevel().getByLabelText(/report only/i, { selector: 'input' })); + expect( + screen.getByText(/findings are reported without exploitation/i), + ).toBeInTheDocument(); - await user.click(validationLevel().getByLabelText(/safe proof/i)); - expect(screen.getByText(/balanced validation/i)).toBeInTheDocument(); + await user.click(validationLevel().getByLabelText(/safe proof/i, { selector: 'input' })); + expect( + screen.getByText(/findings are validated with non-destructive proofs/i), + ).toBeInTheDocument(); - await user.click(validationLevel().getByLabelText(/impact proof/i)); - expect(screen.getByText(/highest confidence, longer runtime/i)).toBeInTheDocument(); + await user.click(validationLevel().getByLabelText(/impact proof/i, { selector: 'input' })); + expect( + screen.getByText(/findings are validated with active exploitation/i), + ).toBeInTheDocument(); }); it('keeps preset cards concise without validation metadata', () => { @@ -245,7 +251,46 @@ describe('CreateRunPanel', () => { expect(screen.queryByText('Impact proof · 12 checks')).not.toBeInTheDocument(); }); - it('requires confirmation for Deep profile before submit', async () => { + it('blocks submit when authorization is not confirmed', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(async () => ({ id: 'never' })); + + render(); + + await user.type(screen.getByLabelText(/target url/i), 'app.example.com'); + await user.click(screen.getByRole('button', { name: /start scan/i })); + + await waitFor(() => { + expect( + screen.getByText(/confirm you own or are authorized to test this target/i), + ).toBeInTheDocument(); + }); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('exposes authorization error to assistive tech via aria-invalid and aria-describedby', async () => { + const user = userEvent.setup(); + + render(); + + const checkbox = screen.getByRole('checkbox', { name: /i own this target/i }); + expect(checkbox).toHaveAttribute('aria-invalid', 'false'); + expect(checkbox).toHaveAttribute('aria-describedby', 'pt-authorized-help'); + + await user.type(screen.getByLabelText(/target url/i), 'app.example.com'); + await user.click(screen.getByRole('button', { name: /start scan/i })); + + await waitFor(() => { + expect(checkbox).toHaveAttribute('aria-invalid', 'true'); + }); + expect(checkbox.getAttribute('aria-describedby')).toContain('pt-authorized-error'); + + const alert = screen.getByRole('alert'); + expect(alert).toHaveAttribute('id', 'pt-authorized-error'); + expect(alert).toHaveTextContent(/confirm you own or are authorized to test this target/i); + }); + + it('requires confirmation for impact-proof validation before submit', async () => { const user = userEvent.setup(); const onSubmit = vi.fn(async () => ({ id: 'run_deep' })); @@ -253,13 +298,17 @@ describe('CreateRunPanel', () => { await user.type(screen.getByLabelText(/target url/i), 'app.example.com'); await user.click(screen.getByText('Deep')); + await confirmAuthorization(user); await user.click(screen.getByRole('button', { name: /start scan/i })); expect(onSubmit).not.toHaveBeenCalled(); const dialog = screen.getByRole('alertdialog'); - expect(within(dialog).getByText(/confirm scan intensity/i)).toBeInTheDocument(); + expect(within(dialog).getByText(/confirm impact-proof scan/i)).toBeInTheDocument(); + expect( + within(dialog).getByText(/actively exploits findings/i), + ).toBeInTheDocument(); - await user.click(within(dialog).getByRole('button', { name: /start scan/i })); + await user.click(within(dialog).getByRole('button', { name: /run impact-proof scan/i })); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( @@ -270,6 +319,30 @@ describe('CreateRunPanel', () => { ); }); }); + + it('does not show the impact-proof modal for Standard profile', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(async () => ({ id: 'run_standard' })); + + render(); + + await user.type(screen.getByLabelText(/target url/i), 'app.example.com'); + await confirmAuthorization(user); + await user.click(screen.getByRole('button', { name: /start scan/i })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled(); + }); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + + it('exposes a tooltip trigger for each vulnerability check', () => { + render(); + + expect(screen.getByRole('button', { name: /about xss/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /about csrf/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /about business logic/i })).toBeInTheDocument(); + }); }); function validationLevel() { diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.tsx index 06ca221ed9..cbfbdbdd9a 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CreateRunPanel.tsx @@ -20,6 +20,7 @@ import { useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; +import { AuthorizationConsentField } from './AuthorizationConsentField'; import { CreateRunTargetFields } from './CreateRunTargetFields'; import { RunExpectationSummary } from './RunExpectationSummary'; import { ScanAdvancedOptions } from './ScanAdvancedOptions'; @@ -65,6 +66,11 @@ const createRunSchema = z.object({ ]), ) .min(1, 'Select at least one check.'), + authorized: z + .boolean() + .refine((value) => value === true, { + message: 'Confirm you own or are authorized to test this target.', + }), }); export type CreateRunForm = z.infer; @@ -78,7 +84,7 @@ export function CreateRunPanel({ quotaLabel = 'Plan', }: CreateRunPanelProps) { const router = useRouter(); - const [advancedOpen, setAdvancedOpen] = useState(false); + const [advancedOpen, setAdvancedOpen] = useState(true); const [confirmationOpen, setConfirmationOpen] = useState(false); const canCreate = balance === undefined ? true : balance > 0; const standardDefaults = scanProfiles.standard; @@ -91,11 +97,12 @@ export function CreateRunPanel({ scanDepth: standardDefaults.scanDepth, evidenceLevel: standardDefaults.evidenceLevel, checks: standardDefaults.checks, + authorized: false, }, }); - const selectedProfile = form.watch('selectedProfile'); const evidenceLevel = form.watch('evidenceLevel'); const checks = form.watch('checks'); + const authorized = form.watch('authorized'); const effectiveMode = useMemo( () => resolveEffectiveScanMode({ @@ -155,7 +162,7 @@ export function CreateRunPanel({ const submitScanDepth = resolvedMode === 'custom' ? values.scanDepth : scanProfiles[resolvedMode].scanDepth; - if (!confirmed && (submitScanDepth === 'deep' || values.evidenceLevel === 'impact_proof')) { + if (!confirmed && values.evidenceLevel === 'impact_proof') { setConfirmationOpen(true); return; } @@ -247,6 +254,17 @@ export function CreateRunPanel({ checksError={form.formState.errors.checks?.message} /> + + form.setValue('authorized', nextChecked, { + shouldDirty: true, + shouldValidate: true, + }) + } + errorMessage={form.formState.errors.authorized?.message} + /> +

Must be reachable from the scanner - localhost and private IPs are rejected. + External, unauthenticated scan — we don't log in.

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 index b270f32858..6a25f8a3a0 100644 --- 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 @@ -18,8 +18,9 @@ const STEPS = [ Icon: Link, }, { - title: 'Configure scope', - description: 'Optionally attach a public repository for deeper, code-aware coverage.', + title: 'Add code context', + description: + 'Optionally attach a public repository — we use it to write better remediation steps.', Icon: Settings, }, { diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/ScanAdvancedOptions.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/ScanAdvancedOptions.tsx index b94ea4f319..74e9da6d8a 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/ScanAdvancedOptions.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/ScanAdvancedOptions.tsx @@ -1,6 +1,21 @@ import type { EvidenceLevel, PentestCheck } from '@/lib/security/penetration-tests-client'; -import { Checkbox, RadioGroup, RadioGroupItem } from '@trycompai/design-system'; -import { allPentestChecks, checkLabels, evidenceLabels } from './scan-profiles'; +import { + Checkbox, + RadioGroup, + RadioGroupItem, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@trycompai/design-system'; +import { Information } from '@trycompai/design-system/icons'; +import { + allPentestChecks, + checkDescriptions, + checkLabels, + evidenceDescriptions, + evidenceLabels, +} from './scan-profiles'; interface ScanAdvancedOptionsProps { open: boolean; @@ -13,13 +28,6 @@ interface ScanAdvancedOptionsProps { const evidenceOptions: EvidenceLevel[] = ['report_only', 'safe_proof', 'impact_proof']; -const evidenceHelperText: Record = { - report_only: 'Fastest and lowest risk. Some findings may need manual confirmation.', - safe_proof: 'Balanced validation. Good default for production or staging targets.', - impact_proof: - 'Highest confidence, longer runtime, and stronger target interaction. Requires authorization.', -}; - export function ScanAdvancedOptions({ open, evidenceLevel, @@ -50,10 +58,10 @@ export function ScanAdvancedOptions({ > - Customize scan + Scan coverage - Evidence level and individual checks + What we'll check and how we'll validate it @@ -62,66 +70,81 @@ export function ScanAdvancedOptions({ {open && ( -
-
- - Validation level - - { - if (isEvidenceLevel(nextValue)) { - onEvidenceLevelChange(nextValue); - } - }} - aria-label="Validation level" - > -
- {evidenceOptions.map((option) => ( - - ))} -
-
-

- {evidenceHelperText[evidenceLevel]} -

-
+ +
+
+ + Validation level + + { + if (isEvidenceLevel(nextValue)) { + onEvidenceLevelChange(nextValue); + } + }} + aria-label="Validation level" + > +
+ {evidenceOptions.map((option) => ( + + ))} +
+
+

+ {evidenceDescriptions[evidenceLevel]} +

+
-
- - Checks - -
- {allPentestChecks.map((check) => { - const checked = checks.includes(check); - const disabled = check === 'discovery' && hasDiscoveryDependentChecks; +
+ + Checks + +
+ {allPentestChecks.map((check) => { + const checked = checks.includes(check); + const disabled = check === 'discovery' && hasDiscoveryDependentChecks; - return ( - - ); - })} -
-
-
+ return ( + + ); + })} +
+ +
+ )} ); diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/scan-profiles.ts b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/scan-profiles.ts index a44a71e987..ec4b03a09d 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/scan-profiles.ts +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/scan-profiles.ts @@ -63,12 +63,41 @@ export const checkLabels: Record = { business_logic: 'Business logic', }; +export const checkDescriptions: Record = { + discovery: 'Maps the target attack surface — endpoints, subdomains, and exposed services.', + secrets_info_disclosure: + 'Looks for leaked API keys, tokens, debug output, and exposed files like .git or .env.', + technology_config: + 'Checks for missing security headers, outdated libraries, and exposed admin panels.', + xss: 'Cross-site scripting — reflected, stored, and DOM-based variants.', + injection: 'SQL, NoSQL, command, and LDAP injection in user-controlled inputs.', + authentication: + 'Login flow weaknesses, weak password policies, and broken session handling.', + authorization: 'Privilege escalation and missing access controls between roles.', + idor_bola: + 'Insecure Direct Object References — whether one user can access another user\'s data by changing an ID.', + ssrf_xxe: 'Server-side request forgery and XML external-entity attacks.', + csrf: 'Cross-site request forgery — actions performed without user consent.', + race_conditions: + 'Concurrency bugs like double-spend and time-of-check vs time-of-use issues.', + business_logic: 'App-specific workflow abuse (e.g., skipping payment or approval steps).', +}; + export const evidenceLabels: Record = { report_only: 'Report only', safe_proof: 'Safe proof', impact_proof: 'Impact proof', }; +export const evidenceDescriptions: Record = { + report_only: + 'Findings are reported without exploitation. No active attack attempted. Fastest and safest — some findings may need manual confirmation.', + safe_proof: + 'Findings are validated with non-destructive proofs. Safe for production and staging. Recommended default.', + impact_proof: + 'Findings are validated with active exploitation attempts (no data destruction). May trigger WAF alerts or rate limits. Use on staging or with explicit owner approval.', +}; + const checkWeights: Record = { discovery: 5, secrets_info_disclosure: 5,