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
@@ -0,0 +1,5 @@
---
"@cdot65/prisma-airs-cli": patch
---

Interpolate known Mustache-style severity placeholders (`{{CRITICAL_RISK}}`, `{{HIGH_RISK}}`, `{{MEDIUM_RISK}}`, `{{LOW_RISK}}`, `{{INFORMATIONAL_RISK}}`) in the `airs redteam report` summary text. The upstream report renderer leaks these tokens uninterpolated; the CLI now maps them to readable strings (e.g. `high risk`) at the SDK normalizer boundary. Unknown `{{...}}` tokens are left intact so future leaks remain visible.
26 changes: 25 additions & 1 deletion src/airs/redteam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,30 @@ function normalizeJob(raw: Record<string, unknown>): RedTeamJob {
};
}

/**
* Map known Mustache-style severity tokens in static-report summaries to
* readable strings. Upstream's report renderer occasionally ships placeholders
* like `{{HIGH_RISK}}` un-interpolated. Unknown `{{...}}` tokens are left
* intact so future upstream additions remain visible instead of silently
* stripped.
*/
const REPORT_SUMMARY_TOKENS: Record<string, string> = {
'{{CRITICAL_RISK}}': 'critical risk',
'{{HIGH_RISK}}': 'high risk',
'{{MEDIUM_RISK}}': 'medium risk',
'{{LOW_RISK}}': 'low risk',
'{{INFORMATIONAL_RISK}}': 'informational risk',
};

export function interpolateReportSummary<T extends string | null | undefined>(summary: T): T {
if (summary == null || summary === '') return summary;
let out = summary as string;
for (const [token, replacement] of Object.entries(REPORT_SUMMARY_TOKENS)) {
if (out.includes(token)) out = out.split(token).join(replacement);
}
return out as T;
}

/**
* Drop noisy "you didn't opt in" fields from `target_metadata` when the
* corresponding feature is disabled. `multi_turn_error_message` always comes
Expand Down Expand Up @@ -388,7 +412,7 @@ export class SdkRedTeamService implements RedTeamService {
successful: (s.successful ?? 0) as number,
failed: (s.failed ?? 0) as number,
})),
reportSummary: raw.report_summary as string | null | undefined,
reportSummary: interpolateReportSummary(raw.report_summary as string | null | undefined),
categories: subCategories.map((sc) => {
const successful = (sc.successful ?? 0) as number;
const failed = (sc.failed ?? 0) as number;
Expand Down
55 changes: 54 additions & 1 deletion tests/unit/airs/redteam.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SdkRedTeamService } from '../../../src/airs/redteam.js';
import { interpolateReportSummary, SdkRedTeamService } from '../../../src/airs/redteam.js';

const mockTargetsList = vi.fn();
const mockTargetsGet = vi.fn();
Expand Down Expand Up @@ -409,6 +409,59 @@ describe('SdkRedTeamService', () => {
expect(result.categories).toEqual([]);
expect(result.severityBreakdown).toEqual([]);
});

it('interpolates {{HIGH_RISK}} in reportSummary', async () => {
mockReportsGetStaticReport.mockResolvedValue({
score: 83.63,
asr: 0.823,
severity_report: { stats: [] },
report_summary:
'The application has {{HIGH_RISK}} with an overall Risk Score of 83.63/100.',
});

const result = await service.getStaticReport('job-1');
expect(result.reportSummary).toBe(
'The application has high risk with an overall Risk Score of 83.63/100.',
);
expect(result.reportSummary).not.toContain('{{HIGH_RISK}}');
});
});

describe('interpolateReportSummary', () => {
it.each([
['{{CRITICAL_RISK}}', 'critical risk'],
['{{HIGH_RISK}}', 'high risk'],
['{{MEDIUM_RISK}}', 'medium risk'],
['{{LOW_RISK}}', 'low risk'],
['{{INFORMATIONAL_RISK}}', 'informational risk'],
])('maps %s to "%s"', (token, replacement) => {
const input = `The application has ${token} overall.`;
expect(interpolateReportSummary(input)).toBe(`The application has ${replacement} overall.`);
});

it('replaces every occurrence, not just the first', () => {
const input = '{{HIGH_RISK}} and {{HIGH_RISK}} again and {{LOW_RISK}}.';
expect(interpolateReportSummary(input)).toBe('high risk and high risk again and low risk.');
});

it('leaves unknown placeholders intact so future leaks remain visible', () => {
const input = 'has {{FUTURE_THING}} and {{HIGH_RISK}}';
expect(interpolateReportSummary(input)).toBe('has {{FUTURE_THING}} and high risk');
});

it('returns input unchanged when no placeholders are present', () => {
const input = 'plain summary with no tokens at all.';
expect(interpolateReportSummary(input)).toBe(input);
});

it('passes through null and undefined safely', () => {
expect(interpolateReportSummary(null)).toBeNull();
expect(interpolateReportSummary(undefined)).toBeUndefined();
});

it('handles empty string', () => {
expect(interpolateReportSummary('')).toBe('');
});
});

describe('getCustomReport', () => {
Expand Down
Loading