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
109 changes: 109 additions & 0 deletions apps/api/src/cloud-security/ai-remediation.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,3 +399,112 @@ describe('AiRemediationService.refineStepFromError', () => {
expect(callArgs.temperature).toBe(0);
});
});

describe('AiRemediationService.generateManualSteps', () => {
const generateObjectMock = generateObject as unknown as jest.Mock;

const finding = {
title: 'No CloudTrail trails configured',
description: 'Account has no active CloudTrail trails.',
severity: 'high',
resourceType: 'AwsAccount',
resourceId: 'account-level',
remediation: 'Create a multi-region trail with log file validation.',
findingKey: 'cloudtrail-no-trails',
evidence: { awsAccountId: '123456789012', region: 'us-east-1' },
};

beforeEach(() => {
generateObjectMock.mockReset();
});

it('returns AI-generated guidedSteps + reason in the happy path', async () => {
generateObjectMock.mockResolvedValueOnce({
object: {
guidedSteps: [
'Open AWS Console → CloudTrail → Trails.',
'Click "Create trail" and name it compai-cloudtrail.',
'Enable multi-region and log file validation, then Save.',
],
reason:
'Auto-fix could not generate valid create-trail params for this account.',
},
});

const result = await new AiRemediationService().generateManualSteps({
finding,
failureReason: 'Required param "S3BucketName" is missing or empty',
});

expect(result.guidedSteps).toHaveLength(3);
expect(result.guidedSteps[0]).toMatch(/CloudTrail/);
expect(result.reason).toMatch(/Auto-fix could not/);
});

it('falls back to the adapter remediation text when the AI call throws', async () => {
// Hard guarantee: even if the AI is down, the customer must see
// SOMETHING actionable instead of a raw error.
generateObjectMock.mockRejectedValueOnce(new Error('AI provider down'));

const result = await new AiRemediationService().generateManualSteps({
finding,
failureReason: 'anything',
});

expect(result.guidedSteps).toEqual([
'Create a multi-region trail with log file validation.',
]);
expect(result.reason).toMatch(/Automatic fix is not available/i);
});

it('falls back to a generic step when there is no adapter remediation text either', async () => {
generateObjectMock.mockRejectedValueOnce(new Error('AI down'));

const result = await new AiRemediationService().generateManualSteps({
finding: { ...finding, remediation: null },
failureReason: 'x',
});

expect(result.guidedSteps).toHaveLength(1);
expect(result.guidedSteps[0]).toMatch(/AWS Console/i);
});

it('passes failing-step context to the model so manual steps reference the same resources', async () => {
generateObjectMock.mockResolvedValueOnce({
object: { guidedSteps: ['x'], reason: 'y' },
});

await new AiRemediationService().generateManualSteps({
finding,
failedPlan: {
canAutoFix: true,
risk: 'low',
description: 'd',
currentState: {},
proposedState: {},
requiredPermissions: [],
readSteps: [],
fixSteps: [
{
service: 's3',
command: 'CreateBucketCommand',
params: { Bucket: '' },
purpose: 'create log bucket',
},
],
rollbackSteps: [],
rollbackSupported: false,
requiresAcknowledgment: false,
},
failureReason: 'Required param "Bucket" is missing or empty',
});

const callArgs = generateObjectMock.mock.calls[0][0];
// The prompt must include both the failing reason AND the original
// command so the model can translate it into a real manual step,
// not just regurgitate the finding text.
expect(callArgs.prompt).toContain('Required param "Bucket" is missing');
expect(callArgs.prompt).toContain('s3:CreateBucketCommand');
expect(callArgs.prompt).toContain('account-level');
});
});
117 changes: 116 additions & 1 deletion apps/api/src/cloud-security/ai-remediation.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { generateObject } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';
import {
type FixPlan,
type PermissionFix,
Expand Down Expand Up @@ -28,9 +29,13 @@ import {
import { normalizeFixPlan } from './plan-normalizer';

const MODEL = anthropic('claude-opus-4-6');
// Cheaper, faster model for the manual-steps fallback. The output is pure
// natural language with no SDK-call shape to validate, so the strongest
// model is overkill — we just need clear instructions.
const FALLBACK_MODEL = anthropic('claude-sonnet-4-5');
const REMEDIATION_ROLE_NAME = 'CompAI-Remediator';

interface FindingContext {
export interface FindingContext {
title: string;
description: string | null;
severity: string | null;
Expand Down Expand Up @@ -328,6 +333,104 @@ INSTRUCTIONS:
}
}

/**
* Generate real, user-actionable manual steps when auto-fix cannot
* proceed for a finding. Called by the remediation service as a
* fallback so customers never see a raw "Fix could not be applied —
* <cryptic error>" — they get clear instructions instead.
*
* Inputs:
* - the finding (so the AI knows what's actually broken),
* - the fix plan we tried to apply (so steps reference the same
* resources / commands the customer expected),
* - the concrete failure reason from validation or AWS execution.
*
* Output: ordered list of steps that the customer can copy-paste
* into AWS Console / CLI to resolve the finding manually.
*
* Uses Sonnet for cost — this only fires on the failure path and
* the output is plain natural language. On AI failure, falls back
* to the finding's `remediation` text so the customer at least sees
* the adapter's pre-baked guidance instead of an error.
*/
async generateManualSteps(params: {
finding: FindingContext;
failedPlan?: FixPlan;
failureReason: string;
}): Promise<{ guidedSteps: string[]; reason: string }> {
const fallback = (): { guidedSteps: string[]; reason: string } => ({
guidedSteps: params.finding.remediation
? [params.finding.remediation]
: [
'Review the finding in your AWS Console and apply the recommended remediation manually.',
],
reason:
'Automatic fix is not available for this finding. Follow the guided steps to resolve it manually.',
});

try {
const stepsSummary = params.failedPlan?.fixSteps
? params.failedPlan.fixSteps
.map(
(s, i) =>
`${i + 1}. ${s.service}:${s.command} — ${s.purpose}`,
)
.join('\n')
: '(no fix steps were generated)';

const { object } = await generateObject({
model: FALLBACK_MODEL,
schema: z.object({
guidedSteps: z
.array(z.string())
.min(1)
.describe(
'Ordered list of clear, copy-pasteable manual instructions the customer can follow in AWS Console or CLI. Each step is one concrete action.',
),
reason: z
.string()
.describe(
'One-sentence summary of WHY automatic fix could not proceed — phrased for the customer, not for an engineer.',
),
}),
system:
'You are an AWS security expert writing manual remediation steps for a customer whose automatic fix failed. Be concrete: name exact services, exact resources, and exact actions. Prefer AWS Console clicks over CLI when the path is short, but include CLI commands when they are clearer. Never reference SDK class names. Never apologize. Never speculate about "if the issue persists" — just give the steps.',
prompt: `A finding could not be auto-remediated. Generate clear manual steps the customer can follow.

FINDING:
- title: ${params.finding.title}
- description: ${params.finding.description ?? '(none)'}
- severity: ${params.finding.severity ?? '(unknown)'}
- resource type: ${params.finding.resourceType}
- resource id: ${params.finding.resourceId}
- adapter remediation guidance: ${params.finding.remediation ?? '(none)'}
- evidence: ${JSON.stringify(params.finding.evidence ?? {}, null, 2)}

WHAT WE TRIED TO DO AUTOMATICALLY (do not just repeat — translate into customer-facing actions):
${stepsSummary}

WHY IT FAILED:
${params.failureReason}

Produce 3-8 ordered steps. Each step is a single concrete action the customer can perform in AWS Console or CLI. Reference the EXACT resource (${params.finding.resourceType} ${params.finding.resourceId}) and the EXACT region from evidence when relevant. End with a verification step so the customer knows they fixed it.`,
temperature: 0.2,
});

this.logger.log(
`Manual-steps fallback for ${params.finding.findingKey}: ${object.guidedSteps.length} step(s)`,
);
return {
guidedSteps: object.guidedSteps,
reason: object.reason,
};
} catch (err) {
this.logger.warn(
`Manual-steps AI call failed for ${params.finding.findingKey}; using adapter remediation as fallback. ${err instanceof Error ? err.message : String(err)}`,
);
return fallback();
}
}

// ─── GCP Methods ──────────────────────────────────────────────────────

async generateGcpFixPlan(finding: FindingContext): Promise<GcpFixPlan> {
Expand Down Expand Up @@ -556,6 +659,18 @@ const ACTIONABLE_PREFIXES = [
'Enable',
'Attach',
'Set',
'Authorize',
'Revoke',
'Allow',
'Deny',
'Disable',
'Detach',
'Add',
'Remove',
'Register',
'Deregister',
'Tag',
'Untag',
] as const;

/**
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/cloud-security/aws-command-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,11 @@ export function looksLikeValidationError(message: string): boolean {
lower.includes('must be a valid') ||
lower.includes('is required') ||
lower.includes('missing required') ||
lower.includes('must contain')
lower.includes('must contain') ||
lower.includes('missingparameter') ||
lower.includes('missing parameter') ||
lower.includes('parameter is required') ||
lower.includes('must specify')
);
}

Expand Down
Loading
Loading