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: 1 addition & 1 deletion apps/api/src/cloud-security/ai-remediation.prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ NEVER omit AWSServiceName, leave it as null, or use a placeholder string.
- NEVER use placeholder values like "{{variable}}", "<PLACEHOLDER>", or template syntax
- ALWAYS use concrete values in fix step params
- If a value depends on the account (like a log group name), put the discovery in readSteps and use a reasonable default or convention in fixSteps:
- CloudTrail log group: discover the trail's CloudWatch Logs log group in a read step (e.g. from the trail's CloudWatchLogsLogGroupArn) and use that exact, real log group name in fixSteps — do not invent a name
- CloudTrail log group: the finding evidence provides the real log group as "cloudWatchLogGroupName" — use that exact value for logGroupName in fixSteps. Only if it is absent, discover it in a read step (from the trail's CloudWatchLogsLogGroupArn) and use that exact, real name. Never invent a name and never use a placeholder like "CloudTrail/DefaultLogGroup"
- SNS topic: use "CompAI-CIS-Alerts" (will be created if it doesn't exist)
- KMS keys: use "alias/aws/service-name" for AWS-managed keys
- The finding evidence contains REAL data from the AWS account scan — use those values
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/cloud-security/ai-remediation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ import {
} from './azure-ai-remediation.prompt';
import { normalizeFixPlan } from './plan-normalizer';

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

export interface FindingContext {
Expand Down
56 changes: 56 additions & 0 deletions apps/api/src/cloud-security/metric-filter-loggroup.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { applyResolvedMetricFilterLogGroup } from './metric-filter-loggroup';
import type { AwsCommandStep } from './ai-remediation.prompt';

const putMetricFilterStep = (params: Record<string, unknown>): AwsCommandStep => ({
service: 'cloudwatch-logs',
command: 'PutMetricFilterCommand',
params,
purpose: 'create metric filter',
});

describe('applyResolvedMetricFilterLogGroup', () => {
it('pins the trail log group from cloudWatchLogGroupName (missing-filter case)', () => {
const steps = [putMetricFilterStep({ logGroupName: '', filterName: 'f' })];
applyResolvedMetricFilterLogGroup(steps, {
cloudWatchLogGroupName: 'my-ct-logs',
});
expect(steps[0].params.logGroupName).toBe('my-ct-logs');
});

it('uses the existing filter log group (update case) when cloudWatchLogGroupName is absent', () => {
const steps = [putMetricFilterStep({ logGroupName: 'wrong', filterName: 'f' })];
applyResolvedMetricFilterLogGroup(steps, { logGroupName: 'existing-lg' });
expect(steps[0].params.logGroupName).toBe('existing-lg');
});

it('overwrites a wrong/placeholder value the AI produced', () => {
const steps = [
putMetricFilterStep({ logGroupName: 'CloudTrail/DefaultLogGroup' }),
];
applyResolvedMetricFilterLogGroup(steps, {
cloudWatchLogGroupName: 'real-lg',
});
expect(steps[0].params.logGroupName).toBe('real-lg');
});

it('only touches PutMetricFilter steps', () => {
const other: AwsCommandStep = {
service: 'sns',
command: 'CreateTopicCommand',
params: { Name: 'compai-cis-alerts' },
purpose: 'create topic',
};
const steps = [other, putMetricFilterStep({ logGroupName: '' })];
applyResolvedMetricFilterLogGroup(steps, {
cloudWatchLogGroupName: 'my-ct-logs',
});
expect(steps[0].params).toEqual({ Name: 'compai-cis-alerts' }); // untouched
expect(steps[1].params.logGroupName).toBe('my-ct-logs');
});

it('is a no-op when no log group was resolved (e.g. DescribeTrails denied)', () => {
const steps = [putMetricFilterStep({ logGroupName: '' })];
applyResolvedMetricFilterLogGroup(steps, { keywords: ['Root'] });
expect(steps[0].params.logGroupName).toBe('');
});
});
38 changes: 38 additions & 0 deletions apps/api/src/cloud-security/metric-filter-loggroup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { AwsCommandStep } from './ai-remediation.prompt';

/**
* Force the real CloudTrail log group onto every PutMetricFilter fix step.
*
* PutMetricFilter cannot work without the exact CloudWatch Logs log group, and
* the AI must never be the source of truth for it (it guessed and failed before,
* surfacing "could not determine which CloudTrail log group..."). The scan
* resolves the real log group deterministically and carries it in the finding
* evidence — `cloudWatchLogGroupName` for a missing filter, `logGroupName` for an
* existing-filter update — so we overwrite the step's logGroupName from evidence,
* guaranteeing the executed value is correct regardless of what the model
* produced. No-op for any non-PutMetricFilter step or when no log group was
* resolved (e.g. DescribeTrails was denied at scan time).
*/
export function applyResolvedMetricFilterLogGroup(
steps: AwsCommandStep[],
evidence: Record<string, unknown>,
): void {
const fromName =
typeof evidence.cloudWatchLogGroupName === 'string' &&
evidence.cloudWatchLogGroupName.trim().length > 0
? evidence.cloudWatchLogGroupName
: null;
const fromExisting =
typeof evidence.logGroupName === 'string' &&
evidence.logGroupName.trim().length > 0
? evidence.logGroupName
: null;
const resolved = fromName ?? fromExisting;
if (!resolved) return;

for (const step of steps) {
if (step.command !== 'PutMetricFilterCommand') continue;
if (!step.params || typeof step.params !== 'object') continue;
(step.params as Record<string, unknown>).logGroupName = resolved;
}
}
157 changes: 157 additions & 0 deletions apps/api/src/cloud-security/providers/aws/cloudwatch.adapter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
const mockTrailSend = jest.fn();
const mockLogsSend = jest.fn();
const mockCwSend = jest.fn();

jest.mock('@aws-sdk/client-cloudtrail', () => ({
CloudTrailClient: jest.fn(() => ({ send: mockTrailSend })),
DescribeTrailsCommand: jest.fn((input: unknown) => ({
_cmd: 'DescribeTrails',
input,
})),
}));
jest.mock('@aws-sdk/client-cloudwatch-logs', () => ({
CloudWatchLogsClient: jest.fn(() => ({ send: mockLogsSend })),
DescribeMetricFiltersCommand: jest.fn((input: unknown) => ({
_cmd: 'DescribeMetricFilters',
input,
})),
}));
jest.mock('@aws-sdk/client-cloudwatch', () => ({
CloudWatchClient: jest.fn(() => ({ send: mockCwSend })),
DescribeAlarmsForMetricCommand: jest.fn((input: unknown) => ({
_cmd: 'DescribeAlarmsForMetric',
input,
})),
}));

import { CloudWatchAdapter, logGroupNameFromArn } from './cloudwatch.adapter';

const CREDS = {
accessKeyId: 'AKIA',
secretAccessKey: 'secret',
sessionToken: 'token',
};

const scan = () =>
new CloudWatchAdapter().scan({ credentials: CREDS, region: 'us-east-1' });

beforeEach(() => {
jest.clearAllMocks();
mockCwSend.mockResolvedValue({ MetricAlarms: [] });
});

describe('logGroupNameFromArn', () => {
it('derives the bare name and strips the trailing :*', () => {
expect(
logGroupNameFromArn(
'arn:aws:logs:us-east-1:123456789012:log-group:aws-cloudtrail-logs-xyz:*',
),
).toBe('aws-cloudtrail-logs-xyz');
});

it('handles an ARN without a trailing :*', () => {
expect(
logGroupNameFromArn('arn:aws:logs:us-east-1:123:log-group:my-lg'),
).toBe('my-lg');
});

it('handles the GovCloud partition', () => {
expect(
logGroupNameFromArn(
'arn:aws-us-gov:logs:us-gov-west-1:123:log-group:ct-logs:*',
),
).toBe('ct-logs');
});

it('returns null for a missing or malformed ARN', () => {
expect(logGroupNameFromArn(undefined)).toBeNull();
expect(logGroupNameFromArn('not-an-arn')).toBeNull();
});
});

describe('CloudWatchAdapter — CloudTrail log group resolution', () => {
it('injects the real log group name into the metric-filter-missing finding (the customer bug)', async () => {
mockTrailSend.mockResolvedValue({
trailList: [
{
Name: 'main',
CloudWatchLogsLogGroupArn:
'arn:aws:logs:us-east-1:123456789012:log-group:my-ct-logs:*',
},
],
});
mockLogsSend.mockResolvedValue({ metricFilters: [] }); // nothing configured

const findings = await scan();
const missing = findings.find((f) =>
f.title.includes('metric filter missing'),
);

expect(missing).toBeDefined();
expect(missing!.evidence?.cloudWatchLogGroupName).toBe('my-ct-logs');
expect(missing!.remediation).toContain('logGroupName set to "my-ct-logs"');
// The old generic phrasing that forced the AI to guess must be gone.
expect(missing!.remediation).not.toContain(
'logGroupName set to the CloudTrail log group',
);
});

it('uses the existing filter\'s own log group for the no-transformation update', async () => {
mockTrailSend.mockResolvedValue({
trailList: [
{
Name: 'main',
CloudWatchLogsLogGroupArn:
'arn:aws:logs:us-east-1:123:log-group:my-ct-logs:*',
},
],
});
// A filter matching CIS 4.3 (Root account usage) keywords, but with no
// metric transformation → "no metric transformation" finding.
mockLogsSend.mockResolvedValue({
metricFilters: [
{
filterName: 'root-filter',
filterPattern: '{ $.userIdentity.type = "Root" }',
logGroupName: 'existing-lg',
metricTransformations: [],
},
],
});

const findings = await scan();
const noTransform = findings.find((f) =>
f.title.includes('no metric transformation'),
);

expect(noTransform).toBeDefined();
expect(noTransform!.evidence?.logGroupName).toBe('existing-lg');
expect(noTransform!.remediation).toContain('logGroupName set to "existing-lg"');
});

it('returns the prerequisite finding when no trail integrates with CloudWatch Logs', async () => {
mockTrailSend.mockResolvedValue({
trailList: [{ Name: 'main' }], // no CloudWatchLogsLogGroupArn
});

const findings = await scan();
expect(findings).toHaveLength(1);
expect(findings[0].title).toMatch(/not integrated with CloudWatch Logs/i);
});

it('falls back to a generic instruction when DescribeTrails is denied', async () => {
// DescribeTrails throws (e.g. missing cloudtrail:DescribeTrails) -> the log
// group stays unknown, so the finding keeps the generic text rather than a
// wrong name. (Not the customer's case, but must not crash or fabricate.)
mockTrailSend.mockRejectedValue(new Error('AccessDenied'));
mockLogsSend.mockResolvedValue({ metricFilters: [] });

const findings = await scan();
const missing = findings.find((f) =>
f.title.includes('metric filter missing'),
);
expect(missing).toBeDefined();
expect(missing!.evidence?.cloudWatchLogGroupName).toBeUndefined();
expect(missing!.remediation).toContain("CloudWatch Logs log group");
});
});
Loading
Loading