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
131 changes: 127 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,126 @@ describe('validatePlanSteps — REQUIRED_PARAMS', () => {
).toHaveLength(1);
});

it('requires a group identifier for property-based security-group revoke commands', () => {
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" is required',
]),
);
});

it('rejects revoke commands that mix rule IDs with rule properties', () => {
const errors = validatePlanSteps([
step({
service: 'ec2',
command: 'RevokeSecurityGroupIngressCommand',
params: {
GroupId: 'sg-0123abc',
SecurityGroupRuleIds: ['sgr-0123abc'],
IpPermissions: [
{
IpProtocol: 'tcp',
FromPort: 22,
ToPort: 22,
IpRanges: [{ CidrIp: '0.0.0.0/0' }],
},
],
},
}),
]);

expect(errors).toEqual(
expect.arrayContaining([
'Step 1 (RevokeSecurityGroupIngressCommand): SecurityGroupRuleIds cannot be combined with rule property params',
]),
);
});

it('requires a rule selector for security-group revoke commands', () => {
const errors = validatePlanSteps([
step({
service: 'ec2',
command: 'RevokeSecurityGroupIngressCommand',
params: { GroupId: 'sg-0123abc' },
}),
]);

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

it('allows property-based security-group revoke commands when GroupId is present', () => {
const errors = validatePlanSteps([
step({
service: 'ec2',
command: 'RevokeSecurityGroupIngressCommand',
params: {
GroupId: 'sg-0123abc',
IpPermissions: [
{
IpProtocol: 'tcp',
FromPort: 22,
ToPort: 22,
IpRanges: [{ CidrIp: '0.0.0.0/0' }],
},
],
},
}),
]);

expect(
errors.some((e) => /RevokeSecurityGroupIngressCommand/.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 "SecurityGroupRuleIds" or rule property params 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 +219,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 +261,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
81 changes: 76 additions & 5 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,20 @@ export const REQUIRED_PARAMS: Record<string, readonly string[]> = {
CreateTrailCommand: ['Name', 'S3BucketName'],
};

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

const REVOKE_SECURITY_GROUP_INGRESS_RULE_PROPERTY_PARAMS = [
'CidrIp',
'FromPort',
'IpPermissions',
'IpProtocol',
'SourceSecurityGroupName',
'SourceSecurityGroupOwnerId',
'ToPort',
] as const;

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 @@ -236,8 +250,9 @@ function normaliseInputParams(

// Rule 2: S3 CreateBucket needs LocationConstraint for non-us-east-1
if (command === 'CreateBucketCommand') {
if (input.Bucket) {
input.Bucket = String(input.Bucket).toLowerCase().replace(/_/g, '-');
const bucket = input.Bucket;
if (typeof bucket === 'string' || typeof bucket === 'number') {
input.Bucket = String(bucket).toLowerCase().replace(/_/g, '-');
}
if (region !== 'us-east-1' && !input.CreateBucketConfiguration) {
input.CreateBucketConfiguration = { LocationConstraint: region };
Expand Down Expand Up @@ -412,7 +427,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,18 +549,73 @@ 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`,
);
}
}
}

if (step.command === 'RevokeSecurityGroupIngressCommand') {
errors.push(...validateRevokeSecurityGroupIngressParams(step, prefix));
}
}

return errors;
}

function validateRevokeSecurityGroupIngressParams(
step: AwsCommandStep,
prefix: string,
): string[] {
const params = step.params ?? {};
const hasGroupIdentifier =
hasRequiredParamValue(params.GroupId) ||
hasRequiredParamValue(params.GroupName);
const hasRuleIds = hasRequiredParamValue(params.SecurityGroupRuleIds);
const hasRuleProperties =
REVOKE_SECURITY_GROUP_INGRESS_RULE_PROPERTY_PARAMS.some((key) =>
hasRequiredParamValue(params[key]),
);

if (!hasRuleIds && !hasRuleProperties) {
return [
`${prefix}: One of "SecurityGroupRuleIds" or rule property params is required`,
];
}

if (hasRuleIds && hasRuleProperties) {
return [
`${prefix}: SecurityGroupRuleIds cannot be combined with rule property params`,
];
}

if (hasRuleIds && !hasRuleProperties) return [];
if (hasGroupIdentifier) return [];
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

return [`${prefix}: One of "GroupId" or "GroupName" is required`];
}

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
Loading
Loading