From e554269c02f6432473e8e0bf168dd2d475f6311f Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 10 Jun 2026 20:36:58 -0400 Subject: [PATCH] fix(integration-platform): skip GCP projects whose service API is disabled instead of a false permission finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customer saw 4 'Could not verify Cloud SQL SSL: ' findings on Google sample/AI projects (gen-lang-client-*, deploy-example-*) telling them to 'Grant cloudsql.instances.list'. Those projects simply don't use Cloud SQL — the Cloud SQL Admin API is disabled, which Google returns as HTTP 403 reason SERVICE_DISABLED, so toHttpReadFailure classified it as a permission denial and emitted a misleading medium finding. A project that hasn't enabled the API has no resources of that type to evaluate, so the per-project GCP checks (cloud-sql-ssl, cloud-sql-backups, vpc-open-firewalls, storage-public-access) now skip it — like a project with zero resources — when isGcpApiDisabled() matches Google's specific SERVICE_DISABLED signature. A genuine PERMISSION_DENIED (API enabled, role missing) still surfaces as a finding. Detection is narrow to avoid hiding real coverage gaps. Co-Authored-By: Claude Fable 5 --- .../gcp/checks/__tests__/gcp-checks.test.ts | 55 +++++++++++++++++++ .../manifests/gcp/checks/cloud-sql-backups.ts | 12 +++- .../src/manifests/gcp/checks/cloud-sql-ssl.ts | 12 +++- .../src/manifests/gcp/checks/shared.ts | 22 ++++++++ .../gcp/checks/storage-public-access.ts | 12 +++- .../gcp/checks/vpc-open-firewalls.ts | 13 +++-- 6 files changed, 113 insertions(+), 13 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 30b8f2b99..db4cd9a58 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 @@ -9,6 +9,7 @@ import { cloudSqlSslCheck } from '../cloud-sql-ssl'; import { iamPrimitiveRolesCheck } from '../iam-primitive-roles'; import { storagePublicAccessCheck } from '../storage-public-access'; import { vpcOpenFirewallsCheck } from '../vpc-open-firewalls'; +import { isGcpApiDisabled } from '../shared'; interface Captured { passed: Array<{ resourceId: string; title: string }>; @@ -70,6 +71,60 @@ async function runCheck( return { passed, failed }; } +describe('isGcpApiDisabled — service-not-enabled detection', () => { + const httpErr = (status: number, message: string) => { + const e = new Error(`HTTP ${status}: Forbidden - ${message}`); + (e as Error & { status: number }).status = status; + return e; + }; + it('matches the real SERVICE_DISABLED 403 body', () => { + expect( + isGcpApiDisabled( + httpErr(403, '{"error":{"code":403,"message":"Cloud SQL Admin API has not been used in project gen-lang-client-0670714718 before or it is disabled.","status":"PERMISSION_DENIED","details":[{"reason":"SERVICE_DISABLED"}]}}'), + ), + ).toBe(true); + }); + it('does NOT match a genuine permission denial', () => { + expect( + isGcpApiDisabled( + httpErr(403, '{"error":{"code":403,"message":"The caller does not have permission","status":"PERMISSION_DENIED"}}'), + ), + ).toBe(false); + }); + it('does NOT match a transient 500', () => { + expect(isGcpApiDisabled(httpErr(500, 'Internal error'))).toBe(false); + }); +}); + +describe('GCP checks skip projects whose service API is disabled', () => { + const apiDisabled = () => { + const e = new Error('HTTP 403: Forbidden - Cloud SQL Admin API has not been used in project p before or it is disabled. (SERVICE_DISABLED)'); + (e as Error & { status: number }).status = 403; + return e; + }; + it('cloud-sql-ssl emits NO finding when the API is disabled (vs a false "grant permission")', async () => { + const out = await runCheck(cloudSqlSslCheck, { + variables: { project_ids: ['p'] }, + fetch: () => { throw apiDisabled(); }, + }); + expect(out.failed).toHaveLength(0); + expect(out.passed).toHaveLength(0); + }); + it('still reports a finding for a genuine permission denial', async () => { + const denied = () => { + const e = new Error('HTTP 403: Forbidden - The caller does not have permission'); + (e as Error & { status: number }).status = 403; + return e; + }; + const out = await runCheck(cloudSqlSslCheck, { + variables: { project_ids: ['p'] }, + fetch: () => { throw denied(); }, + }); + expect(out.failed).toHaveLength(1); + expect(out.failed[0]!.title).toMatch(/Could not verify Cloud SQL SSL/); + }); +}); + describe('GCP read-failure remediation gating', () => { const httpError = (status: number, message: string) => { const err = new Error(message); diff --git a/packages/integration-platform/src/manifests/gcp/checks/cloud-sql-backups.ts b/packages/integration-platform/src/manifests/gcp/checks/cloud-sql-backups.ts index b3de68f40..0362bf9dc 100644 --- a/packages/integration-platform/src/manifests/gcp/checks/cloud-sql-backups.ts +++ b/packages/integration-platform/src/manifests/gcp/checks/cloud-sql-backups.ts @@ -4,7 +4,7 @@ import { remediationForReadFailure, toHttpReadFailure, } from '../../http-read-failure'; -import { gcpListItems, resolveGcpProjectIds } from './shared'; +import { gcpListItems, resolveGcpProjectIds, isGcpApiDisabled } from './shared'; interface SqlInstance { name: string; @@ -76,8 +76,14 @@ export const cloudSqlBackupsCheck: IntegrationCheck = { } } } catch (err) { - // Unverified project → emit a finding, not a warn-and-skip, so an - // all-projects-failed run doesn't leave the task stale (silent pass). + // The service's API simply isn't enabled on this project (403 + // SERVICE_DISABLED) — nothing of this type exists here to evaluate, + // so skip it like a zero-resource project instead of emitting a + // false "grant permission" finding. + 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 backups: ${projectId}`, diff --git a/packages/integration-platform/src/manifests/gcp/checks/cloud-sql-ssl.ts b/packages/integration-platform/src/manifests/gcp/checks/cloud-sql-ssl.ts index 3e1c3dbbe..57d7b92c3 100644 --- a/packages/integration-platform/src/manifests/gcp/checks/cloud-sql-ssl.ts +++ b/packages/integration-platform/src/manifests/gcp/checks/cloud-sql-ssl.ts @@ -4,7 +4,7 @@ import { remediationForReadFailure, toHttpReadFailure, } from '../../http-read-failure'; -import { gcpListItems, resolveGcpProjectIds } from './shared'; +import { gcpListItems, resolveGcpProjectIds, isGcpApiDisabled } from './shared'; interface SqlInstance { name: string; @@ -86,8 +86,14 @@ export const cloudSqlSslCheck: IntegrationCheck = { } } } catch (err) { - // Unverified project → emit a finding, not a warn-and-skip, so an - // all-projects-failed run doesn't leave the task stale (silent pass). + // The service's API simply isn't enabled on this project (403 + // SERVICE_DISABLED) — nothing of this type exists here to evaluate, + // so skip it like a zero-resource project instead of emitting a + // false "grant permission" finding. + 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 SSL: ${projectId}`, diff --git a/packages/integration-platform/src/manifests/gcp/checks/shared.ts b/packages/integration-platform/src/manifests/gcp/checks/shared.ts index 9a6cef22c..2d8d61660 100644 --- a/packages/integration-platform/src/manifests/gcp/checks/shared.ts +++ b/packages/integration-platform/src/manifests/gcp/checks/shared.ts @@ -127,3 +127,25 @@ export function portsCover( return Number(spec) === target; }); } + +/** + * True when a GCP API call failed only because the service's API is not + * enabled on the project (HTTP 403 with reason SERVICE_DISABLED, e.g. "Cloud + * SQL Admin API has not been used in project X ... or it is disabled"). + * + * This is NOT a permission problem: a project that hasn't enabled the API has + * no resources of that type to evaluate, so the per-project check should skip + * it (like a project with zero instances) rather than emit a false "could not + * verify — grant " finding. A genuine PERMISSION_DENIED (API + * enabled, role missing) does NOT match and still surfaces as a finding. + */ +export function isGcpApiDisabled(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + // Match only Google's specific SERVICE_DISABLED signature, not any mention of + // "API"/"disabled" — over-broad matching could hide a genuine permission gap. + return ( + /SERVICE_DISABLED/i.test(message) || + /has not been used in project .* before or it is disabled/i.test(message) || + /Enable it by visiting/i.test(message) + ); +} diff --git a/packages/integration-platform/src/manifests/gcp/checks/storage-public-access.ts b/packages/integration-platform/src/manifests/gcp/checks/storage-public-access.ts index a6dc3374b..9f1038fba 100644 --- a/packages/integration-platform/src/manifests/gcp/checks/storage-public-access.ts +++ b/packages/integration-platform/src/manifests/gcp/checks/storage-public-access.ts @@ -4,7 +4,7 @@ import { remediationForReadFailure, toHttpReadFailure, } from '../../http-read-failure'; -import { gcpListItems, resolveGcpProjectIds } from './shared'; +import { gcpListItems, resolveGcpProjectIds, isGcpApiDisabled } from './shared'; interface Bucket { name: string; @@ -56,8 +56,14 @@ export const storagePublicAccessCheck: IntegrationCheck = { await evaluateBucket(ctx, projectId, bucket); } } catch (err) { - // Unverified project → emit a finding, not a warn-and-skip, so an - // all-projects-failed run doesn't leave the task stale (silent pass). + // The service's API simply isn't enabled on this project (403 + // SERVICE_DISABLED) — nothing of this type exists here to evaluate, + // so skip it like a zero-resource project instead of emitting 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: ${projectId}`, diff --git a/packages/integration-platform/src/manifests/gcp/checks/vpc-open-firewalls.ts b/packages/integration-platform/src/manifests/gcp/checks/vpc-open-firewalls.ts index fe9560fbd..ceb1a6798 100644 --- a/packages/integration-platform/src/manifests/gcp/checks/vpc-open-firewalls.ts +++ b/packages/integration-platform/src/manifests/gcp/checks/vpc-open-firewalls.ts @@ -4,7 +4,7 @@ import { remediationForReadFailure, toHttpReadFailure, } from '../../http-read-failure'; -import { gcpListItems, portsCover, resolveGcpProjectIds } from './shared'; +import { gcpListItems, portsCover, resolveGcpProjectIds, isGcpApiDisabled } from './shared'; interface FirewallRule { name: string; @@ -114,9 +114,14 @@ export const vpcOpenFirewallsCheck: IntegrationCheck = { }); } } catch (err) { - // A read failure for this project is unverified — emit a finding rather - // than warn-and-skip, otherwise an all-projects-failed run emits no - // outcomes and leaves the mapped task stale (a silent clean run). + // The service's API simply isn't enabled on this project (403 + // SERVICE_DISABLED) — nothing of this type exists here to evaluate, + // so skip it like a zero-resource project instead of emitting a + // false "grant permission" finding. + if (isGcpApiDisabled(err)) { + ctx.log(`GCP Compute: API not enabled in project "${projectId}" — no firewall rules to evaluate; skipping`); + continue; + } const failure = toHttpReadFailure(err); ctx.fail({ title: `Could not verify VPC firewall rules: ${projectId}`,