diff --git a/apps/api/src/cloud-security/ai-remediation.prompt.ts b/apps/api/src/cloud-security/ai-remediation.prompt.ts index 9aa1ec9ce2..644fff1db7 100644 --- a/apps/api/src/cloud-security/ai-remediation.prompt.ts +++ b/apps/api/src/cloud-security/ai-remediation.prompt.ts @@ -290,7 +290,7 @@ NEVER omit AWSServiceName, leave it as null, or use a placeholder string. - NEVER use placeholder values like "{{variable}}", "", 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 diff --git a/apps/api/src/cloud-security/ai-remediation.service.ts b/apps/api/src/cloud-security/ai-remediation.service.ts index 03c76378cb..747909cb1e 100644 --- a/apps/api/src/cloud-security/ai-remediation.service.ts +++ b/apps/api/src/cloud-security/ai-remediation.service.ts @@ -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 { diff --git a/apps/api/src/cloud-security/metric-filter-loggroup.spec.ts b/apps/api/src/cloud-security/metric-filter-loggroup.spec.ts new file mode 100644 index 0000000000..934507ffb7 --- /dev/null +++ b/apps/api/src/cloud-security/metric-filter-loggroup.spec.ts @@ -0,0 +1,56 @@ +import { applyResolvedMetricFilterLogGroup } from './metric-filter-loggroup'; +import type { AwsCommandStep } from './ai-remediation.prompt'; + +const putMetricFilterStep = (params: Record): 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(''); + }); +}); diff --git a/apps/api/src/cloud-security/metric-filter-loggroup.ts b/apps/api/src/cloud-security/metric-filter-loggroup.ts new file mode 100644 index 0000000000..b8847103b9 --- /dev/null +++ b/apps/api/src/cloud-security/metric-filter-loggroup.ts @@ -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, +): 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).logGroupName = resolved; + } +} diff --git a/apps/api/src/cloud-security/providers/aws/cloudwatch.adapter.spec.ts b/apps/api/src/cloud-security/providers/aws/cloudwatch.adapter.spec.ts new file mode 100644 index 0000000000..6bde8d8545 --- /dev/null +++ b/apps/api/src/cloud-security/providers/aws/cloudwatch.adapter.spec.ts @@ -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"); + }); +}); diff --git a/apps/api/src/cloud-security/providers/aws/cloudwatch.adapter.ts b/apps/api/src/cloud-security/providers/aws/cloudwatch.adapter.ts index c0b7d3ee7e..e23f91c3a8 100644 --- a/apps/api/src/cloud-security/providers/aws/cloudwatch.adapter.ts +++ b/apps/api/src/cloud-security/providers/aws/cloudwatch.adapter.ts @@ -87,6 +87,24 @@ const CIS_CHECKS: CisCheck[] = [ }, ]; +/** + * Derive the bare CloudWatch Logs log-group NAME from a CloudWatchLogsLogGroupArn. + * e.g. "arn:aws:logs:us-east-1:123456789012:log-group:aws-cloudtrail-logs-xyz:*" + * -> "aws-cloudtrail-logs-xyz" + * Log-group names cannot contain ":", so the trailing ":*" (all log streams) suffix + * is stripped safely. Returns null when the ARN is missing or malformed. + */ +export function logGroupNameFromArn(arn: string | undefined): string | null { + if (!arn) return null; + const marker = ':log-group:'; + const idx = arn.indexOf(marker); + if (idx === -1) return null; + let name = arn.slice(idx + marker.length); + if (name.endsWith(':*')) name = name.slice(0, -2); + name = name.trim(); + return name.length > 0 ? name : null; +} + export class CloudWatchAdapter implements AwsServiceAdapter { readonly serviceId = 'cloudwatch'; readonly isGlobal = false; @@ -103,16 +121,24 @@ export class CloudWatchAdapter implements AwsServiceAdapter { const cwClient = new CloudWatchClient({ credentials, region }); const findings: SecurityFinding[] = []; - // Prerequisite: check if any CloudTrail trail has CloudWatch Logs integration + // Prerequisite: find a CloudTrail trail that delivers to CloudWatch Logs, and + // capture the exact log group so the metric-filter auto-fix can target it. + // PutMetricFilter requires a real logGroupName — the AI cannot guess it, so we + // resolve it here (zero extra AWS calls: DescribeTrails is already invoked) + // and surface it in each finding's remediation text + evidence. + let cloudWatchLogGroupName: string | null = null; try { const ctClient = new CloudTrailClient({ credentials, region }); const trailsResp = await ctClient.send(new DescribeTrailsCommand({})); const trails = trailsResp.trailList ?? []; - const hasCloudWatchIntegration = trails.some( + const integratedTrail = trails.find( (trail) => !!trail.CloudWatchLogsLogGroupArn, ); + cloudWatchLogGroupName = logGroupNameFromArn( + integratedTrail?.CloudWatchLogsLogGroupArn, + ); - if (!hasCloudWatchIntegration) { + if (!integratedTrail) { return [ this.makeFinding({ checkId: 'cloudwatch-no-cloudtrail-integration', @@ -146,14 +172,24 @@ export class CloudWatchAdapter implements AwsServiceAdapter { }); if (!matchingFilter) { + // Give the auto-fix the concrete log group to attach the filter to. The + // generic phrase "the CloudTrail log group" forced the AI to guess and + // fail ("could not determine which CloudTrail log group ..."). + const logGroupClause = cloudWatchLogGroupName + ? `logGroupName set to "${cloudWatchLogGroupName}"` + : `logGroupName set to your CloudTrail trail's CloudWatch Logs log group`; findings.push( this.makeFinding({ checkId: check.id, title: `${check.name} — metric filter missing`, description: `No CloudWatch metric filter found for CIS ${check.id} (${check.name}). A metric filter matching keywords [${check.keywords.join(', ')}] is required.`, severity: 'medium', - remediation: `Step 1: Create a CloudWatch Logs metric filter using logs:PutMetricFilterCommand with logGroupName set to the CloudTrail log group, filterName set to "compai-cis-${check.id}-${check.name.toLowerCase().replace(/\s+/g, '-')}", filterPattern set to the required CIS pattern for ${check.name} matching keywords [${check.keywords.join(', ')}], and metricTransformations containing metricName, metricNamespace "CloudTrailMetrics", and metricValue "1". Step 2: Create an SNS topic using sns:CreateTopicCommand with Name "compai-cis-alerts" if one does not already exist. Step 3: Create a CloudWatch alarm using cloudwatch:PutMetricAlarmCommand with AlarmName "compai-cis-${check.id}-alarm", MetricName matching the filter metric, Namespace "CloudTrailMetrics", Statistic "Sum", Period 300, EvaluationPeriods 1, Threshold 1, ComparisonOperator "GreaterThanOrEqualToThreshold", and AlarmActions set to the SNS topic ARN. Rollback by deleting the alarm with cloudwatch:DeleteAlarmsCommand, deleting the metric filter with logs:DeleteMetricFilterCommand, and optionally deleting the SNS topic with sns:DeleteTopicCommand.`, - evidence: { keywords: check.keywords, filterFound: false }, + remediation: `Step 1: Create a CloudWatch Logs metric filter using logs:PutMetricFilterCommand with ${logGroupClause}, filterName set to "compai-cis-${check.id}-${check.name.toLowerCase().replace(/\s+/g, '-')}", filterPattern set to the required CIS pattern for ${check.name} matching keywords [${check.keywords.join(', ')}], and metricTransformations containing metricName, metricNamespace "CloudTrailMetrics", and metricValue "1". Step 2: Create an SNS topic using sns:CreateTopicCommand with Name "compai-cis-alerts" if one does not already exist. Step 3: Create a CloudWatch alarm using cloudwatch:PutMetricAlarmCommand with AlarmName "compai-cis-${check.id}-alarm", MetricName matching the filter metric, Namespace "CloudTrailMetrics", Statistic "Sum", Period 300, EvaluationPeriods 1, Threshold 1, ComparisonOperator "GreaterThanOrEqualToThreshold", and AlarmActions set to the SNS topic ARN. Rollback by deleting the alarm with cloudwatch:DeleteAlarmsCommand, deleting the metric filter with logs:DeleteMetricFilterCommand, and optionally deleting the SNS topic with sns:DeleteTopicCommand.`, + evidence: { + keywords: check.keywords, + filterFound: false, + cloudWatchLogGroupName: cloudWatchLogGroupName ?? undefined, + }, passed: false, }), ); @@ -165,15 +201,23 @@ export class CloudWatchAdapter implements AwsServiceAdapter { matchingFilter.metricTransformations?.[0]?.metricName; if (!metricName) { + // An existing filter is updated in place — target its own log group + // (fall back to the trail's resolved log group if unavailable). + const updateLogGroup = + matchingFilter.logGroupName ?? cloudWatchLogGroupName; + const updateLogGroupClause = updateLogGroup + ? `logGroupName set to "${updateLogGroup}"` + : `logGroupName set to the metric filter's existing log group`; findings.push( this.makeFinding({ checkId: check.id, title: `${check.name} — no metric transformation`, description: `Metric filter for CIS ${check.id} (${check.name}) exists but has no metric transformation configured.`, severity: 'medium', - remediation: `Step 1: Update the existing metric filter using logs:PutMetricFilterCommand with logGroupName, filterName set to the existing filter name, filterPattern preserved, and metricTransformations containing metricName "compai-cis-${check.id}-metric", metricNamespace "CloudTrailMetrics", and metricValue "1". Step 2: Create an SNS topic using sns:CreateTopicCommand with Name "compai-cis-alerts" if one does not already exist. Step 3: Create a CloudWatch alarm using cloudwatch:PutMetricAlarmCommand with AlarmName "compai-cis-${check.id}-alarm", MetricName "compai-cis-${check.id}-metric", Namespace "CloudTrailMetrics", Statistic "Sum", Period 300, EvaluationPeriods 1, Threshold 1, ComparisonOperator "GreaterThanOrEqualToThreshold", and AlarmActions set to the SNS topic ARN. Rollback by deleting the alarm with cloudwatch:DeleteAlarmsCommand and removing the metric transformation by calling logs:PutMetricFilterCommand with the original filter settings.`, + remediation: `Step 1: Update the existing metric filter using logs:PutMetricFilterCommand with ${updateLogGroupClause}, filterName set to the existing filter name, filterPattern preserved, and metricTransformations containing metricName "compai-cis-${check.id}-metric", metricNamespace "CloudTrailMetrics", and metricValue "1". Step 2: Create an SNS topic using sns:CreateTopicCommand with Name "compai-cis-alerts" if one does not already exist. Step 3: Create a CloudWatch alarm using cloudwatch:PutMetricAlarmCommand with AlarmName "compai-cis-${check.id}-alarm", MetricName "compai-cis-${check.id}-metric", Namespace "CloudTrailMetrics", Statistic "Sum", Period 300, EvaluationPeriods 1, Threshold 1, ComparisonOperator "GreaterThanOrEqualToThreshold", and AlarmActions set to the SNS topic ARN. Rollback by deleting the alarm with cloudwatch:DeleteAlarmsCommand and removing the metric transformation by calling logs:PutMetricFilterCommand with the original filter settings.`, evidence: { filterName: matchingFilter.filterName, + logGroupName: updateLogGroup ?? undefined, metricTransformations: null, }, passed: false, @@ -231,12 +275,14 @@ export class CloudWatchAdapter implements AwsServiceAdapter { { filterName?: string; filterPattern?: string; + logGroupName?: string; metricTransformations?: { metricName?: string }[]; }[] > { const filters: { filterName?: string; filterPattern?: string; + logGroupName?: string; metricTransformations?: { metricName?: string }[]; }[] = []; @@ -251,6 +297,7 @@ export class CloudWatchAdapter implements AwsServiceAdapter { filters.push({ filterName: filter.filterName, filterPattern: filter.filterPattern, + logGroupName: filter.logGroupName, metricTransformations: filter.metricTransformations?.map((t) => ({ metricName: t.metricName, })), diff --git a/apps/api/src/cloud-security/remediation.service.ts b/apps/api/src/cloud-security/remediation.service.ts index 9a2e760726..8871d78bc7 100644 --- a/apps/api/src/cloud-security/remediation.service.ts +++ b/apps/api/src/cloud-security/remediation.service.ts @@ -22,6 +22,7 @@ import { buildManualRemediationPreview, isManualRemediation, } from './manual-remediation'; +import { applyResolvedMetricFilterLogGroup } from './metric-filter-loggroup'; import type { FixPlan, AwsCommandStep } from './ai-remediation.prompt'; const UNSUPPORTED_S3_ACL_PERMISSIONS = new Set(['s3:PutBucketAcl']); @@ -279,6 +280,11 @@ export class RemediationService { }; } + // Pin the real CloudTrail log group on metric-filter steps so the + // preview matches what execution will actually apply (deterministic, + // not AI-dependent). + applyResolvedMetricFilterLogGroup(refined.fixSteps, evidence); + // Build the COMPLETE permission list from ALL sources const aiPermissions = await this.aiRemediationService.analyzeRequiredPermissions(refined); @@ -646,6 +652,11 @@ export class RemediationService { }); } + // Deterministically pin the CloudTrail log group on any PutMetricFilter + // step from the finding evidence — the AI must never be the source of + // truth for it (this is what failed for the customer before). + applyResolvedMetricFilterLogGroup(plannedFix.fixSteps, findingCtx.evidence); + // Phase 3: Execute the refined fix steps (now with REAL values). // Pass rollback steps for automatic undo on partial failure. // Pass a repairStep callback so that when AWS rejects any step with diff --git a/packages/integration-platform/src/manifests/azure/checks/__tests__/azure-checks.test.ts b/packages/integration-platform/src/manifests/azure/checks/__tests__/azure-checks.test.ts index dc46790cf1..d71656d4c6 100644 --- a/packages/integration-platform/src/manifests/azure/checks/__tests__/azure-checks.test.ts +++ b/packages/integration-platform/src/manifests/azure/checks/__tests__/azure-checks.test.ts @@ -7,7 +7,17 @@ import type { import { rbacLeastPrivilegeCheck } from '../entra-id'; import { keyVaultProtectionCheck, keyVaultRbacCheck } from '../key-vault'; import { monitorLoggingAlertingCheck } from '../monitor'; +import { + evaluateMySqlTls, + isMySqlTlsVersionCompliant, + mysqlFlexibleTlsCheck, +} from '../mysql-flexible'; import { nsgNoOpenPortsCheck } from '../network'; +import { + evaluatePgTls, + isPgTlsVersionCompliant, + postgresqlFlexibleTlsCheck, +} from '../postgresql-flexible'; import { sqlAuditingCheck, sqlPublicAccessCheck, sqlTlsCheck } from '../sql'; import { storageEncryptionCheck, @@ -384,3 +394,219 @@ describe('Azure ARM pagination safety', () => { expect(fetched.some((u) => u.includes('evil'))).toBe(false); }); }); + +// ── MySQL Flexible Server TLS ────────────────────────────────────────────── + +// Mock fetch for the MySQL TLS check: returns the server list for the +// flexibleServers list call, and the two configuration GETs (passing null to +// simulate a config read failure). +function mysqlFetch( + requireSecureTransport: string | null, + tlsVersion: string | null, + servers: Array<{ id: string; name: string }> = [ + { + id: '/subscriptions/sub-1/resourceGroups/rg/providers/Microsoft.DBforMySQL/flexibleServers/db1', + name: 'db1', + }, + ], +) { + return (url: string) => { + if (url.includes('/configurations/require_secure_transport')) { + if (requireSecureTransport === null) throw new Error('HTTP 403'); + return { properties: { value: requireSecureTransport } }; + } + if (url.includes('/configurations/tls_version')) { + if (tlsVersion === null) throw new Error('HTTP 403'); + return { properties: { value: tlsVersion } }; + } + if (url.includes('/flexibleServers?')) { + return { value: servers }; + } + return {}; + }; +} + +describe('isMySqlTlsVersionCompliant', () => { + it('accepts only TLS 1.2+ (single or comma-separated set), case-insensitive', () => { + expect(isMySqlTlsVersionCompliant('TLSv1.2')).toBe(true); + expect(isMySqlTlsVersionCompliant('TLSv1.2,TLSv1.3')).toBe(true); + expect(isMySqlTlsVersionCompliant('tlsv1.3')).toBe(true); + }); + + it('rejects any set that enables TLS 1.0/1.1, or is empty/unknown', () => { + expect(isMySqlTlsVersionCompliant('TLSv1.1,TLSv1.2')).toBe(false); + expect(isMySqlTlsVersionCompliant('TLSv1')).toBe(false); + expect(isMySqlTlsVersionCompliant('')).toBe(false); + expect(isMySqlTlsVersionCompliant('TLSv1.2,Foo')).toBe(false); + }); +}); + +describe('evaluateMySqlTls', () => { + it('is compliant only when secure transport is ON and TLS floor is 1.2+', () => { + expect(evaluateMySqlTls('ON', 'TLSv1.2').compliant).toBe(true); + expect(evaluateMySqlTls('on', 'TLSv1.2,TLSv1.3').compliant).toBe(true); + expect(evaluateMySqlTls('OFF', 'TLSv1.2').compliant).toBe(false); + expect(evaluateMySqlTls('ON', 'TLSv1.1,TLSv1.2').compliant).toBe(false); + }); +}); + +describe('Azure MySQL Flexible Server TLS check', () => { + it('passes when secure transport is ON and TLS >= 1.2', async () => { + const { passed, failed } = await run(mysqlFlexibleTlsCheck, mysqlFetch('ON', 'TLSv1.2')); + expect(failed).toHaveLength(0); + expect(passed).toHaveLength(1); + }); + + it('passes with the comma-separated TLSv1.2,TLSv1.3 set', async () => { + const { passed, failed } = await run( + mysqlFlexibleTlsCheck, + mysqlFetch('ON', 'TLSv1.2,TLSv1.3'), + ); + expect(failed).toHaveLength(0); + expect(passed).toHaveLength(1); + }); + + it('fails when secure transport is OFF', async () => { + const { passed, failed } = await run(mysqlFlexibleTlsCheck, mysqlFetch('OFF', 'TLSv1.2')); + expect(passed).toHaveLength(0); + expect(failed).toHaveLength(1); + expect(failed[0]!.severity).toBe('medium'); + }); + + it('fails when TLS 1.1 is still enabled', async () => { + const { passed, failed } = await run( + mysqlFlexibleTlsCheck, + mysqlFetch('ON', 'TLSv1.1,TLSv1.2'), + ); + expect(passed).toHaveLength(0); + expect(failed).toHaveLength(1); + }); + + it('emits "could not verify" when a config read fails (no false pass)', async () => { + const { passed, failed } = await run(mysqlFlexibleTlsCheck, mysqlFetch(null, 'TLSv1.2')); + expect(passed).toHaveLength(0); + expect(failed).toHaveLength(1); + expect(failed[0]!.title).toMatch(/Could not verify/); + }); + + it('no-ops when there are no MySQL flexible servers (0 passed, 0 failed)', async () => { + const { passed, failed } = await run( + mysqlFlexibleTlsCheck, + mysqlFetch('ON', 'TLSv1.2', []), + ); + expect(passed).toHaveLength(0); + expect(failed).toHaveLength(0); + }); +}); + +// ── PostgreSQL Flexible Server TLS ───────────────────────────────────────── + +// Mock fetch for the PostgreSQL TLS check. Pass null for a config to simulate a +// read failure; pass '' for ssl to simulate an unset floor. +function pgFetch( + requireSecureTransport: string | null, + sslMinProtocolVersion: string | null, + servers: Array<{ id: string; name: string }> = [ + { + id: '/subscriptions/sub-1/resourceGroups/rg/providers/Microsoft.DBforPostgreSQL/flexibleServers/pg1', + name: 'pg1', + }, + ], +) { + return (url: string) => { + if (url.includes('/configurations/require_secure_transport')) { + if (requireSecureTransport === null) throw new Error('HTTP 403'); + return { properties: { value: requireSecureTransport } }; + } + if (url.includes('/configurations/ssl_min_protocol_version')) { + if (sslMinProtocolVersion === null) return {}; // unset → no value field + return { properties: { value: sslMinProtocolVersion } }; + } + if (url.includes('/flexibleServers?')) { + return { value: servers }; + } + return {}; + }; +} + +describe('isPgTlsVersionCompliant', () => { + it('accepts TLSv1.2 / TLSv1.3 and treats unset as compliant (platform floor is 1.2)', () => { + expect(isPgTlsVersionCompliant('TLSv1.2')).toBe(true); + expect(isPgTlsVersionCompliant('TLSv1.3')).toBe(true); + expect(isPgTlsVersionCompliant('')).toBe(true); + }); + + it('rejects an explicit sub-1.2 floor', () => { + expect(isPgTlsVersionCompliant('TLSv1.1')).toBe(false); + expect(isPgTlsVersionCompliant('TLSv1')).toBe(false); + }); +}); + +describe('evaluatePgTls', () => { + it('is compliant when secure transport is ON (with set or unset SSL floor)', () => { + expect(evaluatePgTls('ON', 'TLSv1.2').compliant).toBe(true); + expect(evaluatePgTls('ON', '').compliant).toBe(true); + expect(evaluatePgTls('OFF', 'TLSv1.2').compliant).toBe(false); + }); +}); + +describe('Azure PostgreSQL Flexible Server TLS check', () => { + it('passes when secure transport is ON and SSL floor is 1.2', async () => { + const { passed, failed } = await run(postgresqlFlexibleTlsCheck, pgFetch('ON', 'TLSv1.2')); + expect(failed).toHaveLength(0); + expect(passed).toHaveLength(1); + }); + + it('passes when secure transport is ON and ssl_min_protocol_version is unset', async () => { + const { passed, failed } = await run(postgresqlFlexibleTlsCheck, pgFetch('ON', null)); + expect(failed).toHaveLength(0); + expect(passed).toHaveLength(1); + }); + + it('fails when secure transport is OFF', async () => { + const { passed, failed } = await run(postgresqlFlexibleTlsCheck, pgFetch('OFF', 'TLSv1.2')); + expect(passed).toHaveLength(0); + expect(failed).toHaveLength(1); + }); + + it('emits "could not verify" when require_secure_transport cannot be read', async () => { + const { passed, failed } = await run(postgresqlFlexibleTlsCheck, pgFetch(null, 'TLSv1.2')); + expect(passed).toHaveLength(0); + expect(failed).toHaveLength(1); + expect(failed[0]!.title).toMatch(/Could not verify/); + }); + + it('emits "could not verify" when the ssl_min_protocol_version READ fails (not a silent pass)', async () => { + // Regression for the cubic finding: a thrown ssl read (permission/transient) + // must NOT be coalesced into a compliant result. Distinct from an unset + // value, which reads back as "" on a successful response (compliant floor). + const { passed, failed } = await run(postgresqlFlexibleTlsCheck, (url: string) => { + if (url.includes('/configurations/require_secure_transport')) { + return { properties: { value: 'ON' } }; + } + if (url.includes('/configurations/ssl_min_protocol_version')) { + throw new Error('HTTP 403'); + } + if (url.includes('/flexibleServers?')) { + return { + value: [ + { + id: '/subscriptions/sub-1/resourceGroups/rg/providers/Microsoft.DBforPostgreSQL/flexibleServers/pg1', + name: 'pg1', + }, + ], + }; + } + return {}; + }); + expect(passed).toHaveLength(0); + expect(failed).toHaveLength(1); + expect(failed[0]!.title).toMatch(/Could not verify/); + }); + + it('no-ops when there are no PostgreSQL flexible servers', async () => { + const { passed, failed } = await run(postgresqlFlexibleTlsCheck, pgFetch('ON', 'TLSv1.2', [])); + expect(passed).toHaveLength(0); + expect(failed).toHaveLength(0); + }); +}); diff --git a/packages/integration-platform/src/manifests/azure/checks/index.ts b/packages/integration-platform/src/manifests/azure/checks/index.ts index 58db3094cd..ce7d9f9888 100644 --- a/packages/integration-platform/src/manifests/azure/checks/index.ts +++ b/packages/integration-platform/src/manifests/azure/checks/index.ts @@ -4,6 +4,8 @@ export { storageEncryptionCheck, } from './storage'; export { sqlTlsCheck, sqlPublicAccessCheck, sqlAuditingCheck } from './sql'; +export { mysqlFlexibleTlsCheck } from './mysql-flexible'; +export { postgresqlFlexibleTlsCheck } from './postgresql-flexible'; export { keyVaultProtectionCheck, keyVaultRbacCheck } from './key-vault'; export { nsgNoOpenPortsCheck } from './network'; export { rbacLeastPrivilegeCheck } from './entra-id'; diff --git a/packages/integration-platform/src/manifests/azure/checks/mysql-flexible.ts b/packages/integration-platform/src/manifests/azure/checks/mysql-flexible.ts new file mode 100644 index 0000000000..d077f83e16 --- /dev/null +++ b/packages/integration-platform/src/manifests/azure/checks/mysql-flexible.ts @@ -0,0 +1,162 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { ARM_BASE, armListAllOrFail, resolveAzureSubscriptionId } from './shared'; + +// Pinned stable api-version for Azure Database for MySQL Flexible Server. +const MYSQL_API_VERSION = '2023-12-30'; + +interface MySqlFlexibleServer { + id: string; + name: string; +} + +interface MySqlConfiguration { + properties?: { value?: string }; +} + +// TLS enforcement on MySQL Flexible Server is controlled by SERVER PARAMETERS +// (configurations), not a top-level property like Azure SQL's `minimalTlsVersion`: +// - require_secure_transport: ON/OFF (forces SSL/TLS) +// - tls_version: a comma-separated SET of enabled protocols (e.g. "TLSv1.2", +// "TLSv1.2,TLSv1.3"). Compliant = secure transport ON and every enabled +// version is 1.2+ (no TLSv1 / TLSv1.1). +const TLS_ALLOWED_VERSIONS = new Set(['TLSV1.2', 'TLSV1.3']); + +/** + * True when `tls_version` permits only TLS 1.2+ — i.e. every enabled protocol in + * the comma-separated set is TLSv1.2 or TLSv1.3. An empty/unknown set is treated + * as non-compliant (we can't assert a 1.2 floor). Comparison is case-insensitive. + */ +export function isMySqlTlsVersionCompliant(tlsVersion: string): boolean { + const versions = tlsVersion + .split(',') + .map((v) => v.trim().toUpperCase()) + .filter((v) => v.length > 0); + if (versions.length === 0) return false; + return versions.every((v) => TLS_ALLOWED_VERSIONS.has(v)); +} + +/** + * Pure evaluator: given the two server-parameter values, decide compliance and + * collect human-readable issues. Kept separate from the ARM I/O so it can be + * unit-tested directly. + */ +export function evaluateMySqlTls( + requireSecureTransport: string, + tlsVersion: string, +): { compliant: boolean; issues: string[] } { + const issues: string[] = []; + if (requireSecureTransport.trim().toUpperCase() !== 'ON') { + issues.push('secure transport not required (require_secure_transport is OFF)'); + } + if (!isMySqlTlsVersionCompliant(tlsVersion)) { + issues.push(`minimum TLS allows versions below 1.2 (tls_version: ${tlsVersion})`); + } + return { compliant: issues.length === 0, issues }; +} + +async function listMySqlFlexibleServers( + ctx: CheckContext, + sub: string, +): Promise { + return armListAllOrFail( + ctx, + `${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.DBforMySQL/flexibleServers?api-version=${MYSQL_API_VERSION}`, + { + what: 'MySQL flexible servers', + resourceType: 'azure-mysql-flexible-server', + subscriptionId: sub, + }, + ); +} + +/** + * Read a single server configuration value. Returns null when the read fails + * (permission/transient) or the value is absent, so the caller can emit an + * explicit "could not verify" finding instead of a false pass. + */ +async function readConfigValue( + ctx: CheckContext, + serverId: string, + name: string, +): Promise { + const res = await ctx + .fetch( + `${ARM_BASE}${serverId}/configurations/${name}?api-version=${MYSQL_API_VERSION}`, + ) + .catch(() => null); + const value = res?.properties?.value; + return typeof value === 'string' ? value : null; +} + +/** + * Azure Database for MySQL Flexible Server minimum TLS 1.2 → TLS / HTTPS. + * + * Mirrors the Azure SQL Database and Storage TLS checks, but targets the MySQL + * Flexible Server resource type (Microsoft.DBforMySQL/flexibleServers), whose + * TLS enforcement lives in server parameters rather than a top-level property. + */ +export const mysqlFlexibleTlsCheck: IntegrationCheck = { + id: 'azure-mysql-flexible-tls', + name: 'Database for MySQL — TLS 1.2 enforced', + description: + 'Verify Azure Database for MySQL Flexible Servers require secure transport and a minimum TLS version of 1.2.', + service: 'mysql-flexible', + taskMapping: TASK_TEMPLATES.tlsHttps, + run: async (ctx: CheckContext) => { + const sub = await resolveAzureSubscriptionId(ctx); + if (!sub) return; + const servers = await listMySqlFlexibleServers(ctx, sub); + if (!servers) return; + if (servers.length === 0) return; + for (const s of servers) { + const requireSecureTransport = await readConfigValue( + ctx, + s.id, + 'require_secure_transport', + ); + const tlsVersion = await readConfigValue(ctx, s.id, 'tls_version'); + + if (requireSecureTransport === null || tlsVersion === null) { + // Couldn't read the TLS parameters — fail explicitly so the TLS task + // isn't falsely satisfied by other servers/checks that read cleanly. + ctx.fail({ + title: `Could not verify MySQL TLS settings: ${s.name}`, + description: `Unable to read the TLS server parameters for MySQL flexible server "${s.name}", so TLS enforcement cannot be verified.`, + resourceType: 'azure-mysql-flexible-server', + resourceId: s.id, + severity: 'medium', + remediation: + 'Grant read access to server configurations (Microsoft.DBforMySQL/flexibleServers/configurations/read), then re-run the check.', + evidence: { server: s.name, requireSecureTransport, tlsVersion }, + }); + continue; + } + + const { compliant, issues } = evaluateMySqlTls( + requireSecureTransport, + tlsVersion, + ); + if (compliant) { + ctx.pass({ + title: `TLS 1.2 enforced: ${s.name}`, + description: `MySQL flexible server "${s.name}" requires secure transport and a minimum TLS version of 1.2.`, + resourceType: 'azure-mysql-flexible-server', + resourceId: s.id, + evidence: { server: s.name, requireSecureTransport, tlsVersion }, + }); + } else { + ctx.fail({ + title: `Outdated TLS configuration: ${s.name}`, + description: `MySQL flexible server "${s.name}": ${issues.join('; ')}.`, + resourceType: 'azure-mysql-flexible-server', + resourceId: s.id, + severity: 'medium', + remediation: + 'Set require_secure_transport to ON and tls_version to TLSv1.2 (or TLSv1.2,TLSv1.3).', + evidence: { server: s.name, requireSecureTransport, tlsVersion }, + }); + } + } + }, +}; diff --git a/packages/integration-platform/src/manifests/azure/checks/postgresql-flexible.ts b/packages/integration-platform/src/manifests/azure/checks/postgresql-flexible.ts new file mode 100644 index 0000000000..8377f5511a --- /dev/null +++ b/packages/integration-platform/src/manifests/azure/checks/postgresql-flexible.ts @@ -0,0 +1,170 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { ARM_BASE, armListAllOrFail, resolveAzureSubscriptionId } from './shared'; + +// Pinned stable api-version for Azure Database for PostgreSQL Flexible Server. +// NOTE: PostgreSQL is a SEPARATE resource provider from MySQL with its own +// version train — do NOT reuse the MySQL check's 2023-12-30. +const POSTGRES_API_VERSION = '2024-08-01'; + +interface PgFlexibleServer { + id: string; + name: string; +} + +interface PgConfiguration { + properties?: { value?: string }; +} + +// TLS enforcement on PostgreSQL Flexible Server is controlled by SERVER +// PARAMETERS (configurations), not a top-level property: +// - require_secure_transport: ON/OFF (forces SSL/TLS; default ON) +// - ssl_min_protocol_version: a SINGLE floor value (TLSv1.2 / TLSv1.3). Unlike +// MySQL's comma-separated `tls_version`, this is one value, and it may be +// UNSET by default — PostgreSQL Flexible Server only permits TLS 1.2/1.3 and +// denies 1.0/1.1 regardless, so an unset floor still means TLS >= 1.2. +const TLS_ALLOWED_VERSIONS = new Set(['TLSV1.2', 'TLSV1.3']); + +/** + * True when `ssl_min_protocol_version` permits only TLS 1.2+. An empty/unset + * value is treated as compliant: PostgreSQL Flexible Server only supports TLS + * 1.2/1.3 and rejects 1.0/1.1 by default, so the effective floor is already 1.2. + * Comparison is case-insensitive. + */ +export function isPgTlsVersionCompliant(sslMinProtocolVersion: string): boolean { + const v = sslMinProtocolVersion.trim().toUpperCase(); + if (v === '') return true; + return TLS_ALLOWED_VERSIONS.has(v); +} + +/** + * Pure evaluator: decide compliance from the two server-parameter values. + * `require_secure_transport == ON` is the real determinant of TLS enforcement; + * the SSL floor is additionally checked but an unset value is fine (see above). + */ +export function evaluatePgTls( + requireSecureTransport: string, + sslMinProtocolVersion: string, +): { compliant: boolean; issues: string[] } { + const issues: string[] = []; + if (requireSecureTransport.trim().toUpperCase() !== 'ON') { + issues.push('secure transport not required (require_secure_transport is OFF)'); + } + if (!isPgTlsVersionCompliant(sslMinProtocolVersion)) { + issues.push( + `minimum TLS below 1.2 (ssl_min_protocol_version: ${sslMinProtocolVersion})`, + ); + } + return { compliant: issues.length === 0, issues }; +} + +async function listPgFlexibleServers( + ctx: CheckContext, + sub: string, +): Promise { + return armListAllOrFail( + ctx, + `${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.DBforPostgreSQL/flexibleServers?api-version=${POSTGRES_API_VERSION}`, + { + what: 'PostgreSQL flexible servers', + resourceType: 'azure-postgresql-flexible-server', + subscriptionId: sub, + }, + ); +} + +/** + * Read a single server configuration, distinguishing a genuine read FAILURE + * (the fetch threw — permission/transient) from a successful read whose value is + * absent/unset. This separation matters: a read failure must surface as "could + * not verify", whereas an unset ssl_min_protocol_version is a legitimate TLS 1.2 + * floor (compliant) — the two must never be collapsed into the same outcome. + */ +async function readConfig( + ctx: CheckContext, + serverId: string, + name: string, +): Promise<{ ok: true; value: string } | { ok: false }> { + try { + const res = await ctx.fetch( + `${ARM_BASE}${serverId}/configurations/${name}?api-version=${POSTGRES_API_VERSION}`, + ); + const value = res?.properties?.value; + return { ok: true, value: typeof value === 'string' ? value : '' }; + } catch { + return { ok: false }; + } +} + +/** + * Azure Database for PostgreSQL Flexible Server minimum TLS 1.2 → TLS / HTTPS. + * + * The direct sibling of the MySQL Flexible Server check, for the PostgreSQL + * resource type (Microsoft.DBforPostgreSQL/flexibleServers). Without it, a + * customer running only PostgreSQL Flexible Server gets 0 servers found by the + * Azure SQL check → "0 passed" for the TLS task (the reported bug class). + */ +export const postgresqlFlexibleTlsCheck: IntegrationCheck = { + id: 'azure-postgresql-flexible-tls', + name: 'Database for PostgreSQL — TLS 1.2 enforced', + description: + 'Verify Azure Database for PostgreSQL Flexible Servers require secure transport and a minimum TLS version of 1.2.', + service: 'postgresql-flexible', + taskMapping: TASK_TEMPLATES.tlsHttps, + run: async (ctx: CheckContext) => { + const sub = await resolveAzureSubscriptionId(ctx); + if (!sub) return; + const servers = await listPgFlexibleServers(ctx, sub); + if (!servers) return; + if (servers.length === 0) return; + for (const s of servers) { + const requireSecure = await readConfig(ctx, s.id, 'require_secure_transport'); + const sslMin = await readConfig(ctx, s.id, 'ssl_min_protocol_version'); + + // A genuine read FAILURE on either parameter surfaces as "could not + // verify" — never a silent pass. (An unset ssl_min_protocol_version reads + // back as an empty string on a SUCCESSFUL response, which evaluatePgTls + // treats as a compliant TLS 1.2 floor; that is distinct from a failed read.) + if (!requireSecure.ok || !sslMin.ok) { + ctx.fail({ + title: `Could not verify PostgreSQL TLS settings: ${s.name}`, + description: `Unable to read the TLS server parameters for PostgreSQL flexible server "${s.name}", so TLS enforcement cannot be verified.`, + resourceType: 'azure-postgresql-flexible-server', + resourceId: s.id, + severity: 'medium', + remediation: + 'Grant read access to server configurations (Microsoft.DBforPostgreSQL/flexibleServers/configurations/read), then re-run the check.', + evidence: { server: s.name }, + }); + continue; + } + + const { compliant, issues } = evaluatePgTls(requireSecure.value, sslMin.value); + const evidence = { + server: s.name, + requireSecureTransport: requireSecure.value, + sslMinProtocolVersion: sslMin.value, + }; + if (compliant) { + ctx.pass({ + title: `TLS 1.2 enforced: ${s.name}`, + description: `PostgreSQL flexible server "${s.name}" requires secure transport and a minimum TLS version of 1.2.`, + resourceType: 'azure-postgresql-flexible-server', + resourceId: s.id, + evidence, + }); + } else { + ctx.fail({ + title: `Outdated TLS configuration: ${s.name}`, + description: `PostgreSQL flexible server "${s.name}": ${issues.join('; ')}.`, + resourceType: 'azure-postgresql-flexible-server', + resourceId: s.id, + severity: 'medium', + remediation: + 'Set require_secure_transport to ON and ssl_min_protocol_version to TLSv1.2 (or TLSv1.3).', + evidence, + }); + } + } + }, +}; diff --git a/packages/integration-platform/src/manifests/azure/index.ts b/packages/integration-platform/src/manifests/azure/index.ts index c81aed3b26..ac8cc144c1 100644 --- a/packages/integration-platform/src/manifests/azure/index.ts +++ b/packages/integration-platform/src/manifests/azure/index.ts @@ -3,7 +3,9 @@ import { keyVaultProtectionCheck, keyVaultRbacCheck, monitorLoggingAlertingCheck, + mysqlFlexibleTlsCheck, nsgNoOpenPortsCheck, + postgresqlFlexibleTlsCheck, rbacLeastPrivilegeCheck, sqlAuditingCheck, sqlPublicAccessCheck, @@ -84,6 +86,8 @@ Our integration only makes read-only API calls for security scanning.`, { id: 'network-watcher', name: 'Network Watcher', description: 'Network security group and flow log monitoring', enabledByDefault: false, implemented: true }, { id: 'storage-account', name: 'Storage Accounts', description: 'HTTPS enforcement, public access, TLS version, and encryption checks', enabledByDefault: false, implemented: true }, { id: 'sql-database', name: 'SQL Database', description: 'Auditing, TDE, firewall rules, and public access checks', enabledByDefault: false, implemented: true }, + { id: 'mysql-flexible', name: 'Database for MySQL', description: 'Flexible Server TLS 1.2 / secure transport enforcement checks', enabledByDefault: false, implemented: true }, + { id: 'postgresql-flexible', name: 'Database for PostgreSQL', description: 'Flexible Server TLS 1.2 / secure transport enforcement checks', enabledByDefault: false, implemented: true }, { id: 'virtual-machine', name: 'Virtual Machines', description: 'Disk encryption, managed identity, and secure boot checks', enabledByDefault: false, implemented: true }, { id: 'app-service', name: 'App Service', description: 'HTTPS enforcement, TLS, managed identity, and remote debugging checks', enabledByDefault: false, implemented: true }, { id: 'aks', name: 'AKS', description: 'Kubernetes RBAC, network policies, private cluster, and auto-upgrade checks', enabledByDefault: false, implemented: true }, @@ -110,6 +114,8 @@ Our integration only makes read-only API calls for security scanning.`, sqlTlsCheck, sqlPublicAccessCheck, sqlAuditingCheck, + mysqlFlexibleTlsCheck, + postgresqlFlexibleTlsCheck, keyVaultProtectionCheck, keyVaultRbacCheck, nsgNoOpenPortsCheck,