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
6 changes: 6 additions & 0 deletions apps/api/src/cloud-security/ai-remediation.prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ A human will ALWAYS review your plan before execution. Be precise and correct.
- To make a recorder record ALL supported resource types, first read the existing recorder with DescribeConfigurationRecordersCommand (service "config-service", in readSteps) to get its exact name and roleARN, then call PutConfigurationRecorderCommand with ConfigurationRecorder = { name, roleARN, recordingGroup: { allSupported: true, includeGlobalResourceTypes: true } }.
- NEVER set allSupported:true together with recordingStrategy, exclusionByResourceTypes, or resourceTypes — they are mutually exclusive and AWS rejects the request with a ValidationException. Omit those fields entirely (this also overwrites an existing exclusion-based strategy so global IAM resources are recorded).

## CLOUDWATCH METRIC FILTERS (IMPORTANT)
- For logs:PutMetricFilterCommand (service "cloudwatch-logs"), ALL of these are required: logGroupName, filterName, filterPattern, and metricTransformations.
- metricTransformations MUST be an ARRAY of objects (never a single object), and each object MUST set metricName, metricNamespace, and metricValue. Example: "metricTransformations": [{ "metricName": "compai-cis-metric", "metricNamespace": "CloudTrailMetrics", "metricValue": "1" }].
- metricValue MUST be a STRING ("1"), not the number 1 — AWS rejects a numeric metricValue.
- logGroupName must be the REAL CloudTrail CloudWatch Logs group name from the read step — never a placeholder.

## IDEMPOTENCY (CRITICAL)
- All fix steps MUST be safe to run even if the resource already exists
- For Create operations: our executor automatically handles "already exists" errors — they are treated as success, not failure
Expand Down
89 changes: 89 additions & 0 deletions apps/api/src/cloud-security/aws-command-executor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
REQUIRED_PARAMS,
looksLikeValidationError,
normalizeConfigRecordingGroup,
normalizeMetricFilterTransformations,
validatePlanSteps,
} from './aws-command-executor';

Expand Down Expand Up @@ -401,3 +402,91 @@ describe('normalizeConfigRecordingGroup', () => {
expect(input2).toEqual({ ConfigurationRecorder: { name: 'default' } });
});
});

/**
* CloudWatch metric filters: `metricTransformations` is a required, non-empty
* array whose entries' `metricValue` must be a string. The model often emits a
* single object or a numeric metricValue, which AWS rejects ("metric
* transformations were not properly provided…") and sends the auto-fix to
* manual steps. normalizeMetricFilterTransformations coerces the valid shape;
* REQUIRED_PARAMS catches a truly-missing field before execution.
*/
describe('PutMetricFilterCommand required params + normalization', () => {
it('enforces the required PutMetricFilter params', () => {
const errors = validatePlanSteps([
step({
service: 'cloudwatch-logs',
command: 'PutMetricFilterCommand',
params: { logGroupName: 'lg', filterName: 'fn' }, // missing pattern + transforms
}),
]);
expect(errors).toEqual(
expect.arrayContaining([
expect.stringMatching(/Required param "filterPattern" is missing/),
expect.stringMatching(/Required param "metricTransformations" is missing/),
]),
);
});

it('does not error when all PutMetricFilter params are present', () => {
const errors = validatePlanSteps([
step({
service: 'cloudwatch-logs',
command: 'PutMetricFilterCommand',
params: {
logGroupName: 'lg',
filterName: 'fn',
filterPattern: '{ $.eventName = "X" }',
metricTransformations: [
{ metricName: 'm', metricNamespace: 'CloudTrailMetrics', metricValue: '1' },
],
},
}),
]);
expect(
errors.filter((e) => e.includes('PutMetricFilterCommand')),
).toHaveLength(0);
});

it('wraps a single metricTransformations object in an array', () => {
const input: Record<string, unknown> = {
logGroupName: 'lg',
metricTransformations: {
metricName: 'm',
metricNamespace: 'CloudTrailMetrics',
metricValue: '1',
},
};
normalizeMetricFilterTransformations(input);
expect(input.metricTransformations).toEqual([
{ metricName: 'm', metricNamespace: 'CloudTrailMetrics', metricValue: '1' },
]);
});

it('coerces a numeric metricValue to a string', () => {
const input: Record<string, unknown> = {
metricTransformations: [
{ metricName: 'm', metricNamespace: 'CloudTrailMetrics', metricValue: 1 },
],
};
normalizeMetricFilterTransformations(input);
expect(input.metricTransformations).toEqual([
{ metricName: 'm', metricNamespace: 'CloudTrailMetrics', metricValue: '1' },
]);
});

it('leaves a well-formed metricTransformations array untouched', () => {
const good = [
{ metricName: 'm', metricNamespace: 'CloudTrailMetrics', metricValue: '1' },
];
const input: Record<string, unknown> = { metricTransformations: good };
normalizeMetricFilterTransformations(input);
expect(input.metricTransformations).toEqual(good);
});

it('is a no-op when metricTransformations is absent', () => {
const input: Record<string, unknown> = { logGroupName: 'lg' };
expect(() => normalizeMetricFilterTransformations(input)).not.toThrow();
expect(input).toEqual({ logGroupName: 'lg' });
});
});
50 changes: 50 additions & 0 deletions apps/api/src/cloud-security/aws-command-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ export const REQUIRED_PARAMS: Record<string, readonly string[]> = {
StartConfigurationRecorderCommand: ['ConfigurationRecorderName'],
PutBucketPolicyCommand: ['Bucket', 'Policy'],
CreateTrailCommand: ['Name', 'S3BucketName'],
PutMetricFilterCommand: [
'logGroupName',
'filterName',
'filterPattern',
'metricTransformations',
],
};

const REQUIRED_PARAM_ONE_OF: Record<string, readonly (readonly string[])[]> = {
Expand Down Expand Up @@ -276,6 +282,50 @@ function normaliseInputParams(
if (command === 'PutConfigurationRecorderCommand') {
normalizeConfigRecordingGroup(input);
}

// Rule 5: CloudWatch Logs metric filters — `metricTransformations` is a
// required, NON-EMPTY ARRAY of { metricName, metricNamespace, metricValue }
// where `metricValue` must be a STRING. The model frequently emits it as a
// single object instead of an array, or as a number `1` instead of `"1"`,
// which AWS rejects (the customer-visible "metric transformations were not
// properly provided to the CloudWatch Logs API" failure that sends the
// auto-fix to manual steps). Coerce to the one valid shape.
if (command === 'PutMetricFilterCommand') {
normalizeMetricFilterTransformations(input);
}
}

export function normalizeMetricFilterTransformations(
input: Record<string, unknown>,
): void {
let transformations = input.metricTransformations;

// A single transformation object → wrap in an array (AWS expects a list).
if (
transformations &&
typeof transformations === 'object' &&
!Array.isArray(transformations)
) {
transformations = [transformations];
input.metricTransformations = transformations;
}

if (!Array.isArray(transformations)) return;

input.metricTransformations = transformations.map((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
return entry;
}
const transform = entry as Record<string, unknown>;
// metricValue must be a string (e.g. "1"); the model often emits a number.
if (
transform.metricValue != null &&
typeof transform.metricValue !== 'string'
) {
return { ...transform, metricValue: String(transform.metricValue) };
}
return transform;
});
}

export function normalizeConfigRecordingGroup(
Expand Down
Loading