From d294ba0312015dd3fa30abbb50f8644d5cec0d52 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 17 Jun 2026 13:32:17 -0400 Subject: [PATCH 1/2] feat(integrations): add GCP Monitoring & Alerting and Encryption at Rest checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GCP was absent from the Monitoring & Alerting and Encryption at Rest evidence tasks while AWS and Azure appeared, because an integration only shows on a task when one of its checks declares a matching taskMapping — and GCP shipped no checks for those templates (it covered only 4 of the templates AWS/Azure cover). Add three GCP checks, reusing the existing GCP check runtime (resolveGcpProjectIds, gcpListItems, ctx.fetch, isGcpApiDisabled): - gcp-cloud-monitoring-alerting (→ monitoringAlerting): two prongs per project, mirroring the Azure Monitor check — an enabled alert policy wired to a notification channel, and a configured log-export sink beyond the managed _Default/_Required sinks. An unreadable prong fails "could not verify" rather than silently passing. - gcp-storage-encryption / gcp-cloud-sql-encryption (→ encryptionAtRest): GCP always encrypts at rest (Google-managed AES-256, non-disableable), so these pass each resource and record the key type (Google-managed vs CMEK), matching the intent of the AWS default-encryption checks. Only read failures emit a finding. Both run identically on the manual run-check path and the scheduled orchestrator: GCP is a static manifest (getManifest resolves it with these checks), shouldRunOnServer('gcp') is false so it runs in-process in the Trigger.dev runtime, and it calls public *.googleapis.com — so it is unaffected by the AWS VPC / dynamic-integration scheduler constraints. GCP coverage: 4 → 6 task templates (5 → 8 checks). Adds 20 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../gcp/checks/__tests__/gcp-checks.test.ts | 249 +++++++++++++++++- .../gcp/checks/cloud-monitoring-alerting.ts | 221 ++++++++++++++++ .../gcp/checks/cloud-sql-encryption.ts | 90 +++++++ .../src/manifests/gcp/checks/index.ts | 3 + .../gcp/checks/storage-encryption.ts | 92 +++++++ .../src/manifests/gcp/index.ts | 6 + 6 files changed, 659 insertions(+), 2 deletions(-) create mode 100644 packages/integration-platform/src/manifests/gcp/checks/cloud-monitoring-alerting.ts create mode 100644 packages/integration-platform/src/manifests/gcp/checks/cloud-sql-encryption.ts create mode 100644 packages/integration-platform/src/manifests/gcp/checks/storage-encryption.ts diff --git a/packages/integration-platform/src/manifests/gcp/checks/__tests__/gcp-checks.test.ts b/packages/integration-platform/src/manifests/gcp/checks/__tests__/gcp-checks.test.ts index db4cd9a58d..85e0174e70 100644 --- a/packages/integration-platform/src/manifests/gcp/checks/__tests__/gcp-checks.test.ts +++ b/packages/integration-platform/src/manifests/gcp/checks/__tests__/gcp-checks.test.ts @@ -4,15 +4,22 @@ import type { CheckVariableValues, IntegrationCheck, } from '../../../../types'; +import { cloudMonitoringAlertingCheck } from '../cloud-monitoring-alerting'; import { cloudSqlBackupsCheck } from '../cloud-sql-backups'; +import { cloudSqlEncryptionCheck } from '../cloud-sql-encryption'; import { cloudSqlSslCheck } from '../cloud-sql-ssl'; import { iamPrimitiveRolesCheck } from '../iam-primitive-roles'; +import { storageEncryptionCheck } from '../storage-encryption'; import { storagePublicAccessCheck } from '../storage-public-access'; import { vpcOpenFirewallsCheck } from '../vpc-open-firewalls'; import { isGcpApiDisabled } from '../shared'; interface Captured { - passed: Array<{ resourceId: string; title: string }>; + passed: Array<{ + resourceId: string; + title: string; + evidence?: Record; + }>; failed: Array<{ resourceId: string; title: string; @@ -43,7 +50,12 @@ async function runCheck( log: () => {}, warn: () => {}, error: () => {}, - pass: (r) => passed.push({ resourceId: r.resourceId, title: r.title }), + pass: (r) => + passed.push({ + resourceId: r.resourceId, + title: r.title, + evidence: r.evidence, + }), fail: (r) => failed.push({ resourceId: r.resourceId, @@ -578,3 +590,236 @@ describe('No projects resolved → check no-ops (no false pass)', () => { expect(failed).toHaveLength(0); }); }); + +describe('GCP Cloud Monitoring — alerting and log export check', () => { + // Branch a single mock by which API the check is calling. + const monitorFetch = + (opts: { policies?: unknown[]; sinks?: unknown[] }) => (url: string) => { + if (url.includes('/alertPolicies')) { + return { alertPolicies: opts.policies ?? [] }; + } + if (url.includes('/sinks')) return { sinks: opts.sinks ?? [] }; + return {}; + }; + + const status = (err: Error, code: number) => { + (err as Error & { status: number }).status = code; + return err; + }; + + it('passes both prongs: enabled alert policy with a channel + a configured sink', async () => { + const out = await runCheck(cloudMonitoringAlertingCheck, { + fetch: monitorFetch({ + policies: [ + { + name: 'p1', + displayName: 'High CPU', + enabled: true, + notificationChannels: ['projects/x/notificationChannels/1'], + }, + ], + sinks: [ + { name: 'export-bq', destination: 'bigquery.googleapis.com/x', disabled: false }, + { name: '_Default', disabled: false }, + ], + }), + }); + expect(out.failed).toHaveLength(0); + expect(out.passed).toHaveLength(2); + expect(out.passed.some((p) => /Alerting configured/.test(p.title))).toBe(true); + expect(out.passed.some((p) => /Log export configured/.test(p.title))).toBe(true); + }); + + it('fails alerting when a policy has no notification channel (log export still passes)', async () => { + const out = await runCheck(cloudMonitoringAlertingCheck, { + fetch: monitorFetch({ + policies: [{ name: 'p1', enabled: true, notificationChannels: [] }], + sinks: [{ name: 'export-bq', disabled: false }], + }), + }); + expect(out.failed).toHaveLength(1); + expect(out.failed[0]!.title).toMatch(/No alerting configured/); + expect(out.passed).toHaveLength(1); + expect(out.passed[0]!.title).toMatch(/Log export configured/); + }); + + it('fails alerting when there are zero alert policies', async () => { + const out = await runCheck(cloudMonitoringAlertingCheck, { + fetch: monitorFetch({ policies: [], sinks: [{ name: 'export', disabled: false }] }), + }); + expect(out.failed.some((f) => /No alerting configured/.test(f.title))).toBe(true); + }); + + it('treats an unset `enabled` field as enabled (API default)', async () => { + const out = await runCheck(cloudMonitoringAlertingCheck, { + fetch: monitorFetch({ + policies: [{ name: 'p1', notificationChannels: ['c1'] }], // no `enabled` + sinks: [{ name: 'export', disabled: false }], + }), + }); + expect(out.failed).toHaveLength(0); + expect(out.passed).toHaveLength(2); + }); + + it('fails log export when only the managed _Default/_Required sinks exist', async () => { + const out = await runCheck(cloudMonitoringAlertingCheck, { + fetch: monitorFetch({ + policies: [{ name: 'p1', enabled: true, notificationChannels: ['c1'] }], + sinks: [ + { name: '_Default', disabled: false }, + { name: '_Required', disabled: false }, + ], + }), + }); + expect(out.passed.some((p) => /Alerting configured/.test(p.title))).toBe(true); + expect(out.failed.some((f) => /No log export configured/.test(f.title))).toBe(true); + }); + + it('does not count a disabled sink as log export', async () => { + const out = await runCheck(cloudMonitoringAlertingCheck, { + fetch: monitorFetch({ + policies: [{ name: 'p1', enabled: true, notificationChannels: ['c1'] }], + sinks: [{ name: 'export', disabled: true }], + }), + }); + expect(out.failed.some((f) => /No log export configured/.test(f.title))).toBe(true); + }); + + it('fails "could not verify" alerting on a genuine permission error (log export unaffected)', async () => { + const out = await runCheck(cloudMonitoringAlertingCheck, { + fetch: (url) => { + if (url.includes('/alertPolicies')) { + throw status( + new Error('HTTP 403: Forbidden - The caller does not have permission'), + 403, + ); + } + if (url.includes('/sinks')) return { sinks: [{ name: 'export', disabled: false }] }; + return {}; + }, + }); + expect(out.failed.some((f) => /Could not verify alerting/.test(f.title))).toBe(true); + expect(out.passed.some((p) => /Log export configured/.test(p.title))).toBe(true); + }); + + it('skips a project whose Monitoring/Logging APIs are disabled (no false finding)', async () => { + const out = await runCheck(cloudMonitoringAlertingCheck, { + fetch: () => { + throw status( + new Error( + 'HTTP 403: Forbidden - Cloud Monitoring API has not been used in project p before or it is disabled. (SERVICE_DISABLED)', + ), + 403, + ); + }, + }); + expect(out.passed).toHaveLength(0); + expect(out.failed).toHaveLength(0); + }); +}); + +describe('GCP Cloud Storage encryption check', () => { + const status = (err: Error, code: number) => { + (err as Error & { status: number }).status = code; + return err; + }; + + it('passes a bucket and reports Google-managed encryption by default', async () => { + const out = await runCheck(storageEncryptionCheck, { + fetch: () => ({ items: [{ name: 'b1', location: 'US' }] }), + }); + expect(out.failed).toHaveLength(0); + expect(out.passed).toHaveLength(1); + expect(out.passed[0]!.evidence).toMatchObject({ + keyType: 'Google-managed', + defaultKmsKeyName: null, + }); + }); + + it('reports CMEK when a default KMS key is set on the bucket', async () => { + const key = 'projects/x/locations/us/keyRings/r/cryptoKeys/k'; + const out = await runCheck(storageEncryptionCheck, { + fetch: () => ({ + items: [{ name: 'b1', encryption: { defaultKmsKeyName: key } }], + }), + }); + expect(out.passed[0]!.evidence).toMatchObject({ + keyType: 'CMEK', + defaultKmsKeyName: key, + }); + }); + + it('emits nothing when a project has no buckets', async () => { + const out = await runCheck(storageEncryptionCheck, { + fetch: () => ({ items: [] }), + }); + expect(out.passed).toHaveLength(0); + expect(out.failed).toHaveLength(0); + }); + + it('fails "could not verify" when the bucket list read fails', async () => { + const out = await runCheck(storageEncryptionCheck, { + fetch: () => { + throw status(new Error('HTTP 403: Forbidden'), 403); + }, + }); + expect(out.passed).toHaveLength(0); + expect(out.failed).toHaveLength(1); + expect(out.failed[0]!.title).toMatch(/Could not verify Cloud Storage encryption/); + }); + + it('skips a project whose Storage API is disabled (no false finding)', async () => { + const out = await runCheck(storageEncryptionCheck, { + fetch: () => { + throw status( + new Error( + 'HTTP 403: Forbidden - Cloud Storage API has not been used in project p before or it is disabled. (SERVICE_DISABLED)', + ), + 403, + ); + }, + }); + expect(out.passed).toHaveLength(0); + expect(out.failed).toHaveLength(0); + }); +}); + +describe('GCP Cloud SQL encryption check', () => { + const status = (err: Error, code: number) => { + (err as Error & { status: number }).status = code; + return err; + }; + + it('passes an instance and reports Google-managed encryption by default', async () => { + const out = await runCheck(cloudSqlEncryptionCheck, { + fetch: () => ({ items: [{ name: 'db1', region: 'us-central1' }] }), + }); + expect(out.failed).toHaveLength(0); + expect(out.passed).toHaveLength(1); + expect(out.passed[0]!.evidence).toMatchObject({ + keyType: 'Google-managed', + kmsKeyName: null, + }); + }); + + it('reports CMEK when diskEncryptionConfiguration is set', async () => { + const key = 'projects/x/locations/us/keyRings/r/cryptoKeys/k'; + const out = await runCheck(cloudSqlEncryptionCheck, { + fetch: () => ({ + items: [{ name: 'db1', diskEncryptionConfiguration: { kmsKeyName: key } }], + }), + }); + expect(out.passed[0]!.evidence).toMatchObject({ keyType: 'CMEK', kmsKeyName: key }); + }); + + it('fails "could not verify" when the instance list read fails', async () => { + const out = await runCheck(cloudSqlEncryptionCheck, { + fetch: () => { + throw status(new Error('HTTP 403: Forbidden'), 403); + }, + }); + expect(out.passed).toHaveLength(0); + expect(out.failed).toHaveLength(1); + expect(out.failed[0]!.title).toMatch(/Could not verify Cloud SQL encryption/); + }); +}); diff --git a/packages/integration-platform/src/manifests/gcp/checks/cloud-monitoring-alerting.ts b/packages/integration-platform/src/manifests/gcp/checks/cloud-monitoring-alerting.ts new file mode 100644 index 0000000000..840506edc4 --- /dev/null +++ b/packages/integration-platform/src/manifests/gcp/checks/cloud-monitoring-alerting.ts @@ -0,0 +1,221 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { + remediationForReadFailure, + toHttpReadFailure, +} from '../../http-read-failure'; +import { gcpListItems, isGcpApiDisabled, resolveGcpProjectIds } from './shared'; + +interface AlertPolicy { + name: string; + displayName?: string; + /** Defaults to enabled when unset (per Cloud Monitoring API). */ + enabled?: boolean; + notificationChannels?: string[]; +} + +interface LogSink { + name: string; + destination?: string; + disabled?: boolean; +} + +// Every project has two managed sinks (`_Required`, `_Default`) writing to the +// project's `_Default` log bucket. They are present by default and are NOT +// evidence that the customer configured durable log routing/retention, so they +// don't count as a "log export". Any other enabled sink is operator-configured. +const DEFAULT_SINK_NAMES = new Set(['_Required', '_Default']); + +/** + * Per-project alerting prong: at least one enabled alert policy must be wired to + * a notification channel, otherwise alerts fire into the void. Mirrors the + * Azure Monitor check's "activity log alerts" half. + */ +async function evaluateAlerting( + ctx: CheckContext, + projectId: string, +): Promise { + let policies: AlertPolicy[]; + try { + policies = await gcpListItems( + ctx, + `https://monitoring.googleapis.com/v3/projects/${encodeURIComponent(projectId)}/alertPolicies`, + 'alertPolicies', + ); + } catch (err) { + // Monitoring API not enabled on this project — no alerting exists to + // evaluate here. Skip like a zero-resource project rather than emit a + // false "grant permission" finding (consistent with the other GCP checks). + if (isGcpApiDisabled(err)) { + ctx.log( + `GCP Cloud Monitoring: API not enabled in project "${projectId}" — skipping alerting`, + ); + return; + } + const failure = toHttpReadFailure(err); + ctx.fail({ + title: `Could not verify alerting: ${projectId}`, + description: `Alert policies for project "${projectId}" could not be listed (${failure.error}), so alerting is unverified.`, + resourceType: 'gcp-project', + resourceId: projectId, + severity: 'medium', + remediation: remediationForReadFailure( + failure, + 'Grant monitoring.alertPolicies.list (e.g. roles/monitoring.viewer) to the connection for this project, then re-run.', + ), + evidence: { projectId, error: failure.error }, + }); + return; + } + + // enabled defaults to true when unset; a policy only "notifies" if it targets + // at least one notification channel. + const active = policies.filter( + (p) => p.enabled !== false && (p.notificationChannels?.length ?? 0) > 0, + ); + + if (active.length > 0) { + ctx.pass({ + title: `Alerting configured: ${projectId}`, + description: `Project "${projectId}" has ${active.length} enabled alert ${active.length === 1 ? 'policy' : 'policies'} wired to a notification channel.`, + resourceType: 'gcp-project', + resourceId: projectId, + evidence: { + projectId, + enabledPoliciesWithChannel: active.length, + totalPolicies: policies.length, + samplePolicies: active + .slice(0, 5) + .map((p) => p.displayName ?? p.name), + }, + }); + return; + } + + ctx.fail({ + title: `No alerting configured: ${projectId}`, + description: + policies.length === 0 + ? `Project "${projectId}" has no Cloud Monitoring alert policies.` + : `Project "${projectId}" has alert policies, but none are enabled with a notification channel, so no alerts reach anyone.`, + resourceType: 'gcp-project', + resourceId: projectId, + severity: 'medium', + remediation: + 'Create a Cloud Monitoring alert policy and attach a notification channel (email, Slack, PagerDuty, etc.) so incidents are surfaced.', + evidence: { + projectId, + totalPolicies: policies.length, + enabledPoliciesWithChannel: 0, + }, + }); +} + +/** + * Per-project log-export prong: at least one operator-configured, enabled log + * sink must route logs to durable storage (BigQuery / Cloud Storage / Pub/Sub + * or a non-default log bucket). Mirrors the Azure Monitor check's "diagnostic + * log export" half. GCP always captures logs short-term, so the meaningful + * control is durable export/retention beyond the managed `_Default` sink. + */ +async function evaluateLogExport( + ctx: CheckContext, + projectId: string, +): Promise { + let sinks: LogSink[]; + try { + sinks = await gcpListItems( + ctx, + `https://logging.googleapis.com/v2/projects/${encodeURIComponent(projectId)}/sinks`, + 'sinks', + ); + } catch (err) { + if (isGcpApiDisabled(err)) { + ctx.log( + `GCP Cloud Logging: API not enabled in project "${projectId}" — skipping log export`, + ); + return; + } + const failure = toHttpReadFailure(err); + ctx.fail({ + title: `Could not verify log export: ${projectId}`, + description: `Log sinks for project "${projectId}" could not be listed (${failure.error}), so log export is unverified.`, + resourceType: 'gcp-project', + resourceId: projectId, + severity: 'medium', + remediation: remediationForReadFailure( + failure, + 'Grant logging.sinks.list (e.g. roles/logging.viewer) to the connection for this project, then re-run.', + ), + evidence: { projectId, error: failure.error }, + }); + return; + } + + const exportSinks = sinks.filter( + (s) => s.disabled !== true && !DEFAULT_SINK_NAMES.has(s.name), + ); + + if (exportSinks.length > 0) { + ctx.pass({ + title: `Log export configured: ${projectId}`, + description: `Project "${projectId}" routes logs to ${exportSinks.length} configured sink ${exportSinks.length === 1 ? 'destination' : 'destinations'} for durable retention.`, + resourceType: 'gcp-project', + resourceId: projectId, + evidence: { + projectId, + exportSinks: exportSinks.length, + destinations: exportSinks + .slice(0, 5) + .map((s) => s.destination ?? s.name), + }, + }); + return; + } + + ctx.fail({ + title: `No log export configured: ${projectId}`, + description: `Project "${projectId}" has no enabled log sink exporting logs beyond the default in-project retention.`, + resourceType: 'gcp-project', + resourceId: projectId, + severity: 'medium', + remediation: + 'Create a log sink that exports logs to BigQuery, Cloud Storage, Pub/Sub, or a dedicated log bucket with extended retention.', + evidence: { + projectId, + // Total includes the managed `_Default`/`_Required` sinks, which do not + // count as durable export. + sinksFound: sinks.length, + exportSinks: 0, + }, + }); +} + +/** + * Cloud Monitoring & Alerting check (direct API, no SCC). Two prongs per + * project — alert policies wired to a notification channel, and durable log + * export via a configured sink — mirroring the AWS CloudTrail and Azure Monitor + * checks that share the "Monitoring & Alerting" task. An unreadable prong fails + * "could not verify" rather than silently passing the shared task. + */ +export const cloudMonitoringAlertingCheck: IntegrationCheck = { + id: 'gcp-cloud-monitoring-alerting', + name: 'Cloud Monitoring — alerting and log export', + description: + 'Verify alert policies notify a channel and logs are exported to durable storage.', + service: 'cloud-monitoring', + taskMapping: TASK_TEMPLATES.monitoringAlerting, + + run: async (ctx: CheckContext) => { + const projectIds = await resolveGcpProjectIds(ctx); + if (projectIds.length === 0) { + ctx.log('GCP Cloud Monitoring check: no projects resolved — skipping'); + return; + } + + for (const projectId of projectIds) { + await evaluateAlerting(ctx, projectId); + await evaluateLogExport(ctx, projectId); + } + }, +}; diff --git a/packages/integration-platform/src/manifests/gcp/checks/cloud-sql-encryption.ts b/packages/integration-platform/src/manifests/gcp/checks/cloud-sql-encryption.ts new file mode 100644 index 0000000000..e0e67cb4b8 --- /dev/null +++ b/packages/integration-platform/src/manifests/gcp/checks/cloud-sql-encryption.ts @@ -0,0 +1,90 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { + remediationForReadFailure, + toHttpReadFailure, +} from '../../http-read-failure'; +import { gcpListItems, isGcpApiDisabled, resolveGcpProjectIds } from './shared'; + +interface SqlInstance { + name: string; + region?: string; + diskEncryptionConfiguration?: { + /** Set only when the instance disk uses a customer-managed key (CMEK). */ + kmsKeyName?: string; + }; +} + +/** + * Cloud SQL encryption-at-rest check (direct API, no SCC). + * + * Cloud SQL data and backups are always encrypted at rest by default with + * Google-managed keys — this cannot be disabled — so every instance passes. + * The check surfaces GCP on the Encryption at Rest task and records per-instance + * evidence of the key type (Google-managed vs CMEK). Mirrors the AWS RDS + * storage-encryption check. Only read failures produce a finding. + */ +export const cloudSqlEncryptionCheck: IntegrationCheck = { + id: 'gcp-cloud-sql-encryption', + name: 'Cloud SQL — encryption at rest', + description: + 'Verify Cloud SQL instances are encrypted at rest (Google-managed by default; reports CMEK).', + service: 'cloud-sql', + taskMapping: TASK_TEMPLATES.encryptionAtRest, + + run: async (ctx: CheckContext) => { + const projectIds = await resolveGcpProjectIds(ctx); + if (projectIds.length === 0) { + ctx.log('GCP Cloud SQL encryption check: no projects resolved — skipping'); + return; + } + + for (const projectId of projectIds) { + try { + const instances = await gcpListItems( + ctx, + `https://sqladmin.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/instances`, + ); + if (instances.length === 0) continue; + + for (const inst of instances) { + const cmekKey = inst.diskEncryptionConfiguration?.kmsKeyName ?? null; + ctx.pass({ + title: `Encrypted at rest: ${inst.name}`, + description: `Cloud SQL instance "${inst.name}" is encrypted at rest with ${cmekKey ? 'a customer-managed key (CMEK)' : 'Google-managed encryption (AES-256)'}.`, + resourceType: 'gcp-cloud-sql-instance', + resourceId: `${projectId}/${inst.name}`, + evidence: { + projectId, + instance: inst.name, + region: inst.region ?? null, + keyType: cmekKey ? 'CMEK' : 'Google-managed', + kmsKeyName: cmekKey, + }, + }); + } + } catch (err) { + if (isGcpApiDisabled(err)) { + ctx.log( + `GCP Cloud SQL: API not enabled in project "${projectId}" — no Cloud SQL instances to evaluate; skipping`, + ); + continue; + } + const failure = toHttpReadFailure(err); + ctx.fail({ + title: `Could not verify Cloud SQL encryption: ${projectId}`, + description: `Cloud SQL instances for project "${projectId}" could not be listed (${failure.error}), so encryption at rest is unverified.`, + resourceType: 'gcp-project', + resourceId: projectId, + severity: 'medium', + remediation: remediationForReadFailure( + failure, + 'Grant cloudsql.instances.list (e.g. roles/cloudsql.viewer) to the connection for this project, then re-run.', + ), + evidence: { projectId, error: failure.error }, + }); + continue; + } + } + }, +}; diff --git a/packages/integration-platform/src/manifests/gcp/checks/index.ts b/packages/integration-platform/src/manifests/gcp/checks/index.ts index 9f310d28de..cce40f8388 100644 --- a/packages/integration-platform/src/manifests/gcp/checks/index.ts +++ b/packages/integration-platform/src/manifests/gcp/checks/index.ts @@ -3,3 +3,6 @@ export { storagePublicAccessCheck } from './storage-public-access'; export { vpcOpenFirewallsCheck } from './vpc-open-firewalls'; export { cloudSqlSslCheck } from './cloud-sql-ssl'; export { cloudSqlBackupsCheck } from './cloud-sql-backups'; +export { cloudMonitoringAlertingCheck } from './cloud-monitoring-alerting'; +export { storageEncryptionCheck } from './storage-encryption'; +export { cloudSqlEncryptionCheck } from './cloud-sql-encryption'; diff --git a/packages/integration-platform/src/manifests/gcp/checks/storage-encryption.ts b/packages/integration-platform/src/manifests/gcp/checks/storage-encryption.ts new file mode 100644 index 0000000000..505df6f8af --- /dev/null +++ b/packages/integration-platform/src/manifests/gcp/checks/storage-encryption.ts @@ -0,0 +1,92 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { + remediationForReadFailure, + toHttpReadFailure, +} from '../../http-read-failure'; +import { gcpListItems, isGcpApiDisabled, resolveGcpProjectIds } from './shared'; + +interface Bucket { + name: string; + location?: string; + encryption?: { + /** Set only when a customer-managed key (CMEK) is the bucket default. */ + defaultKmsKeyName?: string; + }; +} + +/** + * Cloud Storage encryption-at-rest check (direct API, no SCC). + * + * Google Cloud encrypts ALL data at rest by default with Google-managed + * AES-256 keys — this cannot be disabled — so every bucket passes. The check + * exists to (a) surface GCP on the Encryption at Rest task like AWS/Azure and + * (b) record per-bucket evidence of the key type (Google-managed vs CMEK), + * which is the deliverable an auditor wants. Mirrors the AWS S3 default- + * encryption check. Only read failures produce a finding (never a silent pass). + */ +export const storageEncryptionCheck: IntegrationCheck = { + id: 'gcp-storage-encryption', + name: 'Cloud Storage — encryption at rest', + description: + 'Verify Cloud Storage buckets are encrypted at rest (Google-managed by default; reports CMEK).', + service: 'cloud-storage', + taskMapping: TASK_TEMPLATES.encryptionAtRest, + + run: async (ctx: CheckContext) => { + const projectIds = await resolveGcpProjectIds(ctx); + if (projectIds.length === 0) { + ctx.log('GCP storage encryption check: no projects resolved — skipping'); + return; + } + + for (const projectId of projectIds) { + try { + const buckets = await gcpListItems( + ctx, + `https://storage.googleapis.com/storage/v1/b?project=${encodeURIComponent(projectId)}`, + ); + if (buckets.length === 0) continue; // nothing to evidence for this project + + for (const bucket of buckets) { + const cmekKey = bucket.encryption?.defaultKmsKeyName ?? null; + ctx.pass({ + title: `Encrypted at rest: ${bucket.name}`, + description: `Bucket "${bucket.name}" is encrypted at rest with ${cmekKey ? 'a customer-managed key (CMEK)' : 'Google-managed encryption (AES-256)'}.`, + resourceType: 'gcp-storage-bucket', + resourceId: `${projectId}/${bucket.name}`, + evidence: { + projectId, + bucket: bucket.name, + location: bucket.location ?? null, + keyType: cmekKey ? 'CMEK' : 'Google-managed', + defaultKmsKeyName: cmekKey, + }, + }); + } + } catch (err) { + // API not enabled on this project → no buckets to evaluate; skip rather + // than emit a false "grant permission" finding. + if (isGcpApiDisabled(err)) { + ctx.log( + `GCP Cloud Storage: API not enabled in project "${projectId}" — no buckets to evaluate; skipping`, + ); + continue; + } + const failure = toHttpReadFailure(err); + ctx.fail({ + title: `Could not verify Cloud Storage encryption: ${projectId}`, + description: `Buckets for project "${projectId}" could not be listed (${failure.error}), so encryption at rest is unverified.`, + resourceType: 'gcp-project', + resourceId: projectId, + severity: 'medium', + remediation: remediationForReadFailure( + failure, + 'Grant storage.buckets.list (e.g. roles/storage.legacyBucketReader or Viewer) to the connection for this project, then re-run.', + ), + evidence: { projectId, error: failure.error }, + }); + } + } + }, +}; diff --git a/packages/integration-platform/src/manifests/gcp/index.ts b/packages/integration-platform/src/manifests/gcp/index.ts index 0d3441e340..2cd7394c06 100644 --- a/packages/integration-platform/src/manifests/gcp/index.ts +++ b/packages/integration-platform/src/manifests/gcp/index.ts @@ -1,8 +1,11 @@ import type { IntegrationManifest } from '../../types'; import { + cloudMonitoringAlertingCheck, cloudSqlBackupsCheck, + cloudSqlEncryptionCheck, cloudSqlSslCheck, iamPrimitiveRolesCheck, + storageEncryptionCheck, storagePublicAccessCheck, vpcOpenFirewallsCheck, } from './checks'; @@ -162,5 +165,8 @@ This is industry standard - all GCP security monitoring tools use the same scope vpcOpenFirewallsCheck, cloudSqlSslCheck, cloudSqlBackupsCheck, + cloudMonitoringAlertingCheck, + storageEncryptionCheck, + cloudSqlEncryptionCheck, ], }; From 600f277e54aa4f85afcfd85dbe5e74649d27dcc0 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 17 Jun 2026 13:47:30 -0400 Subject: [PATCH 2/2] fix(integrations): require a durable destination for GCP log-export prong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback: the Monitoring & Alerting log-export prong counted any enabled, non-default-named sink as durable export. A custom-named sink can still target the project's `_Default` log bucket, which is not durable export — a false positive. Now key off the sink DESTINATION (BigQuery / Cloud Storage / Pub/Sub, or a non-default Cloud Logging bucket), matching the Azure Monitor check's "export to a real destination" bar. Adds 2 tests (custom-named sink to the _Default bucket fails; sink to a dedicated log bucket passes). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../gcp/checks/__tests__/gcp-checks.test.ts | 74 +++++++++++++++++-- .../gcp/checks/cloud-monitoring-alerting.ts | 32 +++++++- 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/packages/integration-platform/src/manifests/gcp/checks/__tests__/gcp-checks.test.ts b/packages/integration-platform/src/manifests/gcp/checks/__tests__/gcp-checks.test.ts index 85e0174e70..b2552eb7a2 100644 --- a/packages/integration-platform/src/manifests/gcp/checks/__tests__/gcp-checks.test.ts +++ b/packages/integration-platform/src/manifests/gcp/checks/__tests__/gcp-checks.test.ts @@ -634,7 +634,9 @@ describe('GCP Cloud Monitoring — alerting and log export check', () => { const out = await runCheck(cloudMonitoringAlertingCheck, { fetch: monitorFetch({ policies: [{ name: 'p1', enabled: true, notificationChannels: [] }], - sinks: [{ name: 'export-bq', disabled: false }], + sinks: [ + { name: 'export-bq', destination: 'storage.googleapis.com/b1', disabled: false }, + ], }), }); expect(out.failed).toHaveLength(1); @@ -645,7 +647,10 @@ describe('GCP Cloud Monitoring — alerting and log export check', () => { it('fails alerting when there are zero alert policies', async () => { const out = await runCheck(cloudMonitoringAlertingCheck, { - fetch: monitorFetch({ policies: [], sinks: [{ name: 'export', disabled: false }] }), + fetch: monitorFetch({ + policies: [], + sinks: [{ name: 'export', destination: 'bigquery.googleapis.com/x', disabled: false }], + }), }); expect(out.failed.some((f) => /No alerting configured/.test(f.title))).toBe(true); }); @@ -654,7 +659,13 @@ describe('GCP Cloud Monitoring — alerting and log export check', () => { const out = await runCheck(cloudMonitoringAlertingCheck, { fetch: monitorFetch({ policies: [{ name: 'p1', notificationChannels: ['c1'] }], // no `enabled` - sinks: [{ name: 'export', disabled: false }], + sinks: [ + { + name: 'export', + destination: 'pubsub.googleapis.com/projects/x/topics/logs', + disabled: false, + }, + ], }), }); expect(out.failed).toHaveLength(0); @@ -666,8 +677,18 @@ describe('GCP Cloud Monitoring — alerting and log export check', () => { fetch: monitorFetch({ policies: [{ name: 'p1', enabled: true, notificationChannels: ['c1'] }], sinks: [ - { name: '_Default', disabled: false }, - { name: '_Required', disabled: false }, + { + name: '_Default', + destination: + 'logging.googleapis.com/projects/x/locations/global/buckets/_Default', + disabled: false, + }, + { + name: '_Required', + destination: + 'logging.googleapis.com/projects/x/locations/global/buckets/_Required', + disabled: false, + }, ], }), }); @@ -679,12 +700,47 @@ describe('GCP Cloud Monitoring — alerting and log export check', () => { const out = await runCheck(cloudMonitoringAlertingCheck, { fetch: monitorFetch({ policies: [{ name: 'p1', enabled: true, notificationChannels: ['c1'] }], - sinks: [{ name: 'export', disabled: true }], + sinks: [{ name: 'export', destination: 'bigquery.googleapis.com/x', disabled: true }], }), }); expect(out.failed.some((f) => /No log export configured/.test(f.title))).toBe(true); }); + it('does not count a custom-named sink that still targets the _Default bucket', async () => { + const out = await runCheck(cloudMonitoringAlertingCheck, { + fetch: monitorFetch({ + policies: [{ name: 'p1', enabled: true, notificationChannels: ['c1'] }], + sinks: [ + { + name: 'my-sink', // non-default NAME, but routes to the default bucket + destination: + 'logging.googleapis.com/projects/x/locations/global/buckets/_Default', + disabled: false, + }, + ], + }), + }); + expect(out.failed.some((f) => /No log export configured/.test(f.title))).toBe(true); + }); + + it('counts a sink to a dedicated (non-default) Cloud Logging bucket as export', async () => { + const out = await runCheck(cloudMonitoringAlertingCheck, { + fetch: monitorFetch({ + policies: [{ name: 'p1', enabled: true, notificationChannels: ['c1'] }], + sinks: [ + { + name: 'audit', + destination: + 'logging.googleapis.com/projects/x/locations/global/buckets/audit-7yr', + disabled: false, + }, + ], + }), + }); + expect(out.failed).toHaveLength(0); + expect(out.passed.some((p) => /Log export configured/.test(p.title))).toBe(true); + }); + it('fails "could not verify" alerting on a genuine permission error (log export unaffected)', async () => { const out = await runCheck(cloudMonitoringAlertingCheck, { fetch: (url) => { @@ -694,7 +750,11 @@ describe('GCP Cloud Monitoring — alerting and log export check', () => { 403, ); } - if (url.includes('/sinks')) return { sinks: [{ name: 'export', disabled: false }] }; + if (url.includes('/sinks')) { + return { + sinks: [{ name: 'export', destination: 'storage.googleapis.com/b', disabled: false }], + }; + } return {}; }, }); diff --git a/packages/integration-platform/src/manifests/gcp/checks/cloud-monitoring-alerting.ts b/packages/integration-platform/src/manifests/gcp/checks/cloud-monitoring-alerting.ts index 840506edc4..970b166176 100644 --- a/packages/integration-platform/src/manifests/gcp/checks/cloud-monitoring-alerting.ts +++ b/packages/integration-platform/src/manifests/gcp/checks/cloud-monitoring-alerting.ts @@ -22,10 +22,33 @@ interface LogSink { // Every project has two managed sinks (`_Required`, `_Default`) writing to the // project's `_Default` log bucket. They are present by default and are NOT -// evidence that the customer configured durable log routing/retention, so they -// don't count as a "log export". Any other enabled sink is operator-configured. +// evidence that the customer configured durable log routing/retention. const DEFAULT_SINK_NAMES = new Set(['_Required', '_Default']); +/** + * A sink only proves durable log retention/export when it writes OUTSIDE the + * default in-project log bucket: to BigQuery, Cloud Storage, Pub/Sub, or a + * dedicated (non-default) Cloud Logging bucket. Keying off the DESTINATION (not + * just the sink name) prevents a custom-named sink that still targets the + * `_Default`/`_Required` bucket from being mistaken for durable export. + */ +function isDurableExportDestination(destination: string | undefined): boolean { + if (!destination) return false; + if ( + destination.startsWith('bigquery.googleapis.com/') || + destination.startsWith('storage.googleapis.com/') || + destination.startsWith('pubsub.googleapis.com/') + ) { + return true; + } + // Cloud Logging bucket destination — durable only when it is NOT the managed + // `_Default`/`_Required` bucket (those hold default in-project retention). + const bucket = destination.match(/\/buckets\/([^/]+)$/)?.[1]; + if (bucket) return !DEFAULT_SINK_NAMES.has(bucket); + // Unknown/unsupported destination shape — be conservative and don't count it. + return false; +} + /** * Per-project alerting prong: at least one enabled alert policy must be wired to * a notification channel, otherwise alerts fire into the void. Mirrors the @@ -153,7 +176,10 @@ async function evaluateLogExport( } const exportSinks = sinks.filter( - (s) => s.disabled !== true && !DEFAULT_SINK_NAMES.has(s.name), + (s) => + s.disabled !== true && + !DEFAULT_SINK_NAMES.has(s.name) && + isDurableExportDestination(s.destination), ); if (exportSinks.length > 0) {