diff --git a/apps/api/src/assistant-chat/assistant-chat.controller.ts b/apps/api/src/assistant-chat/assistant-chat.controller.ts index 8c9c30f7df..d06124b88c 100644 --- a/apps/api/src/assistant-chat/assistant-chat.controller.ts +++ b/apps/api/src/assistant-chat/assistant-chat.controller.ts @@ -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' }) @@ -129,6 +130,7 @@ Important: system: systemPrompt, messages: await convertToModelMessages(messages), tools, + providerOptions: ASSISTANT_OPENAI_PROVIDER_OPTIONS, stopWhen: stepCountIs(5), }); diff --git a/apps/api/src/assistant-chat/openai-options.spec.ts b/apps/api/src/assistant-chat/openai-options.spec.ts new file mode 100644 index 0000000000..d4ffac6551 --- /dev/null +++ b/apps/api/src/assistant-chat/openai-options.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/assistant-chat/openai-options.ts b/apps/api/src/assistant-chat/openai-options.ts new file mode 100644 index 0000000000..0180080b88 --- /dev/null +++ b/apps/api/src/assistant-chat/openai-options.ts @@ -0,0 +1,7 @@ +import type { OpenAIResponsesProviderOptions } from '@ai-sdk/openai'; + +export const ASSISTANT_OPENAI_PROVIDER_OPTIONS = { + openai: { + store: false, + } satisfies OpenAIResponsesProviderOptions, +}; diff --git a/apps/api/src/cloud-security/ai-remediation.service.ts b/apps/api/src/cloud-security/ai-remediation.service.ts index 134695b829..85a788d057 100644 --- a/apps/api/src/cloud-security/ai-remediation.service.ts +++ b/apps/api/src/cloud-security/ai-remediation.service.ts @@ -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)}`, @@ -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, + }); } } @@ -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, @@ -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 @@ -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); diff --git a/apps/api/src/cloud-security/aws-command-executor.spec.ts b/apps/api/src/cloud-security/aws-command-executor.spec.ts index 572b8ac334..dbf0864b2f 100644 --- a/apps/api/src/cloud-security/aws-command-executor.spec.ts +++ b/apps/api/src/cloud-security/aws-command-executor.spec.ts @@ -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])( @@ -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). @@ -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', @@ -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) => { diff --git a/apps/api/src/cloud-security/aws-command-executor.ts b/apps/api/src/cloud-security/aws-command-executor.ts index 88d75bcd77..53fd16f81d 100644 --- a/apps/api/src/cloud-security/aws-command-executor.ts +++ b/apps/api/src/cloud-security/aws-command-executor.ts @@ -167,6 +167,13 @@ export const REQUIRED_PARAMS: Record = { CreateTrailCommand: ['Name', 'S3BucketName'], }; +const REQUIRED_PARAM_ONE_OF: Record = { + 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:'); @@ -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') ); } @@ -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`, ); } } @@ -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; diff --git a/apps/api/src/cloud-security/manual-remediation.spec.ts b/apps/api/src/cloud-security/manual-remediation.spec.ts new file mode 100644 index 0000000000..a3f143bb34 --- /dev/null +++ b/apps/api/src/cloud-security/manual-remediation.spec.ts @@ -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.', + ]); + }); +}); diff --git a/apps/api/src/cloud-security/manual-remediation.ts b/apps/api/src/cloud-security/manual-remediation.ts new file mode 100644 index 0000000000..c0f989046e --- /dev/null +++ b/apps/api/src/cloud-security/manual-remediation.ts @@ -0,0 +1,56 @@ +const MANUAL_PREFIX = '[MANUAL]'; + +type ManualRemediationRisk = 'low' | 'medium' | 'high' | 'critical'; + +export interface ManualRemediationPreview { + currentState: Record; + proposedState: Record; + 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'; +} diff --git a/apps/api/src/cloud-security/plan-normalizer-aws-edge-cases.spec.ts b/apps/api/src/cloud-security/plan-normalizer-aws-edge-cases.spec.ts new file mode 100644 index 0000000000..895d50e5da --- /dev/null +++ b/apps/api/src/cloud-security/plan-normalizer-aws-edge-cases.spec.ts @@ -0,0 +1,115 @@ +import type { AwsCommandStep, FixPlan } from './ai-remediation.prompt'; +import { normalizeFixPlan } from './plan-normalizer'; + +function makeStep(overrides: Partial = {}): AwsCommandStep { + return { + service: overrides.service ?? 'iam', + command: overrides.command ?? 'CreateRoleCommand', + params: overrides.params ?? {}, + purpose: overrides.purpose ?? 'test step', + }; +} + +function makePlan( + opts: { + fixSteps?: AwsCommandStep[]; + rollbackSteps?: AwsCommandStep[]; + requiredPermissions?: string[]; + } = {}, +): FixPlan { + return { + canAutoFix: true, + risk: 'medium', + description: 'test plan', + currentState: {}, + proposedState: {}, + requiredPermissions: opts.requiredPermissions ?? [], + readSteps: [], + fixSteps: opts.fixSteps ?? [], + rollbackSteps: opts.rollbackSteps ?? [], + rollbackSupported: true, + requiresAcknowledgment: false, + }; +} + +describe('normalizeFixPlan — AWS remediation edge cases', () => { + it('removes S3 PutBucketAcl steps and permissions because ACLs are disabled on modern buckets', () => { + const plan = makePlan({ + requiredPermissions: [ + 's3:CreateBucket', + 's3:PutBucketAcl', + 'cloudtrail:CreateTrail', + ], + fixSteps: [ + makeStep({ + service: 's3', + command: 'PutBucketAclCommand', + params: { Bucket: 'compai-cloudtrail-123-us-east-1', ACL: 'private' }, + }), + makeStep({ + service: 'cloudtrail', + command: 'CreateTrailCommand', + params: { Name: 'compai-cloudtrail', S3BucketName: 'logs' }, + }), + ], + }); + + const result = normalizeFixPlan(plan); + + expect(result.fixSteps.map((step) => step.command)).toEqual([ + 'CreateTrailCommand', + ]); + expect(result.requiredPermissions).toEqual([ + 's3:CreateBucket', + 'cloudtrail:CreateTrail', + ]); + }); + + it('backfills GroupId on EC2 security-group ingress commands from the finding resource id', () => { + const ipPermissions = [ + { + IpProtocol: 'tcp', + FromPort: 22, + ToPort: 22, + IpRanges: [{ CidrIp: '0.0.0.0/0' }], + }, + ]; + const plan = makePlan({ + fixSteps: [ + makeStep({ + service: 'ec2', + command: 'RevokeSecurityGroupIngressCommand', + params: { IpPermissions: ipPermissions }, + }), + ], + rollbackSteps: [ + makeStep({ + service: 'ec2', + command: 'AuthorizeSecurityGroupIngressCommand', + params: { IpPermissions: ipPermissions }, + }), + ], + }); + + const result = normalizeFixPlan(plan, { resourceId: 'sg-0123abc' }); + + expect(result.fixSteps[0].params.GroupId).toBe('sg-0123abc'); + expect(result.rollbackSteps[0].params.GroupId).toBe('sg-0123abc'); + }); + + it('does not overwrite an explicit security-group GroupName', () => { + const plan = makePlan({ + fixSteps: [ + makeStep({ + service: 'ec2', + command: 'RevokeSecurityGroupIngressCommand', + params: { GroupName: 'default' }, + }), + ], + }); + + const result = normalizeFixPlan(plan, { resourceId: 'sg-0123abc' }); + + expect(result.fixSteps[0].params).toEqual({ GroupName: 'default' }); + }); +}); diff --git a/apps/api/src/cloud-security/plan-normalizer.spec.ts b/apps/api/src/cloud-security/plan-normalizer.spec.ts index cc5b933cc6..e47076bd3b 100644 --- a/apps/api/src/cloud-security/plan-normalizer.spec.ts +++ b/apps/api/src/cloud-security/plan-normalizer.spec.ts @@ -191,10 +191,6 @@ describe('normalizeFixPlan — CreateServiceLinkedRoleCommand backfill', () => { }); it('backfills each SLR step independently via nearest-neighbor in a multi-SLR plan', () => { - // Layout: [SLR-A, guardduty, SLR-B, config] - // SLR-A (idx 0) nearest non-IAM = guardduty (offset 1). - // SLR-B (idx 2) nearest non-IAM = config (offset 1 to the right beats - // guardduty at offset 1 to the left because we check right first). const plan = makePlan({ fixSteps: [ makeStep({ @@ -282,14 +278,18 @@ describe('normalizeFixPlan — CreateServiceLinkedRoleCommand backfill', () => { }); it('exports a non-empty AWS_SERVICE_LINKED_ROLE_PRINCIPAL map covering core services', () => { - expect(AWS_SERVICE_LINKED_ROLE_PRINCIPAL.config).toBe('config.amazonaws.com'); + expect(AWS_SERVICE_LINKED_ROLE_PRINCIPAL.config).toBe( + 'config.amazonaws.com', + ); expect(AWS_SERVICE_LINKED_ROLE_PRINCIPAL.guardduty).toBe( 'guardduty.amazonaws.com', ); expect(AWS_SERVICE_LINKED_ROLE_PRINCIPAL.inspector2).toBe( 'inspector2.amazonaws.com', ); - expect(AWS_SERVICE_LINKED_ROLE_PRINCIPAL.macie2).toBe('macie.amazonaws.com'); + expect(AWS_SERVICE_LINKED_ROLE_PRINCIPAL.macie2).toBe( + 'macie.amazonaws.com', + ); expect(AWS_SERVICE_LINKED_ROLE_PRINCIPAL.securityhub).toBe( 'securityhub.amazonaws.com', ); diff --git a/apps/api/src/cloud-security/plan-normalizer.ts b/apps/api/src/cloud-security/plan-normalizer.ts index 286f12d02d..c4f70b5e53 100644 --- a/apps/api/src/cloud-security/plan-normalizer.ts +++ b/apps/api/src/cloud-security/plan-normalizer.ts @@ -32,6 +32,16 @@ export const AWS_SERVICE_LINKED_ROLE_PRINCIPAL: Record = { const SLR_COMMAND = 'CreateServiceLinkedRoleCommand'; const IAM_LIKE_SERVICES = new Set(['iam', 'sts']); +const EC2_SECURITY_GROUP_COMMANDS = new Set([ + 'AuthorizeSecurityGroupIngressCommand', + 'RevokeSecurityGroupIngressCommand', +]); +const S3_ACL_COMMANDS = new Set(['PutBucketAclCommand']); +const S3_ACL_PERMISSIONS = new Set(['s3:PutBucketAcl']); + +export interface NormalizeFixPlanContext { + resourceId?: string | null; +} /** * Deterministic post-processing for an AI-generated fix plan. Runs after @@ -42,14 +52,77 @@ const IAM_LIKE_SERVICES = new Set(['iam', 'sts']); * * Pure, idempotent, and a no-op when the plan is already well-formed. */ -export function normalizeFixPlan(plan: FixPlan): FixPlan { +export function normalizeFixPlan( + plan: FixPlan, + context: NormalizeFixPlanContext = {}, +): FixPlan { + const securityGroupId = extractSecurityGroupId(context.resourceId); return { ...plan, - fixSteps: backfillServiceLinkedRoleParams(plan.fixSteps), - rollbackSteps: backfillServiceLinkedRoleParams(plan.rollbackSteps), + requiredPermissions: removeS3AclPermissions(plan.requiredPermissions), + readSteps: normalizeStepList(plan.readSteps, securityGroupId), + fixSteps: normalizeStepList(plan.fixSteps, securityGroupId), + rollbackSteps: normalizeStepList(plan.rollbackSteps, securityGroupId), }; } +function normalizeStepList( + steps: AwsCommandStep[], + securityGroupId: string | null, +): AwsCommandStep[] { + return backfillSecurityGroupParams( + removeUnsupportedS3AclSteps(backfillServiceLinkedRoleParams(steps)), + securityGroupId, + ); +} + +function removeS3AclPermissions(permissions: string[]): string[] { + return permissions.filter( + (permission) => !S3_ACL_PERMISSIONS.has(permission), + ); +} + +function removeUnsupportedS3AclSteps( + steps: AwsCommandStep[], +): AwsCommandStep[] { + return steps.filter( + (step) => !(step.service === 's3' && S3_ACL_COMMANDS.has(step.command)), + ); +} + +function backfillSecurityGroupParams( + steps: AwsCommandStep[], + securityGroupId: string | null, +): AwsCommandStep[] { + if (!securityGroupId) return steps; + + return steps.map((step) => { + if ( + step.service !== 'ec2' || + !EC2_SECURITY_GROUP_COMMANDS.has(step.command) || + step.params?.GroupId || + step.params?.GroupName + ) { + return step; + } + + return { + ...step, + params: { ...(step.params ?? {}), GroupId: securityGroupId }, + }; + }); +} + +function extractSecurityGroupId(resourceId?: string | null): string | null { + if (!resourceId) return null; + + const directMatch = resourceId.match(/^sg-[a-z0-9]+$/i); + if (directMatch) return directMatch[0]; + + const arnMatch = resourceId.match(/security-group\/(sg-[a-z0-9]+)/i); + return arnMatch?.[1] ?? null; +} + function backfillServiceLinkedRoleParams( steps: AwsCommandStep[], ): AwsCommandStep[] { diff --git a/apps/api/src/cloud-security/providers/aws/iam.adapter.spec.ts b/apps/api/src/cloud-security/providers/aws/iam.adapter.spec.ts new file mode 100644 index 0000000000..b953103160 --- /dev/null +++ b/apps/api/src/cloud-security/providers/aws/iam.adapter.spec.ts @@ -0,0 +1,149 @@ +import { + GenerateCredentialReportCommand, + GetAccountPasswordPolicyCommand, + GetCredentialReportCommand, + GetLoginProfileCommand, + IAMClient, + ListAccessKeysCommand, + ListMFADevicesCommand, + ListUsersCommand, +} from '@aws-sdk/client-iam'; +import { IamAdapter } from './iam.adapter'; + +const CREDENTIAL_REPORT = [ + [ + 'user', + 'arn', + 'user_creation_time', + 'password_enabled', + 'password_last_used', + 'password_last_changed', + 'password_next_rotation', + 'mfa_active', + 'access_key_1_active', + 'access_key_1_last_rotated', + 'access_key_1_last_used_date', + 'access_key_1_last_used_region', + 'access_key_1_last_used_service', + 'access_key_2_active', + 'access_key_2_last_rotated', + 'access_key_2_last_used_date', + 'access_key_2_last_used_region', + 'access_key_2_last_used_service', + 'cert_1_active', + 'cert_1_last_rotated', + 'cert_2_active', + 'cert_2_last_rotated', + ].join(','), + [ + '', + 'arn:aws:iam::123456789012:root', + '2024-01-01T00:00:00+00:00', + 'not_supported', + 'N/A', + 'not_supported', + 'not_supported', + 'true', + 'false', + 'N/A', + 'N/A', + 'N/A', + 'N/A', + 'false', + 'N/A', + 'N/A', + 'N/A', + 'N/A', + 'false', + 'N/A', + 'false', + 'N/A', + ].join(','), +].join('\n'); + +function makeNoSuchEntityError(): Error { + const error = new Error('Login profile does not exist'); + error.name = 'NoSuchEntity'; + return error; +} + +describe('IamAdapter', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not raise MFA findings for IAM users without console access', async () => { + const mfaCheckedUsers: string[] = []; + jest + .spyOn(IAMClient.prototype, 'send') + .mockImplementation(async (command) => { + if (command instanceof GetAccountPasswordPolicyCommand) { + return { + PasswordPolicy: { + MinimumPasswordLength: 14, + RequireUppercaseCharacters: true, + RequireLowercaseCharacters: true, + RequireNumbers: true, + RequireSymbols: true, + }, + }; + } + + if (command instanceof ListUsersCommand) { + return { + Users: [ + { + UserName: 'console-user', + Arn: 'arn:aws:iam::123456789012:user/console-user', + }, + { + UserName: 'api-only-user', + Arn: 'arn:aws:iam::123456789012:user/api-only-user', + }, + ], + }; + } + + if (command instanceof GetLoginProfileCommand) { + if (command.input.UserName === 'api-only-user') { + throw makeNoSuchEntityError(); + } + return { LoginProfile: { UserName: command.input.UserName } }; + } + + if (command instanceof ListMFADevicesCommand) { + if (command.input.UserName) + mfaCheckedUsers.push(command.input.UserName); + return { MFADevices: [] }; + } + + if (command instanceof ListAccessKeysCommand) { + return { AccessKeyMetadata: [] }; + } + + if (command instanceof GenerateCredentialReportCommand) return {}; + if (command instanceof GetCredentialReportCommand) { + return { Content: Buffer.from(CREDENTIAL_REPORT, 'utf-8') }; + } + + return {}; + }); + + const findings = await new IamAdapter().scan({ + credentials: { + accessKeyId: 'key', + secretAccessKey: 'secret', + }, + region: 'us-east-1', + accountId: '123456789012', + }); + + expect(mfaCheckedUsers).toEqual(['console-user']); + expect(findings.map((finding) => finding.id)).toContain( + 'iam-no-mfa-console-user', + ); + expect(findings.map((finding) => finding.id)).not.toContain( + 'iam-no-mfa-api-only-user', + ); + }); +}); diff --git a/apps/api/src/cloud-security/providers/aws/iam.adapter.ts b/apps/api/src/cloud-security/providers/aws/iam.adapter.ts index 2276ace00e..69fc013836 100644 --- a/apps/api/src/cloud-security/providers/aws/iam.adapter.ts +++ b/apps/api/src/cloud-security/providers/aws/iam.adapter.ts @@ -1,6 +1,7 @@ import { IAMClient, GetAccountPasswordPolicyCommand, + GetLoginProfileCommand, ListUsersCommand, ListMFADevicesCommand, ListAccessKeysCommand, @@ -148,6 +149,12 @@ export class IamAdapter implements AwsServiceAdapter { for (const user of users) { if (!user.UserName) continue; + const hasConsoleAccess = await this.userHasConsoleAccess({ + iam, + userName: user.UserName, + }); + if (!hasConsoleAccess) continue; + const mfaResp = await iam.send( new ListMFADevicesCommand({ UserName: user.UserName }), ); @@ -174,6 +181,21 @@ export class IamAdapter implements AwsServiceAdapter { return findings; } + private async userHasConsoleAccess(params: { + iam: IAMClient; + userName: string; + }): Promise { + try { + await params.iam.send( + new GetLoginProfileCommand({ UserName: params.userName }), + ); + return true; + } catch (error) { + if (isNoSuchEntityError(error)) return false; + throw error; + } + } + private async checkStaleAccessKeys( iam: IAMClient, accountId?: string, @@ -264,3 +286,10 @@ export class IamAdapter implements AwsServiceAdapter { }; } } + +function isNoSuchEntityError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return ( + error.name === 'NoSuchEntity' || error.name === 'NoSuchEntityException' + ); +} diff --git a/apps/api/src/cloud-security/remediation.service.spec.ts b/apps/api/src/cloud-security/remediation.service.spec.ts new file mode 100644 index 0000000000..7436e8dcc4 --- /dev/null +++ b/apps/api/src/cloud-security/remediation.service.spec.ts @@ -0,0 +1,77 @@ +import { db } from '@db'; +import { RemediationService } from './remediation.service'; +import { CredentialVaultService } from '../integration-platform/services/credential-vault.service'; +import { AWSSecurityService } from './providers/aws-security.service'; +import { AiRemediationService } from './ai-remediation.service'; +import { GcpRemediationService } from './gcp-remediation.service'; +import { AzureRemediationService } from './azure-remediation.service'; + +jest.mock('@db', () => ({ + db: { + integrationConnection: { findFirst: jest.fn() }, + integrationCheckResult: { findFirst: jest.fn() }, + }, + Prisma: {}, +})); + +const mockDb = db as unknown as { + integrationConnection: { findFirst: jest.Mock }; + integrationCheckResult: { findFirst: jest.Mock }; +}; + +function makeService(params?: { + credentialVaultService?: Partial; +}): RemediationService { + const credentialVaultService = { + getDecryptedCredentials: jest.fn(), + ...(params?.credentialVaultService ?? {}), + }; + + return new RemediationService( + credentialVaultService as unknown as CredentialVaultService, + {} as unknown as AWSSecurityService, + {} as unknown as AiRemediationService, + {} as unknown as GcpRemediationService, + {} as unknown as AzureRemediationService, + ); +} + +describe('RemediationService.previewRemediation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns manual previews without requiring decrypted AWS credentials', async () => { + const getDecryptedCredentials = jest.fn(); + const service = makeService({ + credentialVaultService: { getDecryptedCredentials }, + }); + + mockDb.integrationConnection.findFirst.mockResolvedValue({ + id: 'conn_123', + provider: { slug: 'aws' }, + }); + mockDb.integrationCheckResult.findFirst.mockResolvedValue({ + id: 'chk_123', + title: 'RDS instance is not encrypted', + description: 'RDS encryption requires snapshot copy and restore.', + severity: 'high', + resourceId: 'arn:aws:rds:us-east-1:123456789012:db:test', + resourceType: 'AwsRdsDbInstance', + evidence: { findingKey: 'rds-encryption-test' }, + remediation: + '[MANUAL] Cannot be auto-fixed. RDS encryption can only be enabled at creation time.', + }); + + const preview = await service.previewRemediation({ + connectionId: 'conn_123', + organizationId: 'org_123', + checkResultId: 'chk_123', + remediationKey: 'rds-encryption-test', + }); + + expect(preview.guidedOnly).toBe(true); + expect(preview.apiCalls).toEqual([]); + expect(getDecryptedCredentials).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/cloud-security/remediation.service.ts b/apps/api/src/cloud-security/remediation.service.ts index 59b5c67aa4..bf29f982d5 100644 --- a/apps/api/src/cloud-security/remediation.service.ts +++ b/apps/api/src/cloud-security/remediation.service.ts @@ -15,8 +15,14 @@ import { getAwsDefaultRegion, normalizeAwsPartition, } from './aws-partition.utils'; +import { + buildManualRemediationPreview, + isManualRemediation, +} from './manual-remediation'; import type { FixPlan, AwsCommandStep } from './ai-remediation.prompt'; +const UNSUPPORTED_S3_ACL_PERMISSIONS = new Set(['s3:PutBucketAcl']); + @Injectable() export class RemediationService { private readonly logger = new Logger(RemediationService.name); @@ -96,8 +102,23 @@ export class RemediationService { if (connection.provider.slug === 'azure') { return this.azureRemediationService.previewRemediation(params); } + if (connection.provider.slug !== 'aws') { + throw new Error('Remediation is only supported for AWS'); + } + + const finding = await this.getFinding(params); + if (isManualRemediation(finding.remediation)) { + return buildManualRemediationPreview({ + remediation: finding.remediation ?? '', + description: finding.description, + severity: finding.severity, + }); + } - const { finding, credentials, region } = await this.resolveContext(params); + const { credentials, region } = await this.resolveAwsExecutionContext({ + connectionId: params.connectionId, + finding, + }); const evidence = (finding.evidence ?? {}) as Record; const findingKey = evidence.findingKey as string; @@ -293,6 +314,7 @@ export class RemediationService { .filter( (p) => p !== 'sts:GetCallerIdentity' && p !== 'sts:AssumeRole', ) + .filter((p) => !UNSUPPORTED_S3_ACL_PERMISSIONS.has(p)) .sort(); // Check permissions by reading the ACTUAL policies on CompAI-Remediator let missingPermissions: string[] | undefined; @@ -397,8 +419,21 @@ export class RemediationService { if (connection.provider.slug === 'azure') { return this.azureRemediationService.executeRemediation(params); } + if (connection.provider.slug !== 'aws') { + throw new Error('Remediation is only supported for AWS'); + } - const { finding, credentials, region } = await this.resolveContext(params); + const finding = await this.getFinding(params); + if (isManualRemediation(finding.remediation)) { + throw new Error( + 'This finding requires manual remediation and cannot be auto-fixed.', + ); + } + + const { credentials, region } = await this.resolveAwsExecutionContext({ + connectionId: params.connectionId, + finding, + }); // Get plan from cache or regenerate let plan: FixPlan; @@ -803,17 +838,10 @@ export class RemediationService { return connection; } - private async resolveContext(params: { + private async getFinding(params: { connectionId: string; - organizationId: string; checkResultId: string; - remediationKey: string; }) { - const connection = await this.getConnection(params); - if (connection.provider.slug !== 'aws') { - throw new Error('Remediation is only supported for AWS'); - } - const finding = await db.integrationCheckResult.findFirst({ where: { id: params.checkResultId, @@ -822,6 +850,13 @@ export class RemediationService { }); if (!finding) throw new Error('Finding not found'); + return finding; + } + + private async resolveAwsExecutionContext(params: { + connectionId: string; + finding: { resourceId: string | null; evidence: unknown }; + }) { const credentials = await this.credentialVaultService.getDecryptedCredentials( params.connectionId, @@ -829,8 +864,8 @@ export class RemediationService { if (!credentials) throw new Error('No credentials found'); // Extract region from finding evidence or resourceId (not just first configured region) - const region = this.getRegionForFinding(finding, credentials); - return { finding, credentials, region }; + const region = this.getRegionForFinding(params.finding, credentials); + return { credentials, region }; } /** diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/batch-fix.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/batch-fix.ts index 01a93c90fb..3e8109d988 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/batch-fix.ts +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/batch-fix.ts @@ -1,7 +1,8 @@ 'use server'; -import { auth, runs, tasks } from '@trigger.dev/sdk'; import { serverApi } from '@/lib/api-server'; +import { classifyExecuteResult } from '@/trigger/tasks/cloud-security/execute-result'; +import { auth, runs, tasks } from '@trigger.dev/sdk'; interface BatchFixInput { organizationId: string; @@ -15,10 +16,13 @@ export async function startBatchFix( try { // Step 1: Create batch record in DB via API const api = serverApi; - const batchResp = await api.post<{ data: { id: string } }>('/v1/cloud-security/remediation/batch', { - connectionId: input.connectionId, - findings: input.findings, - }); + const batchResp = await api.post<{ data: { id: string } }>( + '/v1/cloud-security/remediation/batch', + { + connectionId: input.connectionId, + findings: input.findings, + }, + ); if (batchResp.error || !batchResp.data?.data?.id) { return { error: 'Failed to create batch record' }; @@ -66,9 +70,7 @@ export async function cancelBatchFix(runId: string, batchId: string): Promise, + findings: batch.findings as Array<{ + id: string; + title: string; + status: string; + error?: string; + }>, }; } catch { return null; @@ -127,7 +141,11 @@ export async function retryFinding( connectionId: string, checkResultId: string, remediationKey: string, -): Promise<{ status: 'fixed' | 'failed' | 'needs_permissions'; error?: string; missingPermissions?: string[] }> { +): Promise<{ + status: 'fixed' | 'failed' | 'needs_permissions'; + error?: string; + missingPermissions?: string[]; +}> { try { // Preview first const preview = await serverApi.post<{ @@ -141,23 +159,36 @@ export async function retryFinding( if (preview.error) return { status: 'failed', error: String(preview.error) }; - const data = preview.data as { guidedOnly?: boolean; missingPermissions?: string[] } | undefined; + const data = preview.data as + | { guidedOnly?: boolean; missingPermissions?: string[] } + | undefined; if (data?.missingPermissions && data.missingPermissions.length > 0) { return { status: 'needs_permissions', missingPermissions: data.missingPermissions }; } // Execute - const execute = await serverApi.post<{ status: string; error?: string }>( - '/v1/cloud-security/remediation/execute', - { connectionId, checkResultId, remediationKey, acknowledgment: 'acknowledged' }, - ); + const execute = await serverApi.post('/v1/cloud-security/remediation/execute', { + connectionId, + checkResultId, + remediationKey, + acknowledgment: 'acknowledged', + }); + + if (execute.error) { + return { status: 'failed', error: String(execute.error) }; + } - const execData = execute.data as { status?: string; error?: string } | undefined; - if (execute.error || execData?.status === 'failed') { - return { status: 'failed', error: String(execute.error ?? execData?.error ?? 'Failed') }; + const result = classifyExecuteResult(execute.data); + if (result.type === 'success') return { status: 'fixed' }; + if (result.type === 'needs_permissions') { + return { + status: 'needs_permissions', + error: result.error, + missingPermissions: result.permissionError.missingActions, + }; } - return { status: 'fixed' }; + return { status: 'failed', error: result.error }; } catch (err) { return { status: 'failed', error: err instanceof Error ? err.message : 'Failed' }; } diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/actions/batch-fix.ts b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/actions/batch-fix.ts index 01a93c90fb..3e8109d988 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/actions/batch-fix.ts +++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/actions/batch-fix.ts @@ -1,7 +1,8 @@ 'use server'; -import { auth, runs, tasks } from '@trigger.dev/sdk'; import { serverApi } from '@/lib/api-server'; +import { classifyExecuteResult } from '@/trigger/tasks/cloud-security/execute-result'; +import { auth, runs, tasks } from '@trigger.dev/sdk'; interface BatchFixInput { organizationId: string; @@ -15,10 +16,13 @@ export async function startBatchFix( try { // Step 1: Create batch record in DB via API const api = serverApi; - const batchResp = await api.post<{ data: { id: string } }>('/v1/cloud-security/remediation/batch', { - connectionId: input.connectionId, - findings: input.findings, - }); + const batchResp = await api.post<{ data: { id: string } }>( + '/v1/cloud-security/remediation/batch', + { + connectionId: input.connectionId, + findings: input.findings, + }, + ); if (batchResp.error || !batchResp.data?.data?.id) { return { error: 'Failed to create batch record' }; @@ -66,9 +70,7 @@ export async function cancelBatchFix(runId: string, batchId: string): Promise, + findings: batch.findings as Array<{ + id: string; + title: string; + status: string; + error?: string; + }>, }; } catch { return null; @@ -127,7 +141,11 @@ export async function retryFinding( connectionId: string, checkResultId: string, remediationKey: string, -): Promise<{ status: 'fixed' | 'failed' | 'needs_permissions'; error?: string; missingPermissions?: string[] }> { +): Promise<{ + status: 'fixed' | 'failed' | 'needs_permissions'; + error?: string; + missingPermissions?: string[]; +}> { try { // Preview first const preview = await serverApi.post<{ @@ -141,23 +159,36 @@ export async function retryFinding( if (preview.error) return { status: 'failed', error: String(preview.error) }; - const data = preview.data as { guidedOnly?: boolean; missingPermissions?: string[] } | undefined; + const data = preview.data as + | { guidedOnly?: boolean; missingPermissions?: string[] } + | undefined; if (data?.missingPermissions && data.missingPermissions.length > 0) { return { status: 'needs_permissions', missingPermissions: data.missingPermissions }; } // Execute - const execute = await serverApi.post<{ status: string; error?: string }>( - '/v1/cloud-security/remediation/execute', - { connectionId, checkResultId, remediationKey, acknowledgment: 'acknowledged' }, - ); + const execute = await serverApi.post('/v1/cloud-security/remediation/execute', { + connectionId, + checkResultId, + remediationKey, + acknowledgment: 'acknowledged', + }); + + if (execute.error) { + return { status: 'failed', error: String(execute.error) }; + } - const execData = execute.data as { status?: string; error?: string } | undefined; - if (execute.error || execData?.status === 'failed') { - return { status: 'failed', error: String(execute.error ?? execData?.error ?? 'Failed') }; + const result = classifyExecuteResult(execute.data); + if (result.type === 'success') return { status: 'fixed' }; + if (result.type === 'needs_permissions') { + return { + status: 'needs_permissions', + error: result.error, + missingPermissions: result.permissionError.missingActions, + }; } - return { status: 'fixed' }; + return { status: 'failed', error: result.error }; } catch (err) { return { status: 'failed', error: err instanceof Error ? err.message : 'Failed' }; } diff --git a/apps/app/src/trigger/tasks/cloud-security/api-response.test.ts b/apps/app/src/trigger/tasks/cloud-security/api-response.test.ts new file mode 100644 index 0000000000..f9cfdc762d --- /dev/null +++ b/apps/app/src/trigger/tasks/cloud-security/api-response.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { parseApiResponse } from './api-response'; + +describe('parseApiResponse', () => { + it('returns parsed JSON for successful responses', async () => { + const response = new Response(JSON.stringify({ status: 'success' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + + const parsed = await parseApiResponse<{ status: string }>( + response, + 'https://api.trycomp.ai/v1/cloud-security/remediation/execute', + ); + + expect(parsed.ok).toBe(true); + expect(parsed.data).toEqual({ status: 'success' }); + }); + + it('uses JSON message fields for failed API responses', async () => { + const response = new Response(JSON.stringify({ message: 'Bad request' }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }); + + const parsed = await parseApiResponse(response, 'https://api.test/v1/x'); + + expect(parsed.ok).toBe(false); + expect(parsed.error).toBe('Bad request'); + }); + + it('turns HTML responses into actionable errors instead of JSON parse text', async () => { + const response = new Response('

Not Found

', { + status: 404, + headers: { 'content-type': 'text/html; charset=utf-8' }, + }); + + const parsed = await parseApiResponse(response, 'https://app.test/v1/x'); + + expect(parsed.ok).toBe(false); + expect(parsed.error).toContain('HTTP 404 from https://app.test/v1/x'); + expect(parsed.error).toContain('text/html'); + expect(parsed.error).toContain('not JSON'); + }); + + it('treats empty response bodies as failed responses', async () => { + const response = new Response('', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + + const parsed = await parseApiResponse(response, 'https://api.test/v1/x'); + + expect(parsed.ok).toBe(false); + expect(parsed.error).toBe( + 'HTTP 200 from https://api.test/v1/x returned an empty response body.', + ); + }); +}); diff --git a/apps/app/src/trigger/tasks/cloud-security/api-response.ts b/apps/app/src/trigger/tasks/cloud-security/api-response.ts new file mode 100644 index 0000000000..674197bc69 --- /dev/null +++ b/apps/app/src/trigger/tasks/cloud-security/api-response.ts @@ -0,0 +1,120 @@ +export interface ParsedApiResponse { + ok: boolean; + status: number; + data?: T; + error?: string; +} + +export function getCloudSecurityApiBaseUrl(): string { + return process.env.NEXT_PUBLIC_API_URL || process.env.API_BASE_URL || 'http://localhost:3333'; +} + +export function makeServiceTokenHeaders(params: { + organizationId: string; + userId?: string; +}): Record { + return { + 'Content-Type': 'application/json', + 'x-service-token': process.env.SERVICE_TOKEN_TRIGGER ?? '', + 'x-organization-id': params.organizationId, + ...(params.userId && { 'x-user-id': params.userId }), + }; +} + +export async function postCloudSecurityApi(params: { + path: string; + body: Record; + organizationId: string; + userId?: string; +}): Promise<{ data?: T; error?: string }> { + const url = `${getCloudSecurityApiBaseUrl()}${params.path}`; + try { + const response = await fetch(url, { + method: 'POST', + headers: makeServiceTokenHeaders({ + organizationId: params.organizationId, + userId: params.userId, + }), + body: JSON.stringify(params.body), + }); + const parsed = await parseApiResponse(response, url); + if (!parsed.ok) { + return { error: parsed.error ?? `HTTP ${parsed.status}` }; + } + return { data: parsed.data }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function parseApiResponse( + response: Response, + url: string, +): Promise> { + const body = await response.text(); + const contentType = response.headers.get('content-type') ?? ''; + const parsed = parseJsonBody(body); + + if (!parsed.ok) { + const error = + parsed.reason === 'empty' + ? `HTTP ${response.status} from ${url} returned an empty response body.` + : buildNonJsonError({ + status: response.status, + url, + contentType, + body, + }); + + return { + ok: false, + status: response.status, + error, + }; + } + + if (!response.ok) { + return { + ok: false, + status: response.status, + error: getMessage(parsed.value) ?? `HTTP ${response.status}`, + data: parsed.value as T, + }; + } + + return { + ok: true, + status: response.status, + data: parsed.value as T, + }; +} + +function parseJsonBody( + body: string, +): { ok: true; value: unknown } | { ok: false; reason: 'empty' | 'invalid' } { + if (!body.trim()) return { ok: false, reason: 'empty' }; + + try { + return { ok: true, value: JSON.parse(body) }; + } catch { + return { ok: false, reason: 'invalid' }; + } +} + +function getMessage(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const message = (value as Record).message; + return typeof message === 'string' ? message : undefined; +} + +function buildNonJsonError(params: { + status: number; + url: string; + contentType: string; + body: string; +}): string { + const contentType = params.contentType || 'unknown content type'; + const snippet = params.body.trim().replace(/\s+/g, ' ').slice(0, 160); + const suffix = snippet ? ` Body starts with: ${snippet}` : ''; + return `HTTP ${params.status} from ${params.url} returned ${contentType}, not JSON.${suffix}`; +} diff --git a/apps/app/src/trigger/tasks/cloud-security/execute-result.test.ts b/apps/app/src/trigger/tasks/cloud-security/execute-result.test.ts new file mode 100644 index 0000000000..3dde1ef486 --- /dev/null +++ b/apps/app/src/trigger/tasks/cloud-security/execute-result.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { classifyExecuteResult } from './execute-result'; + +describe('classifyExecuteResult', () => { + it('requires an explicit success status before reporting success', () => { + expect(classifyExecuteResult({ status: 'success', actionId: 'act_123' })).toEqual({ + type: 'success', + actionId: 'act_123', + }); + }); + + it('treats empty objects as invalid responses', () => { + expect(classifyExecuteResult({})).toEqual({ + type: 'failed', + error: 'API returned an invalid remediation response', + }); + }); + + it('treats missing response data as an empty response', () => { + expect(classifyExecuteResult(undefined)).toEqual({ + type: 'failed', + error: 'API returned an empty remediation response', + }); + }); + + it('does not report unknown statuses as fixed', () => { + expect(classifyExecuteResult({ status: 'queued' })).toEqual({ + type: 'failed', + error: 'API returned an invalid remediation response', + }); + }); + + it('preserves permission errors for retry flows', () => { + expect( + classifyExecuteResult({ + status: 'failed', + error: 'Access denied', + permissionError: { + missingActions: ['s3:CreateBucket'], + fixScript: 'aws iam put-role-policy ...', + }, + }), + ).toEqual({ + type: 'needs_permissions', + error: 'Access denied', + permissionError: { + missingActions: ['s3:CreateBucket'], + fixScript: 'aws iam put-role-policy ...', + }, + }); + }); +}); diff --git a/apps/app/src/trigger/tasks/cloud-security/execute-result.ts b/apps/app/src/trigger/tasks/cloud-security/execute-result.ts new file mode 100644 index 0000000000..ba6672467d --- /dev/null +++ b/apps/app/src/trigger/tasks/cloud-security/execute-result.ts @@ -0,0 +1,76 @@ +interface PermissionError { + missingActions: string[]; + fixScript?: string; +} + +type ExecuteClassification = + | { type: 'success'; actionId?: string } + | { + type: 'needs_permissions'; + error: string; + permissionError: PermissionError; + } + | { type: 'failed'; error: string }; + +export function classifyExecuteResult(value: unknown): ExecuteClassification { + if (!value || typeof value !== 'object') { + return { type: 'failed', error: 'API returned an empty remediation response' }; + } + + const record = value as Record; + const permissionError = parsePermissionError(record.permissionError); + const error = getErrorMessage(record.error); + const status = record.status; + + if (permissionError) { + return { + type: 'needs_permissions', + error: error ?? 'Missing permissions', + permissionError, + }; + } + + if (status === 'success') { + return { + type: 'success', + actionId: typeof record.actionId === 'string' ? record.actionId : undefined, + }; + } + + if (status === 'failed') { + return { type: 'failed', error: error ?? 'Remediation failed' }; + } + + if (status === 'unverified') { + return { + type: 'failed', + error: error ?? 'Remediation completed but could not be verified', + }; + } + + return { + type: 'failed', + error: 'API returned an invalid remediation response', + }; +} + +function parsePermissionError(value: unknown): PermissionError | undefined { + if (!value || typeof value !== 'object') return undefined; + + const record = value as Record; + if (!Array.isArray(record.missingActions)) return undefined; + + const missingActions = record.missingActions.filter( + (action): action is string => typeof action === 'string', + ); + if (missingActions.length === 0) return undefined; + + return { + missingActions, + ...(typeof record.fixScript === 'string' && { fixScript: record.fixScript }), + }; +} + +function getErrorMessage(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined; +} diff --git a/apps/app/src/trigger/tasks/cloud-security/remediate-batch-helpers.ts b/apps/app/src/trigger/tasks/cloud-security/remediate-batch-helpers.ts new file mode 100644 index 0000000000..f75c19ad0e --- /dev/null +++ b/apps/app/src/trigger/tasks/cloud-security/remediate-batch-helpers.ts @@ -0,0 +1,141 @@ +import { db } from '@db/server'; +import { metadata } from '@trigger.dev/sdk'; +import { postCloudSecurityApi } from './api-response'; +import { classifyExecuteResult } from './execute-result'; + +export type FindingStatus = + | 'pending' + | 'fixing' + | 'fixed' + | 'skipped' + | 'failed' + | 'cancelled' + | 'needs_permissions'; + +export interface FindingProgress { + id: string; + key: string; + title: string; + status: FindingStatus; + error?: string; + /** Per-finding missing permissions (only for needs_permissions status) */ + missingPermissions?: string[]; +} + +export interface BatchProgress { + current: number; + total: number; + fixed: number; + skipped: number; + failed: number; + findings: FindingProgress[]; + phase: 'running' | 'waiting_for_permissions' | 'retrying' | 'scanning' | 'done' | 'cancelled'; + permChecksLeft?: number; + /** All confirmed-available permissions (dedup: don't ask for these again) */ + confirmedPermissions?: string[]; +} + +interface PreviewResult { + guidedOnly?: boolean; + missingPermissions?: string[]; +} + +async function apiPost( + path: string, + body: Record, + organizationId: string, + userId?: string, +): Promise<{ data?: T; error?: string }> { + return postCloudSecurityApi({ path, body, organizationId, userId }); +} + +export function sync(progress: BatchProgress) { + metadata.set('progress', JSON.parse(JSON.stringify(progress))); +} + +export async function tryFix( + finding: FindingProgress, + connectionId: string, + organizationId: string, + userId?: string, +): Promise<{ status: FindingStatus; error?: string; missingPerms?: string[] }> { + const preview = await apiPost( + '/v1/cloud-security/remediation/preview', + { connectionId, checkResultId: finding.id, remediationKey: finding.key }, + organizationId, + userId, + ); + + if (preview.error) return { status: 'failed', error: preview.error }; + if (preview.data?.guidedOnly) { + return { status: 'skipped', error: 'Requires manual fix' }; + } + + if (preview.data?.missingPermissions && preview.data.missingPermissions.length > 0) { + return { + status: 'needs_permissions', + missingPerms: preview.data.missingPermissions, + }; + } + + const execute = await apiPost( + '/v1/cloud-security/remediation/execute', + { + connectionId, + checkResultId: finding.id, + remediationKey: finding.key, + acknowledgment: 'acknowledged', + }, + organizationId, + userId, + ); + + if (execute.error) { + return { + status: 'failed', + error: execute.error, + }; + } + + const result = classifyExecuteResult(execute.data); + if (result.type === 'success') return { status: 'fixed' }; + if (result.type === 'needs_permissions') { + return { + status: 'needs_permissions', + error: result.error, + missingPerms: result.permissionError.missingActions, + }; + } + + return { status: 'failed', error: result.error }; +} + +export async function isCancelled(batchId: string): Promise { + const b = await db.remediationBatch.findUnique({ + where: { id: batchId }, + select: { status: true }, + }); + return !b || b.status === 'cancelled'; +} + +export async function isFindingCancelled(batchId: string, findingId: string): Promise { + const b = await db.remediationBatch.findUnique({ + where: { id: batchId }, + select: { findings: true }, + }); + if (!b) return true; + const findings = b.findings as unknown as FindingProgress[]; + return findings.find((f) => f.id === findingId)?.status === 'cancelled'; +} + +export async function persistProgress(batchId: string, progress: BatchProgress) { + await db.remediationBatch.update({ + where: { id: batchId }, + data: { + findings: JSON.parse(JSON.stringify(progress.findings)), + fixed: progress.fixed, + skipped: progress.skipped, + failed: progress.failed, + }, + }); +} diff --git a/apps/app/src/trigger/tasks/cloud-security/remediate-batch.ts b/apps/app/src/trigger/tasks/cloud-security/remediate-batch.ts index 282564a309..5a982cd8ee 100644 --- a/apps/app/src/trigger/tasks/cloud-security/remediate-batch.ts +++ b/apps/app/src/trigger/tasks/cloud-security/remediate-batch.ts @@ -1,147 +1,21 @@ import { db } from '@db/server'; -import { logger, metadata, task } from '@trigger.dev/sdk'; - -type FindingStatus = 'pending' | 'fixing' | 'fixed' | 'skipped' | 'failed' | 'cancelled' | 'needs_permissions'; - -interface FindingProgress { - id: string; - key: string; - title: string; - status: FindingStatus; - error?: string; - /** Per-finding missing permissions (only for needs_permissions status) */ - missingPermissions?: string[]; -} - -interface BatchProgress { - current: number; - total: number; - fixed: number; - skipped: number; - failed: number; - findings: FindingProgress[]; - phase: 'running' | 'waiting_for_permissions' | 'retrying' | 'scanning' | 'done' | 'cancelled'; - permChecksLeft?: number; - /** All confirmed-available permissions (dedup: don't ask for these again) */ - confirmedPermissions?: string[]; -} - -const getApiBaseUrl = () => - process.env.NEXT_PUBLIC_API_URL || process.env.API_BASE_URL || 'http://localhost:3333'; - -function makeHeaders(organizationId: string, userId?: string): Record { - return { - 'Content-Type': 'application/json', - 'x-service-token': process.env.SERVICE_TOKEN_TRIGGER!, - 'x-organization-id': organizationId, - ...(userId && { 'x-user-id': userId }), - }; -} - -async function apiPost( - path: string, - body: Record, - organizationId: string, - userId?: string, -): Promise<{ data?: T; error?: string }> { - const url = `${getApiBaseUrl()}${path}`; - try { - const resp = await fetch(url, { - method: 'POST', - headers: makeHeaders(organizationId, userId), - body: JSON.stringify(body), - }); - const json = await resp.json(); - if (!resp.ok) { - return { error: (json as { message?: string }).message ?? `HTTP ${resp.status}` }; - } - return { data: json as T }; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } -} - -function sync(progress: BatchProgress) { - metadata.set('progress', JSON.parse(JSON.stringify(progress))); -} - -interface PreviewResult { - guidedOnly?: boolean; - missingPermissions?: string[]; - allRequiredPermissions?: string[]; -} - -/** Try to fix a single finding. Returns result + which permissions it needed. */ -async function tryFix( - finding: FindingProgress, - connectionId: string, - organizationId: string, - userId?: string, -): Promise<{ status: FindingStatus; error?: string; missingPerms?: string[] }> { - const preview = await apiPost( - '/v1/cloud-security/remediation/preview', - { connectionId, checkResultId: finding.id, remediationKey: finding.key }, - organizationId, - userId, - ); - - if (preview.error) return { status: 'failed', error: preview.error }; - if (preview.data?.guidedOnly) return { status: 'skipped', error: 'Requires manual fix' }; - - if (preview.data?.missingPermissions && preview.data.missingPermissions.length > 0) { - return { - status: 'needs_permissions', - missingPerms: preview.data.missingPermissions, - }; - } - - const execute = await apiPost<{ status: string; error?: string }>( - '/v1/cloud-security/remediation/execute', - { connectionId, checkResultId: finding.id, remediationKey: finding.key, acknowledgment: 'acknowledged' }, - organizationId, - userId, - ); - - if (execute.error || execute.data?.status === 'failed') { - return { status: 'failed', error: execute.error ?? execute.data?.error ?? 'Unknown error' }; - } - - return { status: 'fixed' }; -} - -async function isCancelled(batchId: string): Promise { - const b = await db.remediationBatch.findUnique({ where: { id: batchId }, select: { status: true } }); - return !b || b.status === 'cancelled'; -} - -async function isFindingCancelled(batchId: string, findingId: string): Promise { - const b = await db.remediationBatch.findUnique({ where: { id: batchId }, select: { findings: true } }); - if (!b) return true; - const findings = b.findings as unknown as FindingProgress[]; - return findings.find((f) => f.id === findingId)?.status === 'cancelled'; -} - -async function persistProgress(batchId: string, progress: BatchProgress) { - await db.remediationBatch.update({ - where: { id: batchId }, - data: { - findings: JSON.parse(JSON.stringify(progress.findings)), - fixed: progress.fixed, - skipped: progress.skipped, - failed: progress.failed, - }, - }); -} +import { logger, task } from '@trigger.dev/sdk'; +import { postCloudSecurityApi } from './api-response'; +import { + type BatchProgress, + type FindingProgress, + isCancelled, + isFindingCancelled, + persistProgress, + sync, + tryFix, +} from './remediate-batch-helpers'; export const remediateBatch = task({ id: 'remediate-batch', maxDuration: 60 * 30, // 30 minutes (seconds, not ms) retry: { maxAttempts: 1 }, - run: async (payload: { - batchId: string; - organizationId: string; - connectionId: string; - }) => { + run: async (payload: { batchId: string; organizationId: string; connectionId: string }) => { const { batchId, organizationId, connectionId } = payload; const batch = await db.remediationBatch.findUnique({ where: { id: batchId } }); @@ -168,7 +42,11 @@ export const remediateBatch = task({ // ─── Pass 1: Process all findings, never stop ─── for (let i = 0; i < findings.length; i++) { - if (await isCancelled(batchId)) { progress.phase = 'cancelled'; sync(progress); break; } + if (await isCancelled(batchId)) { + progress.phase = 'cancelled'; + sync(progress); + break; + } if (await isFindingCancelled(batchId, findings[i]!.id)) { progress.findings[i]!.status = 'cancelled'; progress.skipped++; @@ -222,9 +100,10 @@ export const remediateBatch = task({ } // ─── Pass 2: Recheck permission-blocked findings ─── - const needsPerms = () => progress.findings - .map((f, i) => ({ f, i })) - .filter(({ f }) => f.status === 'needs_permissions'); + const needsPerms = () => + progress.findings + .map((f, i) => ({ f, i })) + .filter(({ f }) => f.status === 'needs_permissions'); const MAX_CHECKS = 2; // Just a safety net — user has per-finding Retry button for instant action const CHECK_INTERVAL = 30_000; // 30s @@ -235,7 +114,11 @@ export const remediateBatch = task({ sync(progress); for (let check = 0; check < MAX_CHECKS; check++) { - if (await isCancelled(batchId)) { progress.phase = 'cancelled'; sync(progress); break; } + if (await isCancelled(batchId)) { + progress.phase = 'cancelled'; + sync(progress); + break; + } await new Promise((r) => setTimeout(r, CHECK_INTERVAL)); progress.permChecksLeft = MAX_CHECKS - check - 1; @@ -248,14 +131,24 @@ export const remediateBatch = task({ const test = blocked[0]!; const testResult = await tryFix(test.f, connectionId, organizationId, userId); - if (testResult.status === 'fixed' || (testResult.status === 'needs_permissions' && (testResult.missingPerms?.length ?? 0) === 0)) { + if ( + testResult.status === 'fixed' || + (testResult.status === 'needs_permissions' && + (testResult.missingPerms?.length ?? 0) === 0) + ) { // Permissions appeared! Retry ALL blocked findings - logger.info(`Permissions detected on check ${check + 1} — retrying ${blocked.length} findings`); + logger.info( + `Permissions detected on check ${check + 1} — retrying ${blocked.length} findings`, + ); progress.phase = 'retrying'; sync(progress); for (const { f, i } of needsPerms()) { - if (await isCancelled(batchId)) { progress.phase = 'cancelled'; sync(progress); break; } + if (await isCancelled(batchId)) { + progress.phase = 'cancelled'; + sync(progress); + break; + } progress.findings[i]!.status = 'fixing'; progress.findings[i]!.missingPermissions = undefined; @@ -274,7 +167,8 @@ export const remediateBatch = task({ const still = (retry.missingPerms ?? []).filter((p) => !confirmed.has(p)); progress.findings[i]!.status = still.length > 0 ? 'needs_permissions' : 'failed'; progress.findings[i]!.missingPermissions = still.length > 0 ? still : undefined; - progress.findings[i]!.error = still.length > 0 ? undefined : 'Still missing permissions after retry'; + progress.findings[i]!.error = + still.length > 0 ? undefined : 'Still missing permissions after retry'; } else { progress.findings[i]!.status = retry.status; progress.findings[i]!.error = retry.error; @@ -304,7 +198,12 @@ export const remediateBatch = task({ if (progress.fixed > 0 && progress.phase !== 'cancelled') { progress.phase = 'scanning'; sync(progress); - await apiPost(`/v1/cloud-security/scan/${connectionId}`, {}, organizationId, userId); + await postCloudSecurityApi({ + path: `/v1/cloud-security/scan/${connectionId}`, + body: {}, + organizationId, + userId, + }); } progress.phase = progress.phase === 'cancelled' ? 'cancelled' : 'done'; @@ -321,6 +220,11 @@ export const remediateBatch = task({ }, }); - return { success: true, fixed: progress.fixed, skipped: progress.skipped, failed: progress.failed }; + return { + success: true, + fixed: progress.fixed, + skipped: progress.skipped, + failed: progress.failed, + }; }, }); diff --git a/apps/app/src/trigger/tasks/cloud-security/remediate-preview.ts b/apps/app/src/trigger/tasks/cloud-security/remediate-preview.ts index 6656bebd91..ffd86a3866 100644 --- a/apps/app/src/trigger/tasks/cloud-security/remediate-preview.ts +++ b/apps/app/src/trigger/tasks/cloud-security/remediate-preview.ts @@ -1,4 +1,9 @@ import { logger, metadata, task } from '@trigger.dev/sdk'; +import { + getCloudSecurityApiBaseUrl, + makeServiceTokenHeaders, + parseApiResponse, +} from './api-response'; interface PreviewProgress { phase: 'analyzing' | 'complete' | 'failed'; @@ -6,18 +11,6 @@ interface PreviewProgress { preview?: Record; } -const getApiBaseUrl = () => - process.env.NEXT_PUBLIC_API_URL || process.env.API_BASE_URL || 'http://localhost:3333'; - -function makeHeaders(organizationId: string, userId?: string): Record { - return { - 'Content-Type': 'application/json', - 'x-service-token': process.env.SERVICE_TOKEN_TRIGGER!, - 'x-organization-id': organizationId, - ...(userId && { 'x-user-id': userId }), - }; -} - function sync(progress: PreviewProgress) { metadata.set('progress', JSON.parse(JSON.stringify(progress))); } @@ -34,7 +27,14 @@ export const remediatePreview = task({ userId: string; cachedPermissions?: string[]; }) => { - const { connectionId, organizationId, checkResultId, remediationKey, userId, cachedPermissions } = payload; + const { + connectionId, + organizationId, + checkResultId, + remediationKey, + userId, + cachedPermissions, + } = payload; logger.info(`Preview: ${remediationKey} on ${checkResultId} (user: ${userId})`); @@ -42,10 +42,10 @@ export const remediatePreview = task({ sync(progress); try { - const url = `${getApiBaseUrl()}/v1/cloud-security/remediation/preview`; + const url = `${getCloudSecurityApiBaseUrl()}/v1/cloud-security/remediation/preview`; const resp = await fetch(url, { method: 'POST', - headers: makeHeaders(organizationId, userId), + headers: makeServiceTokenHeaders({ organizationId, userId }), body: JSON.stringify({ connectionId, checkResultId, @@ -54,10 +54,10 @@ export const remediatePreview = task({ }), }); - const json = await resp.json(); + const parsed = await parseApiResponse>(resp, url); - if (!resp.ok) { - const errorMsg = (json as { message?: string }).message ?? `HTTP ${resp.status}`; + if (!parsed.ok) { + const errorMsg = parsed.error ?? `HTTP ${parsed.status}`; progress.phase = 'failed'; progress.error = errorMsg; sync(progress); @@ -66,10 +66,10 @@ export const remediatePreview = task({ } progress.phase = 'complete'; - progress.preview = json as Record; + progress.preview = parsed.data; sync(progress); logger.info(`Preview complete for ${remediationKey}`); - return { success: true, preview: json }; + return { success: true, preview: parsed.data }; } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); progress.phase = 'failed'; diff --git a/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts b/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts index 988536d5d5..3382f3b16f 100644 --- a/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts +++ b/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts @@ -1,4 +1,10 @@ import { logger, metadata, task } from '@trigger.dev/sdk'; +import { + getCloudSecurityApiBaseUrl, + makeServiceTokenHeaders, + parseApiResponse, +} from './api-response'; +import { classifyExecuteResult } from './execute-result'; interface SingleFixProgress { phase: 'executing' | 'success' | 'failed' | 'needs_permissions'; @@ -7,29 +13,10 @@ interface SingleFixProgress { permissionError?: { missingActions: string[]; fixScript?: string }; } -const getApiBaseUrl = () => - process.env.NEXT_PUBLIC_API_URL || process.env.API_BASE_URL || 'http://localhost:3333'; - -function makeHeaders(organizationId: string, userId?: string): Record { - return { - 'Content-Type': 'application/json', - 'x-service-token': process.env.SERVICE_TOKEN_TRIGGER!, - 'x-organization-id': organizationId, - ...(userId && { 'x-user-id': userId }), - }; -} - function sync(progress: SingleFixProgress) { metadata.set('progress', JSON.parse(JSON.stringify(progress))); } -interface ExecuteResult { - actionId?: string; - status: string; - error?: string; - permissionError?: { missingActions: string[]; fixScript?: string }; -} - export const remediateSingle = task({ id: 'remediate-single', maxDuration: 60 * 5, // 5 minutes (seconds, not ms) @@ -42,7 +29,8 @@ export const remediateSingle = task({ userId: string; acknowledgment?: string; }) => { - const { connectionId, organizationId, checkResultId, remediationKey, userId, acknowledgment } = payload; + const { connectionId, organizationId, checkResultId, remediationKey, userId, acknowledgment } = + payload; logger.info(`Single fix: ${remediationKey} on ${checkResultId} (user: ${userId})`); @@ -50,10 +38,10 @@ export const remediateSingle = task({ sync(progress); try { - const url = `${getApiBaseUrl()}/v1/cloud-security/remediation/execute`; + const url = `${getCloudSecurityApiBaseUrl()}/v1/cloud-security/remediation/execute`; const resp = await fetch(url, { method: 'POST', - headers: makeHeaders(organizationId, userId), + headers: makeServiceTokenHeaders({ organizationId, userId }), body: JSON.stringify({ connectionId, checkResultId, @@ -62,10 +50,10 @@ export const remediateSingle = task({ }), }); - const json = (await resp.json()) as ExecuteResult; + const parsed = await parseApiResponse(resp, url); - if (!resp.ok) { - const errorMsg = (json as { message?: string }).message ?? `HTTP ${resp.status}`; + if (!parsed.ok) { + const errorMsg = parsed.error ?? `HTTP ${parsed.status}`; progress.phase = 'failed'; progress.error = errorMsg; sync(progress); @@ -73,28 +61,36 @@ export const remediateSingle = task({ return { success: false, error: errorMsg }; } - if (json.status === 'success') { + const result = classifyExecuteResult(parsed.data); + + if (result.type === 'success') { progress.phase = 'success'; - progress.actionId = json.actionId; + progress.actionId = result.actionId; sync(progress); - logger.info(`Single fix succeeded: ${json.actionId}`); - return { success: true, actionId: json.actionId }; + logger.info(`Single fix succeeded: ${result.actionId}`); + return { success: true, actionId: result.actionId }; } - if (json.permissionError) { + if (result.type === 'needs_permissions') { progress.phase = 'needs_permissions'; - progress.error = json.error ?? 'Missing permissions'; - progress.permissionError = json.permissionError; + progress.error = result.error; + progress.permissionError = result.permissionError; sync(progress); - logger.warn(`Single fix needs permissions: ${json.permissionError.missingActions.join(', ')}`); - return { success: false, needsPermissions: true, permissionError: json.permissionError }; + logger.warn( + `Single fix needs permissions: ${result.permissionError.missingActions.join(', ')}`, + ); + return { + success: false, + needsPermissions: true, + permissionError: result.permissionError, + }; } progress.phase = 'failed'; - progress.error = json.error ?? 'Remediation failed'; + progress.error = result.error; sync(progress); - logger.error(`Single fix failed: ${json.error}`); - return { success: false, error: json.error }; + logger.error(`Single fix failed: ${result.error}`); + return { success: false, error: result.error }; } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); progress.phase = 'failed'; diff --git a/packages/integration-platform/src/manifests/aws/credentials.ts b/packages/integration-platform/src/manifests/aws/credentials.ts index efe999b5be..da889e02b0 100644 --- a/packages/integration-platform/src/manifests/aws/credentials.ts +++ b/packages/integration-platform/src/manifests/aws/credentials.ts @@ -198,7 +198,7 @@ aws iam put-role-policy --role-name "$ROLE_NAME" --policy-name CompAI-CostExplor --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"ce:GetCostAndUsage","Resource":"*"}]}' aws iam put-role-policy --role-name "$ROLE_NAME" --policy-name CompAI-ExtraReadAccess \\ - --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["ssm:GetDocument","ssm:DescribeDocument","ssm:ListDocuments"],"Resource":"*"}]}' + --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["ssm:GetDocument","ssm:DescribeDocument","ssm:ListDocuments","iam:GetLoginProfile"],"Resource":"*"}]}' echo "" echo "============================================"