Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}`,
Expand Down
22 changes: 22 additions & 0 deletions packages/integration-platform/src/manifests/gcp/checks/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <permission>" 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)
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}`,
Expand Down
Loading