From 030d25c644f31700476a8f96df0cdfb2a90ffce3 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 8 Jun 2026 18:06:21 -0400 Subject: [PATCH 1/6] feat(integration-platform): add azure mysql flexible server tls check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A customer (MySQL Flexible Server) saw the "Azure SQL Database — TLS 1.2 enforced" evidence check show "0 passed" while the Storage check showed a count. The ticket suspected a missing ctx.pass() summary, but the SQL TLS check already emits ctx.pass() identically to Storage. The real cause is a coverage gap: the SQL check targets Microsoft.Sql/servers (Azure SQL Database), and the customer runs Azure Database for MySQL Flexible Server (Microsoft.DBforMySQL/flexibleServers) — a different resource type — so the check finds zero servers and no-ops (0 results), while Storage found 2. Add a MySQL Flexible Server TLS check that targets the correct resource type and reads TLS enforcement from server parameters (require_secure_transport + tls_version), since MySQL Flexible Server has no top-level minimalTlsVersion like Azure SQL. Compliant = require_secure_transport ON and tls_version floor permits only TLS 1.2+ (handles the comma-separated TLSv1.2,TLSv1.3 set). Maps to the same TLS/HTTPS evidence task so the customer's databases are verified. Follows existing conventions: no-op on zero resources, explicit "could not verify" on a config read failure (no false pass), pass/fail with evidence + remediation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../checks/__tests__/azure-checks.test.ts | 109 ++++++++++++ .../src/manifests/azure/checks/index.ts | 1 + .../manifests/azure/checks/mysql-flexible.ts | 162 ++++++++++++++++++ .../src/manifests/azure/index.ts | 3 + 4 files changed, 275 insertions(+) create mode 100644 packages/integration-platform/src/manifests/azure/checks/mysql-flexible.ts 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..99cdced889 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,6 +7,11 @@ 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 { sqlAuditingCheck, sqlPublicAccessCheck, sqlTlsCheck } from '../sql'; import { @@ -384,3 +389,107 @@ 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); + }); +}); diff --git a/packages/integration-platform/src/manifests/azure/checks/index.ts b/packages/integration-platform/src/manifests/azure/checks/index.ts index 58db3094cd..9ab2b2af5d 100644 --- a/packages/integration-platform/src/manifests/azure/checks/index.ts +++ b/packages/integration-platform/src/manifests/azure/checks/index.ts @@ -4,6 +4,7 @@ export { storageEncryptionCheck, } from './storage'; export { sqlTlsCheck, sqlPublicAccessCheck, sqlAuditingCheck } from './sql'; +export { mysqlFlexibleTlsCheck } from './mysql-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/index.ts b/packages/integration-platform/src/manifests/azure/index.ts index c81aed3b26..108ea2daa2 100644 --- a/packages/integration-platform/src/manifests/azure/index.ts +++ b/packages/integration-platform/src/manifests/azure/index.ts @@ -3,6 +3,7 @@ import { keyVaultProtectionCheck, keyVaultRbacCheck, monitorLoggingAlertingCheck, + mysqlFlexibleTlsCheck, nsgNoOpenPortsCheck, rbacLeastPrivilegeCheck, sqlAuditingCheck, @@ -84,6 +85,7 @@ 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: '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 +112,7 @@ Our integration only makes read-only API calls for security scanning.`, sqlTlsCheck, sqlPublicAccessCheck, sqlAuditingCheck, + mysqlFlexibleTlsCheck, keyVaultProtectionCheck, keyVaultRbacCheck, nsgNoOpenPortsCheck, From a992862af3b40a6da1de74a5d467c1f4b97bf738 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 8 Jun 2026 18:14:09 -0400 Subject: [PATCH 2/6] feat(integration-platform): add azure postgresql flexible server tls check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same coverage-gap class as the MySQL Flexible Server fix: the Azure manifest scans Microsoft.Sql/servers and Microsoft.DBforMySQL/flexibleServers but NOT Microsoft.DBforPostgreSQL/flexibleServers, so a customer running only PostgreSQL Flexible Server gets 0 servers found → "0 passed" for the TLS task. Add azure-postgresql-flexible-tls, mapped to the same TLS/HTTPS task. Like MySQL, TLS lives in server parameters, but PostgreSQL differs: ssl_min_protocol_ version is a SINGLE value (not MySQL's comma-separated tls_version set) and may be unset by default — PostgreSQL Flexible Server only allows TLS 1.2/1.3 and denies 1.0/1.1 regardless, so require_secure_transport=ON is the real determinant and an unset SSL floor is treated as compliant (avoids false-failing default-secure servers). api-version 2024-08-01 (its own RP/ version train, not MySQL's 2023-12-30). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../checks/__tests__/azure-checks.test.ts | 89 +++++++++ .../src/manifests/azure/checks/index.ts | 1 + .../azure/checks/postgresql-flexible.ts | 179 ++++++++++++++++++ .../src/manifests/azure/index.ts | 3 + 4 files changed, 272 insertions(+) create mode 100644 packages/integration-platform/src/manifests/azure/checks/postgresql-flexible.ts 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 99cdced889..28f43b1423 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 @@ -13,6 +13,11 @@ import { mysqlFlexibleTlsCheck, } from '../mysql-flexible'; import { nsgNoOpenPortsCheck } from '../network'; +import { + evaluatePgTls, + isPgTlsVersionCompliant, + postgresqlFlexibleTlsCheck, +} from '../postgresql-flexible'; import { sqlAuditingCheck, sqlPublicAccessCheck, sqlTlsCheck } from '../sql'; import { storageEncryptionCheck, @@ -493,3 +498,87 @@ describe('Azure MySQL Flexible Server TLS check', () => { 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('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 9ab2b2af5d..ce7d9f9888 100644 --- a/packages/integration-platform/src/manifests/azure/checks/index.ts +++ b/packages/integration-platform/src/manifests/azure/checks/index.ts @@ -5,6 +5,7 @@ export { } 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/postgresql-flexible.ts b/packages/integration-platform/src/manifests/azure/checks/postgresql-flexible.ts new file mode 100644 index 0000000000..32137951bc --- /dev/null +++ b/packages/integration-platform/src/manifests/azure/checks/postgresql-flexible.ts @@ -0,0 +1,179 @@ +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 value. Returns null when the read fails + * (permission/transient) or the value is absent. + */ +async function readConfigValue( + ctx: CheckContext, + serverId: string, + name: string, +): Promise { + const res = await ctx + .fetch( + `${ARM_BASE}${serverId}/configurations/${name}?api-version=${POSTGRES_API_VERSION}`, + ) + .catch(() => null); + const value = res?.properties?.value; + return typeof value === 'string' ? value : null; +} + +/** + * 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) { + // require_secure_transport is the primary TLS-enforcement gate and always + // carries a value — a null read means we genuinely could not read it. + const requireSecureTransport = await readConfigValue( + ctx, + s.id, + 'require_secure_transport', + ); + if (requireSecureTransport === null) { + 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; + } + + // ssl_min_protocol_version may be unset (null) by default — that still + // means a TLS 1.2 floor, so treat null as "" (compliant) rather than a + // read failure. + const sslMinProtocolVersion = await readConfigValue( + ctx, + s.id, + 'ssl_min_protocol_version', + ); + const { compliant, issues } = evaluatePgTls( + requireSecureTransport, + sslMinProtocolVersion ?? '', + ); + 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: { + server: s.name, + requireSecureTransport, + sslMinProtocolVersion, + }, + }); + } 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: { + server: s.name, + requireSecureTransport, + sslMinProtocolVersion, + }, + }); + } + } + }, +}; diff --git a/packages/integration-platform/src/manifests/azure/index.ts b/packages/integration-platform/src/manifests/azure/index.ts index 108ea2daa2..ac8cc144c1 100644 --- a/packages/integration-platform/src/manifests/azure/index.ts +++ b/packages/integration-platform/src/manifests/azure/index.ts @@ -5,6 +5,7 @@ import { monitorLoggingAlertingCheck, mysqlFlexibleTlsCheck, nsgNoOpenPortsCheck, + postgresqlFlexibleTlsCheck, rbacLeastPrivilegeCheck, sqlAuditingCheck, sqlPublicAccessCheck, @@ -86,6 +87,7 @@ Our integration only makes read-only API calls for security scanning.`, { 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 }, @@ -113,6 +115,7 @@ Our integration only makes read-only API calls for security scanning.`, sqlPublicAccessCheck, sqlAuditingCheck, mysqlFlexibleTlsCheck, + postgresqlFlexibleTlsCheck, keyVaultProtectionCheck, keyVaultRbacCheck, nsgNoOpenPortsCheck, From bec693a33ad62ef3c7902ef4772ceb3cc638035f Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 8 Jun 2026 18:31:58 -0400 Subject: [PATCH 3/6] fix(integration-platform): surface postgres ssl-config read failures as "could not verify" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic flagged that a read failure of ssl_min_protocol_version was silently treated as compliant: readConfigValue returned null for BOTH "value unset" and "fetch failed", and `?? ''` coalesced both into a pass — masking permission / transient errors. Replace it with readConfig() returning a discriminated { ok, value }: a thrown fetch -> { ok:false } -> explicit "Could not verify" finding; a successful read with an absent/unset value -> "" -> a legitimate TLS 1.2 floor (PostgreSQL Flexible Server denies <1.2 regardless) -> compliant. The two outcomes are no longer conflated. Added a regression test for the ssl-read-failure path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../checks/__tests__/azure-checks.test.ts | 28 ++++++++ .../azure/checks/postgresql-flexible.ts | 71 ++++++++----------- 2 files changed, 59 insertions(+), 40 deletions(-) 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 28f43b1423..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 @@ -576,6 +576,34 @@ describe('Azure PostgreSQL Flexible Server TLS check', () => { 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); diff --git a/packages/integration-platform/src/manifests/azure/checks/postgresql-flexible.ts b/packages/integration-platform/src/manifests/azure/checks/postgresql-flexible.ts index 32137951bc..8377f5511a 100644 --- a/packages/integration-platform/src/manifests/azure/checks/postgresql-flexible.ts +++ b/packages/integration-platform/src/manifests/azure/checks/postgresql-flexible.ts @@ -74,21 +74,26 @@ async function listPgFlexibleServers( } /** - * Read a single server configuration value. Returns null when the read fails - * (permission/transient) or the value is absent. + * 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 readConfigValue( +async function readConfig( ctx: CheckContext, serverId: string, name: string, -): Promise { - const res = await ctx - .fetch( +): Promise<{ ok: true; value: string } | { ok: false }> { + try { + const res = await ctx.fetch( `${ARM_BASE}${serverId}/configurations/${name}?api-version=${POSTGRES_API_VERSION}`, - ) - .catch(() => null); - const value = res?.properties?.value; - return typeof value === 'string' ? value : null; + ); + const value = res?.properties?.value; + return { ok: true, value: typeof value === 'string' ? value : '' }; + } catch { + return { ok: false }; + } } /** @@ -113,14 +118,14 @@ export const postgresqlFlexibleTlsCheck: IntegrationCheck = { if (!servers) return; if (servers.length === 0) return; for (const s of servers) { - // require_secure_transport is the primary TLS-enforcement gate and always - // carries a value — a null read means we genuinely could not read it. - const requireSecureTransport = await readConfigValue( - ctx, - s.id, - 'require_secure_transport', - ); - if (requireSecureTransport === null) { + 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.`, @@ -134,29 +139,19 @@ export const postgresqlFlexibleTlsCheck: IntegrationCheck = { continue; } - // ssl_min_protocol_version may be unset (null) by default — that still - // means a TLS 1.2 floor, so treat null as "" (compliant) rather than a - // read failure. - const sslMinProtocolVersion = await readConfigValue( - ctx, - s.id, - 'ssl_min_protocol_version', - ); - const { compliant, issues } = evaluatePgTls( - requireSecureTransport, - sslMinProtocolVersion ?? '', - ); + 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: { - server: s.name, - requireSecureTransport, - sslMinProtocolVersion, - }, + evidence, }); } else { ctx.fail({ @@ -167,11 +162,7 @@ export const postgresqlFlexibleTlsCheck: IntegrationCheck = { severity: 'medium', remediation: 'Set require_secure_transport to ON and ssl_min_protocol_version to TLSv1.2 (or TLSv1.3).', - evidence: { - server: s.name, - requireSecureTransport, - sslMinProtocolVersion, - }, + evidence, }); } } From 04c45e970a22ece15b37554890f6884ee1535769 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 8 Jun 2026 18:47:36 -0400 Subject: [PATCH 4/6] fix(cloud-security): resolve the real CloudTrail log group for cloudwatch metric-filter auto-fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customer (aymenrc) auto-fix kept failing with an AI-generated "could not determine which CloudTrail log group to attach the metric filter to" across all CloudWatch CIS metric-filter fixes. Earlier PRs (#3050/#3052/#3054) fixed the PutMetricFilter param shape and prompt wording, but never the actual blocker: nothing ever told the fix WHICH log group to use. The check already calls DescribeTrails and reads CloudWatchLogsLogGroupArn, but only to compute a boolean — it discarded the log group. The finding evidence was just { keywords, filterFound:false } and the remediation said the generic "the CloudTrail log group", so the AI had to guess the name and bailed (canAutoFix: false), surfacing that message verbatim. The prompt's claim that "the system will resolve the real one from readSteps" was unbacked — there is no deterministic resolver, only a flaky second LLM pass. Fix (adapter, the right layer — zero new AWS calls/IAM): - Capture the integrated trail's CloudWatchLogsLogGroupArn and derive the bare log-group name (logGroupNameFromArn). - Put the concrete name into the metric-filter-missing finding's remediation text + evidence (cloudWatchLogGroupName); for the existing-filter update case, use the filter's own logGroupName (from DescribeMetricFilters). - Point the AI prompt at the evidence field instead of a placeholder. The "no CloudTrail->CloudWatch integration" prerequisite is unchanged (separate early-return finding). No customer reconnect — a re-scan regenerates findings with the log group, then auto-fix works. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cloud-security/ai-remediation.prompt.ts | 2 +- .../providers/aws/cloudwatch.adapter.spec.ts | 157 ++++++++++++++++++ .../providers/aws/cloudwatch.adapter.ts | 59 ++++++- 3 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/cloud-security/providers/aws/cloudwatch.adapter.spec.ts 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/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, })), From 2e7e6bab04e5958da27e41730c5a7dfcf2443e2b Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 8 Jun 2026 18:57:25 -0400 Subject: [PATCH 5/6] fix(cloud-security): deterministically pin the metric-filter log group (remove AI dependency) Belt-and-suspenders for the CloudWatch metric-filter fix. The adapter now puts the real CloudTrail log group in the finding evidence, but the fix plan is still AI-generated. Add a deterministic override (applyResolvedMetricFilterLogGroup): after the plan is finalized, force logGroupName on every PutMetricFilterCommand step from the finding evidence (cloudWatchLogGroupName for a missing filter, logGroupName for an existing-filter update), in both the preview and execute paths. The executed value is now guaranteed correct regardless of what the model produced. Scoped to PutMetricFilter steps; no-op for other commands or when no log group was resolved. Extracted to a prisma-free util so it's unit-testable. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../metric-filter-loggroup.spec.ts | 56 +++++++++++++++++++ .../cloud-security/metric-filter-loggroup.ts | 38 +++++++++++++ .../src/cloud-security/remediation.service.ts | 11 ++++ 3 files changed, 105 insertions(+) create mode 100644 apps/api/src/cloud-security/metric-filter-loggroup.spec.ts create mode 100644 apps/api/src/cloud-security/metric-filter-loggroup.ts 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/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 From 1b8b3ee8ffd63191690fea29801a77c9339dc3ba Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 8 Jun 2026 19:02:32 -0400 Subject: [PATCH 6/6] chore(cloud-security): upgrade remediation models to latest (Opus 4.8, Sonnet 4.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-fix flow leans heavily on the LLM to emit exact AWS SDK commands as structured output; newer models follow instructions + schemas more reliably, which should raise auto-fix success rates across the board. Bump the two remediation models that were behind: - MODEL: claude-opus-4-6 -> claude-opus-4-8 (plan generation/refinement/repair) - FALLBACK_MODEL: claude-sonnet-4-5 -> claude-sonnet-4-6 (manual-steps fallback) Descriptions intentionally stay on claude-haiku-4-5 (already current, and the right tier for simple cached explanatory text — Sonnet would add cost + latency for no clear gain). Typecheck confirms the SDK accepts the new model ids; this is a config/behavior change, so validate auto-fix output on a few real findings before relying on it. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/cloud-security/ai-remediation.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 {