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
2 changes: 2 additions & 0 deletions apps/api/src/assistant-chat/assistant-chat.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { AssistantChatService } from './assistant-chat.service';
import { buildTools } from './assistant-chat-tools';
import type { AssistantChatMessage } from './assistant-chat.types';
import { RolesService } from '../roles/roles.service';
import { ASSISTANT_OPENAI_PROVIDER_OPTIONS } from './openai-options';

@ApiTags('Assistant Chat')
@Controller({ path: 'assistant-chat', version: '1' })
Expand Down Expand Up @@ -129,6 +130,7 @@ Important:
system: systemPrompt,
messages: await convertToModelMessages(messages),
tools,
providerOptions: ASSISTANT_OPENAI_PROVIDER_OPTIONS,
stopWhen: stepCountIs(5),
});

Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/assistant-chat/openai-options.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ASSISTANT_OPENAI_PROVIDER_OPTIONS } from './openai-options';

describe('ASSISTANT_OPENAI_PROVIDER_OPTIONS', () => {
it('disables stored Responses API item references for assistant chat', () => {
expect(ASSISTANT_OPENAI_PROVIDER_OPTIONS.openai.store).toBe(false);
});
});
7 changes: 7 additions & 0 deletions apps/api/src/assistant-chat/openai-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { OpenAIResponsesProviderOptions } from '@ai-sdk/openai';

export const ASSISTANT_OPENAI_PROVIDER_OPTIONS = {
openai: {
store: false,
} satisfies OpenAIResponsesProviderOptions,
};
26 changes: 18 additions & 8 deletions apps/api/src/cloud-security/ai-remediation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ export class AiRemediationService {
this.logger.log(
`AI plan for ${finding.findingKey}: canAutoFix=${object.canAutoFix}, risk=${object.risk}`,
);
return normalizeFixPlan(enrichEmptyState(object));
return normalizeFixPlan(enrichEmptyState(object), {
resourceId: finding.resourceId,
});
} catch (err) {
this.logger.error(
`AI plan failed: ${err instanceof Error ? err.message : String(err)}`,
Expand Down Expand Up @@ -101,13 +103,17 @@ Generate the complete fix plan with EXACT values from the real AWS state.`,
});

this.logger.log(`AI refined plan for ${params.finding.findingKey}`);
return normalizeFixPlan(enrichEmptyState(object));
return normalizeFixPlan(enrichEmptyState(object), {
resourceId: params.finding.resourceId,
});
} catch (err) {
this.logger.error(
`AI refine failed: ${err instanceof Error ? err.message : String(err)}`,
);
// Fall back to original plan
return normalizeFixPlan(enrichEmptyState(params.originalPlan));
return normalizeFixPlan(enrichEmptyState(params.originalPlan), {
resourceId: params.finding.resourceId,
});
}
}

Expand Down Expand Up @@ -252,7 +258,11 @@ OVERESTIMATE. Better to have 5 extra permissions than to miss one.`,
const neighbors = [
...params.planContext.readSteps.map((s) => ({ role: 'read', ...s })),
...params.planContext.fixSteps.map((s) => ({ role: 'fix', ...s })),
].filter((s) => s.command !== params.step.command || s.purpose !== params.step.purpose);
].filter(
(s) =>
s.command !== params.step.command ||
s.purpose !== params.step.purpose,
);

const { object } = await generateObject({
model: MODEL,
Expand Down Expand Up @@ -475,9 +485,7 @@ Generate the complete fix plan with EXACT values from the real Azure state.`,

const steps: string[] = [];
if (externalUri) {
steps.push(
`Open the resource in GCP Console: ${externalUri}`,
);
steps.push(`Open the resource in GCP Console: ${externalUri}`);
}
if (finding.remediation) {
// Split SCC remediation text into separate steps if it contains "More info:" or multiple sentences
Expand Down Expand Up @@ -579,7 +587,9 @@ function enrichEmptyState(plan: FixPlan): FixPlan {
const command = typeof step?.command === 'string' ? step.command : '';
const prefix = ACTIONABLE_PREFIXES.find((p) => command.startsWith(p));
if (!prefix) continue;
const resource = command.replace(/Command$/, '').replace(/^[A-Z][a-z]+/, '');
const resource = command
.replace(/Command$/, '')
.replace(/^[A-Z][a-z]+/, '');
const label = step.service ? `${step.service}:${resource}` : resource;
if (prefix === 'Create') {
if (!willCreate.includes(label)) willCreate.push(label);
Expand Down
76 changes: 72 additions & 4 deletions apps/api/src/cloud-security/aws-command-executor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,7 @@ describe('validatePlanSteps — REQUIRED_PARAMS', () => {
params: { AWSServiceName: 'config.amazonaws.com' },
}),
]);
expect(
errors.filter((e) => e.includes('AWSServiceName')),
).toHaveLength(0);
expect(errors.filter((e) => e.includes('AWSServiceName'))).toHaveLength(0);
});

it.each(['', null, undefined])(
Expand Down Expand Up @@ -84,6 +82,71 @@ describe('validatePlanSteps — REQUIRED_PARAMS', () => {
).toHaveLength(1);
});

it('reports a clear one-of error when security-group ingress revoke commands omit GroupId, GroupName, and rule IDs', () => {
const errors = validatePlanSteps([
step({
service: 'ec2',
command: 'RevokeSecurityGroupIngressCommand',
params: {
IpPermissions: [
{
IpProtocol: 'tcp',
FromPort: 22,
ToPort: 22,
IpRanges: [{ CidrIp: '0.0.0.0/0' }],
},
],
},
}),
]);

expect(errors).toEqual(
expect.arrayContaining([
'Step 1 (RevokeSecurityGroupIngressCommand): One of "GroupId" or "GroupName" or "SecurityGroupRuleIds" is required',
]),
);
});

it('allows security-group ingress commands when GroupId is present', () => {
const errors = validatePlanSteps([
step({
service: 'ec2',
command: 'RevokeSecurityGroupIngressCommand',
params: { GroupId: 'sg-0123abc' },
}),
]);

expect(errors.some((e) => /GroupId/.test(e))).toBe(false);
});

it('allows security-group revoke commands that use SecurityGroupRuleIds only', () => {
const errors = validatePlanSteps([
step({
service: 'ec2',
command: 'RevokeSecurityGroupIngressCommand',
params: { SecurityGroupRuleIds: ['sgr-0123abc'] },
}),
]);

expect(errors.some((e) => /GroupId|GroupName/.test(e))).toBe(false);
});

it('treats empty one-of arrays as missing values', () => {
const errors = validatePlanSteps([
step({
service: 'ec2',
command: 'RevokeSecurityGroupIngressCommand',
params: { SecurityGroupRuleIds: [] },
}),
]);

expect(errors).toEqual(
expect.arrayContaining([
'Step 1 (RevokeSecurityGroupIngressCommand): One of "GroupId" or "GroupName" or "SecurityGroupRuleIds" is required',
]),
);
});

it('does NOT apply required-param checks to commands not in REQUIRED_PARAMS', () => {
// PutBucketVersioningCommand isn't in REQUIRED_PARAMS — should pass
// even with no params (the AWS SDK will surface its own errors then).
Expand All @@ -101,7 +164,11 @@ describe('validatePlanSteps — REQUIRED_PARAMS', () => {

it('uses the step index in the error message so customers know which step is broken', () => {
const errors = validatePlanSteps([
step({ service: 's3', command: 'PutBucketVersioningCommand', params: { Bucket: 'b', VersioningConfiguration: { Status: 'Enabled' } } }),
step({
service: 's3',
command: 'PutBucketVersioningCommand',
params: { Bucket: 'b', VersioningConfiguration: { Status: 'Enabled' } },
}),
step({
service: 'iam',
command: 'CreateServiceLinkedRoleCommand',
Expand Down Expand Up @@ -139,6 +206,7 @@ describe('looksLikeValidationError', () => {
'Member must not be null',
'failed to satisfy constraint: Member must have length less than or equal to 64',
'Missing required parameter Bucket',
'The request must contain the parameter groupName or groupId',
'is required',
'must be a valid ARN',
])('detects %p as a validation-class error', (msg) => {
Expand Down
33 changes: 30 additions & 3 deletions apps/api/src/cloud-security/aws-command-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,13 @@ export const REQUIRED_PARAMS: Record<string, readonly string[]> = {
CreateTrailCommand: ['Name', 'S3BucketName'],
};

const REQUIRED_PARAM_ONE_OF: Record<string, readonly (readonly string[])[]> = {
AuthorizeSecurityGroupIngressCommand: [['GroupId', 'GroupName']],
RevokeSecurityGroupIngressCommand: [
['GroupId', 'GroupName', 'SecurityGroupRuleIds'],
],
};

function normalizeArnPartition(value: string, partition: AwsPartition): string {
if (partition === 'aws-us-gov') {
return value.replace(/\barn:aws:/g, 'arn:aws-us-gov:');
Expand Down Expand Up @@ -412,7 +419,8 @@ export function looksLikeValidationError(message: string): boolean {
lower.includes('invalid parameter') ||
lower.includes('must be a valid') ||
lower.includes('is required') ||
lower.includes('missing required')
lower.includes('missing required') ||
lower.includes('must contain')
);
}

Expand Down Expand Up @@ -533,9 +541,22 @@ export function validatePlanSteps(steps: AwsCommandStep[]): string[] {
if (required) {
for (const key of required) {
const value = step.params?.[key];
if (value === undefined || value === null || value === '') {
if (!hasRequiredParamValue(value)) {
errors.push(`${prefix}: Required param "${key}" is missing or empty`);
}
}
}

const oneOfGroups = REQUIRED_PARAM_ONE_OF[step.command];
if (oneOfGroups) {
for (const group of oneOfGroups) {
const hasAny = group.some((key) => {
const value = step.params?.[key];
return hasRequiredParamValue(value);
});
if (!hasAny) {
errors.push(
`${prefix}: Required param "${key}" is missing or empty`,
`${prefix}: One of "${group.join('" or "')}" is required`,
);
}
}
Expand All @@ -545,6 +566,12 @@ export function validatePlanSteps(steps: AwsCommandStep[]): string[] {
return errors;
}

function hasRequiredParamValue(value: unknown): boolean {
if (value === undefined || value === null || value === '') return false;
if (Array.isArray(value)) return value.length > 0;
return true;
}

export interface StepResult {
step: AwsCommandStep;
output: Record<string, unknown>;
Expand Down
31 changes: 31 additions & 0 deletions apps/api/src/cloud-security/manual-remediation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {
buildManualRemediationPreview,
isManualRemediation,
} from './manual-remediation';

describe('manual remediation helpers', () => {
it('detects remediation guidance that starts with the manual marker', () => {
expect(
isManualRemediation('[MANUAL] Cannot be auto-fixed. Recreate resource.'),
).toBe(true);
expect(isManualRemediation('Use rds:ModifyDBInstanceCommand')).toBe(false);
expect(isManualRemediation(null)).toBe(false);
});

it('builds a guided-only preview with no executable API calls', () => {
const preview = buildManualRemediationPreview({
remediation:
'[MANUAL] Cannot be auto-fixed. RDS encryption requires snapshot copy and restore.',
description: 'RDS instance is not encrypted.',
severity: 'high',
});

expect(preview.guidedOnly).toBe(true);
expect(preview.apiCalls).toEqual([]);
expect(preview.rollbackSupported).toBe(false);
expect(preview.risk).toBe('high');
expect(preview.guidedSteps).toEqual([
'Cannot be auto-fixed. RDS encryption requires snapshot copy and restore.',
]);
});
});
56 changes: 56 additions & 0 deletions apps/api/src/cloud-security/manual-remediation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const MANUAL_PREFIX = '[MANUAL]';

type ManualRemediationRisk = 'low' | 'medium' | 'high' | 'critical';

export interface ManualRemediationPreview {
currentState: Record<string, unknown>;
proposedState: Record<string, unknown>;
description: string;
risk: ManualRemediationRisk;
apiCalls: string[];
guidedOnly: true;
guidedSteps: string[];
rollbackSupported: false;
requiresAcknowledgment: undefined;
}

export function isManualRemediation(remediation?: string | null): boolean {
return remediation?.trim().startsWith(MANUAL_PREFIX) ?? false;
}

export function buildManualRemediationPreview(params: {
remediation: string;
description?: string | null;
severity?: string | null;
}): ManualRemediationPreview {
const guidance = params.remediation.trim().replace(MANUAL_PREFIX, '').trim();
const description =
guidance ||
params.description ||
'This finding requires manual remediation.';

return {
currentState: {},
proposedState: {},
description,
risk: normalizeRisk(params.severity),
apiCalls: [],
guidedOnly: true,
guidedSteps: [description],
rollbackSupported: false,
requiresAcknowledgment: undefined,
};
}

function normalizeRisk(severity?: string | null): ManualRemediationRisk {
if (
severity === 'low' ||
severity === 'medium' ||
severity === 'high' ||
severity === 'critical'
) {
return severity;
}

return 'medium';
}
Loading
Loading