Skip to content

Commit 7485b03

Browse files
tofikwestclaude
andcommitted
fix(pentest): cap the composed additionalContext briefing at 20k chars
The provider has no documented limit (verified against its OpenAPI spec and prompt-injection source), but the composed briefing (user context + all stored notes for a target) was unbounded. Cap at 20,000 chars by dropping whole notes — never cutting one mid-sentence — with an explicit '(N more notes omitted for length)' marker so the agent knows the list is incomplete. User-typed context is always kept (DTO-capped at 4000). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9bf3bb1 commit 7485b03

2 files changed

Lines changed: 78 additions & 15 deletions

File tree

apps/api/src/security-penetration-tests/finding-context.util.spec.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
buildAdditionalContext,
3+
MAX_ADDITIONAL_CONTEXT_LENGTH,
34
normalizeTargetUrl,
45
} from './finding-context.util';
56

@@ -92,4 +93,38 @@ describe('buildAdditionalContext', () => {
9293
expect(userIndex).toBeGreaterThanOrEqual(0);
9394
expect(notesIndex).toBeGreaterThan(userIndex);
9495
});
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+
});
95130
});

apps/api/src/security-penetration-tests/finding-context.util.ts

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,21 @@ export function normalizeTargetUrl(value: string): string {
3737
}
3838
}
3939

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+
4055
/**
4156
* Composes the `additionalContext` string sent to the pentest provider on
4257
* run creation: the user's free-text context for this run (if any) followed
@@ -47,24 +62,37 @@ export function buildAdditionalContext(params: {
4762
userProvidedContext?: string;
4863
findingContexts: FindingContextNote[];
4964
}): string | undefined {
50-
const sections: string[] = [];
65+
const userSection = params.userProvidedContext?.trim();
5166

52-
const userProvided = params.userProvidedContext?.trim();
53-
if (userProvided) {
54-
sections.push(userProvided);
67+
if (params.findingContexts.length === 0) {
68+
return userSection || undefined;
5569
}
5670

57-
if (params.findingContexts.length > 0) {
58-
const header =
59-
'Customer-provided context on findings reported in previous scans of this target. ' +
60-
'Take it into account when validating and reporting findings (e.g. behavior that is ' +
61-
'accepted by design, or issues the customer has since remediated):';
62-
const lines = params.findingContexts.map(
63-
(note, index) =>
64-
`${index + 1}. "${note.issueTitle.trim()}": ${note.context.trim()}`,
65-
);
66-
sections.push([header, ...lines].join('\n'));
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;
6795
}
6896

69-
return sections.length > 0 ? sections.join('\n\n') : undefined;
97+
return compose(included);
7098
}

0 commit comments

Comments
 (0)