Skip to content

Commit 425d112

Browse files
authored
Merge pull request #3111 from trycompai/main
[comp] Production Deploy
2 parents 33416ca + c60a1b8 commit 425d112

31 files changed

Lines changed: 2786 additions & 54 deletions

apps/api/src/security-penetration-tests/dto/create-penetration-test.dto.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import {
44
IsBoolean,
55
IsEnum,
66
IsOptional,
7+
IsString,
78
IsUrl,
9+
MaxLength,
810
} from 'class-validator';
911
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
1012

@@ -107,4 +109,15 @@ export class CreatePenetrationTestDto {
107109
@ArrayUnique()
108110
@IsEnum(pentestCheckValues, { each: true })
109111
checks?: PentestCheck[];
112+
113+
@ApiPropertyOptional({
114+
description:
115+
'Free-text context shared with the testing agent, e.g. remediation notes or accepted-by-design explanations from a previous run. Saved per-finding context notes for the same target are appended automatically. Max 4000 characters.',
116+
required: false,
117+
maxLength: 4000,
118+
})
119+
@IsOptional()
120+
@IsString()
121+
@MaxLength(4000)
122+
additionalContext?: string;
110123
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
3+
4+
export class UpsertFindingContextDto {
5+
@ApiProperty({
6+
description: 'Penetration test run ID the finding belongs to',
7+
example: 'pentest-abc123',
8+
})
9+
@IsString()
10+
@IsNotEmpty()
11+
runId!: string;
12+
13+
@ApiProperty({
14+
description:
15+
'Context for the finding, e.g. an accepted-by-design rationale or remediation details. Shared with the testing agent on future scans of the same target. Max 2000 characters.',
16+
example:
17+
'Read access to appConfiguration is accepted by design: the collection only holds non-secret bootstrap configuration and write access is restricted to privileged users.',
18+
maxLength: 2000,
19+
})
20+
@IsString()
21+
@IsNotEmpty()
22+
@MaxLength(2000)
23+
context!: string;
24+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import {
2+
buildAdditionalContext,
3+
MAX_ADDITIONAL_CONTEXT_LENGTH,
4+
normalizeTargetUrl,
5+
} from './finding-context.util';
6+
7+
describe('normalizeTargetUrl', () => {
8+
it('lowercases the host and strips trailing slashes', () => {
9+
expect(normalizeTargetUrl('https://App.Example.com/')).toBe(
10+
'https://app.example.com',
11+
);
12+
});
13+
14+
it('strips trailing slashes from paths but keeps the path itself', () => {
15+
expect(normalizeTargetUrl('https://app.example.com/portal/')).toBe(
16+
'https://app.example.com/portal',
17+
);
18+
});
19+
20+
it('drops URL fragments', () => {
21+
expect(normalizeTargetUrl('https://app.example.com/#section')).toBe(
22+
'https://app.example.com',
23+
);
24+
});
25+
26+
it('keeps query strings', () => {
27+
expect(normalizeTargetUrl('https://app.example.com/?env=staging')).toBe(
28+
'https://app.example.com/?env=staging',
29+
);
30+
});
31+
32+
it('preserves a trailing slash that belongs to a query value', () => {
33+
expect(
34+
normalizeTargetUrl('https://app.example.com/?next=/portal/'),
35+
).toBe('https://app.example.com/?next=/portal/');
36+
});
37+
38+
it('strips path trailing slashes while keeping the query intact', () => {
39+
expect(normalizeTargetUrl('https://app.example.com/app/?v=2')).toBe(
40+
'https://app.example.com/app?v=2',
41+
);
42+
});
43+
44+
it('returns non-URL input trimmed', () => {
45+
expect(normalizeTargetUrl(' not a url ')).toBe('not a url');
46+
});
47+
});
48+
49+
describe('buildAdditionalContext', () => {
50+
it('returns undefined when there is nothing to send', () => {
51+
expect(
52+
buildAdditionalContext({ findingContexts: [] }),
53+
).toBeUndefined();
54+
expect(
55+
buildAdditionalContext({
56+
userProvidedContext: ' ',
57+
findingContexts: [],
58+
}),
59+
).toBeUndefined();
60+
});
61+
62+
it('returns only the user-provided context when there are no notes', () => {
63+
expect(
64+
buildAdditionalContext({
65+
userProvidedContext: ' Focus on auth flows. ',
66+
findingContexts: [],
67+
}),
68+
).toBe('Focus on auth flows.');
69+
});
70+
71+
it('formats stored notes as a numbered list with titles', () => {
72+
const result = buildAdditionalContext({
73+
findingContexts: [
74+
{ issueTitle: 'appConfiguration read access', context: 'By design.' },
75+
{ issueTitle: 'Storage attachment access', context: 'Hardened.' },
76+
],
77+
});
78+
79+
expect(result).toContain('1. "appConfiguration read access": By design.');
80+
expect(result).toContain('2. "Storage attachment access": Hardened.');
81+
expect(result).toContain('Customer-provided context');
82+
});
83+
84+
it('places the user context before the stored notes', () => {
85+
const result = buildAdditionalContext({
86+
userProvidedContext: 'Retest of the May findings.',
87+
findingContexts: [{ issueTitle: 'Issue A', context: 'Fixed.' }],
88+
});
89+
90+
expect(result).toBeDefined();
91+
const userIndex = (result as string).indexOf('Retest of the May findings.');
92+
const notesIndex = (result as string).indexOf('1. "Issue A": Fixed.');
93+
expect(userIndex).toBeGreaterThanOrEqual(0);
94+
expect(notesIndex).toBeGreaterThan(userIndex);
95+
});
96+
97+
it('does not add an omission marker when everything fits', () => {
98+
const result = buildAdditionalContext({
99+
userProvidedContext: 'User intent.',
100+
findingContexts: [{ issueTitle: 'Issue A', context: 'Fixed.' }],
101+
});
102+
103+
expect(result).not.toContain('omitted for length');
104+
});
105+
106+
it('caps the composed briefing, keeping user context and marking omitted notes', () => {
107+
const findingContexts = Array.from({ length: 30 }, (_, i) => ({
108+
issueTitle: `Finding ${i + 1}`,
109+
context: 'x'.repeat(1900),
110+
}));
111+
112+
const result = buildAdditionalContext({
113+
userProvidedContext: 'User intent.',
114+
findingContexts,
115+
});
116+
117+
expect(result).toBeDefined();
118+
expect((result as string).length).toBeLessThanOrEqual(
119+
MAX_ADDITIONAL_CONTEXT_LENGTH,
120+
);
121+
expect(result).toContain('User intent.');
122+
expect(result).toContain('1. "Finding 1"');
123+
expect(result).toMatch(/\d+ more notes omitted for length/);
124+
// Whole notes are dropped, never cut: every included note line ends
125+
// with its full 1900-char body.
126+
const includedBodies = (result as string).match(/x{1900}/g) ?? [];
127+
expect(includedBodies.length).toBeGreaterThan(0);
128+
expect(result).not.toMatch(/x{1901,}/);
129+
});
130+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Pure helpers for pentest finding-context notes. Kept free of Nest/DB
3+
* imports so both the storage service and the run-creation path can use
4+
* them, and so they unit-test without mocks.
5+
*/
6+
7+
export interface FindingContextNote {
8+
issueTitle: string;
9+
context: string;
10+
}
11+
12+
/**
13+
* Canonical form of a scan target so notes written on a run match future
14+
* runs of the same target regardless of casing or a trailing slash
15+
* (`https://App.example.com/` ≡ `https://app.example.com`). Only the
16+
* path's trailing slashes are stripped — a `/` at the end of a query
17+
* value belongs to that value and must survive. Non-URL input is
18+
* returned trimmed — the create DTO already enforces a valid URL.
19+
*/
20+
export function normalizeTargetUrl(value: string): string {
21+
const trimmed = value.trim();
22+
try {
23+
const url = new URL(trimmed);
24+
url.hash = '';
25+
while (url.pathname.length > 1 && url.pathname.endsWith('/')) {
26+
url.pathname = url.pathname.slice(0, -1);
27+
}
28+
const normalized = url.toString();
29+
// URL always renders the bare root path with a trailing slash
30+
// (`https://x.com/`); drop it for origin-only targets so keys stay
31+
// in the `https://x.com` form.
32+
return url.pathname === '/' && !url.search
33+
? normalized.replace(/\/$/, '')
34+
: normalized;
35+
} catch {
36+
return trimmed;
37+
}
38+
}
39+
40+
/**
41+
* Budget for the composed briefing. The provider has no documented limit
42+
* (verified against its OpenAPI spec and prompt-injection source), but an
43+
* unbounded string composed from arbitrarily many notes shouldn't be sent
44+
* to an external API or an agent prompt. When over budget, whole notes
45+
* are dropped — never cut mid-sentence — and an explicit omission marker
46+
* tells the agent the list is incomplete.
47+
*/
48+
export const MAX_ADDITIONAL_CONTEXT_LENGTH = 20_000;
49+
50+
const NOTES_HEADER =
51+
'Customer-provided context on findings reported in previous scans of this target. ' +
52+
'Take it into account when validating and reporting findings (e.g. behavior that is ' +
53+
'accepted by design, or issues the customer has since remediated):';
54+
55+
/**
56+
* Composes the `additionalContext` string sent to the pentest provider on
57+
* run creation: the user's free-text context for this run (if any) followed
58+
* by the stored per-finding notes from previous scans of the same target.
59+
* Returns undefined when there is nothing to send.
60+
*/
61+
export function buildAdditionalContext(params: {
62+
userProvidedContext?: string;
63+
findingContexts: FindingContextNote[];
64+
}): string | undefined {
65+
const userSection = params.userProvidedContext?.trim();
66+
67+
if (params.findingContexts.length === 0) {
68+
return userSection || undefined;
69+
}
70+
71+
const noteLines = params.findingContexts.map(
72+
(note, index) =>
73+
`${index + 1}. "${note.issueTitle.trim()}": ${note.context.trim()}`,
74+
);
75+
76+
const compose = (includedCount: number): string => {
77+
const omitted = noteLines.length - includedCount;
78+
const lines = noteLines.slice(0, includedCount);
79+
if (omitted > 0) {
80+
lines.push(
81+
`(${omitted} more note${omitted === 1 ? '' : 's'} omitted for length — see the finding context notes in Comp AI)`,
82+
);
83+
}
84+
const sections = userSection ? [userSection] : [];
85+
sections.push([NOTES_HEADER, ...lines].join('\n'));
86+
return sections.join('\n\n');
87+
};
88+
89+
let included = noteLines.length;
90+
while (
91+
included > 0 &&
92+
compose(included).length > MAX_ADDITIONAL_CONTEXT_LENGTH
93+
) {
94+
included -= 1;
95+
}
96+
97+
return compose(included);
98+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// The controller's runtime import chain reaches `@db`, which instantiates
2+
// a Prisma client at module load. These tests never touch the database.
3+
jest.mock('@db', () => ({ db: {} }));
4+
5+
jest.mock('../auth/hybrid-auth.guard', () => ({
6+
HybridAuthGuard: class {
7+
canActivate() {
8+
return true;
9+
}
10+
},
11+
}));
12+
13+
// The real PermissionGuard transitively imports better-auth's ESM bundle,
14+
// which Jest cannot parse. The mock must keep PERMISSIONS_KEY's real value
15+
// ('required_permissions') because @RequirePermission writes its metadata
16+
// under it at class-definition time.
17+
jest.mock('../auth/permission.guard', () => ({
18+
PermissionGuard: class {
19+
canActivate() {
20+
return true;
21+
}
22+
},
23+
PERMISSIONS_KEY: 'required_permissions',
24+
}));
25+
26+
import { PERMISSIONS_KEY } from '../auth/permission.guard';
27+
import type { RequiredPermission } from '../auth/permission.guard';
28+
import { PentestFindingContextsController } from './pentest-finding-contexts.controller';
29+
import type { PentestFindingContextsService } from './pentest-finding-contexts.service';
30+
31+
describe('PentestFindingContextsController', () => {
32+
const listForTargetMock = jest.fn();
33+
const upsertMock = jest.fn();
34+
const removeMock = jest.fn();
35+
36+
const serviceMock = {
37+
listForTarget: listForTargetMock,
38+
upsert: upsertMock,
39+
remove: removeMock,
40+
} as unknown as jest.Mocked<PentestFindingContextsService>;
41+
42+
const controller = new PentestFindingContextsController(serviceMock);
43+
44+
beforeEach(() => {
45+
jest.clearAllMocks();
46+
});
47+
48+
it('lists context notes for a target', async () => {
49+
const expected = [{ id: 'ptfc_1', providerIssueId: 'issue_1' }];
50+
listForTargetMock.mockResolvedValueOnce(expected);
51+
52+
const response = await controller.list(
53+
'org_123',
54+
'https://app.example.com',
55+
);
56+
57+
expect(listForTargetMock).toHaveBeenCalledWith(
58+
'org_123',
59+
'https://app.example.com',
60+
);
61+
expect(response).toEqual(expected);
62+
});
63+
64+
it('upserts a context note for a finding', async () => {
65+
const expected = { id: 'ptfc_1', providerIssueId: 'issue_1' };
66+
upsertMock.mockResolvedValueOnce(expected);
67+
68+
const response = await controller.upsert('org_123', 'issue_1', {
69+
runId: 'run_123',
70+
context: 'Accepted by design.',
71+
});
72+
73+
expect(upsertMock).toHaveBeenCalledWith({
74+
organizationId: 'org_123',
75+
issueId: 'issue_1',
76+
runId: 'run_123',
77+
context: 'Accepted by design.',
78+
});
79+
expect(response).toEqual(expected);
80+
});
81+
82+
it('removes a context note for a finding', async () => {
83+
removeMock.mockResolvedValueOnce({ deleted: true });
84+
85+
const response = await controller.remove('org_123', 'issue_1');
86+
87+
expect(removeMock).toHaveBeenCalledWith({
88+
organizationId: 'org_123',
89+
issueId: 'issue_1',
90+
});
91+
expect(response).toEqual({ deleted: true });
92+
});
93+
94+
it('requires pentest permissions on every endpoint', () => {
95+
const requirements = (['list', 'upsert', 'remove'] as const).map(
96+
(method) =>
97+
Reflect.getMetadata(
98+
PERMISSIONS_KEY,
99+
PentestFindingContextsController.prototype[method],
100+
) as RequiredPermission[] | undefined,
101+
);
102+
103+
expect(requirements).toEqual([
104+
[{ resource: 'pentest', actions: ['read'] }],
105+
[{ resource: 'pentest', actions: ['update'] }],
106+
[{ resource: 'pentest', actions: ['update'] }],
107+
]);
108+
});
109+
});

0 commit comments

Comments
 (0)