Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
buildAdditionalContext,
MAX_ADDITIONAL_CONTEXT_LENGTH,
normalizeTargetUrl,
} from './finding-context.util';

Expand Down Expand Up @@ -92,4 +93,38 @@ describe('buildAdditionalContext', () => {
expect(userIndex).toBeGreaterThanOrEqual(0);
expect(notesIndex).toBeGreaterThan(userIndex);
});

it('does not add an omission marker when everything fits', () => {
const result = buildAdditionalContext({
userProvidedContext: 'User intent.',
findingContexts: [{ issueTitle: 'Issue A', context: 'Fixed.' }],
});

expect(result).not.toContain('omitted for length');
});

it('caps the composed briefing, keeping user context and marking omitted notes', () => {
const findingContexts = Array.from({ length: 30 }, (_, i) => ({
issueTitle: `Finding ${i + 1}`,
context: 'x'.repeat(1900),
}));

const result = buildAdditionalContext({
userProvidedContext: 'User intent.',
findingContexts,
});

expect(result).toBeDefined();
expect((result as string).length).toBeLessThanOrEqual(
MAX_ADDITIONAL_CONTEXT_LENGTH,
);
expect(result).toContain('User intent.');
expect(result).toContain('1. "Finding 1"');
expect(result).toMatch(/\d+ more notes omitted for length/);
// Whole notes are dropped, never cut: every included note line ends
// with its full 1900-char body.
const includedBodies = (result as string).match(/x{1900}/g) ?? [];
expect(includedBodies.length).toBeGreaterThan(0);
expect(result).not.toMatch(/x{1901,}/);
});
});
58 changes: 43 additions & 15 deletions apps/api/src/security-penetration-tests/finding-context.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ export function normalizeTargetUrl(value: string): string {
}
}

/**
* Budget for the composed briefing. The provider has no documented limit
* (verified against its OpenAPI spec and prompt-injection source), but an
* unbounded string composed from arbitrarily many notes shouldn't be sent
* to an external API or an agent prompt. When over budget, whole notes
* are dropped — never cut mid-sentence — and an explicit omission marker
* tells the agent the list is incomplete.
*/
export const MAX_ADDITIONAL_CONTEXT_LENGTH = 20_000;

const NOTES_HEADER =
'Customer-provided context on findings reported in previous scans of this target. ' +
'Take it into account when validating and reporting findings (e.g. behavior that is ' +
'accepted by design, or issues the customer has since remediated):';

/**
* Composes the `additionalContext` string sent to the pentest provider on
* run creation: the user's free-text context for this run (if any) followed
Expand All @@ -47,24 +62,37 @@ export function buildAdditionalContext(params: {
userProvidedContext?: string;
findingContexts: FindingContextNote[];
}): string | undefined {
const sections: string[] = [];
const userSection = params.userProvidedContext?.trim();

const userProvided = params.userProvidedContext?.trim();
if (userProvided) {
sections.push(userProvided);
if (params.findingContexts.length === 0) {
return userSection || undefined;
}

if (params.findingContexts.length > 0) {
const header =
'Customer-provided context on findings reported in previous scans of this target. ' +
'Take it into account when validating and reporting findings (e.g. behavior that is ' +
'accepted by design, or issues the customer has since remediated):';
const lines = params.findingContexts.map(
(note, index) =>
`${index + 1}. "${note.issueTitle.trim()}": ${note.context.trim()}`,
);
sections.push([header, ...lines].join('\n'));
const noteLines = params.findingContexts.map(
(note, index) =>
`${index + 1}. "${note.issueTitle.trim()}": ${note.context.trim()}`,
);

const compose = (includedCount: number): string => {
const omitted = noteLines.length - includedCount;
const lines = noteLines.slice(0, includedCount);
if (omitted > 0) {
lines.push(
`(${omitted} more note${omitted === 1 ? '' : 's'} omitted for length — see the finding context notes in Comp AI)`,
);
}
const sections = userSection ? [userSection] : [];
sections.push([NOTES_HEADER, ...lines].join('\n'));
return sections.join('\n\n');
};

let included = noteLines.length;
while (
included > 0 &&
compose(included).length > MAX_ADDITIONAL_CONTEXT_LENGTH
) {
included -= 1;
}

return sections.length > 0 ? sections.join('\n\n') : undefined;
return compose(included);
}
Loading