From 665f4549184879e35f2ae8288827d72ffbf9de23 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Mon, 1 Jun 2026 16:20:44 -0400
Subject: [PATCH 01/13] feat(integrations): add cloud services as evidence
integrations (GCP/Azure/AWS)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Surface AWS/GCP/Azure cloud-posture services as integration-platform
integrations whose checks satisfy evidence tasks — a separate feature from
Cloud Tests, which is left untouched.
- 24 code-based manifest checks (GCP 5, Azure 11, AWS 8), each mapped to an
evidence task; AWS checks assume the cross-account IAM role (STS) and use the
AWS SDK, with the security logic in pure, unit-tested evaluators
- per-service mappedTasks added to both provider API projections
(buildServiceTaskMappings) + IntegrationProviderResponse.services type
- per-service detail page: Cloud Tests scan toggle on top, "evidence provided"
map linking to the tasks each service satisfies; cloud detail-page service
rows navigate to it (status + task count, no inline toggle)
Cloud Tests (apps/api/src/cloud-security + cloud-tests UI) is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../controllers/connections.controller.ts | 24 ++
.../[slug]/components/ProviderDetailView.tsx | 26 +-
.../[slug]/components/ServiceCard.tsx | 108 ++++-----
.../[slug]/components/services-grid.tsx | 48 ++--
.../components/ServiceDetailView.tsx | 200 ++++++++++++++++
.../[slug]/services/[serviceId]/page.tsx | 68 ++++++
bun.lock | 4 +
packages/integration-platform/package.json | 4 +
.../integration-platform/src/api-types.ts | 2 +
.../aws/checks/__tests__/aws-checks.test.ts | 141 +++++++++++
.../src/manifests/aws/checks/cloudtrail.ts | 80 +++++++
.../src/manifests/aws/checks/ec2.ts | 122 ++++++++++
.../src/manifests/aws/checks/iam.ts | 146 ++++++++++++
.../src/manifests/aws/checks/index.ts | 6 +
.../src/manifests/aws/checks/kms.ts | 98 ++++++++
.../src/manifests/aws/checks/rds.ts | 122 ++++++++++
.../src/manifests/aws/checks/s3.ts | 148 ++++++++++++
.../src/manifests/aws/checks/shared.ts | 90 +++++++
.../src/manifests/aws/index.ts | 21 +-
.../checks/__tests__/azure-checks.test.ts | 225 ++++++++++++++++++
.../src/manifests/azure/checks/entra-id.ts | 123 ++++++++++
.../src/manifests/azure/checks/index.ts | 10 +
.../src/manifests/azure/checks/key-vault.ts | 118 +++++++++
.../src/manifests/azure/checks/monitor.ts | 117 +++++++++
.../src/manifests/azure/checks/network.ts | 108 +++++++++
.../src/manifests/azure/checks/shared.ts | 46 ++++
.../src/manifests/azure/checks/sql.ts | 187 +++++++++++++++
.../src/manifests/azure/checks/storage.ts | 166 +++++++++++++
.../src/manifests/azure/index.ts | 27 ++-
.../gcp/checks/__tests__/gcp-checks.test.ts | 218 +++++++++++++++++
.../manifests/gcp/checks/cloud-sql-backups.ts | 63 +++++
.../src/manifests/gcp/checks/cloud-sql-ssl.ts | 78 ++++++
.../gcp/checks/iam-primitive-roles.ts | 72 ++++++
.../src/manifests/gcp/checks/index.ts | 5 +
.../src/manifests/gcp/checks/shared.ts | 54 +++++
.../gcp/checks/storage-public-access.ts | 87 +++++++
.../gcp/checks/vpc-open-firewalls.ts | 100 ++++++++
.../src/manifests/gcp/index.ts | 15 +-
38 files changed, 3160 insertions(+), 117 deletions(-)
create mode 100644 apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/page.tsx
create mode 100644 packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
create mode 100644 packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
create mode 100644 packages/integration-platform/src/manifests/aws/checks/ec2.ts
create mode 100644 packages/integration-platform/src/manifests/aws/checks/iam.ts
create mode 100644 packages/integration-platform/src/manifests/aws/checks/index.ts
create mode 100644 packages/integration-platform/src/manifests/aws/checks/kms.ts
create mode 100644 packages/integration-platform/src/manifests/aws/checks/rds.ts
create mode 100644 packages/integration-platform/src/manifests/aws/checks/s3.ts
create mode 100644 packages/integration-platform/src/manifests/aws/checks/shared.ts
create mode 100644 packages/integration-platform/src/manifests/azure/checks/__tests__/azure-checks.test.ts
create mode 100644 packages/integration-platform/src/manifests/azure/checks/entra-id.ts
create mode 100644 packages/integration-platform/src/manifests/azure/checks/index.ts
create mode 100644 packages/integration-platform/src/manifests/azure/checks/key-vault.ts
create mode 100644 packages/integration-platform/src/manifests/azure/checks/monitor.ts
create mode 100644 packages/integration-platform/src/manifests/azure/checks/network.ts
create mode 100644 packages/integration-platform/src/manifests/azure/checks/shared.ts
create mode 100644 packages/integration-platform/src/manifests/azure/checks/sql.ts
create mode 100644 packages/integration-platform/src/manifests/azure/checks/storage.ts
create mode 100644 packages/integration-platform/src/manifests/gcp/checks/__tests__/gcp-checks.test.ts
create mode 100644 packages/integration-platform/src/manifests/gcp/checks/cloud-sql-backups.ts
create mode 100644 packages/integration-platform/src/manifests/gcp/checks/cloud-sql-ssl.ts
create mode 100644 packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
create mode 100644 packages/integration-platform/src/manifests/gcp/checks/index.ts
create mode 100644 packages/integration-platform/src/manifests/gcp/checks/shared.ts
create mode 100644 packages/integration-platform/src/manifests/gcp/checks/storage-public-access.ts
create mode 100644 packages/integration-platform/src/manifests/gcp/checks/vpc-open-firewalls.ts
diff --git a/apps/api/src/integration-platform/controllers/connections.controller.ts b/apps/api/src/integration-platform/controllers/connections.controller.ts
index 641b610937..5bfe5ea6ab 100644
--- a/apps/api/src/integration-platform/controllers/connections.controller.ts
+++ b/apps/api/src/integration-platform/controllers/connections.controller.ts
@@ -305,11 +305,34 @@ export class ConnectionsController {
description: s.description,
enabledByDefault: s.enabledByDefault ?? true,
implemented: s.implemented ?? true,
+ mappedTasks: this.buildServiceTaskMappings(m.checks, s.id),
})) ?? [],
};
});
}
+ /**
+ * Evidence tasks a single service's checks satisfy: distinct taskMappings of
+ * the manifest checks whose `service` equals serviceId, resolved to names.
+ */
+ private buildServiceTaskMappings(
+ checks:
+ | ReadonlyArray<{ service?: string; taskMapping?: TaskTemplateId }>
+ | undefined,
+ serviceId: string,
+ ): Array<{ id: string; name: string }> {
+ const out: Array<{ id: string; name: string }> = [];
+ const seen = new Set();
+ for (const check of checks ?? []) {
+ if (check.service !== serviceId || !check.taskMapping) continue;
+ if (seen.has(check.taskMapping)) continue;
+ seen.add(check.taskMapping);
+ const info = TASK_TEMPLATE_INFO[check.taskMapping];
+ if (info) out.push({ id: check.taskMapping, name: info.name });
+ }
+ return out;
+ }
+
/**
* Get a specific provider's details
*/
@@ -398,6 +421,7 @@ export class ConnectionsController {
description: s.description,
enabledByDefault: s.enabledByDefault ?? true,
implemented: s.implemented ?? true,
+ mappedTasks: this.buildServiceTaskMappings(manifest.checks, s.id),
})) ?? [],
};
}
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ProviderDetailView.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ProviderDetailView.tsx
index ebbc13b91e..f703f8cd40 100644
--- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ProviderDetailView.tsx
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ProviderDetailView.tsx
@@ -75,6 +75,7 @@ export function ProviderDetailView({
name: string;
description: string;
implemented?: boolean;
+ mappedTasks?: Array<{ id: string; name: string }>;
}>;
}
).services ?? [],
@@ -97,9 +98,7 @@ export function ProviderDetailView({
services: connectionServices,
meta: servicesMeta,
refresh: refreshServices,
- updateServices,
} = useConnectionServices(selectedConnection?.id ?? null);
- const [togglingService, setTogglingService] = useState(null);
const [gcpOrgs, setGcpOrgs] = useState<
Array<{
id: string;
@@ -111,25 +110,6 @@ export function ProviderDetailView({
const oauthBootstrapHandledRef = useRef(false);
const settingsQueryHandledRef = useRef(false);
- const handleToggleService = useCallback(
- async (serviceId: string, enabled: boolean): Promise => {
- setTogglingService(serviceId);
- try {
- await updateServices(serviceId, enabled);
- toast.success(
- `${services.find((s) => s.id === serviceId)?.name ?? serviceId} ${enabled ? 'enabled' : 'disabled'}`,
- );
- return true;
- } catch (err) {
- toast.error(err instanceof Error ? err.message : 'Failed to update');
- return false;
- } finally {
- setTogglingService(null);
- }
- },
- [updateServices, services],
- );
-
// OAuth return (?success=true): strip query, detect org/projects (NOT services yet — user must select projects first)
useEffect(() => {
if (
@@ -415,8 +395,8 @@ export function ProviderDetailView({
services={services}
connectionServices={connectionServices}
connectionId={selectedConnection?.id ?? null}
- onToggle={handleToggleService}
- togglingService={togglingService}
+ orgId={orgId}
+ slug={provider.id}
/>
)}
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.tsx
index e09ad4dc66..c7c9847673 100644
--- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.tsx
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.tsx
@@ -1,7 +1,9 @@
'use client';
import { useConnectionServices } from '@/hooks/use-integration-platform';
+import { ChevronRight } from '@trycompai/design-system/icons';
import { Badge } from '@trycompai/ui/badge';
+import Link from 'next/link';
import {
Cloud,
Database,
@@ -72,6 +74,7 @@ interface ServiceMeta {
description: string;
enabledByDefault?: boolean;
implemented?: boolean;
+ mappedTasks?: Array<{ id: string; name: string }>;
}
function ServiceIcon({ serviceId }: { serviceId: string }) {
@@ -87,80 +90,67 @@ function ServiceIcon({ serviceId }: { serviceId: string }) {
interface ServiceCardProps {
service: ServiceMeta;
connectionId: string | null;
- isConnected: boolean;
- onToggle?: (id: string, enabled: boolean) => void | Promise;
- toggling?: boolean;
+ orgId: string;
+ slug: string;
}
-export function ServiceCard({
- service,
- connectionId,
- isConnected,
- onToggle,
- toggling,
-}: ServiceCardProps) {
+/**
+ * A service row inside a cloud integration's detail page. Clicking navigates to
+ * the per-service detail page (where the Cloud Tests scan toggle + the evidence
+ * tasks it satisfies live). The row itself shows current scan status + the
+ * count of evidence tasks the service maps to — it is NOT a toggle.
+ */
+export function ServiceCard({ service, connectionId, orgId, slug }: ServiceCardProps) {
const { services } = useConnectionServices(connectionId);
-
const isImplemented = service.implemented !== false;
const liveService = services.find((s) => s.id === service.id);
const isEnabled = liveService?.enabled ?? false;
- const showToggle = isImplemented && isConnected && onToggle;
+ const taskCount = service.mappedTasks?.length ?? 0;
+
+ const href =
+ `/${orgId}/integrations/${slug}/services/${service.id}` +
+ (connectionId ? `?connectionId=${connectionId}` : '');
return (
-
-
-
-
-
- {service.name}
- {!isImplemented && (
-
- Coming Soon
-
- )}
-
-
- {service.description}
-
- {liveService?.projects && liveService.projects.length > 0 && (
-
- {liveService.projects.map((pid) => (
-
- {pid}
-
- ))}
-
+
+
+
+ {service.name}
+ {!isImplemented && (
+
+ Coming Soon
+
)}
- {showToggle && (
-
-
+
+
);
}
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/services-grid.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/services-grid.tsx
index db3e86f87e..da1c64ebac 100644
--- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/services-grid.tsx
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/services-grid.tsx
@@ -2,44 +2,29 @@
import { orderServicesForConnectionGrid } from '@/lib/connection-services-display-order';
import { Search } from '@trycompai/design-system/icons';
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
import { ServiceCard } from './ServiceCard';
export function ServicesGrid({
services,
connectionServices = [],
connectionId,
- onToggle,
- togglingService,
+ orgId,
+ slug,
}: {
- services: Array<{ id: string; name: string; description: string; implemented?: boolean }>;
+ services: Array<{
+ id: string;
+ name: string;
+ description: string;
+ implemented?: boolean;
+ mappedTasks?: Array<{ id: string; name: string }>;
+ }>;
connectionServices?: Array<{ id: string; enabled: boolean }>;
connectionId: string | null;
- onToggle: (id: string, enabled: boolean) => boolean | void | Promise
;
- togglingService: string | null;
+ orgId: string;
+ slug: string;
}) {
const [search, setSearch] = useState('');
- const [tailEnabledIds, setTailEnabledIds] = useState([]);
-
- useEffect(() => {
- setTailEnabledIds([]);
- }, [connectionId]);
-
- const handleToggle = useCallback(
- async (id: string, enabled: boolean) => {
- let rollback: string[] | null = null;
- setTailEnabledIds((prev) => {
- rollback = [...prev];
- if (enabled) return [...prev.filter((x) => x !== id), id];
- return prev.filter((x) => x !== id);
- });
- const result = await Promise.resolve(onToggle(id, enabled));
- if (result === false && rollback) {
- setTailEnabledIds(rollback);
- }
- },
- [onToggle],
- );
const displayedServices = useMemo(
() =>
@@ -47,9 +32,9 @@ export function ServicesGrid({
manifestServices: services,
connectionServices,
search,
- tailEnabledIds,
+ tailEnabledIds: [],
}),
- [services, connectionServices, search, tailEnabledIds],
+ [services, connectionServices, search],
);
return (
@@ -75,9 +60,8 @@ export function ServicesGrid({
key={service.id}
service={service}
connectionId={connectionId}
- isConnected
- onToggle={handleToggle}
- toggling={togglingService === service.id}
+ orgId={orgId}
+ slug={slug}
/>
))}
{displayedServices.length === 0 && search && (
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx
new file mode 100644
index 0000000000..fc112e5d44
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx
@@ -0,0 +1,200 @@
+'use client';
+
+import { useConnectionServices } from '@/hooks/use-integration-platform';
+import { Breadcrumb, Button, Stack } from '@trycompai/design-system';
+import { ArrowRight } from '@trycompai/design-system/icons';
+import type {
+ ConnectionListItemResponse,
+ IntegrationProviderResponse,
+} from '@trycompai/integration-platform';
+import Link from 'next/link';
+import { useMemo, useState } from 'react';
+import { toast } from 'sonner';
+
+interface ServiceMeta {
+ id: string;
+ name: string;
+ description: string;
+ implemented?: boolean;
+ mappedTasks?: Array<{ id: string; name: string }>;
+}
+
+interface TaskTemplate {
+ id: string;
+ taskId: string;
+ name: string;
+ description: string;
+}
+
+interface ServiceDetailViewProps {
+ provider: IntegrationProviderResponse;
+ service: ServiceMeta;
+ connections: ConnectionListItemResponse[];
+ connectionId: string | null;
+ taskTemplates: TaskTemplate[];
+ orgId: string;
+ slug: string;
+}
+
+export function ServiceDetailView({
+ provider,
+ service,
+ connections,
+ connectionId,
+ taskTemplates,
+ orgId,
+ slug,
+}: ServiceDetailViewProps) {
+ // Resolve the connection this service belongs to (URL param, else first active).
+ const effectiveConnectionId = useMemo(() => {
+ if (connectionId) return connectionId;
+ const active = connections.find(
+ (c) => c.status === 'active' || c.status === 'pending',
+ );
+ return active?.id ?? null;
+ }, [connectionId, connections]);
+
+ const { services: connectionServices, updateServices } =
+ useConnectionServices(effectiveConnectionId);
+ const liveService = connectionServices.find((s) => s.id === service.id);
+ const isEnabled = liveService?.enabled ?? false;
+ const isImplemented = service.implemented !== false;
+ const [toggling, setToggling] = useState(false);
+
+ const taskByTemplateId = useMemo(
+ () => new Map(taskTemplates.map((t) => [t.id, t])),
+ [taskTemplates],
+ );
+ const mappedTasks = service.mappedTasks ?? [];
+
+ const handleToggle = async () => {
+ if (!effectiveConnectionId || toggling) return;
+ setToggling(true);
+ const next = !isEnabled;
+ try {
+ await updateServices(service.id, next);
+ toast.success(
+ `${service.name} scanning ${next ? 'enabled' : 'disabled'} in Cloud Tests`,
+ );
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Failed to update');
+ } finally {
+ setToggling(false);
+ }
+ };
+
+ return (
+
+ },
+ },
+ {
+ label: provider.name,
+ href: `/${orgId}/integrations/${slug}`,
+ props: { render: },
+ },
+ { label: service.name, isCurrent: true },
+ ]}
+ />
+
+ {/* Header */}
+
+
{service.name}
+
{service.description}
+
+
+ {/* Cloud Tests scanning toggle */}
+
+
+
+
Cloud Tests scanning
+
+ Whether Cloud Tests scans this service for security findings. This
+ controls scanning only — it's separate from the evidence below.
+
+
+
+
+
+
+ {/* Evidence provided */}
+
+
+
+
+
Evidence provided
+
+ Evidence tasks this service's checks satisfy when they pass.
+
+
+
+ {mappedTasks.length}
+
+
+
+
+ {mappedTasks.length === 0 ? (
+
+ This service doesn't map to any evidence task yet.
+
+ ) : (
+
+ {mappedTasks.map((mapped) => {
+ const task = taskByTemplateId.get(mapped.id);
+ return (
+
+
+
+ {task?.name ?? mapped.name}
+
+
+ {task?.description ||
+ 'Mapped to this template, but the task is not in this organization yet.'}
+
+
+ {task ? (
+
}
+ iconRight={
}
+ >
+ View task
+
+ ) : (
+
+ Not added
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/page.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/page.tsx
new file mode 100644
index 0000000000..62fdfac3f1
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/page.tsx
@@ -0,0 +1,68 @@
+import { serverApi } from '@/lib/api-server';
+import { PageLayout } from '@trycompai/design-system';
+import type {
+ ConnectionListItemResponse,
+ IntegrationProviderResponse,
+} from '@trycompai/integration-platform';
+import { redirect } from 'next/navigation';
+import { ServiceDetailView } from './components/ServiceDetailView';
+
+interface TaskApiResponse {
+ data: Array<{
+ id: string;
+ title: string;
+ description: string;
+ taskTemplateId: string | null;
+ }>;
+}
+
+interface PageProps {
+ params: Promise<{ orgId: string; slug: string; serviceId: string }>;
+ searchParams: Promise>;
+}
+
+export default async function ServiceDetailPage({ params, searchParams }: PageProps) {
+ const { orgId, slug, serviceId } = await params;
+ const sp = await searchParams;
+ const connectionId = typeof sp.connectionId === 'string' ? sp.connectionId : null;
+
+ const [providerResult, connectionsResult, tasksResult] = await Promise.all([
+ serverApi.get(`/v1/integrations/connections/providers/${slug}`),
+ serverApi.get('/v1/integrations/connections'),
+ serverApi.get('/v1/tasks'),
+ ]);
+
+ const provider = providerResult.data;
+ if (!provider || providerResult.error) {
+ redirect(`/${orgId}/integrations`);
+ }
+
+ const service = (provider.services ?? []).find((s) => s.id === serviceId);
+ if (!service) {
+ redirect(`/${orgId}/integrations/${slug}`);
+ }
+
+ const connections = (connectionsResult.data ?? []).filter((c) => c.providerSlug === slug);
+ const taskTemplates = (tasksResult.data?.data ?? [])
+ .filter((task) => task.taskTemplateId)
+ .map((task) => ({
+ id: task.taskTemplateId as string,
+ taskId: task.id,
+ name: task.title,
+ description: task.description,
+ }));
+
+ return (
+
+
+
+ );
+}
diff --git a/bun.lock b/bun.lock
index e5cfd7955e..cfc0752bff 100644
--- a/bun.lock
+++ b/bun.lock
@@ -633,7 +633,11 @@
"version": "1.0.0",
"dependencies": {
"@aws-sdk/client-cloudtrail": "^3.943.0",
+ "@aws-sdk/client-ec2": "^3.943.0",
"@aws-sdk/client-iam": "^3.943.0",
+ "@aws-sdk/client-kms": "^3.943.0",
+ "@aws-sdk/client-rds": "^3.943.0",
+ "@aws-sdk/client-s3": "^3.943.0",
"@aws-sdk/client-securityhub": "^3.943.0",
"@aws-sdk/client-sts": "^3.943.0",
"zod": "^4.0.0",
diff --git a/packages/integration-platform/package.json b/packages/integration-platform/package.json
index 2369797cc0..fa13532f9a 100644
--- a/packages/integration-platform/package.json
+++ b/packages/integration-platform/package.json
@@ -39,7 +39,11 @@
},
"dependencies": {
"@aws-sdk/client-cloudtrail": "^3.943.0",
+ "@aws-sdk/client-ec2": "^3.943.0",
"@aws-sdk/client-iam": "^3.943.0",
+ "@aws-sdk/client-kms": "^3.943.0",
+ "@aws-sdk/client-rds": "^3.943.0",
+ "@aws-sdk/client-s3": "^3.943.0",
"@aws-sdk/client-securityhub": "^3.943.0",
"@aws-sdk/client-sts": "^3.943.0",
"zod": "^4.0.0"
diff --git a/packages/integration-platform/src/api-types.ts b/packages/integration-platform/src/api-types.ts
index 905e1b829f..efc9fa45ba 100644
--- a/packages/integration-platform/src/api-types.ts
+++ b/packages/integration-platform/src/api-types.ts
@@ -43,6 +43,8 @@ export interface IntegrationProviderResponse {
description: string;
enabledByDefault?: boolean;
implemented?: boolean;
+ /** Evidence tasks this service's checks satisfy (template id + name) */
+ mappedTasks?: Array<{ id: string; name: string }>;
}>;
}
diff --git a/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
new file mode 100644
index 0000000000..988ba7bc96
--- /dev/null
+++ b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it } from 'bun:test';
+import { evaluateCloudTrail } from '../cloudtrail';
+import { evaluateSecurityGroups } from '../ec2';
+import { evaluateIamAccount } from '../iam';
+import { evaluateKmsRotation } from '../kms';
+import { evaluateRdsBackups, evaluateRdsEncryption } from '../rds';
+import { evaluateS3Encryption, evaluateS3PublicAccess } from '../s3';
+
+const kinds = (os: { kind: string }[]) => os.map((o) => o.kind);
+
+describe('AWS IAM account evaluator', () => {
+ it('fails on missing policy, root MFA off, and root keys present', () => {
+ const out = evaluateIamAccount({
+ passwordPolicy: null,
+ summary: { AccountMFAEnabled: 0, AccountAccessKeysPresent: 1 },
+ });
+ expect(out.filter((o) => o.kind === 'fail')).toHaveLength(3);
+ });
+
+ it('passes a hardened account', () => {
+ const out = evaluateIamAccount({
+ passwordPolicy: {
+ MinimumPasswordLength: 14,
+ RequireSymbols: true,
+ RequireNumbers: true,
+ RequireUppercaseCharacters: true,
+ RequireLowercaseCharacters: true,
+ },
+ summary: { AccountMFAEnabled: 1, AccountAccessKeysPresent: 0 },
+ });
+ expect(kinds(out)).toEqual(['pass', 'pass', 'pass']);
+ });
+});
+
+describe('AWS S3 evaluators', () => {
+ it('encryption: pass when encrypted, fail (high) when not', () => {
+ const out = evaluateS3Encryption([
+ { name: 'a', encrypted: true, publicAccessBlocked: false },
+ { name: 'b', encrypted: false, publicAccessBlocked: false },
+ ]);
+ expect(out[0]!.kind).toBe('pass');
+ expect(out[1]!.kind).toBe('fail');
+ expect(out[1]!.severity).toBe('high');
+ });
+
+ it('public access: pass when blocked, fail when not', () => {
+ const out = evaluateS3PublicAccess([
+ { name: 'a', encrypted: false, publicAccessBlocked: true },
+ { name: 'b', encrypted: false, publicAccessBlocked: false },
+ ]);
+ expect(kinds(out)).toEqual(['pass', 'fail']);
+ });
+});
+
+describe('AWS EC2 security-group evaluator', () => {
+ it('flags SSH (22) open to 0.0.0.0/0 as high', () => {
+ const out = evaluateSecurityGroups([
+ {
+ groupId: 'sg-1',
+ region: 'us-east-1',
+ permissions: [{ ipProtocol: 'tcp', fromPort: 22, toPort: 22, cidrs: ['0.0.0.0/0'] }],
+ },
+ ]);
+ expect(out).toHaveLength(1);
+ expect(out[0]!.kind).toBe('fail');
+ expect(out[0]!.severity).toBe('high');
+ });
+
+ it('flags all-protocols (-1) open as critical', () => {
+ const out = evaluateSecurityGroups([
+ { groupId: 'sg-2', region: 'us-east-1', permissions: [{ ipProtocol: '-1', cidrs: ['0.0.0.0/0'] }] },
+ ]);
+ expect(out[0]!.severity).toBe('critical');
+ });
+
+ it('passes a group with no internet-open sensitive ports', () => {
+ const out = evaluateSecurityGroups([
+ {
+ groupId: 'sg-3',
+ region: 'us-east-1',
+ permissions: [
+ { ipProtocol: 'tcp', fromPort: 443, toPort: 443, cidrs: ['0.0.0.0/0'] },
+ { ipProtocol: 'tcp', fromPort: 22, toPort: 22, cidrs: ['10.0.0.0/8'] },
+ ],
+ },
+ ]);
+ expect(out).toHaveLength(1);
+ expect(out[0]!.kind).toBe('pass');
+ });
+});
+
+describe('AWS RDS evaluators', () => {
+ it('encryption: pass when encrypted, fail (high) when not', () => {
+ const out = evaluateRdsEncryption([
+ { id: 'db1', region: 'us-east-1', encrypted: true, backupRetentionDays: 7 },
+ { id: 'db2', region: 'us-east-1', encrypted: false, backupRetentionDays: 7 },
+ ]);
+ expect(out[0]!.kind).toBe('pass');
+ expect(out[1]!.severity).toBe('high');
+ });
+
+ it('backups: pass when retention > 0, fail when 0', () => {
+ const out = evaluateRdsBackups([
+ { id: 'db1', region: 'us-east-1', encrypted: true, backupRetentionDays: 7 },
+ { id: 'db2', region: 'us-east-1', encrypted: true, backupRetentionDays: 0 },
+ ]);
+ expect(kinds(out)).toEqual(['pass', 'fail']);
+ });
+});
+
+describe('AWS KMS rotation evaluator', () => {
+ it('only evaluates customer-managed keys', () => {
+ const out = evaluateKmsRotation([
+ { keyId: 'k1', region: 'us-east-1', customerManaged: true, rotationEnabled: true },
+ { keyId: 'k2', region: 'us-east-1', customerManaged: true, rotationEnabled: false },
+ { keyId: 'aws-managed', region: 'us-east-1', customerManaged: false, rotationEnabled: false },
+ ]);
+ expect(out).toHaveLength(2); // aws-managed excluded
+ expect(out[0]!.kind).toBe('pass');
+ expect(out[1]!.kind).toBe('fail');
+ });
+});
+
+describe('AWS CloudTrail evaluator', () => {
+ it('passes when a multi-region trail with log validation exists', () => {
+ const out = evaluateCloudTrail([{ name: 't1', multiRegion: true, logValidation: true }]);
+ expect(out[0]!.kind).toBe('pass');
+ });
+
+ it('fails (high) when no trails exist', () => {
+ const out = evaluateCloudTrail([]);
+ expect(out[0]!.kind).toBe('fail');
+ expect(out[0]!.severity).toBe('high');
+ });
+
+ it('fails (medium) when a trail exists but is not multi-region + validated', () => {
+ const out = evaluateCloudTrail([{ name: 't1', multiRegion: false, logValidation: true }]);
+ expect(out[0]!.kind).toBe('fail');
+ expect(out[0]!.severity).toBe('medium');
+ });
+});
diff --git a/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts b/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
new file mode 100644
index 0000000000..944e78623f
--- /dev/null
+++ b/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
@@ -0,0 +1,80 @@
+import { CloudTrailClient, DescribeTrailsCommand } from '@aws-sdk/client-cloudtrail';
+import { TASK_TEMPLATES } from '../../../task-mappings';
+import type { CheckContext, IntegrationCheck } from '../../../types';
+import { assumeAwsSession, type CheckOutcome, emitOutcomes } from './shared';
+
+export interface TrailInfo {
+ name: string;
+ multiRegion: boolean;
+ logValidation: boolean;
+}
+
+export function evaluateCloudTrail(trails: TrailInfo[]): CheckOutcome[] {
+ const good = trails.find((t) => t.multiRegion && t.logValidation);
+ if (good) {
+ return [
+ {
+ kind: 'pass',
+ title: 'Multi-region CloudTrail with log validation',
+ description: `Trail "${good.name}" is multi-region with log file validation enabled.`,
+ resourceType: 'aws-cloudtrail',
+ resourceId: good.name,
+ evidence: { trail: good.name },
+ },
+ ];
+ }
+ if (trails.length === 0) {
+ return [
+ {
+ kind: 'fail',
+ title: 'No CloudTrail configured',
+ description: 'No CloudTrail trail is configured for the account.',
+ resourceType: 'aws-cloudtrail',
+ resourceId: 'account',
+ severity: 'high',
+ remediation: 'Create a multi-region CloudTrail trail with log file validation enabled.',
+ },
+ ];
+ }
+ return [
+ {
+ kind: 'fail',
+ title: 'No compliant CloudTrail trail',
+ description:
+ 'No trail is both multi-region and has log file validation enabled.',
+ resourceType: 'aws-cloudtrail',
+ resourceId: 'account',
+ severity: 'medium',
+ remediation:
+ 'Enable multi-region coverage and log file validation on a CloudTrail trail.',
+ evidence: { trails: trails.map((t) => t.name) },
+ },
+ ];
+}
+
+export const cloudTrailEnabledCheck: IntegrationCheck = {
+ id: 'aws-cloudtrail-enabled',
+ name: 'CloudTrail — multi-region trail with log validation',
+ description:
+ 'Verify a multi-region CloudTrail trail with log file validation is configured.',
+ service: 'cloudtrail',
+ taskMapping: TASK_TEMPLATES.monitoringAlerting,
+ run: async (ctx: CheckContext) => {
+ const session = await assumeAwsSession(ctx);
+ if (!session) {
+ ctx.log('AWS CloudTrail check: connection not configured — skipping');
+ return;
+ }
+ const ct = new CloudTrailClient({
+ region: session.regions[0],
+ credentials: session.credentials,
+ });
+ const resp = await ct.send(new DescribeTrailsCommand({}));
+ const trails: TrailInfo[] = (resp.trailList ?? []).map((t) => ({
+ name: t.Name ?? 'unknown',
+ multiRegion: t.IsMultiRegionTrail === true,
+ logValidation: t.LogFileValidationEnabled === true,
+ }));
+ emitOutcomes(ctx, evaluateCloudTrail(trails));
+ },
+};
diff --git a/packages/integration-platform/src/manifests/aws/checks/ec2.ts b/packages/integration-platform/src/manifests/aws/checks/ec2.ts
new file mode 100644
index 0000000000..e0e56fb0e6
--- /dev/null
+++ b/packages/integration-platform/src/manifests/aws/checks/ec2.ts
@@ -0,0 +1,122 @@
+import { DescribeSecurityGroupsCommand, EC2Client } from '@aws-sdk/client-ec2';
+import { TASK_TEMPLATES } from '../../../task-mappings';
+import type { CheckContext, FindingSeverity, IntegrationCheck } from '../../../types';
+import { assumeAwsSession, type CheckOutcome, emitOutcomes } from './shared';
+
+export interface SgPermission {
+ ipProtocol: string;
+ fromPort?: number;
+ toPort?: number;
+ cidrs: string[];
+}
+
+export interface SgInfo {
+ groupId: string;
+ groupName?: string;
+ region: string;
+ permissions: SgPermission[];
+}
+
+const SENSITIVE_PORTS: Array<{ port: number; label: string; severity: FindingSeverity }> = [
+ { port: 3389, label: 'RDP', severity: 'critical' },
+ { port: 22, label: 'SSH', severity: 'high' },
+];
+
+function permCoversPort(perm: SgPermission, target: number): boolean {
+ if (perm.fromPort === undefined || perm.toPort === undefined) return false;
+ return target >= perm.fromPort && target <= perm.toPort;
+}
+
+export function evaluateSecurityGroups(sgs: SgInfo[]): CheckOutcome[] {
+ const out: CheckOutcome[] = [];
+ for (const sg of sgs) {
+ let bad = false;
+ for (const perm of sg.permissions) {
+ if (!perm.cidrs.includes('0.0.0.0/0')) continue;
+ if (perm.ipProtocol === '-1') {
+ bad = true;
+ out.push({
+ kind: 'fail',
+ title: `Security group open to internet (all ports): ${sg.groupId}`,
+ description: `Security group "${sg.groupName ?? sg.groupId}" (${sg.region}) allows all traffic from 0.0.0.0/0.`,
+ resourceType: 'aws-security-group',
+ resourceId: sg.groupId,
+ severity: 'critical',
+ remediation: 'Restrict the inbound rule to specific CIDRs and ports.',
+ evidence: { groupId: sg.groupId, region: sg.region },
+ });
+ continue;
+ }
+ for (const { port, label, severity } of SENSITIVE_PORTS) {
+ if (permCoversPort(perm, port)) {
+ bad = true;
+ out.push({
+ kind: 'fail',
+ title: `${label} open to internet: ${sg.groupId}`,
+ description: `Security group "${sg.groupName ?? sg.groupId}" (${sg.region}) allows ${label} (port ${port}) from 0.0.0.0/0.`,
+ resourceType: 'aws-security-group',
+ resourceId: sg.groupId,
+ severity,
+ remediation: `Remove the 0.0.0.0/0 rule for port ${port}; restrict ${label} to a VPN, bastion, or known CIDRs.`,
+ evidence: { groupId: sg.groupId, region: sg.region, port },
+ });
+ }
+ }
+ }
+ if (!bad) {
+ out.push({
+ kind: 'pass',
+ title: `No internet-open sensitive ports: ${sg.groupId}`,
+ description: `Security group "${sg.groupName ?? sg.groupId}" (${sg.region}) does not expose SSH/RDP/all-ports to 0.0.0.0/0.`,
+ resourceType: 'aws-security-group',
+ resourceId: sg.groupId,
+ evidence: { groupId: sg.groupId, region: sg.region },
+ });
+ }
+ }
+ return out;
+}
+
+export const ec2SecurityGroupsCheck: IntegrationCheck = {
+ id: 'aws-ec2-security-groups',
+ name: 'EC2 — no security groups open to the internet',
+ description:
+ 'Flags security group inbound rules that allow SSH, RDP, or all traffic from 0.0.0.0/0.',
+ service: 'ec2-vpc',
+ taskMapping: TASK_TEMPLATES.productionFirewallNopublicaccessControls,
+ run: async (ctx: CheckContext) => {
+ const session = await assumeAwsSession(ctx);
+ if (!session) {
+ ctx.log('AWS EC2 security-groups check: connection not configured — skipping');
+ return;
+ }
+ const sgs: SgInfo[] = [];
+ for (const region of session.regions) {
+ const ec2 = new EC2Client({ region, credentials: session.credentials });
+ let token: string | undefined;
+ do {
+ const resp = await ec2.send(
+ new DescribeSecurityGroupsCommand({ NextToken: token, MaxResults: 1000 }),
+ );
+ for (const sg of resp.SecurityGroups ?? []) {
+ sgs.push({
+ groupId: sg.GroupId ?? 'unknown',
+ groupName: sg.GroupName,
+ region,
+ permissions: (sg.IpPermissions ?? []).map((p) => ({
+ ipProtocol: p.IpProtocol ?? '-1',
+ fromPort: p.FromPort,
+ toPort: p.ToPort,
+ cidrs: (p.IpRanges ?? [])
+ .map((r) => r.CidrIp)
+ .filter((c): c is string => typeof c === 'string'),
+ })),
+ });
+ }
+ token = resp.NextToken;
+ } while (token);
+ }
+ if (sgs.length === 0) return;
+ emitOutcomes(ctx, evaluateSecurityGroups(sgs));
+ },
+};
diff --git a/packages/integration-platform/src/manifests/aws/checks/iam.ts b/packages/integration-platform/src/manifests/aws/checks/iam.ts
new file mode 100644
index 0000000000..f43015b6d2
--- /dev/null
+++ b/packages/integration-platform/src/manifests/aws/checks/iam.ts
@@ -0,0 +1,146 @@
+import {
+ GetAccountPasswordPolicyCommand,
+ GetAccountSummaryCommand,
+ IAMClient,
+} from '@aws-sdk/client-iam';
+import { TASK_TEMPLATES } from '../../../task-mappings';
+import type { CheckContext, IntegrationCheck } from '../../../types';
+import { assumeAwsSession, type CheckOutcome, emitOutcomes } from './shared';
+
+export interface IamAccountData {
+ /** null = no password policy configured */
+ passwordPolicy: {
+ MinimumPasswordLength?: number;
+ RequireSymbols?: boolean;
+ RequireNumbers?: boolean;
+ RequireUppercaseCharacters?: boolean;
+ RequireLowercaseCharacters?: boolean;
+ } | null;
+ /** GetAccountSummary SummaryMap (AccountMFAEnabled, AccountAccessKeysPresent) */
+ summary: Record;
+}
+
+/** Pure evaluation of IAM account-level posture (unit-tested without the SDK). */
+export function evaluateIamAccount(data: IamAccountData): CheckOutcome[] {
+ const out: CheckOutcome[] = [];
+ const id = 'account';
+
+ const pp = data.passwordPolicy;
+ if (!pp) {
+ out.push({
+ kind: 'fail',
+ title: 'No IAM password policy',
+ description: 'The account has no IAM password policy configured.',
+ resourceType: 'aws-account',
+ resourceId: id,
+ severity: 'high',
+ remediation:
+ 'Set an IAM password policy (min length 14; require symbols, numbers, upper and lower case).',
+ });
+ } else {
+ const weak: string[] = [];
+ if ((pp.MinimumPasswordLength ?? 0) < 14) weak.push('min length < 14');
+ if (!pp.RequireSymbols) weak.push('no symbols required');
+ if (!pp.RequireNumbers) weak.push('no numbers required');
+ if (!pp.RequireUppercaseCharacters) weak.push('no uppercase required');
+ if (!pp.RequireLowercaseCharacters) weak.push('no lowercase required');
+ if (weak.length > 0) {
+ out.push({
+ kind: 'fail',
+ title: 'Weak IAM password policy',
+ description: `IAM password policy is weak: ${weak.join(', ')}.`,
+ resourceType: 'aws-account',
+ resourceId: id,
+ severity: 'medium',
+ remediation:
+ 'Strengthen the IAM password policy: min length 14 and require symbols, numbers, upper and lower case.',
+ evidence: { ...pp },
+ });
+ } else {
+ out.push({
+ kind: 'pass',
+ title: 'Strong IAM password policy',
+ description: 'IAM password policy meets complexity requirements.',
+ resourceType: 'aws-account',
+ resourceId: id,
+ evidence: { ...pp },
+ });
+ }
+ }
+
+ if (data.summary.AccountMFAEnabled === 1) {
+ out.push({
+ kind: 'pass',
+ title: 'Root account MFA enabled',
+ description: 'The root account has MFA enabled.',
+ resourceType: 'aws-account',
+ resourceId: id,
+ });
+ } else {
+ out.push({
+ kind: 'fail',
+ title: 'Root account MFA disabled',
+ description: 'The root account does not have MFA enabled.',
+ resourceType: 'aws-account',
+ resourceId: id,
+ severity: 'high',
+ remediation: 'Enable MFA on the root account.',
+ });
+ }
+
+ if ((data.summary.AccountAccessKeysPresent ?? 0) > 0) {
+ out.push({
+ kind: 'fail',
+ title: 'Root account access keys present',
+ description: 'The root account has active access keys, which should not exist.',
+ resourceType: 'aws-account',
+ resourceId: id,
+ severity: 'high',
+ remediation: 'Delete root account access keys; use IAM users/roles instead.',
+ });
+ } else {
+ out.push({
+ kind: 'pass',
+ title: 'No root account access keys',
+ description: 'The root account has no access keys.',
+ resourceType: 'aws-account',
+ resourceId: id,
+ });
+ }
+
+ return out;
+}
+
+export const iamAccountSecurityCheck: IntegrationCheck = {
+ id: 'aws-iam-account-security',
+ name: 'IAM — password policy and root protections',
+ description:
+ 'Verify a strong IAM password policy, root MFA enabled, and no root access keys.',
+ service: 'iam-analyzer',
+ taskMapping: TASK_TEMPLATES.rolebasedAccessControls,
+ run: async (ctx: CheckContext) => {
+ const session = await assumeAwsSession(ctx);
+ if (!session) {
+ ctx.log('AWS IAM check: connection not configured — skipping');
+ return;
+ }
+ const iam = new IAMClient({
+ region: session.regions[0],
+ credentials: session.credentials,
+ });
+
+ let passwordPolicy: IamAccountData['passwordPolicy'] = null;
+ try {
+ const pp = await iam.send(new GetAccountPasswordPolicyCommand({}));
+ passwordPolicy = pp.PasswordPolicy ?? null;
+ } catch (err) {
+ // NoSuchEntity = no policy set → treat as null (a finding). Re-throw others.
+ if (!(err instanceof Error && err.name === 'NoSuchEntityException')) throw err;
+ }
+
+ const summaryResp = await iam.send(new GetAccountSummaryCommand({}));
+ const summary = (summaryResp.SummaryMap ?? {}) as Record;
+
+ emitOutcomes(ctx, evaluateIamAccount({ passwordPolicy, summary }));
+ },
+};
diff --git a/packages/integration-platform/src/manifests/aws/checks/index.ts b/packages/integration-platform/src/manifests/aws/checks/index.ts
new file mode 100644
index 0000000000..ac43946a32
--- /dev/null
+++ b/packages/integration-platform/src/manifests/aws/checks/index.ts
@@ -0,0 +1,6 @@
+export { iamAccountSecurityCheck } from './iam';
+export { s3EncryptionCheck, s3PublicAccessCheck } from './s3';
+export { ec2SecurityGroupsCheck } from './ec2';
+export { rdsEncryptionCheck, rdsBackupsCheck } from './rds';
+export { kmsKeyRotationCheck } from './kms';
+export { cloudTrailEnabledCheck } from './cloudtrail';
diff --git a/packages/integration-platform/src/manifests/aws/checks/kms.ts b/packages/integration-platform/src/manifests/aws/checks/kms.ts
new file mode 100644
index 0000000000..3c08f369e4
--- /dev/null
+++ b/packages/integration-platform/src/manifests/aws/checks/kms.ts
@@ -0,0 +1,98 @@
+import {
+ DescribeKeyCommand,
+ GetKeyRotationStatusCommand,
+ KMSClient,
+ ListKeysCommand,
+} from '@aws-sdk/client-kms';
+import { TASK_TEMPLATES } from '../../../task-mappings';
+import type { CheckContext, IntegrationCheck } from '../../../types';
+import {
+ assumeAwsSession,
+ type AwsSession,
+ type CheckOutcome,
+ emitOutcomes,
+} from './shared';
+
+export interface KmsKeyInfo {
+ keyId: string;
+ region: string;
+ customerManaged: boolean;
+ rotationEnabled: boolean;
+}
+
+/** Only customer-managed keys are evaluated — AWS-managed keys rotate automatically. */
+export function evaluateKmsRotation(keys: KmsKeyInfo[]): CheckOutcome[] {
+ return keys
+ .filter((k) => k.customerManaged)
+ .map((k) =>
+ k.rotationEnabled
+ ? {
+ kind: 'pass',
+ title: `KMS key rotation enabled: ${k.keyId}`,
+ description: `Customer-managed KMS key "${k.keyId}" (${k.region}) has automatic rotation enabled.`,
+ resourceType: 'aws-kms-key',
+ resourceId: k.keyId,
+ evidence: { keyId: k.keyId, region: k.region },
+ }
+ : {
+ kind: 'fail',
+ title: `KMS key rotation disabled: ${k.keyId}`,
+ description: `Customer-managed KMS key "${k.keyId}" (${k.region}) does not have automatic rotation enabled.`,
+ resourceType: 'aws-kms-key',
+ resourceId: k.keyId,
+ severity: 'medium',
+ remediation: 'Enable automatic annual key rotation on the customer-managed KMS key.',
+ evidence: { keyId: k.keyId, region: k.region },
+ },
+ );
+}
+
+async function listKmsKeys(session: AwsSession): Promise {
+ const out: KmsKeyInfo[] = [];
+ for (const region of session.regions) {
+ const kms = new KMSClient({ region, credentials: session.credentials });
+ let marker: string | undefined;
+ do {
+ const resp = await kms.send(new ListKeysCommand({ Marker: marker }));
+ for (const k of resp.Keys ?? []) {
+ const keyId = k.KeyId;
+ if (!keyId) continue;
+ const desc = await kms.send(new DescribeKeyCommand({ KeyId: keyId }));
+ const meta = desc.KeyMetadata;
+ const customerManaged =
+ meta?.KeyManager === 'CUSTOMER' && meta?.KeyState === 'Enabled';
+ let rotationEnabled = false;
+ if (customerManaged) {
+ try {
+ const rot = await kms.send(new GetKeyRotationStatusCommand({ KeyId: keyId }));
+ rotationEnabled = rot.KeyRotationEnabled === true;
+ } catch {
+ rotationEnabled = false;
+ }
+ }
+ out.push({ keyId, region, customerManaged, rotationEnabled });
+ }
+ marker = resp.NextMarker;
+ } while (marker);
+ }
+ return out;
+}
+
+export const kmsKeyRotationCheck: IntegrationCheck = {
+ id: 'aws-kms-key-rotation',
+ name: 'KMS — customer key rotation enabled',
+ description: 'Verify customer-managed KMS keys have automatic rotation enabled.',
+ service: 'kms',
+ taskMapping: TASK_TEMPLATES.encryptionAtRest,
+ run: async (ctx: CheckContext) => {
+ const session = await assumeAwsSession(ctx);
+ if (!session) {
+ ctx.log('AWS KMS check: connection not configured — skipping');
+ return;
+ }
+ const keys = await listKmsKeys(session);
+ const customerKeys = keys.filter((k) => k.customerManaged);
+ if (customerKeys.length === 0) return; // nothing to evidence
+ emitOutcomes(ctx, evaluateKmsRotation(keys));
+ },
+};
diff --git a/packages/integration-platform/src/manifests/aws/checks/rds.ts b/packages/integration-platform/src/manifests/aws/checks/rds.ts
new file mode 100644
index 0000000000..6e4e3080fa
--- /dev/null
+++ b/packages/integration-platform/src/manifests/aws/checks/rds.ts
@@ -0,0 +1,122 @@
+import { DescribeDBInstancesCommand, RDSClient } from '@aws-sdk/client-rds';
+import { TASK_TEMPLATES } from '../../../task-mappings';
+import type { CheckContext, IntegrationCheck } from '../../../types';
+import {
+ assumeAwsSession,
+ type AwsSession,
+ type CheckOutcome,
+ emitOutcomes,
+} from './shared';
+
+export interface RdsInstanceInfo {
+ id: string;
+ region: string;
+ encrypted: boolean;
+ backupRetentionDays: number;
+}
+
+export function evaluateRdsEncryption(instances: RdsInstanceInfo[]): CheckOutcome[] {
+ return instances.map((i) =>
+ i.encrypted
+ ? {
+ kind: 'pass',
+ title: `RDS storage encrypted: ${i.id}`,
+ description: `RDS instance "${i.id}" (${i.region}) has storage encryption enabled.`,
+ resourceType: 'aws-rds-instance',
+ resourceId: i.id,
+ evidence: { instance: i.id, region: i.region },
+ }
+ : {
+ kind: 'fail',
+ title: `RDS storage not encrypted: ${i.id}`,
+ description: `RDS instance "${i.id}" (${i.region}) does not have storage encryption enabled.`,
+ resourceType: 'aws-rds-instance',
+ resourceId: i.id,
+ severity: 'high',
+ remediation:
+ 'Enable storage encryption (encryption at rest must be set at creation; restore from an encrypted snapshot to remediate).',
+ evidence: { instance: i.id, region: i.region },
+ },
+ );
+}
+
+export function evaluateRdsBackups(instances: RdsInstanceInfo[]): CheckOutcome[] {
+ return instances.map((i) =>
+ i.backupRetentionDays > 0
+ ? {
+ kind: 'pass',
+ title: `RDS automated backups enabled: ${i.id}`,
+ description: `RDS instance "${i.id}" (${i.region}) retains backups for ${i.backupRetentionDays} day(s).`,
+ resourceType: 'aws-rds-instance',
+ resourceId: i.id,
+ evidence: { instance: i.id, backupRetentionDays: i.backupRetentionDays },
+ }
+ : {
+ kind: 'fail',
+ title: `RDS automated backups disabled: ${i.id}`,
+ description: `RDS instance "${i.id}" (${i.region}) has automated backups disabled (retention 0).`,
+ resourceType: 'aws-rds-instance',
+ resourceId: i.id,
+ severity: 'medium',
+ remediation: 'Set a backup retention period of at least 7 days.',
+ evidence: { instance: i.id },
+ },
+ );
+}
+
+async function listRdsInstances(session: AwsSession): Promise {
+ const out: RdsInstanceInfo[] = [];
+ for (const region of session.regions) {
+ const rds = new RDSClient({ region, credentials: session.credentials });
+ let marker: string | undefined;
+ do {
+ const resp = await rds.send(new DescribeDBInstancesCommand({ Marker: marker }));
+ for (const db of resp.DBInstances ?? []) {
+ out.push({
+ id: db.DBInstanceIdentifier ?? 'unknown',
+ region,
+ encrypted: db.StorageEncrypted === true,
+ backupRetentionDays: db.BackupRetentionPeriod ?? 0,
+ });
+ }
+ marker = resp.Marker;
+ } while (marker);
+ }
+ return out;
+}
+
+export const rdsEncryptionCheck: IntegrationCheck = {
+ id: 'aws-rds-encryption',
+ name: 'RDS — storage encryption enabled',
+ description: 'Verify all RDS instances have storage encryption at rest enabled.',
+ service: 'rds',
+ taskMapping: TASK_TEMPLATES.encryptionAtRest,
+ run: async (ctx: CheckContext) => {
+ const session = await assumeAwsSession(ctx);
+ if (!session) {
+ ctx.log('AWS RDS encryption check: connection not configured — skipping');
+ return;
+ }
+ const instances = await listRdsInstances(session);
+ if (instances.length === 0) return;
+ emitOutcomes(ctx, evaluateRdsEncryption(instances));
+ },
+};
+
+export const rdsBackupsCheck: IntegrationCheck = {
+ id: 'aws-rds-backups',
+ name: 'RDS — automated backups enabled',
+ description: 'Verify all RDS instances have automated backups enabled.',
+ service: 'rds',
+ taskMapping: TASK_TEMPLATES.backupLogs,
+ run: async (ctx: CheckContext) => {
+ const session = await assumeAwsSession(ctx);
+ if (!session) {
+ ctx.log('AWS RDS backups check: connection not configured — skipping');
+ return;
+ }
+ const instances = await listRdsInstances(session);
+ if (instances.length === 0) return;
+ emitOutcomes(ctx, evaluateRdsBackups(instances));
+ },
+};
diff --git a/packages/integration-platform/src/manifests/aws/checks/s3.ts b/packages/integration-platform/src/manifests/aws/checks/s3.ts
new file mode 100644
index 0000000000..136910b7d9
--- /dev/null
+++ b/packages/integration-platform/src/manifests/aws/checks/s3.ts
@@ -0,0 +1,148 @@
+import {
+ GetBucketEncryptionCommand,
+ GetPublicAccessBlockCommand,
+ ListBucketsCommand,
+ S3Client,
+} from '@aws-sdk/client-s3';
+import { TASK_TEMPLATES } from '../../../task-mappings';
+import type { CheckContext, IntegrationCheck } from '../../../types';
+import { assumeAwsSession, type CheckOutcome, emitOutcomes } from './shared';
+
+export interface S3BucketInfo {
+ name: string;
+ encrypted: boolean;
+ publicAccessBlocked: boolean;
+}
+
+export function evaluateS3Encryption(buckets: S3BucketInfo[]): CheckOutcome[] {
+ return buckets.map((b) =>
+ b.encrypted
+ ? {
+ kind: 'pass',
+ title: `Default encryption enabled: ${b.name}`,
+ description: `Bucket "${b.name}" has default encryption enabled.`,
+ resourceType: 'aws-s3-bucket',
+ resourceId: b.name,
+ evidence: { bucket: b.name },
+ }
+ : {
+ kind: 'fail',
+ title: `No default encryption: ${b.name}`,
+ description: `Bucket "${b.name}" does not have default server-side encryption enabled.`,
+ resourceType: 'aws-s3-bucket',
+ resourceId: b.name,
+ severity: 'high',
+ remediation: 'Enable default encryption (SSE-S3 or SSE-KMS) on the bucket.',
+ evidence: { bucket: b.name },
+ },
+ );
+}
+
+export function evaluateS3PublicAccess(buckets: S3BucketInfo[]): CheckOutcome[] {
+ return buckets.map((b) =>
+ b.publicAccessBlocked
+ ? {
+ kind: 'pass',
+ title: `Public access blocked: ${b.name}`,
+ description: `Bucket "${b.name}" has S3 Block Public Access fully enabled.`,
+ resourceType: 'aws-s3-bucket',
+ resourceId: b.name,
+ evidence: { bucket: b.name },
+ }
+ : {
+ kind: 'fail',
+ title: `Public access not fully blocked: ${b.name}`,
+ description: `Bucket "${b.name}" does not have all S3 Block Public Access settings enabled.`,
+ resourceType: 'aws-s3-bucket',
+ resourceId: b.name,
+ severity: 'high',
+ remediation: 'Enable all four S3 Block Public Access settings on the bucket (or account).',
+ evidence: { bucket: b.name },
+ },
+ );
+}
+
+async function gatherBuckets(
+ s3: S3Client,
+ opts: { encryption: boolean; publicAccess: boolean },
+): Promise {
+ const list = await s3.send(new ListBucketsCommand({}));
+ const names = (list.Buckets ?? [])
+ .map((b) => b.Name)
+ .filter((n): n is string => typeof n === 'string');
+
+ const infos: S3BucketInfo[] = [];
+ for (const name of names) {
+ let encrypted = false;
+ let publicAccessBlocked = false;
+
+ if (opts.encryption) {
+ try {
+ const enc = await s3.send(new GetBucketEncryptionCommand({ Bucket: name }));
+ encrypted = (enc.ServerSideEncryptionConfiguration?.Rules?.length ?? 0) > 0;
+ } catch {
+ encrypted = false; // no encryption config
+ }
+ }
+ if (opts.publicAccess) {
+ try {
+ const pab = await s3.send(new GetPublicAccessBlockCommand({ Bucket: name }));
+ const c = pab.PublicAccessBlockConfiguration;
+ publicAccessBlocked = Boolean(
+ c?.BlockPublicAcls &&
+ c?.IgnorePublicAcls &&
+ c?.BlockPublicPolicy &&
+ c?.RestrictPublicBuckets,
+ );
+ } catch {
+ publicAccessBlocked = false; // no public access block config
+ }
+ }
+ infos.push({ name, encrypted, publicAccessBlocked });
+ }
+ return infos;
+}
+
+export const s3EncryptionCheck: IntegrationCheck = {
+ id: 'aws-s3-encryption',
+ name: 'S3 — default encryption enabled',
+ description: 'Verify all S3 buckets have default server-side encryption enabled.',
+ service: 's3',
+ taskMapping: TASK_TEMPLATES.encryptionAtRest,
+ run: async (ctx: CheckContext) => {
+ const session = await assumeAwsSession(ctx);
+ if (!session) {
+ ctx.log('AWS S3 encryption check: connection not configured — skipping');
+ return;
+ }
+ const s3 = new S3Client({
+ region: session.regions[0],
+ credentials: session.credentials,
+ });
+ const buckets = await gatherBuckets(s3, { encryption: true, publicAccess: false });
+ if (buckets.length === 0) return;
+ emitOutcomes(ctx, evaluateS3Encryption(buckets));
+ },
+};
+
+export const s3PublicAccessCheck: IntegrationCheck = {
+ id: 'aws-s3-public-access',
+ name: 'S3 — public access blocked',
+ description: 'Verify all S3 buckets have S3 Block Public Access fully enabled.',
+ service: 's3',
+ taskMapping: TASK_TEMPLATES.productionFirewallNopublicaccessControls,
+ run: async (ctx: CheckContext) => {
+ const session = await assumeAwsSession(ctx);
+ if (!session) {
+ ctx.log('AWS S3 public-access check: connection not configured — skipping');
+ return;
+ }
+ const s3 = new S3Client({
+ region: session.regions[0],
+ credentials: session.credentials,
+ });
+ const buckets = await gatherBuckets(s3, { encryption: false, publicAccess: true });
+ if (buckets.length === 0) return;
+ emitOutcomes(ctx, evaluateS3PublicAccess(buckets));
+ },
+};
diff --git a/packages/integration-platform/src/manifests/aws/checks/shared.ts b/packages/integration-platform/src/manifests/aws/checks/shared.ts
new file mode 100644
index 0000000000..504d21ffcb
--- /dev/null
+++ b/packages/integration-platform/src/manifests/aws/checks/shared.ts
@@ -0,0 +1,90 @@
+import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts';
+import type { CheckContext, FindingSeverity } from '../../../types';
+
+export interface AwsSession {
+ credentials: {
+ accessKeyId: string;
+ secretAccessKey: string;
+ sessionToken: string;
+ };
+ regions: string[];
+}
+
+/**
+ * Assume the customer's cross-account IAM role (role ARN + external ID from the
+ * connection credentials) and return temporary credentials + the selected
+ * regions. Returns null when the connection isn't configured — the check
+ * should then no-op (no false pass).
+ */
+export async function assumeAwsSession(
+ ctx: CheckContext,
+): Promise {
+ const raw = ctx.credentials as Record;
+ const roleArn = typeof raw.roleArn === 'string' ? raw.roleArn : '';
+ const externalId = typeof raw.externalId === 'string' ? raw.externalId : '';
+ const regions = Array.isArray(raw.regions)
+ ? raw.regions.filter((r): r is string => typeof r === 'string')
+ : typeof raw.region === 'string'
+ ? [raw.region]
+ : [];
+
+ if (!roleArn || !externalId || regions.length === 0) return null;
+
+ const sts = new STSClient({ region: regions[0] });
+ const res = await sts.send(
+ new AssumeRoleCommand({
+ RoleArn: roleArn,
+ ExternalId: externalId,
+ RoleSessionName: 'CompEvidenceCheck',
+ DurationSeconds: 3600,
+ }),
+ );
+ const c = res.Credentials;
+ if (!c?.AccessKeyId || !c.SecretAccessKey || !c.SessionToken) return null;
+
+ return {
+ credentials: {
+ accessKeyId: c.AccessKeyId,
+ secretAccessKey: c.SecretAccessKey,
+ sessionToken: c.SessionToken,
+ },
+ regions,
+ };
+}
+
+/** A provider-agnostic pass/fail outcome produced by a pure evaluator. */
+export interface CheckOutcome {
+ kind: 'pass' | 'fail';
+ title: string;
+ description: string;
+ resourceType: string;
+ resourceId: string;
+ severity?: FindingSeverity;
+ remediation?: string;
+ evidence?: Record;
+}
+
+/** Map pure evaluator outcomes onto ctx.pass / ctx.fail. */
+export function emitOutcomes(ctx: CheckContext, outcomes: CheckOutcome[]): void {
+ for (const o of outcomes) {
+ if (o.kind === 'pass') {
+ ctx.pass({
+ title: o.title,
+ description: o.description,
+ resourceType: o.resourceType,
+ resourceId: o.resourceId,
+ evidence: o.evidence ?? {},
+ });
+ } else {
+ ctx.fail({
+ title: o.title,
+ description: o.description,
+ resourceType: o.resourceType,
+ resourceId: o.resourceId,
+ severity: o.severity ?? 'medium',
+ remediation: o.remediation ?? 'Review and remediate this finding.',
+ evidence: o.evidence,
+ });
+ }
+ }
+}
diff --git a/packages/integration-platform/src/manifests/aws/index.ts b/packages/integration-platform/src/manifests/aws/index.ts
index 48e3f6285d..3eacd8c4d8 100644
--- a/packages/integration-platform/src/manifests/aws/index.ts
+++ b/packages/integration-platform/src/manifests/aws/index.ts
@@ -1,4 +1,14 @@
import type { IntegrationManifest } from '../../types';
+import {
+ cloudTrailEnabledCheck,
+ ec2SecurityGroupsCheck,
+ iamAccountSecurityCheck,
+ kmsKeyRotationCheck,
+ rdsBackupsCheck,
+ rdsEncryptionCheck,
+ s3EncryptionCheck,
+ s3PublicAccessCheck,
+} from './checks';
import { awsCredentialFields, awsCredentialSchema, awsSetupInstructions, awsCloudShellScript } from './credentials';
export const awsManifest: IntegrationManifest = {
@@ -80,5 +90,14 @@ export const awsManifest: IntegrationManifest = {
{ id: 'appflow', name: 'AppFlow', description: 'Flow encryption, VPC configuration, and data transfer security checks', enabledByDefault: false, implemented: true },
],
- checks: [],
+ checks: [
+ iamAccountSecurityCheck,
+ s3EncryptionCheck,
+ s3PublicAccessCheck,
+ ec2SecurityGroupsCheck,
+ rdsEncryptionCheck,
+ rdsBackupsCheck,
+ kmsKeyRotationCheck,
+ cloudTrailEnabledCheck,
+ ],
};
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
new file mode 100644
index 0000000000..9bf9a5abaa
--- /dev/null
+++ b/packages/integration-platform/src/manifests/azure/checks/__tests__/azure-checks.test.ts
@@ -0,0 +1,225 @@
+import { describe, expect, it } from 'bun:test';
+import type {
+ CheckContext,
+ CheckVariableValues,
+ IntegrationCheck,
+} from '../../../../types';
+import { rbacLeastPrivilegeCheck } from '../entra-id';
+import { keyVaultProtectionCheck, keyVaultRbacCheck } from '../key-vault';
+import { monitorLoggingAlertingCheck } from '../monitor';
+import { nsgNoOpenPortsCheck } from '../network';
+import { sqlAuditingCheck, sqlPublicAccessCheck, sqlTlsCheck } from '../sql';
+import {
+ storageEncryptionCheck,
+ storageHttpsTlsCheck,
+ storagePublicAccessCheck,
+} from '../storage';
+
+interface Captured {
+ passed: string[];
+ failed: Array<{ title: string; severity: string }>;
+}
+
+async function run(
+ check: IntegrationCheck,
+ fetchFn: (url: string) => unknown,
+ variables: CheckVariableValues = { subscription_id: 'sub-1' },
+): Promise {
+ const passed: string[] = [];
+ const failed: Captured['failed'] = [];
+ const ctx = {
+ accessToken: 'tok',
+ credentials: {},
+ variables,
+ connectionId: 'c',
+ organizationId: 'o',
+ metadata: {},
+ log: () => {},
+ warn: () => {},
+ error: () => {},
+ pass: (r) => passed.push(r.title),
+ fail: (r) => failed.push({ title: r.title, severity: r.severity }),
+ fetch: (async (url: string): Promise => fetchFn(url) as T) as CheckContext['fetch'],
+ post: (async () => ({})) as CheckContext['post'],
+ put: (async () => ({})) as CheckContext['put'],
+ patch: (async () => ({})) as CheckContext['patch'],
+ delete: (async () => ({})) as CheckContext['delete'],
+ graphql: (async () => ({})) as CheckContext['graphql'],
+ fetchAllPages: (async () => []) as CheckContext['fetchAllPages'],
+ fetchWithCursor: (async () => []) as CheckContext['fetchWithCursor'],
+ fetchWithLinkHeader: (async () => []) as CheckContext['fetchWithLinkHeader'],
+ getState: (async () => null) as CheckContext['getState'],
+ setState: (async () => {}) as CheckContext['setState'],
+ } as CheckContext;
+ await check.run(ctx);
+ return { passed, failed };
+}
+
+const storageList = (props: Record) => () => ({
+ value: [{ id: 'sa1', name: 'sa1', properties: props }],
+});
+
+describe('Azure storage checks', () => {
+ it('https-tls fails when HTTPS off, passes when enforced', async () => {
+ const bad = await run(
+ storageHttpsTlsCheck,
+ storageList({ supportsHttpsTrafficOnly: false, minimumTlsVersion: 'TLS1_2' }),
+ );
+ expect(bad.failed).toHaveLength(1);
+ expect(bad.failed[0]!.severity).toBe('high');
+
+ const ok = await run(
+ storageHttpsTlsCheck,
+ storageList({ supportsHttpsTrafficOnly: true, minimumTlsVersion: 'TLS1_2' }),
+ );
+ expect(ok.passed).toHaveLength(1);
+ });
+
+ it('public-access fails on public blob, passes when private', async () => {
+ const bad = await run(storagePublicAccessCheck, storageList({ allowBlobPublicAccess: true }));
+ expect(bad.failed).toHaveLength(1);
+
+ const ok = await run(
+ storagePublicAccessCheck,
+ storageList({ allowBlobPublicAccess: false, publicNetworkAccess: 'Disabled' }),
+ );
+ expect(ok.passed).toHaveLength(1);
+ });
+
+ it('encryption fails when a service is disabled, passes when enabled', async () => {
+ const bad = await run(
+ storageEncryptionCheck,
+ storageList({ encryption: { services: { blob: { enabled: false }, file: { enabled: true } } } }),
+ );
+ expect(bad.failed).toHaveLength(1);
+
+ const ok = await run(
+ storageEncryptionCheck,
+ storageList({ encryption: { services: { blob: { enabled: true }, file: { enabled: true } } } }),
+ );
+ expect(ok.passed).toHaveLength(1);
+ });
+});
+
+describe('Azure SQL checks', () => {
+ const server = { id: '/subscriptions/sub-1/srv1', name: 'srv1', properties: {} as Record };
+
+ it('tls fails below 1.2, passes at 1.2', async () => {
+ const bad = await run(sqlTlsCheck, () => ({ value: [{ ...server, properties: { minimalTlsVersion: '1.0' } }] }));
+ expect(bad.failed).toHaveLength(1);
+ const ok = await run(sqlTlsCheck, () => ({ value: [{ ...server, properties: { minimalTlsVersion: '1.2' } }] }));
+ expect(ok.passed).toHaveLength(1);
+ });
+
+ it('public-access flags wide-open firewall as critical', async () => {
+ const { failed } = await run(sqlPublicAccessCheck, (url) =>
+ url.includes('/firewallRules')
+ ? { value: [{ properties: { startIpAddress: '0.0.0.0', endIpAddress: '255.255.255.255' } }] }
+ : { value: [{ ...server, properties: { publicNetworkAccess: 'Disabled' } }] },
+ );
+ expect(failed).toHaveLength(1);
+ expect(failed[0]!.severity).toBe('critical');
+ });
+
+ it('public-access passes when private + no wide-open rule', async () => {
+ const { passed } = await run(sqlPublicAccessCheck, (url) =>
+ url.includes('/firewallRules')
+ ? { value: [] }
+ : { value: [{ ...server, properties: { publicNetworkAccess: 'Disabled' } }] },
+ );
+ expect(passed).toHaveLength(1);
+ });
+
+ it('auditing fails when disabled, passes when enabled', async () => {
+ const bad = await run(sqlAuditingCheck, (url) =>
+ url.includes('/auditingSettings/default')
+ ? { properties: { state: 'Disabled' } }
+ : { value: [server] },
+ );
+ expect(bad.failed).toHaveLength(1);
+ const ok = await run(sqlAuditingCheck, (url) =>
+ url.includes('/auditingSettings/default')
+ ? { properties: { state: 'Enabled' } }
+ : { value: [server] },
+ );
+ expect(ok.passed).toHaveLength(1);
+ });
+});
+
+describe('Azure Key Vault checks', () => {
+ const vaultList = (props: Record) => () => ({
+ value: [{ id: 'kv1', name: 'kv1', properties: props }],
+ });
+
+ it('protection fails when soft delete off, passes when hardened', async () => {
+ const bad = await run(keyVaultProtectionCheck, vaultList({ enableSoftDelete: false, enablePurgeProtection: true, publicNetworkAccess: 'Disabled' }));
+ expect(bad.failed).toHaveLength(1);
+ expect(bad.failed[0]!.severity).toBe('high');
+
+ const ok = await run(keyVaultProtectionCheck, vaultList({ enableSoftDelete: true, enablePurgeProtection: true, publicNetworkAccess: 'Disabled' }));
+ expect(ok.passed).toHaveLength(1);
+ });
+
+ it('rbac fails on legacy access policies, passes when RBAC on', async () => {
+ const bad = await run(keyVaultRbacCheck, vaultList({ enableRbacAuthorization: false }));
+ expect(bad.failed[0]!.severity).toBe('low');
+ const ok = await run(keyVaultRbacCheck, vaultList({ enableRbacAuthorization: true }));
+ expect(ok.passed).toHaveLength(1);
+ });
+});
+
+describe('Azure NSG check', () => {
+ const nsg = (rule: Record) => () => ({
+ value: [{ id: 'nsg1', name: 'nsg1', properties: { securityRules: [rule] } }],
+ });
+
+ it('flags RDP open to internet as critical', async () => {
+ const { failed } = await run(
+ nsgNoOpenPortsCheck,
+ nsg({ name: 'r1', properties: { direction: 'Inbound', access: 'Allow', protocol: 'Tcp', sourceAddressPrefix: '*', destinationPortRange: '3389', priority: 100 } }),
+ );
+ expect(failed.some((f) => f.severity === 'critical')).toBe(true);
+ });
+
+ it('passes when no internet-open sensitive ports', async () => {
+ const { passed } = await run(
+ nsgNoOpenPortsCheck,
+ nsg({ name: 'r1', properties: { direction: 'Inbound', access: 'Allow', protocol: 'Tcp', sourceAddressPrefix: '10.0.0.0/8', destinationPortRange: '22', priority: 100 } }),
+ );
+ expect(passed).toHaveLength(1);
+ });
+});
+
+describe('Azure RBAC (entra) check', () => {
+ it('fails on >5 privileged assignments', async () => {
+ const { failed } = await run(rbacLeastPrivilegeCheck, (url) => {
+ if (url.includes('roleDefinitions')) {
+ return { value: [{ id: 'owner', properties: { roleName: 'Owner', type: 'BuiltInRole', permissions: [] } }] };
+ }
+ return {
+ value: Array.from({ length: 6 }, () => ({
+ properties: { roleDefinitionId: 'owner', principalId: 'p', principalType: 'User' },
+ })),
+ };
+ });
+ expect(failed.some((f) => f.title.match(/Excessive privileged/))).toBe(true);
+ });
+
+ it('passes with few privileged, no wildcard roles', async () => {
+ const { passed } = await run(rbacLeastPrivilegeCheck, (url) => {
+ if (url.includes('roleDefinitions')) {
+ return { value: [{ id: 'reader', properties: { roleName: 'Reader', type: 'BuiltInRole', permissions: [] } }] };
+ }
+ return { value: [{ properties: { roleDefinitionId: 'reader', principalId: 'p', principalType: 'User' } }] };
+ });
+ expect(passed).toHaveLength(1);
+ });
+});
+
+describe('Azure Monitor check', () => {
+ it('fails when no alerts and no log export', async () => {
+ const { failed } = await run(monitorLoggingAlertingCheck, () => ({ value: [] }));
+ // missing alerts + no diagnostic export
+ expect(failed).toHaveLength(2);
+ });
+});
diff --git a/packages/integration-platform/src/manifests/azure/checks/entra-id.ts b/packages/integration-platform/src/manifests/azure/checks/entra-id.ts
new file mode 100644
index 0000000000..3ea2366023
--- /dev/null
+++ b/packages/integration-platform/src/manifests/azure/checks/entra-id.ts
@@ -0,0 +1,123 @@
+import { TASK_TEMPLATES } from '../../../task-mappings';
+import type { CheckContext, IntegrationCheck } from '../../../types';
+import { ARM_BASE, armListAll, resolveAzureSubscriptionId } from './shared';
+
+interface RoleAssignment {
+ properties: { roleDefinitionId: string; principalId: string; principalType: string };
+}
+
+interface RoleDefinition {
+ id: string;
+ properties: {
+ roleName: string;
+ type: string;
+ permissions: Array<{ actions: string[] }>;
+ };
+}
+
+const PRIVILEGED_ROLES = new Set([
+ 'Owner',
+ 'Contributor',
+ 'User Access Administrator',
+ 'Global Administrator',
+ 'Privileged Role Administrator',
+]);
+
+/**
+ * Subscription RBAC least-privilege (ARM role assignments, not Graph) →
+ * Role-based Access Controls. Flags excessive privileged assignments, wildcard
+ * custom roles, and service principals holding privileged roles.
+ */
+export const rbacLeastPrivilegeCheck: IntegrationCheck = {
+ id: 'azure-rbac-least-privilege',
+ name: 'Azure RBAC — least privilege',
+ description:
+ 'Flags excessive privileged role assignments, custom roles with wildcard permissions, and service principals with privileged roles.',
+ service: 'entra-id',
+ taskMapping: TASK_TEMPLATES.rolebasedAccessControls,
+ run: async (ctx: CheckContext) => {
+ const sub = await resolveAzureSubscriptionId(ctx);
+ if (!sub) return;
+
+ const [assignments, definitions] = await Promise.all([
+ armListAll(
+ ctx,
+ `${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01`,
+ ),
+ armListAll(
+ ctx,
+ `${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.Authorization/roleDefinitions?api-version=2022-04-01`,
+ ),
+ ]);
+
+ const defMap = new Map(definitions.map((d) => [d.id, d]));
+ const privileged = assignments.filter((a) => {
+ const def = defMap.get(a.properties.roleDefinitionId);
+ return def ? PRIVILEGED_ROLES.has(def.properties.roleName) : false;
+ });
+
+ let violations = 0;
+
+ if (privileged.length > 5) {
+ violations++;
+ ctx.fail({
+ title: 'Excessive privileged role assignments',
+ description: `${privileged.length} principals hold privileged roles (Owner/Contributor/User Access Administrator). Limit to essential accounts.`,
+ resourceType: 'azure-subscription',
+ resourceId: sub,
+ severity: 'high',
+ remediation:
+ 'Review privileged role assignments and remove unnecessary ones; use just-in-time access via Azure PIM.',
+ evidence: { count: privileged.length },
+ });
+ }
+
+ const spPrivileged = privileged.filter(
+ (a) => a.properties.principalType === 'ServicePrincipal',
+ );
+ if (spPrivileged.length > 0) {
+ violations++;
+ ctx.fail({
+ title: 'Service principals with privileged roles',
+ description: `${spPrivileged.length} service principal(s) hold privileged roles. Service principals should use least-privilege access.`,
+ resourceType: 'azure-subscription',
+ resourceId: sub,
+ severity: 'medium',
+ remediation:
+ 'Replace broad roles with scoped custom roles for service principals.',
+ evidence: { count: spPrivileged.length },
+ });
+ }
+
+ const wildcardRoles = definitions.filter(
+ (d) =>
+ d.properties.type === 'CustomRole' &&
+ d.properties.permissions.some((perm) =>
+ perm.actions.some((act) => act === '*' || act.endsWith('/*')),
+ ),
+ );
+ for (const role of wildcardRoles) {
+ violations++;
+ ctx.fail({
+ title: `Custom role with wildcard permissions: ${role.properties.roleName}`,
+ description: `Custom role "${role.properties.roleName}" grants wildcard (*) permissions, which is overly permissive.`,
+ resourceType: 'azure-role-definition',
+ resourceId: role.id,
+ severity: 'high',
+ remediation:
+ 'Restrict the custom role to only the specific actions required.',
+ evidence: { roleName: role.properties.roleName },
+ });
+ }
+
+ if (violations === 0) {
+ ctx.pass({
+ title: 'RBAC follows least privilege',
+ description: `${privileged.length} privileged assignment(s); no wildcard custom roles or privileged service principals.`,
+ resourceType: 'azure-subscription',
+ resourceId: sub,
+ evidence: { privilegedCount: privileged.length },
+ });
+ }
+ },
+};
diff --git a/packages/integration-platform/src/manifests/azure/checks/index.ts b/packages/integration-platform/src/manifests/azure/checks/index.ts
new file mode 100644
index 0000000000..58db3094cd
--- /dev/null
+++ b/packages/integration-platform/src/manifests/azure/checks/index.ts
@@ -0,0 +1,10 @@
+export {
+ storageHttpsTlsCheck,
+ storagePublicAccessCheck,
+ storageEncryptionCheck,
+} from './storage';
+export { sqlTlsCheck, sqlPublicAccessCheck, sqlAuditingCheck } from './sql';
+export { keyVaultProtectionCheck, keyVaultRbacCheck } from './key-vault';
+export { nsgNoOpenPortsCheck } from './network';
+export { rbacLeastPrivilegeCheck } from './entra-id';
+export { monitorLoggingAlertingCheck } from './monitor';
diff --git a/packages/integration-platform/src/manifests/azure/checks/key-vault.ts b/packages/integration-platform/src/manifests/azure/checks/key-vault.ts
new file mode 100644
index 0000000000..d0ada64058
--- /dev/null
+++ b/packages/integration-platform/src/manifests/azure/checks/key-vault.ts
@@ -0,0 +1,118 @@
+import { TASK_TEMPLATES } from '../../../task-mappings';
+import type { CheckContext, FindingSeverity, IntegrationCheck } from '../../../types';
+import { ARM_BASE, armListAll, resolveAzureSubscriptionId } from './shared';
+
+interface KeyVault {
+ id: string;
+ name: string;
+ properties?: {
+ enableSoftDelete?: boolean;
+ enablePurgeProtection?: boolean;
+ enableRbacAuthorization?: boolean;
+ publicNetworkAccess?: string;
+ networkAcls?: { defaultAction?: string };
+ };
+}
+
+async function listVaults(ctx: CheckContext, sub: string): Promise {
+ return armListAll(
+ ctx,
+ `${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.KeyVault/vaults?api-version=2023-07-01`,
+ );
+}
+
+/** Soft delete + purge protection + no public access on Key Vaults → Secure Secrets. */
+export const keyVaultProtectionCheck: IntegrationCheck = {
+ id: 'azure-key-vault-protection',
+ name: 'Key Vault — soft delete, purge protection, no public access',
+ description:
+ 'Verify Key Vaults enable soft delete and purge protection and restrict public network access.',
+ service: 'key-vault',
+ taskMapping: TASK_TEMPLATES.secureSecrets,
+ run: async (ctx: CheckContext) => {
+ const sub = await resolveAzureSubscriptionId(ctx);
+ if (!sub) return;
+ const vaults = await listVaults(ctx, sub);
+ if (vaults.length === 0) return;
+ for (const v of vaults) {
+ const p = v.properties ?? {};
+ const issues: string[] = [];
+ let severity: FindingSeverity = 'medium';
+ if (!p.enableSoftDelete) {
+ issues.push('soft delete disabled');
+ severity = 'high';
+ }
+ if (!p.enablePurgeProtection) issues.push('purge protection disabled');
+ const isPublic =
+ p.publicNetworkAccess === 'Enabled' ||
+ p.networkAcls?.defaultAction === 'Allow';
+ if (isPublic) {
+ issues.push('public network access');
+ severity = 'high';
+ }
+ if (issues.length > 0) {
+ ctx.fail({
+ title: `Key Vault not fully protected: ${v.name}`,
+ description: `Key Vault "${v.name}": ${issues.join('; ')}.`,
+ resourceType: 'azure-key-vault',
+ resourceId: v.id,
+ severity,
+ remediation:
+ 'Enable soft delete and purge protection, and restrict public network access (use private endpoints).',
+ evidence: {
+ vault: v.name,
+ enableSoftDelete: p.enableSoftDelete,
+ enablePurgeProtection: p.enablePurgeProtection,
+ publicNetworkAccess: p.publicNetworkAccess ?? null,
+ },
+ });
+ } else {
+ ctx.pass({
+ title: `Key Vault protected: ${v.name}`,
+ description: `Key Vault "${v.name}" has soft delete + purge protection and restricts public access.`,
+ resourceType: 'azure-key-vault',
+ resourceId: v.id,
+ evidence: { vault: v.name },
+ });
+ }
+ }
+ },
+};
+
+/** Azure RBAC authorization (not legacy access policies) on Key Vaults → Role-based Access Controls. */
+export const keyVaultRbacCheck: IntegrationCheck = {
+ id: 'azure-key-vault-rbac',
+ name: 'Key Vault — RBAC authorization',
+ description:
+ 'Verify Key Vaults use Azure RBAC instead of legacy vault access policies.',
+ service: 'key-vault',
+ taskMapping: TASK_TEMPLATES.rolebasedAccessControls,
+ run: async (ctx: CheckContext) => {
+ const sub = await resolveAzureSubscriptionId(ctx);
+ if (!sub) return;
+ const vaults = await listVaults(ctx, sub);
+ if (vaults.length === 0) return;
+ for (const v of vaults) {
+ if (v.properties?.enableRbacAuthorization) {
+ ctx.pass({
+ title: `RBAC authorization enabled: ${v.name}`,
+ description: `Key Vault "${v.name}" uses Azure RBAC for access control.`,
+ resourceType: 'azure-key-vault',
+ resourceId: v.id,
+ evidence: { vault: v.name },
+ });
+ } else {
+ ctx.fail({
+ title: `Legacy access policies: ${v.name}`,
+ description: `Key Vault "${v.name}" uses vault access policies instead of Azure RBAC.`,
+ resourceType: 'azure-key-vault',
+ resourceId: v.id,
+ severity: 'low',
+ remediation:
+ 'Migrate to the Azure RBAC permission model for finer-grained, auditable access control.',
+ evidence: { vault: v.name },
+ });
+ }
+ }
+ },
+};
diff --git a/packages/integration-platform/src/manifests/azure/checks/monitor.ts b/packages/integration-platform/src/manifests/azure/checks/monitor.ts
new file mode 100644
index 0000000000..703884c826
--- /dev/null
+++ b/packages/integration-platform/src/manifests/azure/checks/monitor.ts
@@ -0,0 +1,117 @@
+import { TASK_TEMPLATES } from '../../../task-mappings';
+import type { CheckContext, IntegrationCheck } from '../../../types';
+import { ARM_BASE, armListAll, resolveAzureSubscriptionId } from './shared';
+
+interface ActivityLogAlert {
+ properties?: {
+ enabled?: boolean;
+ condition?: { allOf?: Array<{ field: string; equals: string }> };
+ };
+}
+
+interface DiagnosticSetting {
+ properties?: {
+ workspaceId?: string;
+ storageAccountId?: string;
+ eventHubAuthorizationRuleId?: string;
+ };
+}
+
+const RECOMMENDED_ALERTS = [
+ { op: 'Microsoft.Authorization/policyAssignments/write', name: 'Policy assignment changes' },
+ { op: 'Microsoft.Security/securitySolutions/write', name: 'Security solution changes' },
+ { op: 'Microsoft.Network/networkSecurityGroups/write', name: 'NSG changes' },
+ { op: 'Microsoft.Sql/servers/firewallRules/write', name: 'SQL firewall rule changes' },
+];
+
+/** Activity log alerts for critical ops + subscription log export → Monitoring & Alerting. */
+export const monitorLoggingAlertingCheck: IntegrationCheck = {
+ id: 'azure-monitor-logging-alerting',
+ name: 'Azure Monitor — alerts and log export',
+ description:
+ 'Verify activity log alerts exist for critical operations and subscription logs are exported.',
+ service: 'monitor',
+ taskMapping: TASK_TEMPLATES.monitoringAlerting,
+ run: async (ctx: CheckContext) => {
+ const sub = await resolveAzureSubscriptionId(ctx);
+ if (!sub) return;
+ let evaluated = false;
+
+ const alerts = await armListAll(
+ ctx,
+ `${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.Insights/activityLogAlerts?api-version=2020-10-01`,
+ ).catch(() => null);
+ if (alerts !== null) {
+ evaluated = true;
+ const ops = new Set();
+ for (const a of alerts) {
+ if (!a.properties?.enabled) continue;
+ for (const c of a.properties.condition?.allOf ?? []) {
+ if (c.field === 'operationName') ops.add(c.equals);
+ }
+ }
+ const missing = RECOMMENDED_ALERTS.filter((r) => !ops.has(r.op));
+ if (missing.length > 0) {
+ ctx.fail({
+ title: `Missing activity log alerts (${missing.length})`,
+ description: `No activity log alert configured for: ${missing.map((m) => m.name).join(', ')}.`,
+ resourceType: 'azure-subscription',
+ resourceId: sub,
+ severity: 'medium',
+ remediation:
+ 'Create activity log alerts in Azure Monitor for these critical operations.',
+ evidence: { missing: missing.map((m) => m.op) },
+ });
+ } else {
+ ctx.pass({
+ title: 'Activity log alerts configured',
+ description: 'All recommended activity log alerts are configured.',
+ resourceType: 'azure-subscription',
+ resourceId: sub,
+ evidence: { recommended: RECOMMENDED_ALERTS.length },
+ });
+ }
+ }
+
+ const diag = await ctx
+ .fetch<{ value?: DiagnosticSetting[] }>(
+ `${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.Insights/diagnosticSettings?api-version=2021-05-01-preview`,
+ )
+ .catch(() => null);
+ if (diag !== null) {
+ evaluated = true;
+ const settings = diag.value ?? [];
+ const hasExport = settings.some(
+ (s) =>
+ s.properties?.workspaceId ||
+ s.properties?.storageAccountId ||
+ s.properties?.eventHubAuthorizationRuleId,
+ );
+ if (hasExport) {
+ ctx.pass({
+ title: 'Diagnostic log export configured',
+ description: 'Subscription activity logs are exported.',
+ resourceType: 'azure-subscription',
+ resourceId: sub,
+ evidence: { settings: settings.length },
+ });
+ } else {
+ ctx.fail({
+ title: 'No diagnostic log export',
+ description:
+ 'Subscription activity logs are not exported to Log Analytics, a storage account, or an event hub.',
+ resourceType: 'azure-subscription',
+ resourceId: sub,
+ severity: 'medium',
+ remediation:
+ 'Configure a diagnostic setting to export subscription activity logs.',
+ evidence: {},
+ });
+ }
+ }
+
+ if (!evaluated) {
+ ctx.log('Azure monitor check: could not read monitor data — skipping');
+ }
+ },
+};
diff --git a/packages/integration-platform/src/manifests/azure/checks/network.ts b/packages/integration-platform/src/manifests/azure/checks/network.ts
new file mode 100644
index 0000000000..a59c3b8609
--- /dev/null
+++ b/packages/integration-platform/src/manifests/azure/checks/network.ts
@@ -0,0 +1,108 @@
+import { TASK_TEMPLATES } from '../../../task-mappings';
+import type { CheckContext, FindingSeverity, IntegrationCheck } from '../../../types';
+import { ARM_BASE, armListAll, resolveAzureSubscriptionId } from './shared';
+
+interface SecurityRule {
+ name: string;
+ properties: {
+ direction: string;
+ access: string;
+ protocol: string;
+ sourceAddressPrefix?: string;
+ sourceAddressPrefixes?: string[];
+ destinationPortRange?: string;
+ destinationPortRanges?: string[];
+ priority: number;
+ };
+}
+
+interface Nsg {
+ id: string;
+ name: string;
+ properties: { securityRules?: SecurityRule[] };
+}
+
+const DANGEROUS_DB_PORTS = new Set(['3306', '5432', '1433', '27017']);
+const WILDCARD_SOURCES = new Set(['*', '0.0.0.0/0', 'Internet', 'Any']);
+
+function ruleSources(r: SecurityRule): string[] {
+ if (r.properties.sourceAddressPrefixes?.length) {
+ return r.properties.sourceAddressPrefixes;
+ }
+ return r.properties.sourceAddressPrefix ? [r.properties.sourceAddressPrefix] : [];
+}
+
+function rulePorts(r: SecurityRule): string[] {
+ if (r.properties.destinationPortRanges?.length) {
+ return r.properties.destinationPortRanges;
+ }
+ return r.properties.destinationPortRange ? [r.properties.destinationPortRange] : [];
+}
+
+/** NSG inbound rules open to the internet on sensitive ports → Production Firewall / no public access. */
+export const nsgNoOpenPortsCheck: IntegrationCheck = {
+ id: 'azure-nsg-no-open-ports',
+ name: 'Network — no NSG ports open to the internet',
+ description:
+ 'Flags NSG inbound rules that allow SSH, RDP, database ports, or all ports from the internet.',
+ service: 'network-watcher',
+ taskMapping: TASK_TEMPLATES.productionFirewallNopublicaccessControls,
+ run: async (ctx: CheckContext) => {
+ const sub = await resolveAzureSubscriptionId(ctx);
+ if (!sub) return;
+ const nsgs = await armListAll(
+ ctx,
+ `${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.Network/networkSecurityGroups?api-version=2023-11-01`,
+ );
+ if (nsgs.length === 0) return;
+
+ for (const nsg of nsgs) {
+ let violations = 0;
+ const inbound = (nsg.properties.securityRules ?? []).filter(
+ (r) =>
+ r.properties.direction === 'Inbound' &&
+ r.properties.access === 'Allow',
+ );
+
+ for (const rule of inbound) {
+ if (!ruleSources(rule).some((s) => WILDCARD_SOURCES.has(s))) continue;
+ const ports = rulePorts(rule);
+ const conditions: Array<{ when: boolean; label: string; severity: FindingSeverity }> = [
+ { when: ports.includes('*'), label: 'all ports', severity: 'critical' },
+ { when: ports.includes('3389'), label: 'RDP (3389)', severity: 'critical' },
+ {
+ when: ports.some((p) => DANGEROUS_DB_PORTS.has(p)),
+ label: 'database ports',
+ severity: 'critical',
+ },
+ { when: ports.includes('22'), label: 'SSH (22)', severity: 'high' },
+ ];
+ for (const c of conditions) {
+ if (c.when) {
+ violations++;
+ ctx.fail({
+ title: `${c.label} open to internet: ${nsg.name}/${rule.name}`,
+ description: `NSG "${nsg.name}" rule "${rule.name}" allows ${c.label} from the internet.`,
+ resourceType: 'azure-nsg',
+ resourceId: nsg.id,
+ severity: c.severity,
+ remediation:
+ 'Restrict the source to specific IP ranges, or use Azure Bastion / Private Link.',
+ evidence: { nsg: nsg.name, rule: rule.name, priority: rule.properties.priority },
+ });
+ }
+ }
+ }
+
+ if (violations === 0) {
+ ctx.pass({
+ title: `No open ports: ${nsg.name}`,
+ description: `NSG "${nsg.name}" has no overly permissive inbound rules.`,
+ resourceType: 'azure-nsg',
+ resourceId: nsg.id,
+ evidence: { nsg: nsg.name },
+ });
+ }
+ }
+ },
+};
diff --git a/packages/integration-platform/src/manifests/azure/checks/shared.ts b/packages/integration-platform/src/manifests/azure/checks/shared.ts
new file mode 100644
index 0000000000..6c192a98e6
--- /dev/null
+++ b/packages/integration-platform/src/manifests/azure/checks/shared.ts
@@ -0,0 +1,46 @@
+import type { CheckContext } from '../../../types';
+
+const ARM = 'https://management.azure.com';
+
+/**
+ * Resolve the Azure subscription to scan: the user-set `subscription_id`
+ * variable, else the first enabled subscription the token can see. Returns
+ * null when none — the check should then no-op (no false pass).
+ */
+export async function resolveAzureSubscriptionId(
+ ctx: CheckContext,
+): Promise {
+ const configured = ctx.variables.subscription_id;
+ if (typeof configured === 'string' && configured.length > 0) {
+ return configured;
+ }
+ try {
+ const data = await ctx.fetch<{
+ value?: Array<{ subscriptionId: string; state?: string }>;
+ }>(`${ARM}/subscriptions?api-version=2020-01-01`);
+ const subs = data.value ?? [];
+ const active = subs.find((s) => s.state === 'Enabled') ?? subs[0];
+ return active?.subscriptionId ?? null;
+ } catch {
+ return null;
+ }
+}
+
+/** Paginate an Azure ARM list endpoint (`{ value: T[], nextLink? }`). */
+export async function armListAll(
+ ctx: CheckContext,
+ url: string,
+): Promise {
+ const out: T[] = [];
+ let nextUrl: string | undefined = url;
+ let pages = 0;
+ while (nextUrl && pages < 50) {
+ const data: { value?: T[]; nextLink?: string } = await ctx.fetch(nextUrl);
+ if (Array.isArray(data.value)) out.push(...data.value);
+ nextUrl = data.nextLink;
+ pages++;
+ }
+ return out;
+}
+
+export const ARM_BASE = ARM;
diff --git a/packages/integration-platform/src/manifests/azure/checks/sql.ts b/packages/integration-platform/src/manifests/azure/checks/sql.ts
new file mode 100644
index 0000000000..97c67cdc48
--- /dev/null
+++ b/packages/integration-platform/src/manifests/azure/checks/sql.ts
@@ -0,0 +1,187 @@
+import { TASK_TEMPLATES } from '../../../task-mappings';
+import type { CheckContext, IntegrationCheck } from '../../../types';
+import { ARM_BASE, armListAll, resolveAzureSubscriptionId } from './shared';
+
+interface SqlServer {
+ id: string;
+ name: string;
+ properties?: {
+ publicNetworkAccess?: string;
+ minimalTlsVersion?: string;
+ };
+}
+
+interface SqlFirewallRule {
+ properties: { startIpAddress: string; endIpAddress: string };
+}
+
+async function listSqlServers(
+ ctx: CheckContext,
+ sub: string,
+): Promise {
+ return armListAll(
+ ctx,
+ `${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.Sql/servers?api-version=2023-05-01-preview`,
+ );
+}
+
+/** SQL Server minimum TLS 1.2 → TLS / HTTPS. */
+export const sqlTlsCheck: IntegrationCheck = {
+ id: 'azure-sql-tls',
+ name: 'SQL Database — TLS 1.2 enforced',
+ description: 'Verify SQL Servers require a minimum TLS version of 1.2.',
+ service: 'sql-database',
+ taskMapping: TASK_TEMPLATES.tlsHttps,
+ run: async (ctx: CheckContext) => {
+ const sub = await resolveAzureSubscriptionId(ctx);
+ if (!sub) return;
+ const servers = await listSqlServers(ctx, sub);
+ if (servers.length === 0) return;
+ for (const s of servers) {
+ const tls = s.properties?.minimalTlsVersion;
+ if (!tls || tls < '1.2') {
+ ctx.fail({
+ title: `Outdated TLS version: ${s.name}`,
+ description: `SQL Server "${s.name}" allows TLS versions below 1.2 (current: ${tls ?? 'unset'}).`,
+ resourceType: 'azure-sql-server',
+ resourceId: s.id,
+ severity: 'medium',
+ remediation: 'Set the minimum TLS version to 1.2.',
+ evidence: { server: s.name, minimalTlsVersion: tls ?? null },
+ });
+ } else {
+ ctx.pass({
+ title: `TLS 1.2 enforced: ${s.name}`,
+ description: `SQL Server "${s.name}" requires TLS >= 1.2.`,
+ resourceType: 'azure-sql-server',
+ resourceId: s.id,
+ evidence: { server: s.name, minimalTlsVersion: tls },
+ });
+ }
+ }
+ },
+};
+
+/** SQL Server no public network / wide-open firewall → Production Firewall / no public access. */
+export const sqlPublicAccessCheck: IntegrationCheck = {
+ id: 'azure-sql-no-public-access',
+ name: 'SQL Database — no public access',
+ description:
+ 'Verify SQL Servers disable public network access and have no wide-open firewall rules.',
+ service: 'sql-database',
+ taskMapping: TASK_TEMPLATES.productionFirewallNopublicaccessControls,
+ run: async (ctx: CheckContext) => {
+ const sub = await resolveAzureSubscriptionId(ctx);
+ if (!sub) return;
+ const servers = await listSqlServers(ctx, sub);
+ if (servers.length === 0) return;
+ for (const s of servers) {
+ let violation: { title: string; severity: 'high' | 'critical' | 'medium'; detail: string } | null =
+ null;
+
+ if (s.properties?.publicNetworkAccess === 'Enabled') {
+ violation = {
+ title: `SQL public network access enabled: ${s.name}`,
+ severity: 'high',
+ detail: 'allows public network access',
+ };
+ }
+
+ const rules = await armListAll(
+ ctx,
+ `${ARM_BASE}${s.id}/firewallRules?api-version=2023-05-01-preview`,
+ ).catch(() => [] as SqlFirewallRule[]);
+
+ const wideOpen = rules.find(
+ (r) =>
+ r.properties.startIpAddress === '0.0.0.0' &&
+ r.properties.endIpAddress === '255.255.255.255',
+ );
+ const allowAllAzure = rules.find(
+ (r) =>
+ r.properties.startIpAddress === '0.0.0.0' &&
+ r.properties.endIpAddress === '0.0.0.0',
+ );
+ if (wideOpen) {
+ violation = {
+ title: `SQL firewall wide open: ${s.name}`,
+ severity: 'critical',
+ detail: 'allows connections from any IP (0.0.0.0–255.255.255.255)',
+ };
+ } else if (!violation && allowAllAzure) {
+ violation = {
+ title: `SQL allows all Azure services: ${s.name}`,
+ severity: 'medium',
+ detail: 'has the "Allow Azure services" (0.0.0.0) rule',
+ };
+ }
+
+ if (violation) {
+ ctx.fail({
+ title: violation.title,
+ description: `SQL Server "${s.name}" ${violation.detail}.`,
+ resourceType: 'azure-sql-server',
+ resourceId: s.id,
+ severity: violation.severity,
+ remediation:
+ 'Disable public network access and use private endpoints; remove 0.0.0.0 firewall rules.',
+ evidence: { server: s.name, publicNetworkAccess: s.properties?.publicNetworkAccess ?? null },
+ });
+ } else {
+ ctx.pass({
+ title: `No public access: ${s.name}`,
+ description: `SQL Server "${s.name}" restricts public network access and has no wide-open firewall rules.`,
+ resourceType: 'azure-sql-server',
+ resourceId: s.id,
+ evidence: { server: s.name, firewallRuleCount: rules.length },
+ });
+ }
+ }
+ },
+};
+
+interface AuditingSetting {
+ properties?: { state?: string };
+}
+
+/** SQL Server auditing enabled → Monitoring & Alerting. */
+export const sqlAuditingCheck: IntegrationCheck = {
+ id: 'azure-sql-auditing',
+ name: 'SQL Database — auditing enabled',
+ description: 'Verify SQL Servers have auditing enabled to track database operations.',
+ service: 'sql-database',
+ taskMapping: TASK_TEMPLATES.monitoringAlerting,
+ run: async (ctx: CheckContext) => {
+ const sub = await resolveAzureSubscriptionId(ctx);
+ if (!sub) return;
+ const servers = await listSqlServers(ctx, sub);
+ if (servers.length === 0) return;
+ for (const s of servers) {
+ const auditing = await ctx
+ .fetch(
+ `${ARM_BASE}${s.id}/auditingSettings/default?api-version=2021-11-01`,
+ )
+ .catch(() => null);
+ if (auditing === null) continue; // couldn't read — don't assert pass/fail
+ if (auditing.properties?.state === 'Enabled') {
+ ctx.pass({
+ title: `Auditing enabled: ${s.name}`,
+ description: `SQL Server "${s.name}" has auditing enabled.`,
+ resourceType: 'azure-sql-server',
+ resourceId: s.id,
+ evidence: { server: s.name },
+ });
+ } else {
+ ctx.fail({
+ title: `Auditing disabled: ${s.name}`,
+ description: `SQL Server "${s.name}" does not have auditing enabled.`,
+ resourceType: 'azure-sql-server',
+ resourceId: s.id,
+ severity: 'high',
+ remediation: 'Enable SQL auditing in the server security settings.',
+ evidence: { server: s.name, state: auditing.properties?.state ?? null },
+ });
+ }
+ }
+ },
+};
diff --git a/packages/integration-platform/src/manifests/azure/checks/storage.ts b/packages/integration-platform/src/manifests/azure/checks/storage.ts
new file mode 100644
index 0000000000..167d59a00f
--- /dev/null
+++ b/packages/integration-platform/src/manifests/azure/checks/storage.ts
@@ -0,0 +1,166 @@
+import { TASK_TEMPLATES } from '../../../task-mappings';
+import type { CheckContext, IntegrationCheck } from '../../../types';
+import { ARM_BASE, armListAll, resolveAzureSubscriptionId } from './shared';
+
+interface StorageAccount {
+ id: string;
+ name: string;
+ properties?: {
+ supportsHttpsTrafficOnly?: boolean;
+ minimumTlsVersion?: string;
+ allowBlobPublicAccess?: boolean;
+ publicNetworkAccess?: string;
+ networkAcls?: { defaultAction?: string };
+ encryption?: {
+ services?: {
+ blob?: { enabled?: boolean };
+ file?: { enabled?: boolean };
+ };
+ };
+ };
+}
+
+async function listStorageAccounts(
+ ctx: CheckContext,
+ sub: string,
+): Promise {
+ return armListAll(
+ ctx,
+ `${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.Storage/storageAccounts?api-version=2023-05-01`,
+ );
+}
+
+/** HTTPS-only + minimum TLS 1.2 on storage accounts → TLS / HTTPS. */
+export const storageHttpsTlsCheck: IntegrationCheck = {
+ id: 'azure-storage-https-tls',
+ name: 'Storage — HTTPS and TLS 1.2 enforced',
+ description:
+ 'Verify storage accounts enforce HTTPS-only traffic and a minimum TLS version of 1.2.',
+ service: 'storage-account',
+ taskMapping: TASK_TEMPLATES.tlsHttps,
+ run: async (ctx: CheckContext) => {
+ const sub = await resolveAzureSubscriptionId(ctx);
+ if (!sub) return;
+ const accounts = await listStorageAccounts(ctx, sub);
+ if (accounts.length === 0) return;
+ for (const a of accounts) {
+ const p = a.properties ?? {};
+ const issues: string[] = [];
+ if (p.supportsHttpsTrafficOnly === false) issues.push('HTTPS not enforced');
+ if (!p.minimumTlsVersion || p.minimumTlsVersion < 'TLS1_2') {
+ issues.push(`minimum TLS ${p.minimumTlsVersion ?? 'unset'}`);
+ }
+ if (issues.length > 0) {
+ ctx.fail({
+ title: `Weak transit encryption: ${a.name}`,
+ description: `Storage account "${a.name}": ${issues.join('; ')}.`,
+ resourceType: 'azure-storage-account',
+ resourceId: a.id,
+ severity: p.supportsHttpsTrafficOnly === false ? 'high' : 'medium',
+ remediation:
+ 'Enable "Secure transfer required" (HTTPS-only) and set minimum TLS version to 1.2.',
+ evidence: {
+ account: a.name,
+ supportsHttpsTrafficOnly: p.supportsHttpsTrafficOnly,
+ minimumTlsVersion: p.minimumTlsVersion ?? null,
+ },
+ });
+ } else {
+ ctx.pass({
+ title: `HTTPS + TLS 1.2 enforced: ${a.name}`,
+ description: `Storage account "${a.name}" enforces HTTPS-only and TLS >= 1.2.`,
+ resourceType: 'azure-storage-account',
+ resourceId: a.id,
+ evidence: { account: a.name, minimumTlsVersion: p.minimumTlsVersion },
+ });
+ }
+ }
+ },
+};
+
+/** No public blob/network access on storage accounts → Production Firewall / no public access. */
+export const storagePublicAccessCheck: IntegrationCheck = {
+ id: 'azure-storage-no-public-access',
+ name: 'Storage — no public access',
+ description:
+ 'Verify storage accounts disable anonymous blob access and public network access.',
+ service: 'storage-account',
+ taskMapping: TASK_TEMPLATES.productionFirewallNopublicaccessControls,
+ run: async (ctx: CheckContext) => {
+ const sub = await resolveAzureSubscriptionId(ctx);
+ if (!sub) return;
+ const accounts = await listStorageAccounts(ctx, sub);
+ if (accounts.length === 0) return;
+ for (const a of accounts) {
+ const p = a.properties ?? {};
+ const publicBlob = p.allowBlobPublicAccess === true;
+ const publicNetwork =
+ p.publicNetworkAccess === 'Enabled' ||
+ p.networkAcls?.defaultAction === 'Allow';
+ if (publicBlob || publicNetwork) {
+ ctx.fail({
+ title: `Public access enabled: ${a.name}`,
+ description: `Storage account "${a.name}"${publicBlob ? ' allows anonymous blob access' : ''}${publicBlob && publicNetwork ? ' and' : ''}${publicNetwork ? ' allows access from all networks' : ''}.`,
+ resourceType: 'azure-storage-account',
+ resourceId: a.id,
+ severity: publicBlob ? 'high' : 'medium',
+ remediation:
+ 'Disable "Allow Blob public access" and restrict network access to specific VNets/IPs or private endpoints.',
+ evidence: {
+ account: a.name,
+ allowBlobPublicAccess: p.allowBlobPublicAccess,
+ publicNetworkAccess: p.publicNetworkAccess ?? null,
+ },
+ });
+ } else {
+ ctx.pass({
+ title: `No public access: ${a.name}`,
+ description: `Storage account "${a.name}" blocks anonymous blob and public network access.`,
+ resourceType: 'azure-storage-account',
+ resourceId: a.id,
+ evidence: { account: a.name },
+ });
+ }
+ }
+ },
+};
+
+/** Service-side encryption enabled on storage accounts → Encryption at Rest. */
+export const storageEncryptionCheck: IntegrationCheck = {
+ id: 'azure-storage-encryption-at-rest',
+ name: 'Storage — encryption at rest enabled',
+ description:
+ 'Verify storage accounts have blob and file service encryption enabled.',
+ service: 'storage-account',
+ taskMapping: TASK_TEMPLATES.encryptionAtRest,
+ run: async (ctx: CheckContext) => {
+ const sub = await resolveAzureSubscriptionId(ctx);
+ if (!sub) return;
+ const accounts = await listStorageAccounts(ctx, sub);
+ if (accounts.length === 0) return;
+ for (const a of accounts) {
+ const enc = a.properties?.encryption?.services;
+ const blobOk = enc?.blob?.enabled !== false;
+ const fileOk = enc?.file?.enabled !== false;
+ if (blobOk && fileOk) {
+ ctx.pass({
+ title: `Encryption at rest enabled: ${a.name}`,
+ description: `Storage account "${a.name}" has blob and file encryption enabled.`,
+ resourceType: 'azure-storage-account',
+ resourceId: a.id,
+ evidence: { account: a.name },
+ });
+ } else {
+ ctx.fail({
+ title: `Encryption not fully enabled: ${a.name}`,
+ description: `Storage account "${a.name}" does not have encryption enabled for all services.`,
+ resourceType: 'azure-storage-account',
+ resourceId: a.id,
+ severity: 'high',
+ remediation: 'Enable encryption for blob and file services.',
+ evidence: { account: a.name, blobEnabled: blobOk, fileEnabled: fileOk },
+ });
+ }
+ }
+ },
+};
diff --git a/packages/integration-platform/src/manifests/azure/index.ts b/packages/integration-platform/src/manifests/azure/index.ts
index bc6d5fb84a..c81aed3b26 100644
--- a/packages/integration-platform/src/manifests/azure/index.ts
+++ b/packages/integration-platform/src/manifests/azure/index.ts
@@ -1,4 +1,17 @@
import type { IntegrationManifest } from '../../types';
+import {
+ keyVaultProtectionCheck,
+ keyVaultRbacCheck,
+ monitorLoggingAlertingCheck,
+ nsgNoOpenPortsCheck,
+ rbacLeastPrivilegeCheck,
+ sqlAuditingCheck,
+ sqlPublicAccessCheck,
+ sqlTlsCheck,
+ storageEncryptionCheck,
+ storageHttpsTlsCheck,
+ storagePublicAccessCheck,
+} from './checks';
export const azureManifest: IntegrationManifest = {
id: 'azure',
@@ -90,5 +103,17 @@ Our integration only makes read-only API calls for security scanning.`,
},
],
- checks: [],
+ checks: [
+ storageHttpsTlsCheck,
+ storagePublicAccessCheck,
+ storageEncryptionCheck,
+ sqlTlsCheck,
+ sqlPublicAccessCheck,
+ sqlAuditingCheck,
+ keyVaultProtectionCheck,
+ keyVaultRbacCheck,
+ nsgNoOpenPortsCheck,
+ rbacLeastPrivilegeCheck,
+ monitorLoggingAlertingCheck,
+ ],
};
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
new file mode 100644
index 0000000000..2ab7814dcb
--- /dev/null
+++ b/packages/integration-platform/src/manifests/gcp/checks/__tests__/gcp-checks.test.ts
@@ -0,0 +1,218 @@
+import { describe, expect, it } from 'bun:test';
+import type {
+ CheckContext,
+ CheckVariableValues,
+ IntegrationCheck,
+} from '../../../../types';
+import { cloudSqlBackupsCheck } from '../cloud-sql-backups';
+import { cloudSqlSslCheck } from '../cloud-sql-ssl';
+import { iamPrimitiveRolesCheck } from '../iam-primitive-roles';
+import { storagePublicAccessCheck } from '../storage-public-access';
+import { vpcOpenFirewallsCheck } from '../vpc-open-firewalls';
+
+interface Captured {
+ passed: Array<{ resourceId: string; title: string }>;
+ failed: Array<{ resourceId: string; title: string; severity: string }>;
+}
+
+async function runCheck(
+ check: IntegrationCheck,
+ opts: {
+ variables?: CheckVariableValues;
+ fetch?: (url: string) => unknown;
+ post?: (url: string, body?: unknown) => unknown;
+ },
+): Promise {
+ const passed: Captured['passed'] = [];
+ const failed: Captured['failed'] = [];
+
+ const ctx = {
+ accessToken: 'tok',
+ credentials: {},
+ variables: opts.variables ?? { project_ids: ['proj-1'] },
+ connectionId: 'c1',
+ organizationId: 'o1',
+ metadata: {},
+ log: () => {},
+ warn: () => {},
+ error: () => {},
+ pass: (r) => passed.push({ resourceId: r.resourceId, title: r.title }),
+ fail: (r) =>
+ failed.push({
+ resourceId: r.resourceId,
+ title: r.title,
+ severity: r.severity,
+ }),
+ fetch: (async (url: string): Promise =>
+ (opts.fetch ? opts.fetch(url) : {}) as T) as CheckContext['fetch'],
+ post: (async (url: string, body?: unknown): Promise =>
+ (opts.post ? opts.post(url, body) : {}) as T) as CheckContext['post'],
+ put: (async () => ({})) as CheckContext['put'],
+ patch: (async () => ({})) as CheckContext['patch'],
+ delete: (async () => ({})) as CheckContext['delete'],
+ graphql: (async () => ({})) as CheckContext['graphql'],
+ fetchAllPages: (async () => []) as CheckContext['fetchAllPages'],
+ fetchWithCursor: (async () => []) as CheckContext['fetchWithCursor'],
+ fetchWithLinkHeader: (async () => []) as CheckContext['fetchWithLinkHeader'],
+ getState: (async () => null) as CheckContext['getState'],
+ setState: (async () => {}) as CheckContext['setState'],
+ } as CheckContext;
+
+ await check.run(ctx);
+ return { passed, failed };
+}
+
+describe('GCP IAM primitive roles check', () => {
+ it('fails on roles/owner binding (high)', async () => {
+ const { passed, failed } = await runCheck(iamPrimitiveRolesCheck, {
+ post: () => ({ bindings: [{ role: 'roles/owner', members: ['user:a@x.com'] }] }),
+ });
+ expect(passed).toHaveLength(0);
+ expect(failed).toHaveLength(1);
+ expect(failed[0]!.severity).toBe('high');
+ });
+
+ it('passes when only predefined roles are bound', async () => {
+ const { passed, failed } = await runCheck(iamPrimitiveRolesCheck, {
+ post: () => ({ bindings: [{ role: 'roles/viewer', members: ['user:a@x.com'] }] }),
+ });
+ expect(failed).toHaveLength(0);
+ expect(passed).toHaveLength(1);
+ });
+
+ it('ignores primitive roles with no members', async () => {
+ const { passed, failed } = await runCheck(iamPrimitiveRolesCheck, {
+ post: () => ({ bindings: [{ role: 'roles/owner', members: [] }] }),
+ });
+ expect(failed).toHaveLength(0);
+ expect(passed).toHaveLength(1);
+ });
+});
+
+describe('GCP Cloud Storage public-access check', () => {
+ it('fails buckets without uniform access / public-access-prevention', async () => {
+ const { failed } = await runCheck(storagePublicAccessCheck, {
+ fetch: () => ({
+ items: [
+ {
+ name: 'b1',
+ iamConfiguration: {
+ uniformBucketLevelAccess: { enabled: false },
+ publicAccessPrevention: 'inherited',
+ },
+ },
+ ],
+ }),
+ });
+ // both violations on the one bucket
+ expect(failed.map((f) => f.title).join(' ')).toMatch(/Uniform bucket-level access/);
+ expect(failed.map((f) => f.title).join(' ')).toMatch(/Public access prevention/);
+ });
+
+ it('passes when all buckets are locked down', async () => {
+ const secure = {
+ uniformBucketLevelAccess: { enabled: true },
+ publicAccessPrevention: 'enforced',
+ };
+ const { passed, failed } = await runCheck(storagePublicAccessCheck, {
+ fetch: () => ({ items: [{ name: 'b1', iamConfiguration: secure }] }),
+ });
+ expect(failed).toHaveLength(0);
+ expect(passed).toHaveLength(1);
+ });
+
+ it('emits nothing when a project has no buckets', async () => {
+ const { passed, failed } = await runCheck(storagePublicAccessCheck, {
+ fetch: () => ({ items: [] }),
+ });
+ expect(passed).toHaveLength(0);
+ expect(failed).toHaveLength(0);
+ });
+});
+
+describe('GCP VPC open-firewalls check', () => {
+ it('flags RDP (3389) open to 0.0.0.0/0 as critical', async () => {
+ const { failed } = await runCheck(vpcOpenFirewallsCheck, {
+ fetch: () => ({
+ items: [
+ {
+ name: 'allow-rdp',
+ sourceRanges: ['0.0.0.0/0'],
+ allowed: [{ IPProtocol: 'tcp', ports: ['3389'] }],
+ },
+ ],
+ }),
+ });
+ expect(failed).toHaveLength(1);
+ expect(failed[0]!.severity).toBe('critical');
+ });
+
+ it('passes when no rule exposes sensitive ports (incl. disabled/egress/internal)', async () => {
+ const { passed, failed } = await runCheck(vpcOpenFirewallsCheck, {
+ fetch: () => ({
+ items: [
+ { name: 'https', sourceRanges: ['0.0.0.0/0'], allowed: [{ IPProtocol: 'tcp', ports: ['443'] }] },
+ { name: 'internal-ssh', sourceRanges: ['10.0.0.0/8'], allowed: [{ IPProtocol: 'tcp', ports: ['22'] }] },
+ { name: 'disabled', disabled: true, sourceRanges: ['0.0.0.0/0'], allowed: [{ IPProtocol: 'tcp', ports: ['22'] }] },
+ { name: 'egress', direction: 'EGRESS', sourceRanges: ['0.0.0.0/0'], allowed: [{ IPProtocol: 'all' }] },
+ ],
+ }),
+ });
+ expect(failed).toHaveLength(0);
+ expect(passed).toHaveLength(1);
+ });
+
+ it('flags all-ports open as critical via port range covering 22', async () => {
+ const { failed } = await runCheck(vpcOpenFirewallsCheck, {
+ fetch: () => ({
+ items: [
+ { name: 'range', sourceRanges: ['0.0.0.0/0'], allowed: [{ IPProtocol: 'tcp', ports: ['20-25'] }] },
+ ],
+ }),
+ });
+ // 20-25 covers 22 (SSH, high) but not 3389
+ expect(failed).toHaveLength(1);
+ expect(failed[0]!.severity).toBe('high');
+ });
+});
+
+describe('GCP Cloud SQL checks', () => {
+ it('SSL: passes ENCRYPTED_ONLY, fails when unset', async () => {
+ const ok = await runCheck(cloudSqlSslCheck, {
+ fetch: () => ({ items: [{ name: 'db1', settings: { ipConfiguration: { sslMode: 'ENCRYPTED_ONLY' } } }] }),
+ });
+ expect(ok.passed).toHaveLength(1);
+ expect(ok.failed).toHaveLength(0);
+
+ const bad = await runCheck(cloudSqlSslCheck, {
+ fetch: () => ({ items: [{ name: 'db2', settings: { ipConfiguration: {} } }] }),
+ });
+ expect(bad.failed).toHaveLength(1);
+ });
+
+ it('backups: passes when enabled, fails when disabled', async () => {
+ const ok = await runCheck(cloudSqlBackupsCheck, {
+ fetch: () => ({ items: [{ name: 'db1', settings: { backupConfiguration: { enabled: true } } }] }),
+ });
+ expect(ok.passed).toHaveLength(1);
+
+ const bad = await runCheck(cloudSqlBackupsCheck, {
+ fetch: () => ({ items: [{ name: 'db2', settings: { backupConfiguration: { enabled: false } } }] }),
+ });
+ expect(bad.failed).toHaveLength(1);
+ });
+});
+
+describe('No projects resolved → check no-ops (no false pass)', () => {
+ it('emits neither pass nor fail when no projects are selected or detected', async () => {
+ const { passed, failed } = await runCheck(iamPrimitiveRolesCheck, {
+ variables: {}, // no project_ids → falls back to detection
+ fetch: (url) =>
+ url.includes('organizations:search')
+ ? { organizations: [] }
+ : { projects: [] },
+ });
+ expect(passed).toHaveLength(0);
+ expect(failed).toHaveLength(0);
+ });
+});
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
new file mode 100644
index 0000000000..df907cfc11
--- /dev/null
+++ b/packages/integration-platform/src/manifests/gcp/checks/cloud-sql-backups.ts
@@ -0,0 +1,63 @@
+import { TASK_TEMPLATES } from '../../../task-mappings';
+import type { CheckContext, IntegrationCheck } from '../../../types';
+import { resolveGcpProjectIds } from './shared';
+
+interface SqlInstance {
+ name: string;
+ region?: string;
+ settings?: {
+ backupConfiguration?: { enabled?: boolean };
+ };
+}
+
+/**
+ * Cloud SQL backups check (direct API, no SCC). Verifies each Cloud SQL
+ * instance has automated backups enabled.
+ */
+export const cloudSqlBackupsCheck: IntegrationCheck = {
+ id: 'gcp-cloud-sql-backups-enabled',
+ name: 'Cloud SQL — automated backups enabled',
+ description: 'Verify Cloud SQL instances have automated backups enabled.',
+ service: 'cloud-sql',
+ taskMapping: TASK_TEMPLATES.backupLogs,
+
+ run: async (ctx: CheckContext) => {
+ const projectIds = await resolveGcpProjectIds(ctx);
+ if (projectIds.length === 0) {
+ ctx.log('GCP Cloud SQL backups check: no projects resolved — skipping');
+ return;
+ }
+
+ for (const projectId of projectIds) {
+ const data = await ctx.fetch<{ items?: SqlInstance[] }>(
+ `https://sqladmin.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/instances`,
+ );
+ const instances = data.items ?? [];
+ if (instances.length === 0) continue;
+
+ for (const inst of instances) {
+ const enabled = inst.settings?.backupConfiguration?.enabled === true;
+ if (enabled) {
+ ctx.pass({
+ title: `Automated backups enabled: ${inst.name}`,
+ description: `Cloud SQL instance "${inst.name}" has automated backups enabled.`,
+ resourceType: 'gcp-cloud-sql-instance',
+ resourceId: inst.name,
+ evidence: { projectId, instance: inst.name },
+ });
+ } else {
+ ctx.fail({
+ title: `Automated backups disabled: ${inst.name}`,
+ description: `Cloud SQL instance "${inst.name}" does not have automated backups enabled.`,
+ resourceType: 'gcp-cloud-sql-instance',
+ resourceId: inst.name,
+ severity: 'medium',
+ remediation:
+ 'Enable automated backups (and point-in-time recovery) in the instance backup settings.',
+ evidence: { projectId, instance: inst.name },
+ });
+ }
+ }
+ }
+ },
+};
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
new file mode 100644
index 0000000000..9aff6d92bf
--- /dev/null
+++ b/packages/integration-platform/src/manifests/gcp/checks/cloud-sql-ssl.ts
@@ -0,0 +1,78 @@
+import { TASK_TEMPLATES } from '../../../task-mappings';
+import type { CheckContext, IntegrationCheck } from '../../../types';
+import { resolveGcpProjectIds } from './shared';
+
+interface SqlInstance {
+ name: string;
+ region?: string;
+ settings?: {
+ ipConfiguration?: { requireSsl?: boolean; sslMode?: string };
+ };
+}
+
+const SECURE_SSL_MODES = new Set([
+ 'ENCRYPTED_ONLY',
+ 'TRUSTED_CLIENT_CERTIFICATE_REQUIRED',
+]);
+
+/**
+ * Cloud SQL SSL/TLS check (direct API, no SCC). Verifies each Cloud SQL
+ * instance requires encrypted connections (sslMode ENCRYPTED_ONLY / trusted
+ * client cert, or legacy requireSsl).
+ */
+export const cloudSqlSslCheck: IntegrationCheck = {
+ id: 'gcp-cloud-sql-ssl-enforced',
+ name: 'Cloud SQL — SSL/TLS enforced',
+ description: 'Verify Cloud SQL instances require SSL/TLS for connections.',
+ service: 'cloud-sql',
+ taskMapping: TASK_TEMPLATES.tlsHttps,
+
+ run: async (ctx: CheckContext) => {
+ const projectIds = await resolveGcpProjectIds(ctx);
+ if (projectIds.length === 0) {
+ ctx.log('GCP Cloud SQL SSL check: no projects resolved — skipping');
+ return;
+ }
+
+ for (const projectId of projectIds) {
+ const data = await ctx.fetch<{ items?: SqlInstance[] }>(
+ `https://sqladmin.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/instances`,
+ );
+ const instances = data.items ?? [];
+ if (instances.length === 0) continue;
+
+ for (const inst of instances) {
+ const ip = inst.settings?.ipConfiguration;
+ const sslEnforced =
+ ip?.requireSsl === true ||
+ (typeof ip?.sslMode === 'string' && SECURE_SSL_MODES.has(ip.sslMode));
+
+ if (sslEnforced) {
+ ctx.pass({
+ title: `SSL/TLS enforced: ${inst.name}`,
+ description: `Cloud SQL instance "${inst.name}" requires encrypted connections.`,
+ resourceType: 'gcp-cloud-sql-instance',
+ resourceId: inst.name,
+ evidence: {
+ projectId,
+ instance: inst.name,
+ sslMode: ip?.sslMode ?? null,
+ requireSsl: ip?.requireSsl ?? null,
+ },
+ });
+ } else {
+ ctx.fail({
+ title: `SSL/TLS not enforced: ${inst.name}`,
+ description: `Cloud SQL instance "${inst.name}" does not require SSL/TLS for connections.`,
+ resourceType: 'gcp-cloud-sql-instance',
+ resourceId: inst.name,
+ severity: 'medium',
+ remediation:
+ 'Set the SSL mode to ENCRYPTED_ONLY (or require trusted client certificates) to enforce encrypted connections.',
+ evidence: { projectId, instance: inst.name },
+ });
+ }
+ }
+ }
+ },
+};
diff --git a/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts b/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
new file mode 100644
index 0000000000..07251a2500
--- /dev/null
+++ b/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
@@ -0,0 +1,72 @@
+import { TASK_TEMPLATES } from '../../../task-mappings';
+import type { CheckContext, FindingSeverity, IntegrationCheck } from '../../../types';
+import { resolveGcpProjectIds } from './shared';
+
+/** Primitive roles grant broad, non-least-privilege access at the project level. */
+const PRIMITIVE_ROLES: Record = {
+ 'roles/owner': 'high',
+ 'roles/editor': 'medium',
+};
+
+interface IamBinding {
+ role: string;
+ members?: string[];
+}
+
+/**
+ * IAM least-privilege check (direct API, no SCC). Reads the project IAM policy
+ * via Cloud Resource Manager v3 getIamPolicy and flags primitive role bindings
+ * (roles/owner, roles/editor) — the GCP analog of over-privileged access.
+ */
+export const iamPrimitiveRolesCheck: IntegrationCheck = {
+ id: 'gcp-iam-no-primitive-roles',
+ name: 'IAM — no primitive owner/editor roles',
+ description:
+ 'Flags primitive role bindings (roles/owner, roles/editor) on GCP projects, which violate least privilege.',
+ service: 'iam',
+ taskMapping: TASK_TEMPLATES.rolebasedAccessControls,
+
+ run: async (ctx: CheckContext) => {
+ const projectIds = await resolveGcpProjectIds(ctx);
+ if (projectIds.length === 0) {
+ ctx.log('GCP IAM check: no projects resolved — skipping');
+ return;
+ }
+
+ for (const projectId of projectIds) {
+ const policy = await ctx.post<{ bindings?: IamBinding[] }>(
+ `/v3/projects/${encodeURIComponent(projectId)}:getIamPolicy`,
+ { options: { requestedPolicyVersion: 3 } },
+ );
+ const bindings = policy.bindings ?? [];
+ let violations = 0;
+
+ for (const binding of bindings) {
+ const severity = PRIMITIVE_ROLES[binding.role];
+ const members = binding.members ?? [];
+ if (severity && members.length > 0) {
+ violations++;
+ ctx.fail({
+ title: `Primitive role in use: ${binding.role}`,
+ description: `Project "${projectId}" grants the primitive role "${binding.role}" to ${members.length} member(s).`,
+ resourceType: 'gcp-project',
+ resourceId: projectId,
+ severity,
+ remediation: `Replace "${binding.role}" bindings with least-privilege predefined or custom roles.`,
+ evidence: { projectId, role: binding.role, memberCount: members.length },
+ });
+ }
+ }
+
+ if (violations === 0) {
+ ctx.pass({
+ title: 'No primitive owner/editor roles',
+ description: `Project "${projectId}" has no primitive role bindings.`,
+ resourceType: 'gcp-project',
+ resourceId: projectId,
+ evidence: { projectId, bindingCount: bindings.length },
+ });
+ }
+ }
+ },
+};
diff --git a/packages/integration-platform/src/manifests/gcp/checks/index.ts b/packages/integration-platform/src/manifests/gcp/checks/index.ts
new file mode 100644
index 0000000000..9f310d28de
--- /dev/null
+++ b/packages/integration-platform/src/manifests/gcp/checks/index.ts
@@ -0,0 +1,5 @@
+export { iamPrimitiveRolesCheck } from './iam-primitive-roles';
+export { storagePublicAccessCheck } from './storage-public-access';
+export { vpcOpenFirewallsCheck } from './vpc-open-firewalls';
+export { cloudSqlSslCheck } from './cloud-sql-ssl';
+export { cloudSqlBackupsCheck } from './cloud-sql-backups';
diff --git a/packages/integration-platform/src/manifests/gcp/checks/shared.ts b/packages/integration-platform/src/manifests/gcp/checks/shared.ts
new file mode 100644
index 0000000000..2553cf0b19
--- /dev/null
+++ b/packages/integration-platform/src/manifests/gcp/checks/shared.ts
@@ -0,0 +1,54 @@
+import type { CheckContext } from '../../../types';
+
+/**
+ * Resolve which GCP projects a check should evaluate: the user-selected
+ * `project_ids` variable if present, otherwise a bounded best-effort
+ * detection of active projects. Returns [] when none can be resolved — the
+ * check should then no-op (emit neither pass nor fail) rather than produce a
+ * false pass.
+ */
+export async function resolveGcpProjectIds(ctx: CheckContext): Promise {
+ const selected = ctx.variables.project_ids;
+ if (Array.isArray(selected) && selected.length > 0) {
+ return selected.filter((p): p is string => typeof p === 'string');
+ }
+
+ try {
+ const orgData = await ctx.fetch<{
+ organizations?: Array<{ name: string; state?: string }>;
+ }>('https://cloudresourcemanager.googleapis.com/v3/organizations:search');
+ const activeOrg = (orgData.organizations ?? []).find(
+ (o) => o.state === 'ACTIVE',
+ );
+ const orgId = activeOrg?.name?.replace('organizations/', '');
+ const filter = orgId
+ ? `lifecycleState:ACTIVE AND parent.id:${orgId}`
+ : 'lifecycleState:ACTIVE';
+ const data = await ctx.fetch<{ projects?: Array<{ projectId: string }> }>(
+ `/v1/projects?filter=${encodeURIComponent(filter)}&pageSize=50`,
+ );
+ return (data.projects ?? []).map((p) => p.projectId).slice(0, 50);
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * True if a GCP firewall `ports` spec covers `target` (single port or "a-b"
+ * range). An empty/absent spec means "all ports".
+ */
+export function portsCover(
+ ports: string[] | undefined,
+ target: number,
+): boolean {
+ if (!ports || ports.length === 0) return true;
+ return ports.some((spec) => {
+ if (spec.includes('-')) {
+ const [lo, hi] = spec.split('-').map((n) => Number(n));
+ return (
+ Number.isFinite(lo) && Number.isFinite(hi) && target >= lo && target <= hi
+ );
+ }
+ return Number(spec) === target;
+ });
+}
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
new file mode 100644
index 0000000000..a99abe9bb4
--- /dev/null
+++ b/packages/integration-platform/src/manifests/gcp/checks/storage-public-access.ts
@@ -0,0 +1,87 @@
+import { TASK_TEMPLATES } from '../../../task-mappings';
+import type { CheckContext, IntegrationCheck } from '../../../types';
+import { resolveGcpProjectIds } from './shared';
+
+interface Bucket {
+ name: string;
+ location?: string;
+ iamConfiguration?: {
+ uniformBucketLevelAccess?: { enabled?: boolean };
+ publicAccessPrevention?: string;
+ };
+}
+
+/**
+ * Cloud Storage public-access check (direct API, no SCC). Uses bucket metadata
+ * only (no per-bucket IAM calls) to flag buckets that don't enforce uniform
+ * bucket-level access or public access prevention.
+ */
+export const storagePublicAccessCheck: IntegrationCheck = {
+ id: 'gcp-storage-no-public-access',
+ name: 'Cloud Storage — no public access',
+ description:
+ 'Verify Cloud Storage buckets enforce uniform bucket-level access and public access prevention.',
+ service: 'cloud-storage',
+ taskMapping: TASK_TEMPLATES.productionFirewallNopublicaccessControls,
+
+ run: async (ctx: CheckContext) => {
+ const projectIds = await resolveGcpProjectIds(ctx);
+ if (projectIds.length === 0) {
+ ctx.log('GCP storage check: no projects resolved — skipping');
+ return;
+ }
+
+ for (const projectId of projectIds) {
+ const data = await ctx.fetch<{ items?: Bucket[] }>(
+ `https://storage.googleapis.com/storage/v1/b?project=${encodeURIComponent(projectId)}`,
+ );
+ const buckets = data.items ?? [];
+ if (buckets.length === 0) continue; // nothing to evidence for this project
+
+ let violations = 0;
+ for (const bucket of buckets) {
+ const iam = bucket.iamConfiguration;
+ if (iam?.uniformBucketLevelAccess?.enabled !== true) {
+ violations++;
+ ctx.fail({
+ title: `Uniform bucket-level access disabled: ${bucket.name}`,
+ description: `Bucket "${bucket.name}" allows fine-grained ACLs, which can expose individual objects publicly.`,
+ resourceType: 'gcp-storage-bucket',
+ resourceId: bucket.name,
+ severity: 'medium',
+ remediation:
+ 'Enable uniform bucket-level access so permissions are managed exclusively through IAM.',
+ evidence: { projectId, bucket: bucket.name },
+ });
+ }
+ if (iam?.publicAccessPrevention !== 'enforced') {
+ violations++;
+ ctx.fail({
+ title: `Public access prevention not enforced: ${bucket.name}`,
+ description: `Bucket "${bucket.name}" does not enforce public access prevention (current: ${iam?.publicAccessPrevention ?? 'inherited'}).`,
+ resourceType: 'gcp-storage-bucket',
+ resourceId: bucket.name,
+ severity: 'medium',
+ remediation:
+ 'Set public access prevention to "enforced" to block all public access regardless of IAM/ACLs.',
+ evidence: {
+ projectId,
+ bucket: bucket.name,
+ publicAccessPrevention: iam?.publicAccessPrevention ?? null,
+ },
+ });
+ }
+ }
+
+ if (violations === 0) {
+ ctx.pass({
+ title: 'Cloud Storage not publicly accessible',
+ description: `All ${buckets.length} bucket(s) in "${projectId}" enforce uniform access and public access prevention.`,
+ resourceType: 'gcp-project',
+ resourceId: projectId,
+ evidence: { projectId, bucketCount: buckets.length },
+ });
+ }
+ }
+ },
+};
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
new file mode 100644
index 0000000000..f6a2548a98
--- /dev/null
+++ b/packages/integration-platform/src/manifests/gcp/checks/vpc-open-firewalls.ts
@@ -0,0 +1,100 @@
+import { TASK_TEMPLATES } from '../../../task-mappings';
+import type { CheckContext, FindingSeverity, IntegrationCheck } from '../../../types';
+import { portsCover, resolveGcpProjectIds } from './shared';
+
+interface FirewallRule {
+ name: string;
+ direction?: string;
+ disabled?: boolean;
+ sourceRanges?: string[];
+ allowed?: Array<{ IPProtocol: string; ports?: string[] }>;
+}
+
+const SENSITIVE_PORTS: Array<{
+ port: number;
+ label: string;
+ severity: FindingSeverity;
+}> = [
+ { port: 3389, label: 'RDP', severity: 'critical' },
+ { port: 22, label: 'SSH', severity: 'high' },
+];
+
+/**
+ * VPC firewall check (direct API, no SCC). Flags enabled INGRESS firewall rules
+ * open to 0.0.0.0/0 that expose SSH (22), RDP (3389), or all ports/protocols.
+ */
+export const vpcOpenFirewallsCheck: IntegrationCheck = {
+ id: 'gcp-vpc-no-open-firewalls',
+ name: 'VPC — no firewalls open to the internet',
+ description:
+ 'Flags enabled INGRESS firewall rules that allow 0.0.0.0/0 to SSH, RDP, or all ports.',
+ service: 'vpc-network',
+ taskMapping: TASK_TEMPLATES.productionFirewallNopublicaccessControls,
+
+ run: async (ctx: CheckContext) => {
+ const projectIds = await resolveGcpProjectIds(ctx);
+ if (projectIds.length === 0) {
+ ctx.log('GCP VPC firewall check: no projects resolved — skipping');
+ return;
+ }
+
+ for (const projectId of projectIds) {
+ const data = await ctx.fetch<{ items?: FirewallRule[] }>(
+ `https://compute.googleapis.com/compute/v1/projects/${encodeURIComponent(projectId)}/global/firewalls`,
+ );
+ const rules = data.items ?? [];
+ if (rules.length === 0) continue;
+
+ let violations = 0;
+ for (const rule of rules) {
+ if (rule.disabled === true) continue;
+ if (rule.direction && rule.direction !== 'INGRESS') continue;
+ if (!(rule.sourceRanges ?? []).includes('0.0.0.0/0')) continue;
+
+ const allowed = rule.allowed ?? [];
+ if (allowed.some((a) => a.IPProtocol === 'all')) {
+ violations++;
+ ctx.fail({
+ title: `Firewall open to internet (all ports): ${rule.name}`,
+ description: `Firewall rule "${rule.name}" allows ALL protocols/ports from 0.0.0.0/0.`,
+ resourceType: 'gcp-firewall-rule',
+ resourceId: rule.name,
+ severity: 'critical',
+ remediation:
+ 'Restrict source ranges to known CIDRs and limit allowed protocols/ports to only what is required.',
+ evidence: { projectId, rule: rule.name },
+ });
+ continue;
+ }
+
+ const tcp = allowed.find(
+ (a) => a.IPProtocol === 'tcp' || a.IPProtocol === '6',
+ );
+ for (const { port, label, severity } of SENSITIVE_PORTS) {
+ if (tcp && portsCover(tcp.ports, port)) {
+ violations++;
+ ctx.fail({
+ title: `${label} open to internet: ${rule.name}`,
+ description: `Firewall rule "${rule.name}" allows ${label} (port ${port}) from 0.0.0.0/0.`,
+ resourceType: 'gcp-firewall-rule',
+ resourceId: rule.name,
+ severity,
+ remediation: `Remove the 0.0.0.0/0 source for port ${port}; restrict ${label} access to a VPN, bastion, or known CIDR ranges.`,
+ evidence: { projectId, rule: rule.name, port },
+ });
+ }
+ }
+ }
+
+ if (violations === 0) {
+ ctx.pass({
+ title: 'No firewalls open to the internet',
+ description: `No firewall rule in "${projectId}" exposes SSH/RDP/all-ports to 0.0.0.0/0 (${rules.length} rule(s) checked).`,
+ resourceType: 'gcp-project',
+ resourceId: projectId,
+ evidence: { projectId, ruleCount: rules.length },
+ });
+ }
+ }
+ },
+};
diff --git a/packages/integration-platform/src/manifests/gcp/index.ts b/packages/integration-platform/src/manifests/gcp/index.ts
index 39802cda3a..119f9d3b86 100644
--- a/packages/integration-platform/src/manifests/gcp/index.ts
+++ b/packages/integration-platform/src/manifests/gcp/index.ts
@@ -1,4 +1,11 @@
import type { IntegrationManifest } from '../../types';
+import {
+ cloudSqlBackupsCheck,
+ cloudSqlSslCheck,
+ iamPrimitiveRolesCheck,
+ storagePublicAccessCheck,
+ vpcOpenFirewallsCheck,
+} from './checks';
export const gcpManifest: IntegrationManifest = {
id: 'gcp',
@@ -145,5 +152,11 @@ This is industry standard - all GCP security monitoring tools use the same scope
},
],
- checks: [],
+ checks: [
+ iamPrimitiveRolesCheck,
+ storagePublicAccessCheck,
+ vpcOpenFirewallsCheck,
+ cloudSqlSslCheck,
+ cloudSqlBackupsCheck,
+ ],
};
From 5f6bebc9329e2e6dfc8e8ffd5d39de03cd43bf6d Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Mon, 1 Jun 2026 20:07:38 -0400
Subject: [PATCH 02/13] =?UTF-8?q?fix(integrations):=20address=20cubic=20re?=
=?UTF-8?q?view=20=E2=80=94=20fix=2030=20verified=20cloud-check=20bugs?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Independently verified all 37 cubic-dev-ai findings (30 real, 7 false
positives) and fixed the 30 real ones. Highlights:
- False pass / missed exposure: Azure SQL minimalTlsVersion 'None'; GCP firewall
evaluating only the first TCP tuple; IPv6 (::/0) missed by AWS EC2 + GCP VPC +
Azure NSG; Azure NSG port-range parsing; Azure SQL firewall-read errors no
longer coerced to a clean pass; Azure storage/key-vault honor
publicNetworkAccess=Disabled; AWS S3 unions account-level Block Public Access;
AWS KMS only evaluates rotation-eligible (symmetric/AWS_KMS) keys; Azure Entra
detects dataActions wildcards + permission-based privileged roles; Azure
monitor requires enabled log categories and fails on unreadable alerts.
- Robustness: pagination for GCP storage/vpc/cloud-sql lists; GCP IAM pass
scoped to direct project bindings; GCP Cloud SQL replicas skipped; discovery
errors surfaced via ctx.warn; AWS S3 followRegionRedirects; root-key wording;
per-service toggle gated on manageable services; task-fetch error state; a
shared task-template helper (dedup).
Adds @aws-sdk/client-s3-control. +7 regression tests (146 package tests pass).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../integrations/[slug]/lib/task-templates.ts | 38 +++++++
.../[orgId]/integrations/[slug]/page.tsx | 25 ++---
.../components/ServiceDetailView.tsx | 11 ++-
.../[slug]/services/[serviceId]/page.tsx | 25 ++---
bun.lock | 85 ++++++++++++++++
packages/integration-platform/package.json | 1 +
.../aws/checks/__tests__/aws-checks.test.ts | 57 ++++++++---
.../src/manifests/aws/checks/ec2.ts | 9 +-
.../src/manifests/aws/checks/iam.ts | 2 +-
.../src/manifests/aws/checks/kms.ts | 53 +++++++---
.../src/manifests/aws/checks/s3.ts | 99 +++++++++++++++----
.../checks/__tests__/azure-checks.test.ts | 46 ++++++++-
.../src/manifests/azure/checks/entra-id.ts | 34 ++++++-
.../src/manifests/azure/checks/key-vault.ts | 5 +-
.../src/manifests/azure/checks/monitor.ts | 22 ++++-
.../src/manifests/azure/checks/network.ts | 27 +++--
.../src/manifests/azure/checks/shared.ts | 12 ++-
.../src/manifests/azure/checks/sql.ts | 57 ++++++-----
.../src/manifests/azure/checks/storage.ts | 6 +-
.../gcp/checks/__tests__/gcp-checks.test.ts | 34 +++++++
.../manifests/gcp/checks/cloud-sql-backups.ts | 18 +++-
.../src/manifests/gcp/checks/cloud-sql-ssl.ts | 6 +-
.../gcp/checks/iam-primitive-roles.ts | 11 ++-
.../src/manifests/gcp/checks/shared.ts | 33 ++++++-
.../gcp/checks/storage-public-access.ts | 10 +-
.../gcp/checks/vpc-open-firewalls.ts | 13 +--
26 files changed, 582 insertions(+), 157 deletions(-)
create mode 100644 apps/app/src/app/(app)/[orgId]/integrations/[slug]/lib/task-templates.ts
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/lib/task-templates.ts b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/lib/task-templates.ts
new file mode 100644
index 0000000000..c46b15acf5
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/lib/task-templates.ts
@@ -0,0 +1,38 @@
+/**
+ * Shared task-template projection for the integration detail pages.
+ * Resolves the org's tasks into { templateId -> live task } rows and reports
+ * whether the tasks fetch errored (so the UI can distinguish "not added" from
+ * "couldn't load").
+ */
+export interface IntegrationTaskApiResponse {
+ data: Array<{
+ id: string;
+ title: string;
+ description: string;
+ taskTemplateId: string | null;
+ }>;
+}
+
+export interface MappedTaskTemplate {
+ id: string;
+ taskId: string;
+ name: string;
+ description: string;
+}
+
+export function mapTaskTemplates(
+ tasksResult: { data?: IntegrationTaskApiResponse | null; error?: unknown },
+ opts: { sort?: boolean } = {},
+): { templates: MappedTaskTemplate[]; errored: boolean } {
+ const errored = Boolean(tasksResult.error);
+ const templates = (tasksResult.data?.data ?? [])
+ .filter((task) => task.taskTemplateId)
+ .map((task) => ({
+ id: task.taskTemplateId as string,
+ taskId: task.id,
+ name: task.title,
+ description: task.description,
+ }));
+ if (opts.sort) templates.sort((a, b) => a.name.localeCompare(b.name));
+ return { templates, errored };
+}
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/page.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/page.tsx
index 36eaeaa426..0e15d3122c 100644
--- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/page.tsx
@@ -6,15 +6,10 @@ import type {
} from '@trycompai/integration-platform';
import { redirect } from 'next/navigation';
import { ProviderDetailView } from './components/ProviderDetailView';
-
-interface TaskApiResponse {
- data: Array<{
- id: string;
- title: string;
- description: string;
- taskTemplateId: string | null;
- }>;
-}
+import {
+ type IntegrationTaskApiResponse,
+ mapTaskTemplates,
+} from './lib/task-templates';
interface PageProps {
params: Promise<{ orgId: string; slug: string }>;
@@ -31,7 +26,7 @@ export default async function ProviderDetailPage({ params, searchParams }: PageP
const [providerResult, connectionsResult, tasksResult] = await Promise.all([
serverApi.get(`/v1/integrations/connections/providers/${slug}`),
serverApi.get('/v1/integrations/connections'),
- serverApi.get('/v1/tasks'),
+ serverApi.get('/v1/tasks'),
]);
if (!providerResult.data || providerResult.error) {
@@ -40,15 +35,7 @@ export default async function ProviderDetailPage({ params, searchParams }: PageP
const provider = providerResult.data;
const connections = (connectionsResult.data ?? []).filter((c) => c.providerSlug === slug);
- const taskTemplates = (tasksResult.data?.data ?? [])
- .filter((task) => task.taskTemplateId)
- .map((task) => ({
- id: task.taskTemplateId as string,
- taskId: task.id,
- name: task.title,
- description: task.description,
- }))
- .sort((a, b) => a.name.localeCompare(b.name));
+ const { templates: taskTemplates } = mapTaskTemplates(tasksResult, { sort: true });
return (
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx
index fc112e5d44..24a39b661a 100644
--- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx
@@ -32,6 +32,7 @@ interface ServiceDetailViewProps {
connections: ConnectionListItemResponse[];
connectionId: string | null;
taskTemplates: TaskTemplate[];
+ tasksErrored: boolean;
orgId: string;
slug: string;
}
@@ -42,6 +43,7 @@ export function ServiceDetailView({
connections,
connectionId,
taskTemplates,
+ tasksErrored,
orgId,
slug,
}: ServiceDetailViewProps) {
@@ -59,6 +61,9 @@ export function ServiceDetailView({
const liveService = connectionServices.find((s) => s.id === service.id);
const isEnabled = liveService?.enabled ?? false;
const isImplemented = service.implemented !== false;
+ // Only services present in the connection's live service list can be toggled.
+ // (e.g. AWS baseline services are always scanned and aren't in the toggle list.)
+ const isManageable = Boolean(liveService);
const [toggling, setToggling] = useState(false);
const taskByTemplateId = useMemo(
@@ -68,7 +73,7 @@ export function ServiceDetailView({
const mappedTasks = service.mappedTasks ?? [];
const handleToggle = async () => {
- if (!effectiveConnectionId || toggling) return;
+ if (!effectiveConnectionId || toggling || !liveService) return;
setToggling(true);
const next = !isEnabled;
try {
@@ -122,7 +127,7 @@ export function ServiceDetailView({
role="switch"
aria-checked={isEnabled}
aria-label={`Toggle Cloud Tests scanning for ${service.name}`}
- disabled={toggling || !effectiveConnectionId || !isImplemented}
+ disabled={toggling || !effectiveConnectionId || !isImplemented || !isManageable}
onClick={() => void handleToggle()}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
isEnabled ? 'bg-primary' : 'bg-muted-foreground/30'
@@ -186,7 +191,7 @@ export function ServiceDetailView({
) : (
- Not added
+ {tasksErrored ? 'Couldn’t load tasks' : 'Not added'}
)}
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/page.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/page.tsx
index 62fdfac3f1..fb7134ee95 100644
--- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/page.tsx
@@ -5,17 +5,12 @@ import type {
IntegrationProviderResponse,
} from '@trycompai/integration-platform';
import { redirect } from 'next/navigation';
+import {
+ type IntegrationTaskApiResponse,
+ mapTaskTemplates,
+} from '../../lib/task-templates';
import { ServiceDetailView } from './components/ServiceDetailView';
-interface TaskApiResponse {
- data: Array<{
- id: string;
- title: string;
- description: string;
- taskTemplateId: string | null;
- }>;
-}
-
interface PageProps {
params: Promise<{ orgId: string; slug: string; serviceId: string }>;
searchParams: Promise
>;
@@ -29,7 +24,7 @@ export default async function ServiceDetailPage({ params, searchParams }: PagePr
const [providerResult, connectionsResult, tasksResult] = await Promise.all([
serverApi.get(`/v1/integrations/connections/providers/${slug}`),
serverApi.get('/v1/integrations/connections'),
- serverApi.get('/v1/tasks'),
+ serverApi.get('/v1/tasks'),
]);
const provider = providerResult.data;
@@ -43,14 +38,7 @@ export default async function ServiceDetailPage({ params, searchParams }: PagePr
}
const connections = (connectionsResult.data ?? []).filter((c) => c.providerSlug === slug);
- const taskTemplates = (tasksResult.data?.data ?? [])
- .filter((task) => task.taskTemplateId)
- .map((task) => ({
- id: task.taskTemplateId as string,
- taskId: task.id,
- name: task.title,
- description: task.description,
- }));
+ const { templates: taskTemplates, errored: tasksErrored } = mapTaskTemplates(tasksResult);
return (
@@ -60,6 +48,7 @@ export default async function ServiceDetailPage({ params, searchParams }: PagePr
connections={connections}
connectionId={connectionId}
taskTemplates={taskTemplates}
+ tasksErrored={tasksErrored}
orgId={orgId}
slug={slug}
/>
diff --git a/bun.lock b/bun.lock
index cfc0752bff..c9e5bf0d62 100644
--- a/bun.lock
+++ b/bun.lock
@@ -638,6 +638,7 @@
"@aws-sdk/client-kms": "^3.943.0",
"@aws-sdk/client-rds": "^3.943.0",
"@aws-sdk/client-s3": "^3.943.0",
+ "@aws-sdk/client-s3-control": "^3.943.0",
"@aws-sdk/client-securityhub": "^3.943.0",
"@aws-sdk/client-sts": "^3.943.0",
"zod": "^4.0.0",
@@ -965,6 +966,8 @@
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1013.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.22", "@aws-sdk/credential-provider-node": "^3.972.23", "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", "@aws-sdk/middleware-expect-continue": "^3.972.8", "@aws-sdk/middleware-flexible-checksums": "^3.974.2", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-location-constraint": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-sdk-s3": "^3.972.22", "@aws-sdk/middleware-ssec": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.23", "@aws-sdk/region-config-resolver": "^3.972.8", "@aws-sdk/signature-v4-multi-region": "^3.996.10", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.9", "@smithy/config-resolver": "^4.4.11", "@smithy/core": "^3.23.12", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-blob-browser": "^4.2.13", "@smithy/hash-node": "^4.2.12", "@smithy/hash-stream-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/md5-js": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.26", "@smithy/middleware-retry": "^4.4.43", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.42", "@smithy/util-defaults-mode-node": "^4.2.45", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-vFdyRyRatF+xP9Fi+4alZkmzZadqOAM34Pm6SUZsYtumNrWkgMc/pFWITnsq6eltM8qcV/vcinQ1ZBXWm/PlKg=="],
+ "@aws-sdk/client-s3-control": ["@aws-sdk/client-s3-control@3.1058.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-node": "^3.972.48", "@aws-sdk/middleware-bucket-endpoint": "^3.972.17", "@aws-sdk/middleware-sdk-s3-control": "^3.972.18", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/middleware-apply-body-checksum": "^4.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-EQgDVhi4SpXdH804tNxmYUqXWCC/PGn4Yx2XdIvlaAS8AVeD1vFAxReTLtT0UC1fZ+QUHv3rl5mbfMAGuvDFwA=="],
+
"@aws-sdk/client-sagemaker": ["@aws-sdk/client-sagemaker@3.1042.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.8", "@aws-sdk/credential-provider-node": "^3.972.39", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.38", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.24", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", "@smithy/middleware-retry": "^4.5.7", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.49", "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-5JIpQsmCZUjRoh2y7sSk02U/pJaM9NoIj+wq5+nVv6FKv9YSRdMYv5A/earLvbNwcWuX7WRWxSWy2aTMzIwFjg=="],
"@aws-sdk/client-secrets-manager": ["@aws-sdk/client-secrets-manager@3.1042.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.8", "@aws-sdk/credential-provider-node": "^3.972.39", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.38", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.24", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", "@smithy/middleware-retry": "^4.5.7", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.49", "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-doHP17OwqhcuW3e7fKkFfF4rDFM0hY8IVIwqwvBQRLk6IsZXzpl/YRhQWuIiy9O7BSZgzKKE+XytdElsTd9PWQ=="],
@@ -1037,6 +1040,8 @@
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.37", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA=="],
+ "@aws-sdk/middleware-sdk-s3-control": ["@aws-sdk/middleware-sdk-s3-control@3.972.18", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/middleware-bucket-endpoint": "^3.972.17", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kmZDuO2TQLoduu2ckIMMegBv0AuMmycI+4/7vPtDmLDQCEHJzGSjumoWFZ1vn+Aw9hzqPv0kC/2aG7Q+WlPi2g=="],
+
"@aws-sdk/middleware-sdk-sqs": ["@aws-sdk/middleware-sdk-sqs@3.972.22", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-DtR3mEiOUJcnEX/QuXmvbJto6xvQzp2ftnHb29c0aQYdmmzbKf0gsu9ovx1i/yy4ZR6m0rttTucS0iiP32dlGA=="],
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw=="],
@@ -2411,6 +2416,8 @@
"@smithy/md5-js": ["@smithy/md5-js@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA=="],
+ "@smithy/middleware-apply-body-checksum": ["@smithy/middleware-apply-body-checksum@4.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-o6BbTHeCTaSFSUezTVYNFJzLvjZrE+VpI4DR5NqeMb3hz3pmB2sDaBWVwlEjzyw0geshVVmSBa0W4jTup1bNtA=="],
+
"@smithy/middleware-compression": ["@smithy/middleware-compression@4.3.46", "", { "dependencies": { "@smithy/core": "^3.23.17", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-utf8": "^4.2.2", "fflate": "0.8.1", "tslib": "^2.6.2" } }, "sha512-9f4AZ5dKqKRmO49MPhOoxFoQBLfBgxE9YKG8bQ6lsW9xk+Bn8rkfGlpW8OYlvhuarN+8mja9PjhEudFiR8wGFQ=="],
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.14", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw=="],
@@ -6761,6 +6768,32 @@
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
+ "@aws-sdk/client-s3-control/@aws-sdk/core": ["@aws-sdk/core@3.974.15", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.26", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.5", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.48", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-QIbtJP0olSLZ2ImEu636pP+7JJbPfaL3xSJIFXhu472CWuondCc4bGOa8OeyhOFet8z4H1D/ZFKXc39FboWwYA=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-lbDmWuHenc+kiwCNrxz4MyN6nkxCWyTXPIWuspJN0ibziu+8CXci7vI1bK9MAkwy8cwJOEXNu0gBM5S0uTGRIg=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/types": ["@aws-sdk/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="],
+
+ "@aws-sdk/client-s3-control/@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="],
+
+ "@aws-sdk/client-s3-control/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g=="],
+
+ "@aws-sdk/client-s3-control/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-3fya8i7GrJilQouk4cZJKdy5k8MWQBpjfXrRNaXDedH8r779tr0jcxyH3+yoTmsluc2+vF4S343yFbnvu8ExDQ=="],
+
+ "@aws-sdk/client-s3-control/@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="],
+
+ "@aws-sdk/middleware-sdk-s3-control/@aws-sdk/core": ["@aws-sdk/core@3.974.15", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.26", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.5", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw=="],
+
+ "@aws-sdk/middleware-sdk-s3-control/@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-lbDmWuHenc+kiwCNrxz4MyN6nkxCWyTXPIWuspJN0ibziu+8CXci7vI1bK9MAkwy8cwJOEXNu0gBM5S0uTGRIg=="],
+
+ "@aws-sdk/middleware-sdk-s3-control/@aws-sdk/types": ["@aws-sdk/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="],
+
+ "@aws-sdk/middleware-sdk-s3-control/@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="],
+
+ "@aws-sdk/middleware-sdk-s3-control/@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="],
+
"@azure/core-auth/@azure/abort-controller": ["@azure/abort-controller@2.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA=="],
"@azure/core-client/@azure/abort-controller": ["@azure/abort-controller@2.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA=="],
@@ -7235,6 +7268,10 @@
"@sentry/vercel-edge/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
+ "@smithy/middleware-apply-body-checksum/@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="],
+
+ "@smithy/middleware-apply-body-checksum/@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="],
+
"@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="],
"@streamdown/code/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
@@ -8427,6 +8464,28 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
+ "@aws-sdk/client-s3-control/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.26", "", { "dependencies": { "@smithy/types": "^4.14.2", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/token-providers": "3.1056.0", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xj8gq/bjFABAh6qWPSDCYcY3kzQIm4b561C+YnHH4zGq8rOgzQ3Shk+JGlpUxSd41UGiO6FkLdUCtNX1FAeHgg=="],
+
+ "@aws-sdk/middleware-sdk-s3-control/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.26", "", { "dependencies": { "@smithy/types": "^4.14.2", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g=="],
+
+ "@aws-sdk/middleware-sdk-s3-control/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="],
+
"@azure/core-http/xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
@@ -9517,6 +9576,20 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
+ "@aws-sdk/client-s3-control/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1056.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg=="],
+
+ "@aws-sdk/middleware-sdk-s3-control/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="],
+
"@browserbasehq/stagehand/@browserbasehq/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@browserbasehq/stagehand/puppeteer-core/chromium-bidi/zod": ["zod@3.23.8", "", {}, "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="],
@@ -10235,6 +10308,12 @@
"@angular-devkit/schematics/ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.30", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.30", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.30", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw=="],
+
"@calcom/atoms/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"@commitlint/top-level/find-up/locate-path/p-locate/p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="],
@@ -10361,6 +10440,12 @@
"test-exclude/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="],
+
+ "@aws-sdk/client-s3-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="],
+
"@electron/rebuild/node-gyp/npmlog/gauge/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit/p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
diff --git a/packages/integration-platform/package.json b/packages/integration-platform/package.json
index fa13532f9a..c0558bc442 100644
--- a/packages/integration-platform/package.json
+++ b/packages/integration-platform/package.json
@@ -44,6 +44,7 @@
"@aws-sdk/client-kms": "^3.943.0",
"@aws-sdk/client-rds": "^3.943.0",
"@aws-sdk/client-s3": "^3.943.0",
+ "@aws-sdk/client-s3-control": "^3.943.0",
"@aws-sdk/client-securityhub": "^3.943.0",
"@aws-sdk/client-sts": "^3.943.0",
"zod": "^4.0.0"
diff --git a/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
index 988ba7bc96..c3cd2ce9a0 100644
--- a/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
@@ -32,24 +32,42 @@ describe('AWS IAM account evaluator', () => {
});
});
+const ALL_BLOCKED = {
+ blockPublicAcls: true,
+ ignorePublicAcls: true,
+ blockPublicPolicy: true,
+ restrictPublicBuckets: true,
+};
+
describe('AWS S3 evaluators', () => {
it('encryption: pass when encrypted, fail (high) when not', () => {
const out = evaluateS3Encryption([
- { name: 'a', encrypted: true, publicAccessBlocked: false },
- { name: 'b', encrypted: false, publicAccessBlocked: false },
+ { name: 'a', encrypted: true, bucketBpa: null },
+ { name: 'b', encrypted: false, bucketBpa: null },
]);
expect(out[0]!.kind).toBe('pass');
expect(out[1]!.kind).toBe('fail');
expect(out[1]!.severity).toBe('high');
});
- it('public access: pass when blocked, fail when not', () => {
- const out = evaluateS3PublicAccess([
- { name: 'a', encrypted: false, publicAccessBlocked: true },
- { name: 'b', encrypted: false, publicAccessBlocked: false },
- ]);
+ it('public access: bucket-level all-blocked passes, missing fails', () => {
+ const out = evaluateS3PublicAccess(
+ [
+ { name: 'a', encrypted: false, bucketBpa: ALL_BLOCKED },
+ { name: 'b', encrypted: false, bucketBpa: null },
+ ],
+ null,
+ );
expect(kinds(out)).toEqual(['pass', 'fail']);
});
+
+ it('public access: account-level BPA covers buckets lacking bucket config', () => {
+ const out = evaluateS3PublicAccess(
+ [{ name: 'b', encrypted: false, bucketBpa: null }],
+ ALL_BLOCKED,
+ );
+ expect(out[0]!.kind).toBe('pass');
+ });
});
describe('AWS EC2 security-group evaluator', () => {
@@ -66,6 +84,18 @@ describe('AWS EC2 security-group evaluator', () => {
expect(out[0]!.severity).toBe('high');
});
+ it('flags IPv6 ::/0 internet-open rules', () => {
+ const out = evaluateSecurityGroups([
+ {
+ groupId: 'sg-6',
+ region: 'us-east-1',
+ permissions: [{ ipProtocol: 'tcp', fromPort: 22, toPort: 22, cidrs: ['::/0'] }],
+ },
+ ]);
+ expect(out).toHaveLength(1);
+ expect(out[0]!.kind).toBe('fail');
+ });
+
it('flags all-protocols (-1) open as critical', () => {
const out = evaluateSecurityGroups([
{ groupId: 'sg-2', region: 'us-east-1', permissions: [{ ipProtocol: '-1', cidrs: ['0.0.0.0/0'] }] },
@@ -109,13 +139,16 @@ describe('AWS RDS evaluators', () => {
});
describe('AWS KMS rotation evaluator', () => {
- it('only evaluates customer-managed keys', () => {
+ it('evaluates only rotation-eligible keys with a known status', () => {
const out = evaluateKmsRotation([
- { keyId: 'k1', region: 'us-east-1', customerManaged: true, rotationEnabled: true },
- { keyId: 'k2', region: 'us-east-1', customerManaged: true, rotationEnabled: false },
- { keyId: 'aws-managed', region: 'us-east-1', customerManaged: false, rotationEnabled: false },
+ { keyId: 'sym-on', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: true, rotationEnabled: true },
+ { keyId: 'sym-off', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: true, rotationEnabled: false },
+ // RSA/HMAC/etc. — not rotation-eligible → no finding
+ { keyId: 'rsa', region: 'us-east-1', rotationEligible: false, rotationStatusKnown: false, rotationEnabled: false },
+ // eligible but status unreadable → no fabricated finding
+ { keyId: 'unknown', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: false, rotationEnabled: false },
]);
- expect(out).toHaveLength(2); // aws-managed excluded
+ expect(out).toHaveLength(2);
expect(out[0]!.kind).toBe('pass');
expect(out[1]!.kind).toBe('fail');
});
diff --git a/packages/integration-platform/src/manifests/aws/checks/ec2.ts b/packages/integration-platform/src/manifests/aws/checks/ec2.ts
index e0e56fb0e6..8410ae0f0a 100644
--- a/packages/integration-platform/src/manifests/aws/checks/ec2.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/ec2.ts
@@ -32,7 +32,7 @@ export function evaluateSecurityGroups(sgs: SgInfo[]): CheckOutcome[] {
for (const sg of sgs) {
let bad = false;
for (const perm of sg.permissions) {
- if (!perm.cidrs.includes('0.0.0.0/0')) continue;
+ if (!perm.cidrs.includes('0.0.0.0/0') && !perm.cidrs.includes('::/0')) continue;
if (perm.ipProtocol === '-1') {
bad = true;
out.push({
@@ -107,9 +107,10 @@ export const ec2SecurityGroupsCheck: IntegrationCheck = {
ipProtocol: p.IpProtocol ?? '-1',
fromPort: p.FromPort,
toPort: p.ToPort,
- cidrs: (p.IpRanges ?? [])
- .map((r) => r.CidrIp)
- .filter((c): c is string => typeof c === 'string'),
+ cidrs: [
+ ...(p.IpRanges ?? []).map((r) => r.CidrIp),
+ ...(p.Ipv6Ranges ?? []).map((r) => r.CidrIpv6),
+ ].filter((c): c is string => typeof c === 'string'),
})),
});
}
diff --git a/packages/integration-platform/src/manifests/aws/checks/iam.ts b/packages/integration-platform/src/manifests/aws/checks/iam.ts
index f43015b6d2..2e8d58019a 100644
--- a/packages/integration-platform/src/manifests/aws/checks/iam.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/iam.ts
@@ -92,7 +92,7 @@ export function evaluateIamAccount(data: IamAccountData): CheckOutcome[] {
out.push({
kind: 'fail',
title: 'Root account access keys present',
- description: 'The root account has active access keys, which should not exist.',
+ description: 'The root account has access keys (active or inactive), which should not exist.',
resourceType: 'aws-account',
resourceId: id,
severity: 'high',
diff --git a/packages/integration-platform/src/manifests/aws/checks/kms.ts b/packages/integration-platform/src/manifests/aws/checks/kms.ts
index 3c08f369e4..5caffd5035 100644
--- a/packages/integration-platform/src/manifests/aws/checks/kms.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/kms.ts
@@ -16,14 +16,21 @@ import {
export interface KmsKeyInfo {
keyId: string;
region: string;
- customerManaged: boolean;
+ /**
+ * Customer-managed, enabled, symmetric ENCRYPT_DECRYPT key with AWS_KMS
+ * origin — the only key kind that supports automatic rotation. Asymmetric,
+ * HMAC, external, and CloudHSM keys cannot rotate and must not be failed.
+ */
+ rotationEligible: boolean;
+ /** false when GetKeyRotationStatus couldn't be read → emit no finding. */
+ rotationStatusKnown: boolean;
rotationEnabled: boolean;
}
-/** Only customer-managed keys are evaluated — AWS-managed keys rotate automatically. */
+/** Only rotation-eligible keys with a known status are evaluated. */
export function evaluateKmsRotation(keys: KmsKeyInfo[]): CheckOutcome[] {
return keys
- .filter((k) => k.customerManaged)
+ .filter((k) => k.rotationEligible && k.rotationStatusKnown)
.map((k) =>
k.rotationEnabled
? {
@@ -47,7 +54,10 @@ export function evaluateKmsRotation(keys: KmsKeyInfo[]): CheckOutcome[] {
);
}
-async function listKmsKeys(session: AwsSession): Promise {
+async function listKmsKeys(
+ ctx: CheckContext,
+ session: AwsSession,
+): Promise {
const out: KmsKeyInfo[] = [];
for (const region of session.regions) {
const kms = new KMSClient({ region, credentials: session.credentials });
@@ -57,20 +67,31 @@ async function listKmsKeys(session: AwsSession): Promise {
for (const k of resp.Keys ?? []) {
const keyId = k.KeyId;
if (!keyId) continue;
- const desc = await kms.send(new DescribeKeyCommand({ KeyId: keyId }));
- const meta = desc.KeyMetadata;
- const customerManaged =
- meta?.KeyManager === 'CUSTOMER' && meta?.KeyState === 'Enabled';
+ const meta = (await kms.send(new DescribeKeyCommand({ KeyId: keyId }))).KeyMetadata;
+ // Only symmetric, enabled, AWS-managed-material, encrypt/decrypt
+ // customer keys can have automatic rotation.
+ const rotationEligible =
+ meta?.KeyManager === 'CUSTOMER' &&
+ meta?.KeyState === 'Enabled' &&
+ meta?.KeySpec === 'SYMMETRIC_DEFAULT' &&
+ meta?.KeyUsage === 'ENCRYPT_DECRYPT' &&
+ meta?.Origin === 'AWS_KMS';
+
let rotationEnabled = false;
- if (customerManaged) {
+ let rotationStatusKnown = false;
+ if (rotationEligible) {
try {
const rot = await kms.send(new GetKeyRotationStatusCommand({ KeyId: keyId }));
rotationEnabled = rot.KeyRotationEnabled === true;
- } catch {
- rotationEnabled = false;
+ rotationStatusKnown = true;
+ } catch (err) {
+ ctx.log(
+ `KMS: could not read rotation status for ${keyId} in ${region}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ rotationStatusKnown = false;
}
}
- out.push({ keyId, region, customerManaged, rotationEnabled });
+ out.push({ keyId, region, rotationEligible, rotationStatusKnown, rotationEnabled });
}
marker = resp.NextMarker;
} while (marker);
@@ -81,7 +102,7 @@ async function listKmsKeys(session: AwsSession): Promise {
export const kmsKeyRotationCheck: IntegrationCheck = {
id: 'aws-kms-key-rotation',
name: 'KMS — customer key rotation enabled',
- description: 'Verify customer-managed KMS keys have automatic rotation enabled.',
+ description: 'Verify rotation-eligible customer-managed KMS keys have automatic rotation enabled.',
service: 'kms',
taskMapping: TASK_TEMPLATES.encryptionAtRest,
run: async (ctx: CheckContext) => {
@@ -90,9 +111,9 @@ export const kmsKeyRotationCheck: IntegrationCheck = {
ctx.log('AWS KMS check: connection not configured — skipping');
return;
}
- const keys = await listKmsKeys(session);
- const customerKeys = keys.filter((k) => k.customerManaged);
- if (customerKeys.length === 0) return; // nothing to evidence
+ const keys = await listKmsKeys(ctx, session);
+ // Nothing to evidence if there are no rotation-eligible keys.
+ if (!keys.some((k) => k.rotationEligible && k.rotationStatusKnown)) return;
emitOutcomes(ctx, evaluateKmsRotation(keys));
},
};
diff --git a/packages/integration-platform/src/manifests/aws/checks/s3.ts b/packages/integration-platform/src/manifests/aws/checks/s3.ts
index 136910b7d9..ee03a03814 100644
--- a/packages/integration-platform/src/manifests/aws/checks/s3.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/s3.ts
@@ -4,14 +4,38 @@ import {
ListBucketsCommand,
S3Client,
} from '@aws-sdk/client-s3';
+import {
+ GetPublicAccessBlockCommand as GetAccountPublicAccessBlockCommand,
+ S3ControlClient,
+} from '@aws-sdk/client-s3-control';
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, IntegrationCheck } from '../../../types';
import { assumeAwsSession, type CheckOutcome, emitOutcomes } from './shared';
+export interface BpaFlags {
+ blockPublicAcls: boolean;
+ ignorePublicAcls: boolean;
+ blockPublicPolicy: boolean;
+ restrictPublicBuckets: boolean;
+}
+
export interface S3BucketInfo {
name: string;
encrypted: boolean;
- publicAccessBlocked: boolean;
+ /** bucket-level Block Public Access flags, or null when none configured */
+ bucketBpa: BpaFlags | null;
+}
+
+const FLAG_KEYS: Array = [
+ 'blockPublicAcls',
+ 'ignorePublicAcls',
+ 'blockPublicPolicy',
+ 'restrictPublicBuckets',
+];
+
+/** A bucket is protected if the union of account-level + bucket-level BPA has all four flags on. */
+function isFullyBlocked(bucket: BpaFlags | null, account: BpaFlags | null): boolean {
+ return FLAG_KEYS.every((k) => Boolean(bucket?.[k]) || Boolean(account?.[k]));
}
export function evaluateS3Encryption(buckets: S3BucketInfo[]): CheckOutcome[] {
@@ -38,13 +62,16 @@ export function evaluateS3Encryption(buckets: S3BucketInfo[]): CheckOutcome[] {
);
}
-export function evaluateS3PublicAccess(buckets: S3BucketInfo[]): CheckOutcome[] {
+export function evaluateS3PublicAccess(
+ buckets: S3BucketInfo[],
+ accountBpa: BpaFlags | null,
+): CheckOutcome[] {
return buckets.map((b) =>
- b.publicAccessBlocked
+ isFullyBlocked(b.bucketBpa, accountBpa)
? {
kind: 'pass',
title: `Public access blocked: ${b.name}`,
- description: `Bucket "${b.name}" has S3 Block Public Access fully enabled.`,
+ description: `Bucket "${b.name}" has S3 Block Public Access fully enabled (account and/or bucket level).`,
resourceType: 'aws-s3-bucket',
resourceId: b.name,
evidence: { bucket: b.name },
@@ -52,7 +79,7 @@ export function evaluateS3PublicAccess(buckets: S3BucketInfo[]): CheckOutcome[]
: {
kind: 'fail',
title: `Public access not fully blocked: ${b.name}`,
- description: `Bucket "${b.name}" does not have all S3 Block Public Access settings enabled.`,
+ description: `Bucket "${b.name}" does not have all four S3 Block Public Access settings enabled at the account or bucket level.`,
resourceType: 'aws-s3-bucket',
resourceId: b.name,
severity: 'high',
@@ -74,35 +101,43 @@ async function gatherBuckets(
const infos: S3BucketInfo[] = [];
for (const name of names) {
let encrypted = false;
- let publicAccessBlocked = false;
+ let bucketBpa: BpaFlags | null = null;
if (opts.encryption) {
try {
const enc = await s3.send(new GetBucketEncryptionCommand({ Bucket: name }));
encrypted = (enc.ServerSideEncryptionConfiguration?.Rules?.length ?? 0) > 0;
} catch {
- encrypted = false; // no encryption config
+ encrypted = false;
}
}
if (opts.publicAccess) {
try {
const pab = await s3.send(new GetPublicAccessBlockCommand({ Bucket: name }));
const c = pab.PublicAccessBlockConfiguration;
- publicAccessBlocked = Boolean(
- c?.BlockPublicAcls &&
- c?.IgnorePublicAcls &&
- c?.BlockPublicPolicy &&
- c?.RestrictPublicBuckets,
- );
+ bucketBpa = {
+ blockPublicAcls: Boolean(c?.BlockPublicAcls),
+ ignorePublicAcls: Boolean(c?.IgnorePublicAcls),
+ blockPublicPolicy: Boolean(c?.BlockPublicPolicy),
+ restrictPublicBuckets: Boolean(c?.RestrictPublicBuckets),
+ };
} catch {
- publicAccessBlocked = false; // no public access block config
+ bucketBpa = null; // no bucket-level config
}
}
- infos.push({ name, encrypted, publicAccessBlocked });
+ infos.push({ name, encrypted, bucketBpa });
}
return infos;
}
+/** Account ID from the connection's role ARN (arn:aws:iam::ACCOUNT:role/...). */
+function accountIdFromCtx(ctx: CheckContext): string | null {
+ const arn = (ctx.credentials as Record).roleArn;
+ if (typeof arn !== 'string') return null;
+ const parts = arn.split(':');
+ return parts.length >= 5 && parts[4] ? parts[4] : null;
+}
+
export const s3EncryptionCheck: IntegrationCheck = {
id: 'aws-s3-encryption',
name: 'S3 — default encryption enabled',
@@ -118,6 +153,7 @@ export const s3EncryptionCheck: IntegrationCheck = {
const s3 = new S3Client({
region: session.regions[0],
credentials: session.credentials,
+ followRegionRedirects: true,
});
const buckets = await gatherBuckets(s3, { encryption: true, publicAccess: false });
if (buckets.length === 0) return;
@@ -128,7 +164,7 @@ export const s3EncryptionCheck: IntegrationCheck = {
export const s3PublicAccessCheck: IntegrationCheck = {
id: 'aws-s3-public-access',
name: 'S3 — public access blocked',
- description: 'Verify all S3 buckets have S3 Block Public Access fully enabled.',
+ description: 'Verify all S3 buckets have S3 Block Public Access fully enabled (account or bucket level).',
service: 's3',
taskMapping: TASK_TEMPLATES.productionFirewallNopublicaccessControls,
run: async (ctx: CheckContext) => {
@@ -140,9 +176,38 @@ export const s3PublicAccessCheck: IntegrationCheck = {
const s3 = new S3Client({
region: session.regions[0],
credentials: session.credentials,
+ followRegionRedirects: true,
});
+
+ // Account-level Block Public Access applies to every bucket. Read it once;
+ // if denied/absent, fall back to bucket-level only (graceful).
+ let accountBpa: BpaFlags | null = null;
+ const accountId = accountIdFromCtx(ctx);
+ if (accountId) {
+ try {
+ const s3control = new S3ControlClient({
+ region: session.regions[0],
+ credentials: session.credentials,
+ });
+ const resp = await s3control.send(
+ new GetAccountPublicAccessBlockCommand({ AccountId: accountId }),
+ );
+ const c = resp.PublicAccessBlockConfiguration;
+ accountBpa = {
+ blockPublicAcls: Boolean(c?.BlockPublicAcls),
+ ignorePublicAcls: Boolean(c?.IgnorePublicAcls),
+ blockPublicPolicy: Boolean(c?.BlockPublicPolicy),
+ restrictPublicBuckets: Boolean(c?.RestrictPublicBuckets),
+ };
+ } catch (err) {
+ ctx.log(
+ `AWS S3: account-level Block Public Access unavailable (${err instanceof Error ? err.message : String(err)}); using bucket-level only`,
+ );
+ }
+ }
+
const buckets = await gatherBuckets(s3, { encryption: false, publicAccess: true });
if (buckets.length === 0) return;
- emitOutcomes(ctx, evaluateS3PublicAccess(buckets));
+ emitOutcomes(ctx, evaluateS3PublicAccess(buckets, accountBpa));
},
};
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 9bf9a5abaa..281bf6fc7f 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
@@ -86,6 +86,19 @@ describe('Azure storage checks', () => {
expect(ok.passed).toHaveLength(1);
});
+ it("public-access: publicNetworkAccess 'Disabled' overrides networkAcls Allow", async () => {
+ const { passed, failed } = await run(
+ storagePublicAccessCheck,
+ storageList({
+ allowBlobPublicAccess: false,
+ publicNetworkAccess: 'Disabled',
+ networkAcls: { defaultAction: 'Allow' },
+ }),
+ );
+ expect(failed).toHaveLength(0);
+ expect(passed).toHaveLength(1);
+ });
+
it('encryption fails when a service is disabled, passes when enabled', async () => {
const bad = await run(
storageEncryptionCheck,
@@ -104,9 +117,12 @@ describe('Azure storage checks', () => {
describe('Azure SQL checks', () => {
const server = { id: '/subscriptions/sub-1/srv1', name: 'srv1', properties: {} as Record };
- it('tls fails below 1.2, passes at 1.2', async () => {
+ it('tls fails below 1.2 and on None, passes at 1.2', async () => {
const bad = await run(sqlTlsCheck, () => ({ value: [{ ...server, properties: { minimalTlsVersion: '1.0' } }] }));
expect(bad.failed).toHaveLength(1);
+ // 'None' is lexically > '1.2' but means no TLS floor → must fail
+ const none = await run(sqlTlsCheck, () => ({ value: [{ ...server, properties: { minimalTlsVersion: 'None' } }] }));
+ expect(none.failed).toHaveLength(1);
const ok = await run(sqlTlsCheck, () => ({ value: [{ ...server, properties: { minimalTlsVersion: '1.2' } }] }));
expect(ok.passed).toHaveLength(1);
});
@@ -188,6 +204,20 @@ describe('Azure NSG check', () => {
);
expect(passed).toHaveLength(1);
});
+
+ it('flags IPv6 ::/0 source and port ranges covering sensitive ports', async () => {
+ const ipv6 = await run(
+ nsgNoOpenPortsCheck,
+ nsg({ name: 'r6', properties: { direction: 'Inbound', access: 'Allow', protocol: 'Tcp', sourceAddressPrefix: '::/0', destinationPortRange: '3389', priority: 100 } }),
+ );
+ expect(ipv6.failed.some((f) => f.severity === 'critical')).toBe(true);
+
+ const range = await run(
+ nsgNoOpenPortsCheck,
+ nsg({ name: 'rr', properties: { direction: 'Inbound', access: 'Allow', protocol: 'Tcp', sourceAddressPrefix: '*', destinationPortRange: '20-30', priority: 100 } }),
+ );
+ expect(range.failed.some((f) => f.title.match(/SSH/))).toBe(true);
+ });
});
describe('Azure RBAC (entra) check', () => {
@@ -205,6 +235,20 @@ describe('Azure RBAC (entra) check', () => {
expect(failed.some((f) => f.title.match(/Excessive privileged/))).toBe(true);
});
+ it('flags a custom role with wildcard dataActions', async () => {
+ const { failed } = await run(rbacLeastPrivilegeCheck, (url) => {
+ if (url.includes('roleDefinitions')) {
+ return {
+ value: [
+ { id: 'cr', properties: { roleName: 'Custom', type: 'CustomRole', permissions: [{ actions: [], dataActions: ['*'] }] } },
+ ],
+ };
+ }
+ return { value: [] };
+ });
+ expect(failed.some((f) => f.title.match(/[Ww]ildcard/))).toBe(true);
+ });
+
it('passes with few privileged, no wildcard roles', async () => {
const { passed } = await run(rbacLeastPrivilegeCheck, (url) => {
if (url.includes('roleDefinitions')) {
diff --git a/packages/integration-platform/src/manifests/azure/checks/entra-id.ts b/packages/integration-platform/src/manifests/azure/checks/entra-id.ts
index 3ea2366023..df2d65f890 100644
--- a/packages/integration-platform/src/manifests/azure/checks/entra-id.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/entra-id.ts
@@ -11,7 +11,7 @@ interface RoleDefinition {
properties: {
roleName: string;
type: string;
- permissions: Array<{ actions: string[] }>;
+ permissions: Array<{ actions: string[]; dataActions?: string[] }>;
};
}
@@ -19,10 +19,34 @@ const PRIVILEGED_ROLES = new Set([
'Owner',
'Contributor',
'User Access Administrator',
+ // Global Administrator / Privileged Role Administrator are Entra directory
+ // roles (not ARM); kept for completeness — they won't appear on this endpoint.
'Global Administrator',
'Privileged Role Administrator',
]);
+const isWildcardAction = (act: string) => act === '*' || act.endsWith('/*');
+
+/** High-privilege ARM actions that make a role privileged regardless of its name. */
+function actionIsHighPrivilege(act: string): boolean {
+ const a = act.toLowerCase();
+ return (
+ a === '*' ||
+ a === '*/write' ||
+ a === 'microsoft.authorization/*' ||
+ a === 'microsoft.authorization/roleassignments/write' ||
+ a === 'microsoft.authorization/roledefinitions/write'
+ );
+}
+
+/** A role is privileged if it is a known built-in privileged role OR its permissions grant high-privilege actions. */
+function defIsPrivileged(def: RoleDefinition): boolean {
+ if (PRIVILEGED_ROLES.has(def.properties.roleName)) return true;
+ return def.properties.permissions.some((perm) =>
+ (perm.actions ?? []).some(actionIsHighPrivilege),
+ );
+}
+
/**
* Subscription RBAC least-privilege (ARM role assignments, not Graph) →
* Role-based Access Controls. Flags excessive privileged assignments, wildcard
@@ -53,7 +77,7 @@ export const rbacLeastPrivilegeCheck: IntegrationCheck = {
const defMap = new Map(definitions.map((d) => [d.id, d]));
const privileged = assignments.filter((a) => {
const def = defMap.get(a.properties.roleDefinitionId);
- return def ? PRIVILEGED_ROLES.has(def.properties.roleName) : false;
+ return def ? defIsPrivileged(def) : false;
});
let violations = 0;
@@ -92,8 +116,10 @@ export const rbacLeastPrivilegeCheck: IntegrationCheck = {
const wildcardRoles = definitions.filter(
(d) =>
d.properties.type === 'CustomRole' &&
- d.properties.permissions.some((perm) =>
- perm.actions.some((act) => act === '*' || act.endsWith('/*')),
+ d.properties.permissions.some(
+ (perm) =>
+ (perm.actions ?? []).some(isWildcardAction) ||
+ (perm.dataActions ?? []).some(isWildcardAction),
),
);
for (const role of wildcardRoles) {
diff --git a/packages/integration-platform/src/manifests/azure/checks/key-vault.ts b/packages/integration-platform/src/manifests/azure/checks/key-vault.ts
index d0ada64058..89f2d91dbd 100644
--- a/packages/integration-platform/src/manifests/azure/checks/key-vault.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/key-vault.ts
@@ -44,8 +44,9 @@ export const keyVaultProtectionCheck: IntegrationCheck = {
}
if (!p.enablePurgeProtection) issues.push('purge protection disabled');
const isPublic =
- p.publicNetworkAccess === 'Enabled' ||
- p.networkAcls?.defaultAction === 'Allow';
+ p.publicNetworkAccess !== 'Disabled' &&
+ (p.publicNetworkAccess === 'Enabled' ||
+ p.networkAcls?.defaultAction === 'Allow');
if (isPublic) {
issues.push('public network access');
severity = 'high';
diff --git a/packages/integration-platform/src/manifests/azure/checks/monitor.ts b/packages/integration-platform/src/manifests/azure/checks/monitor.ts
index 703884c826..3125c4996a 100644
--- a/packages/integration-platform/src/manifests/azure/checks/monitor.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/monitor.ts
@@ -14,6 +14,7 @@ interface DiagnosticSetting {
workspaceId?: string;
storageAccountId?: string;
eventHubAuthorizationRuleId?: string;
+ logs?: Array<{ enabled?: boolean }>;
};
}
@@ -71,6 +72,20 @@ export const monitorLoggingAlertingCheck: IntegrationCheck = {
evidence: { recommended: RECOMMENDED_ALERTS.length },
});
}
+ } else {
+ // Alerts unreadable — fail rather than let the log-export half pass the
+ // shared Monitoring task on incomplete evaluation.
+ ctx.fail({
+ title: 'Could not read activity log alerts',
+ description:
+ 'Activity log alert coverage could not be read, so alerting was not verified.',
+ resourceType: 'azure-subscription',
+ resourceId: sub,
+ severity: 'medium',
+ remediation:
+ 'Grant Monitoring Reader (or Reader) so activity log alerts can be evaluated.',
+ evidence: {},
+ });
}
const diag = await ctx
@@ -83,9 +98,10 @@ export const monitorLoggingAlertingCheck: IntegrationCheck = {
const settings = diag.value ?? [];
const hasExport = settings.some(
(s) =>
- s.properties?.workspaceId ||
- s.properties?.storageAccountId ||
- s.properties?.eventHubAuthorizationRuleId,
+ (s.properties?.workspaceId ||
+ s.properties?.storageAccountId ||
+ s.properties?.eventHubAuthorizationRuleId) &&
+ (s.properties?.logs ?? []).some((l) => l.enabled),
);
if (hasExport) {
ctx.pass({
diff --git a/packages/integration-platform/src/manifests/azure/checks/network.ts b/packages/integration-platform/src/manifests/azure/checks/network.ts
index a59c3b8609..b38ff93c55 100644
--- a/packages/integration-platform/src/manifests/azure/checks/network.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/network.ts
@@ -22,8 +22,21 @@ interface Nsg {
properties: { securityRules?: SecurityRule[] };
}
-const DANGEROUS_DB_PORTS = new Set(['3306', '5432', '1433', '27017']);
-const WILDCARD_SOURCES = new Set(['*', '0.0.0.0/0', 'Internet', 'Any']);
+const DB_PORTS = [3306, 5432, 1433, 27017];
+const WILDCARD_SOURCES = new Set(['*', '0.0.0.0/0', '::/0', 'Internet', 'Any']);
+
+/** True if an NSG port token ('22', '20-30', '*') covers any of the target ports. */
+function portTokenCoversAny(token: string, targets: number[]): boolean {
+ if (token === '*') return true;
+ const [loStr, hiStr] = token.split('-');
+ const lo = Number(loStr);
+ const hi = hiStr === undefined ? lo : Number(hiStr);
+ if (Number.isNaN(lo) || Number.isNaN(hi)) return false;
+ return targets.some((t) => t >= lo && t <= hi);
+}
+function portsCoverAny(ports: string[], targets: number[]): boolean {
+ return ports.some((tok) => portTokenCoversAny(tok, targets));
+}
function ruleSources(r: SecurityRule): string[] {
if (r.properties.sourceAddressPrefixes?.length) {
@@ -69,13 +82,9 @@ export const nsgNoOpenPortsCheck: IntegrationCheck = {
const ports = rulePorts(rule);
const conditions: Array<{ when: boolean; label: string; severity: FindingSeverity }> = [
{ when: ports.includes('*'), label: 'all ports', severity: 'critical' },
- { when: ports.includes('3389'), label: 'RDP (3389)', severity: 'critical' },
- {
- when: ports.some((p) => DANGEROUS_DB_PORTS.has(p)),
- label: 'database ports',
- severity: 'critical',
- },
- { when: ports.includes('22'), label: 'SSH (22)', severity: 'high' },
+ { when: portsCoverAny(ports, [3389]), label: 'RDP (3389)', severity: 'critical' },
+ { when: portsCoverAny(ports, DB_PORTS), label: 'database ports', severity: 'critical' },
+ { when: portsCoverAny(ports, [22]), label: 'SSH (22)', severity: 'high' },
];
for (const c of conditions) {
if (c.when) {
diff --git a/packages/integration-platform/src/manifests/azure/checks/shared.ts b/packages/integration-platform/src/manifests/azure/checks/shared.ts
index 6c192a98e6..6838c241ab 100644
--- a/packages/integration-platform/src/manifests/azure/checks/shared.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/shared.ts
@@ -21,7 +21,11 @@ export async function resolveAzureSubscriptionId(
const subs = data.value ?? [];
const active = subs.find((s) => s.state === 'Enabled') ?? subs[0];
return active?.subscriptionId ?? null;
- } catch {
+ } catch (err) {
+ ctx.warn(
+ 'Failed to auto-detect Azure subscription; set subscription_id manually',
+ { error: err instanceof Error ? err.message : String(err) },
+ );
return null;
}
}
@@ -40,6 +44,12 @@ export async function armListAll(
nextUrl = data.nextLink;
pages++;
}
+ if (nextUrl) {
+ ctx.warn('Azure ARM list hit the page cap; results may be truncated', {
+ url,
+ pages,
+ });
+ }
return out;
}
diff --git a/packages/integration-platform/src/manifests/azure/checks/sql.ts b/packages/integration-platform/src/manifests/azure/checks/sql.ts
index 97c67cdc48..76e9dc8197 100644
--- a/packages/integration-platform/src/manifests/azure/checks/sql.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/sql.ts
@@ -39,7 +39,9 @@ export const sqlTlsCheck: IntegrationCheck = {
if (servers.length === 0) return;
for (const s of servers) {
const tls = s.properties?.minimalTlsVersion;
- if (!tls || tls < '1.2') {
+ // 'None' means no TLS floor is enforced (insecure). It is lexically > '1.2'
+ // so it must be handled explicitly, not via the `< '1.2'` comparison.
+ if (!tls || tls === 'None' || tls < '1.2') {
ctx.fail({
title: `Outdated TLS version: ${s.name}`,
description: `SQL Server "${s.name}" allows TLS versions below 1.2 (current: ${tls ?? 'unset'}).`,
@@ -87,33 +89,36 @@ export const sqlPublicAccessCheck: IntegrationCheck = {
};
}
+ // null = firewall read failed → do NOT treat as "no wide-open rules".
const rules = await armListAll(
ctx,
`${ARM_BASE}${s.id}/firewallRules?api-version=2023-05-01-preview`,
- ).catch(() => [] as SqlFirewallRule[]);
+ ).catch(() => null);
- const wideOpen = rules.find(
- (r) =>
- r.properties.startIpAddress === '0.0.0.0' &&
- r.properties.endIpAddress === '255.255.255.255',
- );
- const allowAllAzure = rules.find(
- (r) =>
- r.properties.startIpAddress === '0.0.0.0' &&
- r.properties.endIpAddress === '0.0.0.0',
- );
- if (wideOpen) {
- violation = {
- title: `SQL firewall wide open: ${s.name}`,
- severity: 'critical',
- detail: 'allows connections from any IP (0.0.0.0–255.255.255.255)',
- };
- } else if (!violation && allowAllAzure) {
- violation = {
- title: `SQL allows all Azure services: ${s.name}`,
- severity: 'medium',
- detail: 'has the "Allow Azure services" (0.0.0.0) rule',
- };
+ if (rules) {
+ const wideOpen = rules.find(
+ (r) =>
+ r.properties.startIpAddress === '0.0.0.0' &&
+ r.properties.endIpAddress === '255.255.255.255',
+ );
+ const allowAllAzure = rules.find(
+ (r) =>
+ r.properties.startIpAddress === '0.0.0.0' &&
+ r.properties.endIpAddress === '0.0.0.0',
+ );
+ if (wideOpen) {
+ violation = {
+ title: `SQL firewall wide open: ${s.name}`,
+ severity: 'critical',
+ detail: 'allows connections from any IP (0.0.0.0–255.255.255.255)',
+ };
+ } else if (!violation && allowAllAzure) {
+ violation = {
+ title: `SQL allows all Azure services: ${s.name}`,
+ severity: 'medium',
+ detail: 'has the "Allow Azure services" (0.0.0.0) rule',
+ };
+ }
}
if (violation) {
@@ -127,6 +132,10 @@ export const sqlPublicAccessCheck: IntegrationCheck = {
'Disable public network access and use private endpoints; remove 0.0.0.0 firewall rules.',
evidence: { server: s.name, publicNetworkAccess: s.properties?.publicNetworkAccess ?? null },
});
+ } else if (rules === null) {
+ // Public access not Enabled but firewall rules unreadable — can't assert
+ // a clean pass, so emit neither (the task simply isn't satisfied here).
+ continue;
} else {
ctx.pass({
title: `No public access: ${s.name}`,
diff --git a/packages/integration-platform/src/manifests/azure/checks/storage.ts b/packages/integration-platform/src/manifests/azure/checks/storage.ts
index 167d59a00f..a1f8a0153e 100644
--- a/packages/integration-platform/src/manifests/azure/checks/storage.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/storage.ts
@@ -94,9 +94,11 @@ export const storagePublicAccessCheck: IntegrationCheck = {
for (const a of accounts) {
const p = a.properties ?? {};
const publicBlob = p.allowBlobPublicAccess === true;
+ // publicNetworkAccess 'Disabled' overrides the firewall default action.
const publicNetwork =
- p.publicNetworkAccess === 'Enabled' ||
- p.networkAcls?.defaultAction === 'Allow';
+ p.publicNetworkAccess !== 'Disabled' &&
+ (p.publicNetworkAccess === 'Enabled' ||
+ p.networkAcls?.defaultAction === 'Allow');
if (publicBlob || publicNetwork) {
ctx.fail({
title: `Public access enabled: ${a.name}`,
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 2ab7814dcb..7d4004ca9a 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
@@ -174,6 +174,28 @@ describe('GCP VPC open-firewalls check', () => {
expect(failed).toHaveLength(1);
expect(failed[0]!.severity).toBe('high');
});
+
+ it('flags IPv6 ::/0 and sensitive ports across multiple tcp tuples', async () => {
+ const ipv6 = await runCheck(vpcOpenFirewallsCheck, {
+ fetch: () => ({
+ items: [{ name: 'v6', sourceRanges: ['::/0'], allowed: [{ IPProtocol: 'tcp', ports: ['3389'] }] }],
+ }),
+ });
+ expect(ipv6.failed[0]!.severity).toBe('critical');
+
+ const multi = await runCheck(vpcOpenFirewallsCheck, {
+ fetch: () => ({
+ items: [
+ {
+ name: 'm',
+ sourceRanges: ['0.0.0.0/0'],
+ allowed: [{ IPProtocol: 'tcp', ports: ['443'] }, { IPProtocol: 'tcp', ports: ['22'] }],
+ },
+ ],
+ }),
+ });
+ expect(multi.failed.some((f) => f.title.match(/SSH/))).toBe(true);
+ });
});
describe('GCP Cloud SQL checks', () => {
@@ -201,6 +223,18 @@ describe('GCP Cloud SQL checks', () => {
});
expect(bad.failed).toHaveLength(1);
});
+
+ it('backups: skips read replicas (not configurable on them)', async () => {
+ const out = await runCheck(cloudSqlBackupsCheck, {
+ fetch: () => ({
+ items: [
+ { name: 'replica', masterInstanceName: 'primary', settings: { backupConfiguration: { enabled: false } } },
+ ],
+ }),
+ });
+ expect(out.passed).toHaveLength(0);
+ expect(out.failed).toHaveLength(0);
+ });
});
describe('No projects resolved → check no-ops (no false pass)', () => {
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 df907cfc11..2ef0da0f56 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
@@ -1,10 +1,14 @@
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, IntegrationCheck } from '../../../types';
-import { resolveGcpProjectIds } from './shared';
+import { gcpListItems, resolveGcpProjectIds } from './shared';
interface SqlInstance {
name: string;
region?: string;
+ /** CLOUD_SQL_INSTANCE (primary) | READ_REPLICA_INSTANCE | ON_PREMISES_INSTANCE | ... */
+ instanceType?: string;
+ /** Set on read replicas — points at the primary. */
+ masterInstanceName?: string;
settings?: {
backupConfiguration?: { enabled?: boolean };
};
@@ -29,13 +33,21 @@ export const cloudSqlBackupsCheck: IntegrationCheck = {
}
for (const projectId of projectIds) {
- const data = await ctx.fetch<{ items?: SqlInstance[] }>(
+ const instances = await gcpListItems(
+ ctx,
`https://sqladmin.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/instances`,
);
- const instances = data.items ?? [];
if (instances.length === 0) continue;
for (const inst of instances) {
+ // Read replicas / on-prem instances can't configure their own backups
+ // (replicas are protected by the primary's backups) — don't fail them.
+ if (
+ inst.masterInstanceName ||
+ (inst.instanceType && inst.instanceType !== 'CLOUD_SQL_INSTANCE')
+ ) {
+ continue;
+ }
const enabled = inst.settings?.backupConfiguration?.enabled === true;
if (enabled) {
ctx.pass({
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 9aff6d92bf..1f130de4dd 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
@@ -1,6 +1,6 @@
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, IntegrationCheck } from '../../../types';
-import { resolveGcpProjectIds } from './shared';
+import { gcpListItems, resolveGcpProjectIds } from './shared';
interface SqlInstance {
name: string;
@@ -35,10 +35,10 @@ export const cloudSqlSslCheck: IntegrationCheck = {
}
for (const projectId of projectIds) {
- const data = await ctx.fetch<{ items?: SqlInstance[] }>(
+ const instances = await gcpListItems(
+ ctx,
`https://sqladmin.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/instances`,
);
- const instances = data.items ?? [];
if (instances.length === 0) continue;
for (const inst of instances) {
diff --git a/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts b/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
index 07251a2500..e95e44dcfc 100644
--- a/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
+++ b/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
@@ -60,11 +60,16 @@ export const iamPrimitiveRolesCheck: IntegrationCheck = {
if (violations === 0) {
ctx.pass({
- title: 'No primitive owner/editor roles',
- description: `Project "${projectId}" has no primitive role bindings.`,
+ title: 'No primitive owner/editor roles granted directly on the project',
+ description: `Project "${projectId}" has no primitive (owner/editor) role bindings set directly on the project. Note: bindings inherited from parent folders or the organization are not evaluated by this check.`,
resourceType: 'gcp-project',
resourceId: projectId,
- evidence: { projectId, bindingCount: bindings.length },
+ evidence: {
+ projectId,
+ bindingCount: bindings.length,
+ scope: 'direct-project-bindings-only',
+ inheritedBindingsEvaluated: false,
+ },
});
}
}
diff --git a/packages/integration-platform/src/manifests/gcp/checks/shared.ts b/packages/integration-platform/src/manifests/gcp/checks/shared.ts
index 2553cf0b19..8f84306eae 100644
--- a/packages/integration-platform/src/manifests/gcp/checks/shared.ts
+++ b/packages/integration-platform/src/manifests/gcp/checks/shared.ts
@@ -28,11 +28,42 @@ export async function resolveGcpProjectIds(ctx: CheckContext): Promise
`/v1/projects?filter=${encodeURIComponent(filter)}&pageSize=50`,
);
return (data.projects ?? []).map((p) => p.projectId).slice(0, 50);
- } catch {
+ } catch (err) {
+ ctx.warn('GCP project auto-discovery failed; checks will be skipped', {
+ error: err instanceof Error ? err.message : String(err),
+ });
return [];
}
}
+/**
+ * Page through a GCP list endpoint that returns `{ [itemsKey]: T[], nextPageToken? }`,
+ * following `nextPageToken` via the `pageToken` query param. Bounded to avoid
+ * runaway on very large projects.
+ */
+export async function gcpListItems(
+ ctx: CheckContext,
+ url: string,
+ itemsKey = 'items',
+): Promise {
+ const out: T[] = [];
+ let pageToken: string | undefined;
+ let pages = 0;
+ do {
+ const sep = url.includes('?') ? '&' : '?';
+ const pageUrl = pageToken
+ ? `${url}${sep}pageToken=${encodeURIComponent(pageToken)}`
+ : url;
+ const data = await ctx.fetch>(pageUrl);
+ const items = data[itemsKey];
+ if (Array.isArray(items)) out.push(...(items as T[]));
+ pageToken =
+ typeof data.nextPageToken === 'string' ? data.nextPageToken : undefined;
+ pages++;
+ } while (pageToken && pages < 50);
+ return out;
+}
+
/**
* True if a GCP firewall `ports` spec covers `target` (single port or "a-b"
* range). An empty/absent spec means "all ports".
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 a99abe9bb4..865c8092ec 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
@@ -1,6 +1,6 @@
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, IntegrationCheck } from '../../../types';
-import { resolveGcpProjectIds } from './shared';
+import { gcpListItems, resolveGcpProjectIds } from './shared';
interface Bucket {
name: string;
@@ -32,10 +32,10 @@ export const storagePublicAccessCheck: IntegrationCheck = {
}
for (const projectId of projectIds) {
- const data = await ctx.fetch<{ items?: Bucket[] }>(
+ const buckets = await gcpListItems(
+ ctx,
`https://storage.googleapis.com/storage/v1/b?project=${encodeURIComponent(projectId)}`,
);
- const buckets = data.items ?? [];
if (buckets.length === 0) continue; // nothing to evidence for this project
let violations = 0;
@@ -57,8 +57,8 @@ export const storagePublicAccessCheck: IntegrationCheck = {
if (iam?.publicAccessPrevention !== 'enforced') {
violations++;
ctx.fail({
- title: `Public access prevention not enforced: ${bucket.name}`,
- description: `Bucket "${bucket.name}" does not enforce public access prevention (current: ${iam?.publicAccessPrevention ?? 'inherited'}).`,
+ title: `Public access prevention not enforced at the bucket level: ${bucket.name}`,
+ description: `Bucket "${bucket.name}" does not set public access prevention to "enforced" at the bucket level (current: ${iam?.publicAccessPrevention ?? 'inherited'}). If an org policy enforces it, this inherits — verify the org policy or set it explicitly on the bucket.`,
resourceType: 'gcp-storage-bucket',
resourceId: bucket.name,
severity: 'medium',
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 f6a2548a98..5fa76c9322 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
@@ -1,6 +1,6 @@
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, FindingSeverity, IntegrationCheck } from '../../../types';
-import { portsCover, resolveGcpProjectIds } from './shared';
+import { gcpListItems, portsCover, resolveGcpProjectIds } from './shared';
interface FirewallRule {
name: string;
@@ -39,17 +39,18 @@ export const vpcOpenFirewallsCheck: IntegrationCheck = {
}
for (const projectId of projectIds) {
- const data = await ctx.fetch<{ items?: FirewallRule[] }>(
+ const rules = await gcpListItems(
+ ctx,
`https://compute.googleapis.com/compute/v1/projects/${encodeURIComponent(projectId)}/global/firewalls`,
);
- const rules = data.items ?? [];
if (rules.length === 0) continue;
let violations = 0;
for (const rule of rules) {
if (rule.disabled === true) continue;
if (rule.direction && rule.direction !== 'INGRESS') continue;
- if (!(rule.sourceRanges ?? []).includes('0.0.0.0/0')) continue;
+ const srcs = rule.sourceRanges ?? [];
+ if (!srcs.includes('0.0.0.0/0') && !srcs.includes('::/0')) continue;
const allowed = rule.allowed ?? [];
if (allowed.some((a) => a.IPProtocol === 'all')) {
@@ -67,11 +68,11 @@ export const vpcOpenFirewallsCheck: IntegrationCheck = {
continue;
}
- const tcp = allowed.find(
+ const tcpTuples = allowed.filter(
(a) => a.IPProtocol === 'tcp' || a.IPProtocol === '6',
);
for (const { port, label, severity } of SENSITIVE_PORTS) {
- if (tcp && portsCover(tcp.ports, port)) {
+ if (tcpTuples.some((t) => portsCover(t.ports, port))) {
violations++;
ctx.fail({
title: `${label} open to internet: ${rule.name}`,
From d1c63688278febad0f5ee8d98151ea3a8efe008f Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Mon, 1 Jun 2026 22:50:16 -0400
Subject: [PATCH 03/13] fix(integrations): address cubic 2nd-pass review (10
findings)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Verified all 10 follow-up findings and fixed them:
- AWS CloudTrail: require GetTrailStatus.IsLogging (a multi-region+validated
trail can be stopped → was a false pass)
- AWS S3 encryption: distinguish "no encryption configured" from read errors;
indeterminate buckets are excluded instead of failed
- Azure ARM pagination: validate nextLink stays on the ARM host before
following (don't send the bearer token to an unexpected host)
- GCP IAM: evaluate inherited folder/org bindings (ancestry walk); only emit a
pass when the full hierarchy was readable and clean
- GCP Cloud SQL SSL: sslMode takes precedence over legacy requireSsl
- Azure NSG: only flag SSH/RDP/DB on TCP/any-protocol rules
- AWS region parsing rejects blank strings; IAM no-policy detection broadened
- ServiceCard shows "Always scanned" (not "Scanning off") for baseline services
- ServiceDetailView validates the URL connectionId against the provider's
connections before using it
+ regression tests. 149 package tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../[slug]/components/ServiceCard.tsx | 13 +-
.../components/ServiceDetailView.tsx | 6 +-
.../aws/checks/__tests__/aws-checks.test.ts | 33 +++--
.../src/manifests/aws/checks/cloudtrail.ts | 47 +++++--
.../src/manifests/aws/checks/iam.ts | 5 +-
.../src/manifests/aws/checks/s3.ts | 22 +++-
.../src/manifests/aws/checks/shared.ts | 12 +-
.../src/manifests/azure/checks/network.ts | 10 +-
.../src/manifests/azure/checks/shared.ts | 8 ++
.../gcp/checks/__tests__/gcp-checks.test.ts | 34 +++++
.../src/manifests/gcp/checks/cloud-sql-ssl.ts | 6 +-
.../gcp/checks/iam-primitive-roles.ts | 124 +++++++++++++-----
12 files changed, 244 insertions(+), 76 deletions(-)
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.tsx
index c7c9847673..95a9abf9fc 100644
--- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.tsx
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.tsx
@@ -104,7 +104,16 @@ export function ServiceCard({ service, connectionId, orgId, slug }: ServiceCardP
const { services } = useConnectionServices(connectionId);
const isImplemented = service.implemented !== false;
const liveService = services.find((s) => s.id === service.id);
+ const inServiceList = Boolean(liveService);
const isEnabled = liveService?.enabled ?? false;
+ // Services not in the connection's toggle list (e.g. AWS baseline services)
+ // are always scanned — don't render them as "Scanning off".
+ const scanningOn = !inServiceList || isEnabled;
+ const scanningLabel = !inServiceList
+ ? 'Always scanned'
+ : isEnabled
+ ? 'Scanning on'
+ : 'Scanning off';
const taskCount = service.mappedTasks?.length ?? 0;
const href =
@@ -135,10 +144,10 @@ export function ServiceCard({ service, connectionId, orgId, slug }: ServiceCardP
- {isEnabled ? 'Scanning on' : 'Scanning off'}
+ {scanningLabel}
{taskCount > 0 && (
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx
index 24a39b661a..73c32fcda9 100644
--- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx
@@ -49,7 +49,11 @@ export function ServiceDetailView({
}: ServiceDetailViewProps) {
// Resolve the connection this service belongs to (URL param, else first active).
const effectiveConnectionId = useMemo(() => {
- if (connectionId) return connectionId;
+ // Only trust the URL connectionId if it actually belongs to this provider;
+ // otherwise fall back to the active connection (stale/invalid id guard).
+ if (connectionId && connections.some((c) => c.id === connectionId)) {
+ return connectionId;
+ }
const active = connections.find(
(c) => c.status === 'active' || c.status === 'pending',
);
diff --git a/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
index c3cd2ce9a0..25b7f0e159 100644
--- a/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
@@ -40,11 +40,14 @@ const ALL_BLOCKED = {
};
describe('AWS S3 evaluators', () => {
- it('encryption: pass when encrypted, fail (high) when not', () => {
+ it('encryption: pass when encrypted, fail (high) when not, skip indeterminate', () => {
const out = evaluateS3Encryption([
- { name: 'a', encrypted: true, bucketBpa: null },
- { name: 'b', encrypted: false, bucketBpa: null },
+ { name: 'a', encrypted: true, encryptionDetermined: true, bucketBpa: null },
+ { name: 'b', encrypted: false, encryptionDetermined: true, bucketBpa: null },
+ // read error → indeterminate → excluded (no false high finding)
+ { name: 'c', encrypted: false, encryptionDetermined: false, bucketBpa: null },
]);
+ expect(out).toHaveLength(2);
expect(out[0]!.kind).toBe('pass');
expect(out[1]!.kind).toBe('fail');
expect(out[1]!.severity).toBe('high');
@@ -53,8 +56,8 @@ describe('AWS S3 evaluators', () => {
it('public access: bucket-level all-blocked passes, missing fails', () => {
const out = evaluateS3PublicAccess(
[
- { name: 'a', encrypted: false, bucketBpa: ALL_BLOCKED },
- { name: 'b', encrypted: false, bucketBpa: null },
+ { name: 'a', encrypted: false, encryptionDetermined: true, bucketBpa: ALL_BLOCKED },
+ { name: 'b', encrypted: false, encryptionDetermined: true, bucketBpa: null },
],
null,
);
@@ -63,7 +66,7 @@ describe('AWS S3 evaluators', () => {
it('public access: account-level BPA covers buckets lacking bucket config', () => {
const out = evaluateS3PublicAccess(
- [{ name: 'b', encrypted: false, bucketBpa: null }],
+ [{ name: 'b', encrypted: false, encryptionDetermined: true, bucketBpa: null }],
ALL_BLOCKED,
);
expect(out[0]!.kind).toBe('pass');
@@ -155,11 +158,21 @@ describe('AWS KMS rotation evaluator', () => {
});
describe('AWS CloudTrail evaluator', () => {
- it('passes when a multi-region trail with log validation exists', () => {
- const out = evaluateCloudTrail([{ name: 't1', multiRegion: true, logValidation: true }]);
+ it('passes when a multi-region trail with validation is actively logging', () => {
+ const out = evaluateCloudTrail([
+ { name: 't1', multiRegion: true, logValidation: true, logging: true },
+ ]);
expect(out[0]!.kind).toBe('pass');
});
+ it('fails (medium) when an otherwise-compliant trail is not logging', () => {
+ const out = evaluateCloudTrail([
+ { name: 't1', multiRegion: true, logValidation: true, logging: false },
+ ]);
+ expect(out[0]!.kind).toBe('fail');
+ expect(out[0]!.severity).toBe('medium');
+ });
+
it('fails (high) when no trails exist', () => {
const out = evaluateCloudTrail([]);
expect(out[0]!.kind).toBe('fail');
@@ -167,7 +180,9 @@ describe('AWS CloudTrail evaluator', () => {
});
it('fails (medium) when a trail exists but is not multi-region + validated', () => {
- const out = evaluateCloudTrail([{ name: 't1', multiRegion: false, logValidation: true }]);
+ const out = evaluateCloudTrail([
+ { name: 't1', multiRegion: false, logValidation: true, logging: true },
+ ]);
expect(out[0]!.kind).toBe('fail');
expect(out[0]!.severity).toBe('medium');
});
diff --git a/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts b/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
index 944e78623f..d0a8f62d58 100644
--- a/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
@@ -1,4 +1,8 @@
-import { CloudTrailClient, DescribeTrailsCommand } from '@aws-sdk/client-cloudtrail';
+import {
+ CloudTrailClient,
+ DescribeTrailsCommand,
+ GetTrailStatusCommand,
+} from '@aws-sdk/client-cloudtrail';
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, IntegrationCheck } from '../../../types';
import { assumeAwsSession, type CheckOutcome, emitOutcomes } from './shared';
@@ -7,16 +11,18 @@ export interface TrailInfo {
name: string;
multiRegion: boolean;
logValidation: boolean;
+ /** GetTrailStatus.IsLogging — a trail can be configured but stopped. */
+ logging: boolean;
}
export function evaluateCloudTrail(trails: TrailInfo[]): CheckOutcome[] {
- const good = trails.find((t) => t.multiRegion && t.logValidation);
+ const good = trails.find((t) => t.multiRegion && t.logValidation && t.logging);
if (good) {
return [
{
kind: 'pass',
- title: 'Multi-region CloudTrail with log validation',
- description: `Trail "${good.name}" is multi-region with log file validation enabled.`,
+ title: 'Multi-region CloudTrail logging with validation',
+ description: `Trail "${good.name}" is multi-region, actively logging, with log file validation enabled.`,
resourceType: 'aws-cloudtrail',
resourceId: good.name,
evidence: { trail: good.name },
@@ -41,12 +47,12 @@ export function evaluateCloudTrail(trails: TrailInfo[]): CheckOutcome[] {
kind: 'fail',
title: 'No compliant CloudTrail trail',
description:
- 'No trail is both multi-region and has log file validation enabled.',
+ 'No trail is multi-region, actively logging, AND has log file validation enabled.',
resourceType: 'aws-cloudtrail',
resourceId: 'account',
severity: 'medium',
remediation:
- 'Enable multi-region coverage and log file validation on a CloudTrail trail.',
+ 'Ensure a CloudTrail trail is multi-region, logging is started, and log file validation is enabled.',
evidence: { trails: trails.map((t) => t.name) },
},
];
@@ -54,9 +60,9 @@ export function evaluateCloudTrail(trails: TrailInfo[]): CheckOutcome[] {
export const cloudTrailEnabledCheck: IntegrationCheck = {
id: 'aws-cloudtrail-enabled',
- name: 'CloudTrail — multi-region trail with log validation',
+ name: 'CloudTrail — multi-region trail logging with validation',
description:
- 'Verify a multi-region CloudTrail trail with log file validation is configured.',
+ 'Verify a multi-region CloudTrail trail is actively logging with log file validation.',
service: 'cloudtrail',
taskMapping: TASK_TEMPLATES.monitoringAlerting,
run: async (ctx: CheckContext) => {
@@ -70,11 +76,26 @@ export const cloudTrailEnabledCheck: IntegrationCheck = {
credentials: session.credentials,
});
const resp = await ct.send(new DescribeTrailsCommand({}));
- const trails: TrailInfo[] = (resp.trailList ?? []).map((t) => ({
- name: t.Name ?? 'unknown',
- multiRegion: t.IsMultiRegionTrail === true,
- logValidation: t.LogFileValidationEnabled === true,
- }));
+ const trailList = resp.trailList ?? [];
+
+ const trails: TrailInfo[] = [];
+ for (const t of trailList) {
+ const multiRegion = t.IsMultiRegionTrail === true;
+ const logValidation = t.LogFileValidationEnabled === true;
+ let logging = false;
+ // Logging status only matters for otherwise-compliant trails.
+ if (multiRegion && logValidation && t.TrailARN) {
+ try {
+ const status = await ct.send(new GetTrailStatusCommand({ Name: t.TrailARN }));
+ logging = status.IsLogging === true;
+ } catch (err) {
+ ctx.log(
+ `CloudTrail: could not read logging status for ${t.Name ?? t.TrailARN}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ }
+ trails.push({ name: t.Name ?? 'unknown', multiRegion, logValidation, logging });
+ }
emitOutcomes(ctx, evaluateCloudTrail(trails));
},
};
diff --git a/packages/integration-platform/src/manifests/aws/checks/iam.ts b/packages/integration-platform/src/manifests/aws/checks/iam.ts
index 2e8d58019a..e417c381b8 100644
--- a/packages/integration-platform/src/manifests/aws/checks/iam.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/iam.ts
@@ -134,8 +134,9 @@ export const iamAccountSecurityCheck: IntegrationCheck = {
const pp = await iam.send(new GetAccountPasswordPolicyCommand({}));
passwordPolicy = pp.PasswordPolicy ?? null;
} catch (err) {
- // NoSuchEntity = no policy set → treat as null (a finding). Re-throw others.
- if (!(err instanceof Error && err.name === 'NoSuchEntityException')) throw err;
+ // No password policy set surfaces as NoSuchEntity(Exception); treat as
+ // null (a finding). Anything else (e.g. AccessDenied) propagates.
+ if (!(err instanceof Error && /NoSuchEntity/i.test(err.name))) throw err;
}
const summaryResp = await iam.send(new GetAccountSummaryCommand({}));
diff --git a/packages/integration-platform/src/manifests/aws/checks/s3.ts b/packages/integration-platform/src/manifests/aws/checks/s3.ts
index ee03a03814..13f21bf485 100644
--- a/packages/integration-platform/src/manifests/aws/checks/s3.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/s3.ts
@@ -22,6 +22,8 @@ export interface BpaFlags {
export interface S3BucketInfo {
name: string;
encrypted: boolean;
+ /** false when encryption status couldn't be read (error) → excluded from eval */
+ encryptionDetermined: boolean;
/** bucket-level Block Public Access flags, or null when none configured */
bucketBpa: BpaFlags | null;
}
@@ -39,7 +41,9 @@ function isFullyBlocked(bucket: BpaFlags | null, account: BpaFlags | null): bool
}
export function evaluateS3Encryption(buckets: S3BucketInfo[]): CheckOutcome[] {
- return buckets.map((b) =>
+ return buckets
+ .filter((b) => b.encryptionDetermined)
+ .map((b) =>
b.encrypted
? {
kind: 'pass',
@@ -101,14 +105,24 @@ async function gatherBuckets(
const infos: S3BucketInfo[] = [];
for (const name of names) {
let encrypted = false;
+ let encryptionDetermined = true;
let bucketBpa: BpaFlags | null = null;
if (opts.encryption) {
try {
const enc = await s3.send(new GetBucketEncryptionCommand({ Bucket: name }));
encrypted = (enc.ServerSideEncryptionConfiguration?.Rules?.length ?? 0) > 0;
- } catch {
- encrypted = false;
+ } catch (err) {
+ // "no encryption configured" is a genuine finding; any other error
+ // (permissions/transient) is indeterminate → exclude from evaluation.
+ if (
+ err instanceof Error &&
+ /ServerSideEncryptionConfigurationNotFound/i.test(err.name)
+ ) {
+ encrypted = false;
+ } else {
+ encryptionDetermined = false;
+ }
}
}
if (opts.publicAccess) {
@@ -125,7 +139,7 @@ async function gatherBuckets(
bucketBpa = null; // no bucket-level config
}
}
- infos.push({ name, encrypted, bucketBpa });
+ infos.push({ name, encrypted, encryptionDetermined, bucketBpa });
}
return infos;
}
diff --git a/packages/integration-platform/src/manifests/aws/checks/shared.ts b/packages/integration-platform/src/manifests/aws/checks/shared.ts
index 504d21ffcb..8baa844773 100644
--- a/packages/integration-platform/src/manifests/aws/checks/shared.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/shared.ts
@@ -22,11 +22,13 @@ export async function assumeAwsSession(
const raw = ctx.credentials as Record;
const roleArn = typeof raw.roleArn === 'string' ? raw.roleArn : '';
const externalId = typeof raw.externalId === 'string' ? raw.externalId : '';
- const regions = Array.isArray(raw.regions)
- ? raw.regions.filter((r): r is string => typeof r === 'string')
- : typeof raw.region === 'string'
- ? [raw.region]
- : [];
+ const regions = (
+ Array.isArray(raw.regions)
+ ? raw.regions.filter((r): r is string => typeof r === 'string')
+ : typeof raw.region === 'string'
+ ? [raw.region]
+ : []
+ ).filter((r) => r.trim().length > 0);
if (!roleArn || !externalId || regions.length === 0) return null;
diff --git a/packages/integration-platform/src/manifests/azure/checks/network.ts b/packages/integration-platform/src/manifests/azure/checks/network.ts
index b38ff93c55..3149b27d84 100644
--- a/packages/integration-platform/src/manifests/azure/checks/network.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/network.ts
@@ -80,11 +80,15 @@ export const nsgNoOpenPortsCheck: IntegrationCheck = {
for (const rule of inbound) {
if (!ruleSources(rule).some((s) => WILDCARD_SOURCES.has(s))) continue;
const ports = rulePorts(rule);
+ // SSH/RDP/DB are TCP services — only flag them on TCP or any-protocol
+ // rules. "All ports" exposure applies to any protocol.
+ const proto = (rule.properties.protocol ?? '*').toLowerCase();
+ const tcpish = proto === '*' || proto === 'tcp';
const conditions: Array<{ when: boolean; label: string; severity: FindingSeverity }> = [
{ when: ports.includes('*'), label: 'all ports', severity: 'critical' },
- { when: portsCoverAny(ports, [3389]), label: 'RDP (3389)', severity: 'critical' },
- { when: portsCoverAny(ports, DB_PORTS), label: 'database ports', severity: 'critical' },
- { when: portsCoverAny(ports, [22]), label: 'SSH (22)', severity: 'high' },
+ { when: tcpish && portsCoverAny(ports, [3389]), label: 'RDP (3389)', severity: 'critical' },
+ { when: tcpish && portsCoverAny(ports, DB_PORTS), label: 'database ports', severity: 'critical' },
+ { when: tcpish && portsCoverAny(ports, [22]), label: 'SSH (22)', severity: 'high' },
];
for (const c of conditions) {
if (c.when) {
diff --git a/packages/integration-platform/src/manifests/azure/checks/shared.ts b/packages/integration-platform/src/manifests/azure/checks/shared.ts
index 6838c241ab..2ba6b7e467 100644
--- a/packages/integration-platform/src/manifests/azure/checks/shared.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/shared.ts
@@ -42,6 +42,14 @@ export async function armListAll(
const data: { value?: T[]; nextLink?: string } = await ctx.fetch(nextUrl);
if (Array.isArray(data.value)) out.push(...data.value);
nextUrl = data.nextLink;
+ // nextLink is an absolute URL from the API; only follow it if it stays on
+ // the ARM host, so the injected bearer token can't be sent elsewhere.
+ if (nextUrl && !nextUrl.startsWith(`${ARM}/`)) {
+ ctx.warn('Azure ARM nextLink pointed to an unexpected host; stopping pagination', {
+ nextLink: nextUrl,
+ });
+ nextUrl = undefined;
+ }
pages++;
}
if (nextUrl) {
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 7d4004ca9a..ca19a9051f 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
@@ -87,6 +87,40 @@ describe('GCP IAM primitive roles check', () => {
expect(failed).toHaveLength(0);
expect(passed).toHaveLength(1);
});
+
+ it('flags inherited primitive roles from an ancestor organization', async () => {
+ const { failed } = await runCheck(iamPrimitiveRolesCheck, {
+ post: (url) => {
+ if (url.includes(':getAncestry')) {
+ return {
+ ancestor: [
+ { resourceId: { type: 'project', id: 'proj-1' } },
+ { resourceId: { type: 'organization', id: '12345' } },
+ ],
+ };
+ }
+ if (url.includes('/organizations/12345:getIamPolicy')) {
+ return { bindings: [{ role: 'roles/owner', members: ['user:a@x.com'] }] };
+ }
+ return { bindings: [{ role: 'roles/viewer', members: ['user:b@x.com'] }] };
+ },
+ });
+ expect(failed.some((f) => f.title.match(/Primitive role/))).toBe(true);
+ });
+
+ it('does not pass when inherited bindings are unreadable', async () => {
+ const { passed, failed } = await runCheck(iamPrimitiveRolesCheck, {
+ post: (url) => {
+ if (url.includes(':getAncestry')) {
+ return { ancestor: [{ resourceId: { type: 'folder', id: 'f1' } }] };
+ }
+ if (url.includes('/folders/f1:getIamPolicy')) throw new Error('403');
+ return { bindings: [] }; // project clean
+ },
+ });
+ expect(passed).toHaveLength(0);
+ expect(failed).toHaveLength(0);
+ });
});
describe('GCP Cloud Storage public-access check', () => {
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 1f130de4dd..8463b0b56f 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
@@ -43,9 +43,11 @@ export const cloudSqlSslCheck: IntegrationCheck = {
for (const inst of instances) {
const ip = inst.settings?.ipConfiguration;
+ // sslMode is authoritative when present; fall back to legacy requireSsl.
const sslEnforced =
- ip?.requireSsl === true ||
- (typeof ip?.sslMode === 'string' && SECURE_SSL_MODES.has(ip.sslMode));
+ typeof ip?.sslMode === 'string'
+ ? SECURE_SSL_MODES.has(ip.sslMode)
+ : ip?.requireSsl === true;
if (sslEnforced) {
ctx.pass({
diff --git a/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts b/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
index e95e44dcfc..7266eb0e72 100644
--- a/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
+++ b/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
@@ -2,7 +2,7 @@ import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, FindingSeverity, IntegrationCheck } from '../../../types';
import { resolveGcpProjectIds } from './shared';
-/** Primitive roles grant broad, non-least-privilege access at the project level. */
+/** Primitive roles grant broad, non-least-privilege access. */
const PRIMITIVE_ROLES: Record = {
'roles/owner': 'high',
'roles/editor': 'medium',
@@ -13,16 +13,34 @@ interface IamBinding {
members?: string[];
}
+/** Read primitive-role IAM bindings for a resource, or null if it couldn't be read. */
+async function getBindings(
+ ctx: CheckContext,
+ resourcePath: string,
+): Promise {
+ try {
+ const policy = await ctx.post<{ bindings?: IamBinding[] }>(
+ `/${resourcePath}:getIamPolicy`,
+ { options: { requestedPolicyVersion: 3 } },
+ );
+ return policy.bindings ?? [];
+ } catch {
+ return null;
+ }
+}
+
/**
- * IAM least-privilege check (direct API, no SCC). Reads the project IAM policy
- * via Cloud Resource Manager v3 getIamPolicy and flags primitive role bindings
- * (roles/owner, roles/editor) — the GCP analog of over-privileged access.
+ * IAM least-privilege check (direct API, no SCC). Evaluates primitive role
+ * bindings (roles/owner, roles/editor) on the project AND its inherited
+ * folders/organization (effective access). A pass is emitted only when the
+ * full hierarchy was readable and clean, so the RBAC task isn't satisfied on
+ * incomplete data.
*/
export const iamPrimitiveRolesCheck: IntegrationCheck = {
id: 'gcp-iam-no-primitive-roles',
name: 'IAM — no primitive owner/editor roles',
description:
- 'Flags primitive role bindings (roles/owner, roles/editor) on GCP projects, which violate least privilege.',
+ 'Flags primitive role bindings (roles/owner, roles/editor) on GCP projects and their inherited folders/organization.',
service: 'iam',
taskMapping: TASK_TEMPLATES.rolebasedAccessControls,
@@ -34,44 +52,80 @@ export const iamPrimitiveRolesCheck: IntegrationCheck = {
}
for (const projectId of projectIds) {
- const policy = await ctx.post<{ bindings?: IamBinding[] }>(
- `/v3/projects/${encodeURIComponent(projectId)}:getIamPolicy`,
- { options: { requestedPolicyVersion: 3 } },
+ const projectBindings = await getBindings(
+ ctx,
+ `v3/projects/${encodeURIComponent(projectId)}`,
);
- const bindings = policy.bindings ?? [];
+ if (projectBindings === null) continue; // can't read project policy → no assertion
+
+ // Resolve the ancestry (folders/org) so inherited bindings are evaluated.
+ let hierarchyFullyEvaluated = true;
+ const scopes: Array<{ label: string; bindings: IamBinding[] }> = [
+ { label: `Project "${projectId}"`, bindings: projectBindings },
+ ];
+ try {
+ const anc = await ctx.post<{
+ ancestor?: Array<{ resourceId?: { type?: string; id?: string } }>;
+ }>(`/v1/projects/${encodeURIComponent(projectId)}:getAncestry`, {});
+ for (const a of anc.ancestor ?? []) {
+ const type = a.resourceId?.type;
+ const id = a.resourceId?.id;
+ if (!id || type === 'project') continue;
+ const resource =
+ type === 'organization'
+ ? `v3/organizations/${id}`
+ : type === 'folder'
+ ? `v3/folders/${id}`
+ : null;
+ if (!resource) continue;
+ const bindings = await getBindings(ctx, resource);
+ if (bindings === null) {
+ hierarchyFullyEvaluated = false; // couldn't read this ancestor
+ continue;
+ }
+ scopes.push({ label: `${type} ${id}`, bindings });
+ }
+ } catch {
+ hierarchyFullyEvaluated = false;
+ }
+
let violations = 0;
+ for (const scope of scopes) {
+ for (const binding of scope.bindings) {
+ const severity = PRIMITIVE_ROLES[binding.role];
+ const members = binding.members ?? [];
+ if (severity && members.length > 0) {
+ violations++;
+ ctx.fail({
+ title: `Primitive role in use: ${binding.role}`,
+ description: `${scope.label} grants the primitive role "${binding.role}" to ${members.length} member(s). Primitive roles violate least privilege.`,
+ resourceType: 'gcp-project',
+ resourceId: projectId,
+ severity,
+ remediation: `Replace "${binding.role}" bindings with least-privilege predefined or custom roles.`,
+ evidence: { projectId, scope: scope.label, role: binding.role, memberCount: members.length },
+ });
+ }
+ }
+ }
- for (const binding of bindings) {
- const severity = PRIMITIVE_ROLES[binding.role];
- const members = binding.members ?? [];
- if (severity && members.length > 0) {
- violations++;
- ctx.fail({
- title: `Primitive role in use: ${binding.role}`,
- description: `Project "${projectId}" grants the primitive role "${binding.role}" to ${members.length} member(s).`,
+ if (violations === 0) {
+ if (hierarchyFullyEvaluated) {
+ ctx.pass({
+ title: 'No primitive owner/editor roles (project + inherited)',
+ description: `Project "${projectId}" and its inherited folders/organization have no primitive (owner/editor) role bindings.`,
resourceType: 'gcp-project',
resourceId: projectId,
- severity,
- remediation: `Replace "${binding.role}" bindings with least-privilege predefined or custom roles.`,
- evidence: { projectId, role: binding.role, memberCount: members.length },
+ evidence: { projectId, scope: 'project+inherited', inheritedBindingsEvaluated: true },
});
+ } else {
+ // Inherited bindings couldn't be fully read — don't satisfy the task
+ // on incomplete data.
+ ctx.log(
+ `GCP IAM: inherited bindings for "${projectId}" not fully readable; not asserting a pass`,
+ );
}
}
-
- if (violations === 0) {
- ctx.pass({
- title: 'No primitive owner/editor roles granted directly on the project',
- description: `Project "${projectId}" has no primitive (owner/editor) role bindings set directly on the project. Note: bindings inherited from parent folders or the organization are not evaluated by this check.`,
- resourceType: 'gcp-project',
- resourceId: projectId,
- evidence: {
- projectId,
- bindingCount: bindings.length,
- scope: 'direct-project-bindings-only',
- inheritedBindingsEvaluated: false,
- },
- });
- }
}
},
};
From 220982bc7c734824b544a3fd2b9f1728cc39021d Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Tue, 2 Jun 2026 07:38:41 -0400
Subject: [PATCH 04/13] =?UTF-8?q?fix(integrations):=20cubic=203rd-pass=20?=
=?UTF-8?q?=E2=80=94=20scan=20continuity,=20Aurora=20backups,=20IPv6=20wor?=
=?UTF-8?q?ding?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- AWS KMS: wrap DescribeKey in try/catch so one unreadable key is skipped
instead of aborting the entire rotation scan
- AWS RDS: skip Aurora instances in the backups check — Aurora backups are
cluster-level and the instance BackupRetentionPeriod is unreliable, so
coercing it to 0 was a false "backups disabled" finding
- GCP VPC: failure description + remediation now reference the actual open
public range(s) (0.0.0.0/0 and/or ::/0) instead of hardcoded IPv4
+ regression tests (Aurora skip). 149 package tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../aws/checks/__tests__/aws-checks.test.ts | 13 +++++++------
.../src/manifests/aws/checks/kms.ts | 11 ++++++++++-
.../src/manifests/aws/checks/rds.ts | 9 ++++++++-
.../manifests/gcp/checks/vpc-open-firewalls.ts | 17 +++++++++--------
4 files changed, 34 insertions(+), 16 deletions(-)
diff --git a/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
index 25b7f0e159..9f1557eec2 100644
--- a/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
@@ -125,19 +125,20 @@ describe('AWS EC2 security-group evaluator', () => {
describe('AWS RDS evaluators', () => {
it('encryption: pass when encrypted, fail (high) when not', () => {
const out = evaluateRdsEncryption([
- { id: 'db1', region: 'us-east-1', encrypted: true, backupRetentionDays: 7 },
- { id: 'db2', region: 'us-east-1', encrypted: false, backupRetentionDays: 7 },
+ { id: 'db1', region: 'us-east-1', encrypted: true, backupRetentionDays: 7, engine: 'postgres' },
+ { id: 'db2', region: 'us-east-1', encrypted: false, backupRetentionDays: 7, engine: 'postgres' },
]);
expect(out[0]!.kind).toBe('pass');
expect(out[1]!.severity).toBe('high');
});
- it('backups: pass when retention > 0, fail when 0', () => {
+ it('backups: pass when retention > 0, fail when 0, skip Aurora (cluster-level)', () => {
const out = evaluateRdsBackups([
- { id: 'db1', region: 'us-east-1', encrypted: true, backupRetentionDays: 7 },
- { id: 'db2', region: 'us-east-1', encrypted: true, backupRetentionDays: 0 },
+ { id: 'db1', region: 'us-east-1', encrypted: true, backupRetentionDays: 7, engine: 'postgres' },
+ { id: 'db2', region: 'us-east-1', encrypted: true, backupRetentionDays: 0, engine: 'mysql' },
+ { id: 'aur', region: 'us-east-1', encrypted: true, backupRetentionDays: 0, engine: 'aurora-mysql' },
]);
- expect(kinds(out)).toEqual(['pass', 'fail']);
+ expect(kinds(out)).toEqual(['pass', 'fail']); // aurora excluded, not failed
});
});
diff --git a/packages/integration-platform/src/manifests/aws/checks/kms.ts b/packages/integration-platform/src/manifests/aws/checks/kms.ts
index 5caffd5035..c83883ca79 100644
--- a/packages/integration-platform/src/manifests/aws/checks/kms.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/kms.ts
@@ -67,7 +67,16 @@ async function listKmsKeys(
for (const k of resp.Keys ?? []) {
const keyId = k.KeyId;
if (!keyId) continue;
- const meta = (await kms.send(new DescribeKeyCommand({ KeyId: keyId }))).KeyMetadata;
+ let meta;
+ try {
+ meta = (await kms.send(new DescribeKeyCommand({ KeyId: keyId }))).KeyMetadata;
+ } catch (err) {
+ // Skip this key rather than aborting the whole scan.
+ ctx.log(
+ `KMS: could not describe key ${keyId} in ${region}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ continue;
+ }
// Only symmetric, enabled, AWS-managed-material, encrypt/decrypt
// customer keys can have automatic rotation.
const rotationEligible =
diff --git a/packages/integration-platform/src/manifests/aws/checks/rds.ts b/packages/integration-platform/src/manifests/aws/checks/rds.ts
index 6e4e3080fa..804a4a018e 100644
--- a/packages/integration-platform/src/manifests/aws/checks/rds.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/rds.ts
@@ -13,6 +13,8 @@ export interface RdsInstanceInfo {
region: string;
encrypted: boolean;
backupRetentionDays: number;
+ /** e.g. 'postgres', 'mysql', 'aurora-mysql' — Aurora backups are cluster-level */
+ engine: string;
}
export function evaluateRdsEncryption(instances: RdsInstanceInfo[]): CheckOutcome[] {
@@ -41,7 +43,11 @@ export function evaluateRdsEncryption(instances: RdsInstanceInfo[]): CheckOutcom
}
export function evaluateRdsBackups(instances: RdsInstanceInfo[]): CheckOutcome[] {
- return instances.map((i) =>
+ return instances
+ // Aurora backups are managed at the cluster level; the instance-level
+ // BackupRetentionPeriod is unreliable, so don't fail Aurora instances here.
+ .filter((i) => !i.engine.toLowerCase().startsWith('aurora'))
+ .map((i) =>
i.backupRetentionDays > 0
? {
kind: 'pass',
@@ -77,6 +83,7 @@ async function listRdsInstances(session: AwsSession): Promise
region,
encrypted: db.StorageEncrypted === true,
backupRetentionDays: db.BackupRetentionPeriod ?? 0,
+ engine: db.Engine ?? '',
});
}
marker = resp.Marker;
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 5fa76c9322..072c245f09 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
@@ -50,20 +50,21 @@ export const vpcOpenFirewallsCheck: IntegrationCheck = {
if (rule.disabled === true) continue;
if (rule.direction && rule.direction !== 'INGRESS') continue;
const srcs = rule.sourceRanges ?? [];
- if (!srcs.includes('0.0.0.0/0') && !srcs.includes('::/0')) continue;
+ const openRanges = srcs.filter((r) => r === '0.0.0.0/0' || r === '::/0');
+ if (openRanges.length === 0) continue;
+ const openLabel = openRanges.join(' / ');
const allowed = rule.allowed ?? [];
if (allowed.some((a) => a.IPProtocol === 'all')) {
violations++;
ctx.fail({
title: `Firewall open to internet (all ports): ${rule.name}`,
- description: `Firewall rule "${rule.name}" allows ALL protocols/ports from 0.0.0.0/0.`,
+ description: `Firewall rule "${rule.name}" allows ALL protocols/ports from ${openLabel}.`,
resourceType: 'gcp-firewall-rule',
resourceId: rule.name,
severity: 'critical',
- remediation:
- 'Restrict source ranges to known CIDRs and limit allowed protocols/ports to only what is required.',
- evidence: { projectId, rule: rule.name },
+ remediation: `Remove the public source range(s) (${openLabel}); restrict source ranges to known CIDRs and limit allowed protocols/ports to only what is required.`,
+ evidence: { projectId, rule: rule.name, openRanges },
});
continue;
}
@@ -76,12 +77,12 @@ export const vpcOpenFirewallsCheck: IntegrationCheck = {
violations++;
ctx.fail({
title: `${label} open to internet: ${rule.name}`,
- description: `Firewall rule "${rule.name}" allows ${label} (port ${port}) from 0.0.0.0/0.`,
+ description: `Firewall rule "${rule.name}" allows ${label} (port ${port}) from ${openLabel}.`,
resourceType: 'gcp-firewall-rule',
resourceId: rule.name,
severity,
- remediation: `Remove the 0.0.0.0/0 source for port ${port}; restrict ${label} access to a VPN, bastion, or known CIDR ranges.`,
- evidence: { projectId, rule: rule.name, port },
+ remediation: `Remove the public source range(s) (${openLabel}) for port ${port}; restrict ${label} access to a VPN, bastion, or known CIDR ranges.`,
+ evidence: { projectId, rule: rule.name, port, openRanges },
});
}
}
From a467ff9edf878de396bdd2e3a9182690b288f20d Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Tue, 2 Jun 2026 08:52:00 -0400
Subject: [PATCH 05/13] fix(integration-platform): address cubic round-4 review
(27 findings)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Backend checks — apply cross-cutting correctness patterns uniformly:
- error-reads-never-silent-pass: Azure monitor/sql now FAIL (not silently
pass) when diagnostic-settings, SQL auditing, or firewall reads error;
s3 BPA read errors mark public-access indeterminate instead of "missing";
entra-id emits "could not verify" when role definitions can't be resolved.
- per-project resilience (GCP): cloud-sql-backups/ssl, storage, vpc, iam
wrap each project in try/catch so one project's API error no longer aborts
the whole check; project-scoped resourceIds (`${projectId}/${name}`) so
same-named resources across projects don't collide.
- AWS Aurora evaluated at CLUSTER level (DescribeDBClusters) for encryption
and backups; Aurora instances excluded from instance-level checks (no more
false failures).
- protocol gating: EC2/Azure-NSG only flag SSH/RDP/DB on TCP/any-protocol;
all-ports detection now matches explicit full ranges (0-65535), not just '*'.
- CloudTrail scans all selected regions (dedupe by ARN) and treats an
unreadable GetTrailStatus as unknown, not a false "not logging" failure.
- GCP storage: drop the publicAccessPrevention='inherited' false-positive
(org policy may enforce it); keep uniform-bucket-level-access signal.
- VPC firewall check honestly scopes itself to VPC rules (records
firewallPoliciesEvaluated:false) rather than over-claiming.
- input hygiene: trim Azure subscription_id; sanitize GCP project_ids with
fallback to discovery; warn on pagination page-cap truncation.
- entra-id: permission-based privileged-role classification (role-name set is
fallback only); broader wildcard-action detection; resolve cross-scope role
definitions so privileged principals aren't undercounted.
Frontend:
- scan-status no longer shows "Always scanned" before live data loads or on
fetch error — shows Checking…/Status unavailable; baseline (non-manageable)
services never render as "scanning off".
- surface connection-fetch errors instead of swallowing them as an empty list.
- shared EvidenceTaskRow + loadIntegrationPageData remove the duplicated row
markup and data-loading between the provider and per-service pages.
Tests: +6 regressions (RDS cluster evaluators, NSG all-ports range, UDP not
flagged as SSH, CloudTrail unknown-status, GCP PAP-inherited). 156 pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../[slug]/components/EvidenceTaskRow.tsx | 63 ++++++++
.../components/IntegrationEvidenceTasks.tsx | 40 ++---
.../[slug]/components/ServiceCard.tsx | 26 ++--
.../[slug]/lib/load-integration-page-data.ts | 58 ++++++++
.../[orgId]/integrations/[slug]/page.tsx | 23 +--
.../components/ServiceDetailView.tsx | 105 +++++++------
.../[slug]/services/[serviceId]/page.tsx | 30 ++--
.../aws/checks/__tests__/aws-checks.test.ts | 59 +++++++-
.../src/manifests/aws/checks/cloudtrail.ts | 93 +++++++++---
.../src/manifests/aws/checks/ec2.ts | 4 +
.../src/manifests/aws/checks/rds.ts | 107 +++++++++++++-
.../src/manifests/aws/checks/s3.ts | 25 +++-
.../checks/__tests__/azure-checks.test.ts | 18 +++
.../src/manifests/azure/checks/entra-id.ts | 75 +++++++++-
.../src/manifests/azure/checks/key-vault.ts | 2 +-
.../src/manifests/azure/checks/monitor.ts | 14 ++
.../src/manifests/azure/checks/network.ts | 20 ++-
.../src/manifests/azure/checks/shared.ts | 4 +-
.../src/manifests/azure/checks/sql.ts | 32 +++-
.../src/manifests/azure/checks/storage.ts | 8 +-
.../gcp/checks/__tests__/gcp-checks.test.ts | 27 +++-
.../manifests/gcp/checks/cloud-sql-backups.ts | 76 +++++-----
.../src/manifests/gcp/checks/cloud-sql-ssl.ts | 81 +++++-----
.../gcp/checks/iam-primitive-roles.ts | 138 ++++++++++--------
.../src/manifests/gcp/checks/shared.ts | 16 +-
.../gcp/checks/storage-public-access.ts | 84 +++++------
.../gcp/checks/vpc-open-firewalls.ts | 120 ++++++++-------
27 files changed, 926 insertions(+), 422 deletions(-)
create mode 100644 apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/EvidenceTaskRow.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/integrations/[slug]/lib/load-integration-page-data.ts
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/EvidenceTaskRow.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/EvidenceTaskRow.tsx
new file mode 100644
index 0000000000..79bf79ff07
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/EvidenceTaskRow.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import { Button } from '@trycompai/design-system';
+import { ArrowRight } from '@trycompai/design-system/icons';
+import Link from 'next/link';
+
+/** The resolved live task for a mapped template, when it exists in the org. */
+export interface EvidenceTaskRowTask {
+ taskId: string;
+ name: string;
+ description: string;
+}
+
+interface EvidenceTaskRowProps {
+ /** Name shown when the template has no live task in this org. */
+ fallbackName: string;
+ task?: EvidenceTaskRowTask;
+ orgId: string;
+ /** Action label when the task exists (e.g. 'Open', 'View task'). */
+ buttonLabel?: string;
+ /** When the tasks fetch failed, distinguish "couldn't load" from "not added". */
+ tasksErrored?: boolean;
+}
+
+/**
+ * A single evidence-task row: task name + description with an "open" action, or
+ * a not-added / load-error fallback. Shared by the integration detail page and
+ * the per-service detail page so the row markup has one source of truth.
+ */
+export function EvidenceTaskRow({
+ fallbackName,
+ task,
+ orgId,
+ buttonLabel = 'Open',
+ tasksErrored = false,
+}: EvidenceTaskRowProps) {
+ return (
+
+
+
{task?.name ?? fallbackName}
+
+ {task?.description ||
+ 'Mapped to this template, but the task is not in this organization yet.'}
+
+
+
+ {task ? (
+
}
+ iconRight={
}
+ >
+ {buttonLabel}
+
+ ) : (
+
+ {tasksErrored ? 'Couldn’t load tasks' : 'Not added'}
+
+ )}
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/IntegrationEvidenceTasks.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/IntegrationEvidenceTasks.tsx
index 187e86019f..31fbaab0df 100644
--- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/IntegrationEvidenceTasks.tsx
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/IntegrationEvidenceTasks.tsx
@@ -1,10 +1,8 @@
'use client';
import type { IntegrationProvider } from '@/hooks/use-integration-platform';
-import { Button } from '@trycompai/design-system';
-import { ArrowRight } from '@trycompai/design-system/icons';
-import Link from 'next/link';
import { useMemo } from 'react';
+import { EvidenceTaskRow } from './EvidenceTaskRow';
export interface IntegrationTaskTemplate {
id: string;
@@ -56,34 +54,14 @@ export function IntegrationEvidenceTasks({
- {mappedTasks.map((mappedTask) => {
- const task = taskByTemplateId.get(mappedTask.id);
-
- return (
-
-
-
{task?.name ?? mappedTask.name}
-
- {task?.description ||
- 'This task is mapped to the integration template, but is not available in this organization yet.'}
-
-
-
- {task ? (
-
}
- iconRight={
}
- >
- Open
-
- ) : (
-
Not added
- )}
-
- );
- })}
+ {mappedTasks.map((mappedTask) => (
+
+ ))}
);
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.tsx
index 95a9abf9fc..99eddffd97 100644
--- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.tsx
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.tsx
@@ -101,19 +101,27 @@ interface ServiceCardProps {
* count of evidence tasks the service maps to — it is NOT a toggle.
*/
export function ServiceCard({ service, connectionId, orgId, slug }: ServiceCardProps) {
- const { services } = useConnectionServices(connectionId);
+ const { services, isLoading, error } = useConnectionServices(connectionId);
const isImplemented = service.implemented !== false;
const liveService = services.find((s) => s.id === service.id);
const inServiceList = Boolean(liveService);
const isEnabled = liveService?.enabled ?? false;
- // Services not in the connection's toggle list (e.g. AWS baseline services)
- // are always scanned — don't render them as "Scanning off".
- const scanningOn = !inServiceList || isEnabled;
- const scanningLabel = !inServiceList
- ? 'Always scanned'
- : isEnabled
- ? 'Scanning on'
- : 'Scanning off';
+ // Don't assert a scan status until the connection's live services have
+ // loaded. A service absent from the loaded list (e.g. AWS baseline services)
+ // is always scanned — but only treat "absent" as "always scanned" once the
+ // fetch has actually succeeded.
+ const servicesLoaded = Boolean(connectionId) && !isLoading && !error;
+ let scanningOn = false;
+ let scanningLabel: string;
+ if (!servicesLoaded) {
+ scanningLabel = error ? 'Status unavailable' : 'Checking status…';
+ } else if (!inServiceList) {
+ scanningOn = true;
+ scanningLabel = 'Always scanned';
+ } else {
+ scanningOn = isEnabled;
+ scanningLabel = isEnabled ? 'Scanning on' : 'Scanning off';
+ }
const taskCount = service.mappedTasks?.length ?? 0;
const href =
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/lib/load-integration-page-data.ts b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/lib/load-integration-page-data.ts
new file mode 100644
index 0000000000..73da8d7e00
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/lib/load-integration-page-data.ts
@@ -0,0 +1,58 @@
+import { serverApi } from '@/lib/api-server';
+import type {
+ ConnectionListItemResponse,
+ IntegrationProviderResponse,
+} from '@trycompai/integration-platform';
+import {
+ type IntegrationTaskApiResponse,
+ type MappedTaskTemplate,
+ mapTaskTemplates,
+} from './task-templates';
+
+export interface IntegrationPageData {
+ /** Null when the provider couldn't be loaded; the caller should redirect. */
+ provider: IntegrationProviderResponse | null;
+ providerErrored: boolean;
+ /** Connections already filtered to this provider's slug. */
+ connections: ConnectionListItemResponse[];
+ connectionsErrored: boolean;
+ taskTemplates: MappedTaskTemplate[];
+ tasksErrored: boolean;
+}
+
+/**
+ * Shared server-side loader for the integration detail pages (provider page and
+ * per-service page). Fetches the provider, the org's connections (filtered to
+ * this provider), and the org's tasks (projected to mapped templates) in one
+ * round-trip, surfacing each fetch's error so the UI can distinguish "empty"
+ * from "couldn't load" rather than silently swallowing failures.
+ */
+export async function loadIntegrationPageData(
+ slug: string,
+ opts: { sortTasks?: boolean } = {},
+): Promise {
+ const [providerResult, connectionsResult, tasksResult] = await Promise.all([
+ serverApi.get(
+ `/v1/integrations/connections/providers/${slug}`,
+ ),
+ serverApi.get('/v1/integrations/connections'),
+ serverApi.get('/v1/tasks'),
+ ]);
+
+ const connections = (connectionsResult.data ?? []).filter(
+ (c) => c.providerSlug === slug,
+ );
+ const { templates: taskTemplates, errored: tasksErrored } = mapTaskTemplates(
+ tasksResult,
+ { sort: opts.sortTasks },
+ );
+
+ return {
+ provider: providerResult.data ?? null,
+ providerErrored: Boolean(providerResult.error),
+ connections,
+ connectionsErrored: Boolean(connectionsResult.error),
+ taskTemplates,
+ tasksErrored,
+ };
+}
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/page.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/page.tsx
index 0e15d3122c..82e5be303a 100644
--- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/page.tsx
@@ -1,15 +1,7 @@
-import { serverApi } from '@/lib/api-server';
import { PageLayout } from '@trycompai/design-system';
-import type {
- ConnectionListItemResponse,
- IntegrationProviderResponse,
-} from '@trycompai/integration-platform';
import { redirect } from 'next/navigation';
import { ProviderDetailView } from './components/ProviderDetailView';
-import {
- type IntegrationTaskApiResponse,
- mapTaskTemplates,
-} from './lib/task-templates';
+import { loadIntegrationPageData } from './lib/load-integration-page-data';
interface PageProps {
params: Promise<{ orgId: string; slug: string }>;
@@ -23,20 +15,13 @@ export default async function ProviderDetailPage({ params, searchParams }: PageP
const providerParam = typeof sp.provider === 'string' ? sp.provider : '';
const gcpOAuthJustConnected = slug === 'gcp' && success === 'true' && providerParam === 'gcp';
- const [providerResult, connectionsResult, tasksResult] = await Promise.all([
- serverApi.get(`/v1/integrations/connections/providers/${slug}`),
- serverApi.get('/v1/integrations/connections'),
- serverApi.get('/v1/tasks'),
- ]);
+ const { provider, providerErrored, connections, taskTemplates } =
+ await loadIntegrationPageData(slug, { sortTasks: true });
- if (!providerResult.data || providerResult.error) {
+ if (!provider || providerErrored) {
redirect(`/${orgId}/integrations`);
}
- const provider = providerResult.data;
- const connections = (connectionsResult.data ?? []).filter((c) => c.providerSlug === slug);
- const { templates: taskTemplates } = mapTaskTemplates(tasksResult, { sort: true });
-
return (
s.id === service.id);
const isEnabled = liveService?.enabled ?? false;
const isImplemented = service.implemented !== false;
+ const servicesLoaded =
+ Boolean(effectiveConnectionId) && !servicesLoading && !servicesError;
// Only services present in the connection's live service list can be toggled.
// (e.g. AWS baseline services are always scanned and aren't in the toggle list.)
const isManageable = Boolean(liveService);
@@ -126,23 +134,37 @@ export function ServiceDetailView({
controls scanning only — it's separate from the evidence below.
-
+ ) : (
+
+ {servicesLoaded
+ ? 'Always scanned'
+ : servicesError
+ ? 'Status unavailable'
+ : 'Checking…'}
+
+ )}
@@ -168,39 +190,16 @@ export function ServiceDetailView({
) : (
- {mappedTasks.map((mapped) => {
- const task = taskByTemplateId.get(mapped.id);
- return (
-
-
-
- {task?.name ?? mapped.name}
-
-
- {task?.description ||
- 'Mapped to this template, but the task is not in this organization yet.'}
-
-
- {task ? (
-
}
- iconRight={
}
- >
- View task
-
- ) : (
-
- {tasksErrored ? 'Couldn’t load tasks' : 'Not added'}
-
- )}
-
- );
- })}
+ {mappedTasks.map((mapped) => (
+
+ ))}
)}
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/page.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/page.tsx
index fb7134ee95..d22d1141e2 100644
--- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/page.tsx
@@ -1,14 +1,6 @@
-import { serverApi } from '@/lib/api-server';
import { PageLayout } from '@trycompai/design-system';
-import type {
- ConnectionListItemResponse,
- IntegrationProviderResponse,
-} from '@trycompai/integration-platform';
import { redirect } from 'next/navigation';
-import {
- type IntegrationTaskApiResponse,
- mapTaskTemplates,
-} from '../../lib/task-templates';
+import { loadIntegrationPageData } from '../../lib/load-integration-page-data';
import { ServiceDetailView } from './components/ServiceDetailView';
interface PageProps {
@@ -21,14 +13,16 @@ export default async function ServiceDetailPage({ params, searchParams }: PagePr
const sp = await searchParams;
const connectionId = typeof sp.connectionId === 'string' ? sp.connectionId : null;
- const [providerResult, connectionsResult, tasksResult] = await Promise.all([
- serverApi.get(`/v1/integrations/connections/providers/${slug}`),
- serverApi.get('/v1/integrations/connections'),
- serverApi.get('/v1/tasks'),
- ]);
+ const {
+ provider,
+ providerErrored,
+ connections,
+ connectionsErrored,
+ taskTemplates,
+ tasksErrored,
+ } = await loadIntegrationPageData(slug);
- const provider = providerResult.data;
- if (!provider || providerResult.error) {
+ if (!provider || providerErrored) {
redirect(`/${orgId}/integrations`);
}
@@ -37,9 +31,6 @@ export default async function ServiceDetailPage({ params, searchParams }: PagePr
redirect(`/${orgId}/integrations/${slug}`);
}
- const connections = (connectionsResult.data ?? []).filter((c) => c.providerSlug === slug);
- const { templates: taskTemplates, errored: tasksErrored } = mapTaskTemplates(tasksResult);
-
return (
os.map((o) => o.kind);
@@ -42,10 +47,10 @@ const ALL_BLOCKED = {
describe('AWS S3 evaluators', () => {
it('encryption: pass when encrypted, fail (high) when not, skip indeterminate', () => {
const out = evaluateS3Encryption([
- { name: 'a', encrypted: true, encryptionDetermined: true, bucketBpa: null },
- { name: 'b', encrypted: false, encryptionDetermined: true, bucketBpa: null },
+ { name: 'a', encrypted: true, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: null },
+ { name: 'b', encrypted: false, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: null },
// read error → indeterminate → excluded (no false high finding)
- { name: 'c', encrypted: false, encryptionDetermined: false, bucketBpa: null },
+ { name: 'c', encrypted: false, encryptionDetermined: false, publicAccessDetermined: true, bucketBpa: null },
]);
expect(out).toHaveLength(2);
expect(out[0]!.kind).toBe('pass');
@@ -56,8 +61,8 @@ describe('AWS S3 evaluators', () => {
it('public access: bucket-level all-blocked passes, missing fails', () => {
const out = evaluateS3PublicAccess(
[
- { name: 'a', encrypted: false, encryptionDetermined: true, bucketBpa: ALL_BLOCKED },
- { name: 'b', encrypted: false, encryptionDetermined: true, bucketBpa: null },
+ { name: 'a', encrypted: false, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: ALL_BLOCKED },
+ { name: 'b', encrypted: false, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: null },
],
null,
);
@@ -66,7 +71,7 @@ describe('AWS S3 evaluators', () => {
it('public access: account-level BPA covers buckets lacking bucket config', () => {
const out = evaluateS3PublicAccess(
- [{ name: 'b', encrypted: false, encryptionDetermined: true, bucketBpa: null }],
+ [{ name: 'b', encrypted: false, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: null }],
ALL_BLOCKED,
);
expect(out[0]!.kind).toBe('pass');
@@ -120,6 +125,18 @@ describe('AWS EC2 security-group evaluator', () => {
expect(out).toHaveLength(1);
expect(out[0]!.kind).toBe('pass');
});
+
+ it('does not flag a UDP rule on a TCP-only sensitive port (22)', () => {
+ const out = evaluateSecurityGroups([
+ {
+ groupId: 'sg-udp',
+ region: 'us-east-1',
+ permissions: [{ ipProtocol: 'udp', fromPort: 22, toPort: 22, cidrs: ['0.0.0.0/0'] }],
+ },
+ ]);
+ expect(out).toHaveLength(1);
+ expect(out[0]!.kind).toBe('pass');
+ });
});
describe('AWS RDS evaluators', () => {
@@ -140,6 +157,25 @@ describe('AWS RDS evaluators', () => {
]);
expect(kinds(out)).toEqual(['pass', 'fail']); // aurora excluded, not failed
});
+
+ it('cluster encryption: Aurora evaluated at cluster level (pass/fail)', () => {
+ const out = evaluateRdsClusterEncryption([
+ { id: 'c1', region: 'us-east-1', encrypted: true, backupRetentionDays: 7, engine: 'aurora-postgresql' },
+ { id: 'c2', region: 'us-east-1', encrypted: false, backupRetentionDays: 7, engine: 'aurora-mysql' },
+ ]);
+ expect(out[0]!.kind).toBe('pass');
+ expect(out[1]!.kind).toBe('fail');
+ expect(out[1]!.severity).toBe('high');
+ });
+
+ it('cluster backups: Aurora retention evaluated at cluster level (pass/fail)', () => {
+ const out = evaluateRdsClusterBackups([
+ { id: 'c1', region: 'us-east-1', encrypted: true, backupRetentionDays: 7, engine: 'aurora-mysql' },
+ { id: 'c2', region: 'us-east-1', encrypted: true, backupRetentionDays: 0, engine: 'aurora-mysql' },
+ ]);
+ expect(out[0]!.kind).toBe('pass');
+ expect(out[1]!.kind).toBe('fail');
+ });
});
describe('AWS KMS rotation evaluator', () => {
@@ -187,4 +223,13 @@ describe('AWS CloudTrail evaluator', () => {
expect(out[0]!.kind).toBe('fail');
expect(out[0]!.severity).toBe('medium');
});
+
+ it('emits nothing when an otherwise-compliant trail status is unreadable', () => {
+ // multi-region + validated, but GetTrailStatus failed → loggingKnown=false.
+ // We must not assert a false "not logging" failure on unverified data.
+ const out = evaluateCloudTrail([
+ { name: 't1', multiRegion: true, logValidation: true, logging: false, loggingKnown: false },
+ ]);
+ expect(out).toHaveLength(0);
+ });
});
diff --git a/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts b/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
index d0a8f62d58..087667ed1c 100644
--- a/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
@@ -1,6 +1,7 @@
import {
CloudTrailClient,
DescribeTrailsCommand,
+ type DescribeTrailsCommandOutput,
GetTrailStatusCommand,
} from '@aws-sdk/client-cloudtrail';
import { TASK_TEMPLATES } from '../../../task-mappings';
@@ -13,10 +14,18 @@ export interface TrailInfo {
logValidation: boolean;
/** GetTrailStatus.IsLogging — a trail can be configured but stopped. */
logging: boolean;
+ /**
+ * Whether the logging status was actually read. Defaults to known/true when
+ * omitted. When a multi-region + validated candidate trail's status could not
+ * be read, this is set to false so it is NOT misreported as logging=false.
+ */
+ loggingKnown?: boolean;
}
export function evaluateCloudTrail(trails: TrailInfo[]): CheckOutcome[] {
- const good = trails.find((t) => t.multiRegion && t.logValidation && t.logging);
+ const good = trails.find(
+ (t) => t.multiRegion && t.logValidation && t.logging && t.loggingKnown !== false,
+ );
if (good) {
return [
{
@@ -29,6 +38,15 @@ export function evaluateCloudTrail(trails: TrailInfo[]): CheckOutcome[] {
},
];
}
+ // No confirmed-good trail. If an otherwise-compliant (multi-region + validated)
+ // candidate exists whose logging status could not be read, we cannot assert a
+ // failure on unverified data — emit nothing rather than a false negative.
+ const unverifiableCandidate = trails.some(
+ (t) => t.multiRegion && t.logValidation && t.loggingKnown === false,
+ );
+ if (unverifiableCandidate) {
+ return [];
+ }
if (trails.length === 0) {
return [
{
@@ -71,31 +89,64 @@ export const cloudTrailEnabledCheck: IntegrationCheck = {
ctx.log('AWS CloudTrail check: connection not configured — skipping');
return;
}
- const ct = new CloudTrailClient({
- region: session.regions[0],
- credentials: session.credentials,
- });
- const resp = await ct.send(new DescribeTrailsCommand({}));
- const trailList = resp.trailList ?? [];
+ // A single-region trail is only returned by DescribeTrails in its home
+ // region, so scanning just one region can miss trails and misreport "No
+ // CloudTrail configured". Describe trails in every selected region and
+ // dedupe by TrailARN before evaluating.
+ const seenArns = new Set();
const trails: TrailInfo[] = [];
- for (const t of trailList) {
- const multiRegion = t.IsMultiRegionTrail === true;
- const logValidation = t.LogFileValidationEnabled === true;
- let logging = false;
- // Logging status only matters for otherwise-compliant trails.
- if (multiRegion && logValidation && t.TrailARN) {
- try {
- const status = await ct.send(new GetTrailStatusCommand({ Name: t.TrailARN }));
- logging = status.IsLogging === true;
- } catch (err) {
- ctx.log(
- `CloudTrail: could not read logging status for ${t.Name ?? t.TrailARN}: ${err instanceof Error ? err.message : String(err)}`,
- );
+
+ for (const region of session.regions) {
+ const ct = new CloudTrailClient({
+ region,
+ credentials: session.credentials,
+ });
+
+ let trailList: DescribeTrailsCommandOutput['trailList'];
+ try {
+ const resp = await ct.send(new DescribeTrailsCommand({}));
+ trailList = resp.trailList;
+ } catch (err) {
+ ctx.log(
+ `CloudTrail: could not list trails in ${region}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ continue;
+ }
+
+ for (const t of trailList ?? []) {
+ const arnKey = t.TrailARN ?? `${region}/${t.Name ?? 'unknown'}`;
+ if (seenArns.has(arnKey)) continue;
+ seenArns.add(arnKey);
+
+ const multiRegion = t.IsMultiRegionTrail === true;
+ const logValidation = t.LogFileValidationEnabled === true;
+ let logging = false;
+ // Track whether the logging status was actually read so a failed
+ // GetTrailStatus is not misreported as logging=false.
+ let loggingKnown = true;
+ // Logging status only matters for otherwise-compliant trails.
+ if (multiRegion && logValidation && t.TrailARN) {
+ try {
+ const status = await ct.send(new GetTrailStatusCommand({ Name: t.TrailARN }));
+ logging = status.IsLogging === true;
+ } catch (err) {
+ loggingKnown = false;
+ ctx.log(
+ `CloudTrail: could not read logging status for ${t.Name ?? t.TrailARN}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
}
+ trails.push({
+ name: t.Name ?? 'unknown',
+ multiRegion,
+ logValidation,
+ logging,
+ loggingKnown,
+ });
}
- trails.push({ name: t.Name ?? 'unknown', multiRegion, logValidation, logging });
}
+
emitOutcomes(ctx, evaluateCloudTrail(trails));
},
};
diff --git a/packages/integration-platform/src/manifests/aws/checks/ec2.ts b/packages/integration-platform/src/manifests/aws/checks/ec2.ts
index 8410ae0f0a..274b4109ac 100644
--- a/packages/integration-platform/src/manifests/aws/checks/ec2.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/ec2.ts
@@ -47,6 +47,10 @@ export function evaluateSecurityGroups(sgs: SgInfo[]): CheckOutcome[] {
});
continue;
}
+ // SSH/RDP findings only apply to TCP rules. A non-TCP rule (udp/icmp) on
+ // port 22/3389 must not be misclassified as SSH/RDP. The all-protocols
+ // ('-1') case is handled above as critical.
+ if (perm.ipProtocol !== 'tcp' && perm.ipProtocol !== '6') continue;
for (const { port, label, severity } of SENSITIVE_PORTS) {
if (permCoversPort(perm, port)) {
bad = true;
diff --git a/packages/integration-platform/src/manifests/aws/checks/rds.ts b/packages/integration-platform/src/manifests/aws/checks/rds.ts
index 804a4a018e..79e20af5bf 100644
--- a/packages/integration-platform/src/manifests/aws/checks/rds.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/rds.ts
@@ -1,4 +1,8 @@
-import { DescribeDBInstancesCommand, RDSClient } from '@aws-sdk/client-rds';
+import {
+ DescribeDBClustersCommand,
+ DescribeDBInstancesCommand,
+ RDSClient,
+} from '@aws-sdk/client-rds';
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, IntegrationCheck } from '../../../types';
import {
@@ -17,8 +21,22 @@ export interface RdsInstanceInfo {
engine: string;
}
+export interface RdsClusterInfo {
+ id: string;
+ region: string;
+ encrypted: boolean;
+ backupRetentionDays: number;
+ /** e.g. 'aurora-mysql', 'aurora-postgresql', 'mysql' (Multi-AZ cluster) */
+ engine: string;
+}
+
export function evaluateRdsEncryption(instances: RdsInstanceInfo[]): CheckOutcome[] {
- return instances.map((i) =>
+ return instances
+ // Aurora encryption is managed at the cluster level; the instance-level
+ // StorageEncrypted flag is unreliable, so don't evaluate Aurora instances
+ // here (they are evaluated by evaluateRdsClusterEncryption instead).
+ .filter((i) => !i.engine.toLowerCase().startsWith('aurora'))
+ .map((i) =>
i.encrypted
? {
kind: 'pass',
@@ -70,6 +88,55 @@ export function evaluateRdsBackups(instances: RdsInstanceInfo[]): CheckOutcome[]
);
}
+export function evaluateRdsClusterEncryption(clusters: RdsClusterInfo[]): CheckOutcome[] {
+ return clusters.map((c) =>
+ c.encrypted
+ ? {
+ kind: 'pass',
+ title: `RDS cluster storage encrypted: ${c.id}`,
+ description: `RDS cluster "${c.id}" (${c.region}) has storage encryption enabled.`,
+ resourceType: 'aws-rds-cluster',
+ resourceId: c.id,
+ evidence: { cluster: c.id, region: c.region },
+ }
+ : {
+ kind: 'fail',
+ title: `RDS cluster storage not encrypted: ${c.id}`,
+ description: `RDS cluster "${c.id}" (${c.region}) does not have storage encryption enabled.`,
+ resourceType: 'aws-rds-cluster',
+ resourceId: c.id,
+ severity: 'high',
+ remediation:
+ 'Enable storage encryption (encryption at rest must be set at creation; restore from an encrypted snapshot to remediate).',
+ evidence: { cluster: c.id, region: c.region },
+ },
+ );
+}
+
+export function evaluateRdsClusterBackups(clusters: RdsClusterInfo[]): CheckOutcome[] {
+ return clusters.map((c) =>
+ c.backupRetentionDays > 0
+ ? {
+ kind: 'pass',
+ title: `RDS cluster automated backups enabled: ${c.id}`,
+ description: `RDS cluster "${c.id}" (${c.region}) retains backups for ${c.backupRetentionDays} day(s).`,
+ resourceType: 'aws-rds-cluster',
+ resourceId: c.id,
+ evidence: { cluster: c.id, backupRetentionDays: c.backupRetentionDays },
+ }
+ : {
+ kind: 'fail',
+ title: `RDS cluster automated backups disabled: ${c.id}`,
+ description: `RDS cluster "${c.id}" (${c.region}) has automated backups disabled (retention 0).`,
+ resourceType: 'aws-rds-cluster',
+ resourceId: c.id,
+ severity: 'medium',
+ remediation: 'Set a backup retention period of at least 7 days.',
+ evidence: { cluster: c.id },
+ },
+ );
+}
+
async function listRdsInstances(session: AwsSession): Promise {
const out: RdsInstanceInfo[] = [];
for (const region of session.regions) {
@@ -92,6 +159,28 @@ async function listRdsInstances(session: AwsSession): Promise
return out;
}
+async function listRdsClusters(session: AwsSession): Promise {
+ const out: RdsClusterInfo[] = [];
+ for (const region of session.regions) {
+ const rds = new RDSClient({ region, credentials: session.credentials });
+ let marker: string | undefined;
+ do {
+ const resp = await rds.send(new DescribeDBClustersCommand({ Marker: marker }));
+ for (const cluster of resp.DBClusters ?? []) {
+ out.push({
+ id: cluster.DBClusterIdentifier ?? 'unknown',
+ region,
+ encrypted: cluster.StorageEncrypted === true,
+ backupRetentionDays: cluster.BackupRetentionPeriod ?? 0,
+ engine: cluster.Engine ?? '',
+ });
+ }
+ marker = resp.Marker;
+ } while (marker);
+ }
+ return out;
+}
+
export const rdsEncryptionCheck: IntegrationCheck = {
id: 'aws-rds-encryption',
name: 'RDS — storage encryption enabled',
@@ -104,9 +193,14 @@ export const rdsEncryptionCheck: IntegrationCheck = {
ctx.log('AWS RDS encryption check: connection not configured — skipping');
return;
}
+ // Evaluate non-Aurora DB instances at the instance level and DB clusters
+ // (Aurora / Multi-AZ) at the cluster level — instance-level StorageEncrypted
+ // is unreliable for Aurora and produces false failures.
const instances = await listRdsInstances(session);
- if (instances.length === 0) return;
+ const clusters = await listRdsClusters(session);
+ if (instances.length === 0 && clusters.length === 0) return;
emitOutcomes(ctx, evaluateRdsEncryption(instances));
+ emitOutcomes(ctx, evaluateRdsClusterEncryption(clusters));
},
};
@@ -122,8 +216,13 @@ export const rdsBackupsCheck: IntegrationCheck = {
ctx.log('AWS RDS backups check: connection not configured — skipping');
return;
}
+ // Evaluate non-Aurora DB instances at the instance level and DB clusters
+ // (Aurora / Multi-AZ) at the cluster level — instance-level
+ // BackupRetentionPeriod is unreliable for Aurora and produces false failures.
const instances = await listRdsInstances(session);
- if (instances.length === 0) return;
+ const clusters = await listRdsClusters(session);
+ if (instances.length === 0 && clusters.length === 0) return;
emitOutcomes(ctx, evaluateRdsBackups(instances));
+ emitOutcomes(ctx, evaluateRdsClusterBackups(clusters));
},
};
diff --git a/packages/integration-platform/src/manifests/aws/checks/s3.ts b/packages/integration-platform/src/manifests/aws/checks/s3.ts
index 13f21bf485..f02c979cc2 100644
--- a/packages/integration-platform/src/manifests/aws/checks/s3.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/s3.ts
@@ -26,6 +26,8 @@ export interface S3BucketInfo {
encryptionDetermined: boolean;
/** bucket-level Block Public Access flags, or null when none configured */
bucketBpa: BpaFlags | null;
+ /** false when bucket-level Block Public Access couldn't be read (error) → excluded from eval */
+ publicAccessDetermined: boolean;
}
const FLAG_KEYS: Array = [
@@ -70,7 +72,9 @@ export function evaluateS3PublicAccess(
buckets: S3BucketInfo[],
accountBpa: BpaFlags | null,
): CheckOutcome[] {
- return buckets.map((b) =>
+ return buckets
+ .filter((b) => b.publicAccessDetermined)
+ .map((b) =>
isFullyBlocked(b.bucketBpa, accountBpa)
? {
kind: 'pass',
@@ -107,6 +111,7 @@ async function gatherBuckets(
let encrypted = false;
let encryptionDetermined = true;
let bucketBpa: BpaFlags | null = null;
+ let publicAccessDetermined = true;
if (opts.encryption) {
try {
@@ -135,11 +140,21 @@ async function gatherBuckets(
blockPublicPolicy: Boolean(c?.BlockPublicPolicy),
restrictPublicBuckets: Boolean(c?.RestrictPublicBuckets),
};
- } catch {
- bucketBpa = null; // no bucket-level config
+ } catch (err) {
+ // "no bucket-level config" is a genuine finding (account-level may still
+ // cover it); any other error (AccessDenied/transient) is indeterminate →
+ // exclude from evaluation so we don't report a false public-access failure.
+ if (
+ err instanceof Error &&
+ /NoSuchPublicAccessBlockConfiguration/i.test(err.name)
+ ) {
+ bucketBpa = null; // no bucket-level config
+ } else {
+ publicAccessDetermined = false;
+ }
}
}
- infos.push({ name, encrypted, encryptionDetermined, bucketBpa });
+ infos.push({ name, encrypted, encryptionDetermined, bucketBpa, publicAccessDetermined });
}
return infos;
}
@@ -224,4 +239,4 @@ export const s3PublicAccessCheck: IntegrationCheck = {
if (buckets.length === 0) return;
emitOutcomes(ctx, evaluateS3PublicAccess(buckets, accountBpa));
},
-};
+};
\ No newline at end of file
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 281bf6fc7f..8a7552a8ce 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
@@ -218,6 +218,24 @@ describe('Azure NSG check', () => {
);
expect(range.failed.some((f) => f.title.match(/SSH/))).toBe(true);
});
+
+ it('treats an explicit all-ports range (0-65535) as wide open', async () => {
+ const { failed } = await run(
+ nsgNoOpenPortsCheck,
+ nsg({ name: 'rall', properties: { direction: 'Inbound', access: 'Allow', protocol: 'Tcp', sourceAddressPrefix: '*', destinationPortRange: '0-65535', priority: 100 } }),
+ );
+ // covers SSH + RDP just like '*'
+ expect(failed.some((f) => f.severity === 'critical')).toBe(true);
+ });
+
+ it('does not flag a UDP rule on a TCP-only sensitive port', async () => {
+ const { passed, failed } = await run(
+ nsgNoOpenPortsCheck,
+ nsg({ name: 'rudp', properties: { direction: 'Inbound', access: 'Allow', protocol: 'Udp', sourceAddressPrefix: '*', destinationPortRange: '22', priority: 100 } }),
+ );
+ expect(failed).toHaveLength(0);
+ expect(passed).toHaveLength(1);
+ });
});
describe('Azure RBAC (entra) check', () => {
diff --git a/packages/integration-platform/src/manifests/azure/checks/entra-id.ts b/packages/integration-platform/src/manifests/azure/checks/entra-id.ts
index df2d65f890..943ef15cf0 100644
--- a/packages/integration-platform/src/manifests/azure/checks/entra-id.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/entra-id.ts
@@ -15,6 +15,8 @@ interface RoleDefinition {
};
}
+// Secondary, name-based fallback only. Permission-based classification
+// (see actionIsHighPrivilege / defIsPrivileged) is the primary signal.
const PRIVILEGED_ROLES = new Set([
'Owner',
'Contributor',
@@ -25,7 +27,9 @@ const PRIVILEGED_ROLES = new Set([
'Privileged Role Administrator',
]);
-const isWildcardAction = (act: string) => act === '*' || act.endsWith('/*');
+// Any action containing a '*' is treated as a wildcard — covers bare '*',
+// suffix forms (read-all), and mid-path wildcards (e.g. Microsoft.Network).
+const isWildcardAction = (act: string) => act.includes('*');
/** High-privilege ARM actions that make a role privileged regardless of its name. */
function actionIsHighPrivilege(act: string): boolean {
@@ -39,12 +43,16 @@ function actionIsHighPrivilege(act: string): boolean {
);
}
-/** A role is privileged if it is a known built-in privileged role OR its permissions grant high-privilege actions. */
+/**
+ * A role is privileged primarily because its permissions grant high-privilege
+ * actions; the built-in privileged role-name set is only a secondary fallback.
+ */
function defIsPrivileged(def: RoleDefinition): boolean {
- if (PRIVILEGED_ROLES.has(def.properties.roleName)) return true;
- return def.properties.permissions.some((perm) =>
+ const permissionPrivileged = def.properties.permissions.some((perm) =>
(perm.actions ?? []).some(actionIsHighPrivilege),
);
+ if (permissionPrivileged) return true;
+ return PRIVILEGED_ROLES.has(def.properties.roleName);
}
/**
@@ -75,13 +83,64 @@ export const rbacLeastPrivilegeCheck: IntegrationCheck = {
]);
const defMap = new Map(definitions.map((d) => [d.id, d]));
- const privileged = assignments.filter((a) => {
- const def = defMap.get(a.properties.roleDefinitionId);
- return def ? defIsPrivileged(def) : false;
- });
+
+ // Assignments can reference role definitions scoped to a management group or
+ // resource group, which won't appear in the subscription-scope list above.
+ // Resolve any missing definition directly so privileged principals aren't
+ // undercounted. Cache by id to avoid refetching shared definitions.
+ const resolvedDefs = new Map();
+ const resolveDef = async (
+ roleDefinitionId: string,
+ ): Promise => {
+ const cached = defMap.get(roleDefinitionId) ?? resolvedDefs.get(roleDefinitionId);
+ if (cached) return cached;
+ try {
+ const def = await ctx.fetch(
+ `${roleDefinitionId}?api-version=2022-04-01`,
+ );
+ if (def?.properties) {
+ resolvedDefs.set(roleDefinitionId, def);
+ return def;
+ }
+ return null;
+ } catch (err) {
+ ctx.warn('Failed to resolve Azure role definition for assignment', {
+ roleDefinitionId,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ return null;
+ }
+ };
+
+ const privileged: RoleAssignment[] = [];
+ let unresolvedAssignments = 0;
+ for (const a of assignments) {
+ const def = await resolveDef(a.properties.roleDefinitionId);
+ if (!def) {
+ // Could not classify this assignment's role — do not silently treat it
+ // as non-privileged (ERROR-READS-NEVER-SILENT-PASS).
+ unresolvedAssignments++;
+ continue;
+ }
+ if (defIsPrivileged(def)) privileged.push(a);
+ }
let violations = 0;
+ if (unresolvedAssignments > 0) {
+ violations++;
+ ctx.fail({
+ title: 'Could not verify all role assignments',
+ description: `${unresolvedAssignments} role assignment(s) reference role definitions that could not be loaded (e.g. custom roles defined at management-group or resource-group scope), so their privilege level is unverified.`,
+ resourceType: 'azure-subscription',
+ resourceId: sub,
+ severity: 'medium',
+ remediation:
+ 'Ensure the integration principal has read access to all role definitions in scope (including management-group and resource-group scopes), then re-run the check.',
+ evidence: { unresolvedAssignments },
+ });
+ }
+
if (privileged.length > 5) {
violations++;
ctx.fail({
diff --git a/packages/integration-platform/src/manifests/azure/checks/key-vault.ts b/packages/integration-platform/src/manifests/azure/checks/key-vault.ts
index 89f2d91dbd..f680736e1d 100644
--- a/packages/integration-platform/src/manifests/azure/checks/key-vault.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/key-vault.ts
@@ -38,7 +38,7 @@ export const keyVaultProtectionCheck: IntegrationCheck = {
const p = v.properties ?? {};
const issues: string[] = [];
let severity: FindingSeverity = 'medium';
- if (!p.enableSoftDelete) {
+ if (p.enableSoftDelete === false) {
issues.push('soft delete disabled');
severity = 'high';
}
diff --git a/packages/integration-platform/src/manifests/azure/checks/monitor.ts b/packages/integration-platform/src/manifests/azure/checks/monitor.ts
index 3125c4996a..c226963cca 100644
--- a/packages/integration-platform/src/manifests/azure/checks/monitor.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/monitor.ts
@@ -124,6 +124,20 @@ export const monitorLoggingAlertingCheck: IntegrationCheck = {
evidence: {},
});
}
+ } else {
+ // Diagnostic settings unreadable — fail rather than let the alerting half
+ // pass the shared Monitoring task on incomplete evaluation.
+ ctx.fail({
+ title: 'Could not read diagnostic settings',
+ description:
+ 'Subscription diagnostic settings could not be read, so log export was not verified.',
+ resourceType: 'azure-subscription',
+ resourceId: sub,
+ severity: 'medium',
+ remediation:
+ 'Grant Monitoring Reader (or Reader) so diagnostic settings can be evaluated.',
+ evidence: {},
+ });
}
if (!evaluated) {
diff --git a/packages/integration-platform/src/manifests/azure/checks/network.ts b/packages/integration-platform/src/manifests/azure/checks/network.ts
index 3149b27d84..dc11960579 100644
--- a/packages/integration-platform/src/manifests/azure/checks/network.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/network.ts
@@ -24,6 +24,7 @@ interface Nsg {
const DB_PORTS = [3306, 5432, 1433, 27017];
const WILDCARD_SOURCES = new Set(['*', '0.0.0.0/0', '::/0', 'Internet', 'Any']);
+const MAX_PORT = 65535;
/** True if an NSG port token ('22', '20-30', '*') covers any of the target ports. */
function portTokenCoversAny(token: string, targets: number[]): boolean {
@@ -38,6 +39,23 @@ function portsCoverAny(ports: string[], targets: number[]): boolean {
return ports.some((tok) => portTokenCoversAny(tok, targets));
}
+/**
+ * True if an NSG port token represents "all ports": either the '*' wildcard or
+ * a numeric range spanning the full port space (e.g. '0-65535' or '1-65535').
+ */
+function portTokenIsAllPorts(token: string): boolean {
+ if (token === '*') return true;
+ const [loStr, hiStr] = token.split('-');
+ if (hiStr === undefined) return false;
+ const lo = Number(loStr);
+ const hi = Number(hiStr);
+ if (Number.isNaN(lo) || Number.isNaN(hi)) return false;
+ return lo <= 1 && hi >= MAX_PORT;
+}
+function portsCoverAllPorts(ports: string[]): boolean {
+ return ports.some((tok) => portTokenIsAllPorts(tok));
+}
+
function ruleSources(r: SecurityRule): string[] {
if (r.properties.sourceAddressPrefixes?.length) {
return r.properties.sourceAddressPrefixes;
@@ -85,7 +103,7 @@ export const nsgNoOpenPortsCheck: IntegrationCheck = {
const proto = (rule.properties.protocol ?? '*').toLowerCase();
const tcpish = proto === '*' || proto === 'tcp';
const conditions: Array<{ when: boolean; label: string; severity: FindingSeverity }> = [
- { when: ports.includes('*'), label: 'all ports', severity: 'critical' },
+ { when: portsCoverAllPorts(ports), label: 'all ports', severity: 'critical' },
{ when: tcpish && portsCoverAny(ports, [3389]), label: 'RDP (3389)', severity: 'critical' },
{ when: tcpish && portsCoverAny(ports, DB_PORTS), label: 'database ports', severity: 'critical' },
{ when: tcpish && portsCoverAny(ports, [22]), label: 'SSH (22)', severity: 'high' },
diff --git a/packages/integration-platform/src/manifests/azure/checks/shared.ts b/packages/integration-platform/src/manifests/azure/checks/shared.ts
index 2ba6b7e467..f99f725511 100644
--- a/packages/integration-platform/src/manifests/azure/checks/shared.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/shared.ts
@@ -11,8 +11,8 @@ export async function resolveAzureSubscriptionId(
ctx: CheckContext,
): Promise {
const configured = ctx.variables.subscription_id;
- if (typeof configured === 'string' && configured.length > 0) {
- return configured;
+ if (typeof configured === 'string' && configured.trim().length > 0) {
+ return configured.trim();
}
try {
const data = await ctx.fetch<{
diff --git a/packages/integration-platform/src/manifests/azure/checks/sql.ts b/packages/integration-platform/src/manifests/azure/checks/sql.ts
index 76e9dc8197..48e0bfa532 100644
--- a/packages/integration-platform/src/manifests/azure/checks/sql.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/sql.ts
@@ -133,9 +133,19 @@ export const sqlPublicAccessCheck: IntegrationCheck = {
evidence: { server: s.name, publicNetworkAccess: s.properties?.publicNetworkAccess ?? null },
});
} else if (rules === null) {
- // Public access not Enabled but firewall rules unreadable — can't assert
- // a clean pass, so emit neither (the task simply isn't satisfied here).
- continue;
+ // Public access not Enabled but firewall rules unreadable — can't assert a
+ // clean pass. Fail explicitly so the public-access task isn't falsely
+ // satisfied by other servers passing.
+ ctx.fail({
+ title: `Could not read SQL firewall rules: ${s.name}`,
+ description: `Unable to read firewall rules for SQL Server "${s.name}", so wide-open access cannot be ruled out.`,
+ resourceType: 'azure-sql-server',
+ resourceId: s.id,
+ severity: 'medium',
+ remediation:
+ 'Grant read access to SQL firewall rules (Microsoft.Sql/servers/firewallRules/read) so public access can be verified.',
+ evidence: { server: s.name, publicNetworkAccess: s.properties?.publicNetworkAccess ?? null },
+ });
} else {
ctx.pass({
title: `No public access: ${s.name}`,
@@ -171,7 +181,21 @@ export const sqlAuditingCheck: IntegrationCheck = {
`${ARM_BASE}${s.id}/auditingSettings/default?api-version=2021-11-01`,
)
.catch(() => null);
- if (auditing === null) continue; // couldn't read — don't assert pass/fail
+ if (auditing === null) {
+ // Couldn't read auditing settings — fail explicitly so the Monitoring
+ // task isn't falsely passed by other servers that read successfully.
+ ctx.fail({
+ title: `Could not read SQL auditing settings: ${s.name}`,
+ description: `Unable to read auditing settings for SQL Server "${s.name}", so auditing state cannot be verified.`,
+ resourceType: 'azure-sql-server',
+ resourceId: s.id,
+ severity: 'medium',
+ remediation:
+ 'Grant read access to SQL auditing settings (Microsoft.Sql/servers/auditingSettings/read) so auditing can be verified.',
+ evidence: { server: s.name },
+ });
+ continue;
+ }
if (auditing.properties?.state === 'Enabled') {
ctx.pass({
title: `Auditing enabled: ${s.name}`,
diff --git a/packages/integration-platform/src/manifests/azure/checks/storage.ts b/packages/integration-platform/src/manifests/azure/checks/storage.ts
index a1f8a0153e..87e94b132d 100644
--- a/packages/integration-platform/src/manifests/azure/checks/storage.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/storage.ts
@@ -94,9 +94,13 @@ export const storagePublicAccessCheck: IntegrationCheck = {
for (const a of accounts) {
const p = a.properties ?? {};
const publicBlob = p.allowBlobPublicAccess === true;
- // publicNetworkAccess 'Disabled' overrides the firewall default action.
+ // publicNetworkAccess 'Disabled' or 'SecuredByPerimeter' (network security
+ // perimeter) overrides the firewall default action and is not public.
+ const networkRestricted =
+ p.publicNetworkAccess === 'Disabled' ||
+ p.publicNetworkAccess === 'SecuredByPerimeter';
const publicNetwork =
- p.publicNetworkAccess !== 'Disabled' &&
+ !networkRestricted &&
(p.publicNetworkAccess === 'Enabled' ||
p.networkAcls?.defaultAction === 'Allow');
if (publicBlob || publicNetwork) {
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 ca19a9051f..4b73b4db8b 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
@@ -124,7 +124,7 @@ describe('GCP IAM primitive roles check', () => {
});
describe('GCP Cloud Storage public-access check', () => {
- it('fails buckets without uniform access / public-access-prevention', async () => {
+ it('fails a bucket with uniform bucket-level access disabled', async () => {
const { failed } = await runCheck(storagePublicAccessCheck, {
fetch: () => ({
items: [
@@ -132,15 +132,34 @@ describe('GCP Cloud Storage public-access check', () => {
name: 'b1',
iamConfiguration: {
uniformBucketLevelAccess: { enabled: false },
+ // 'inherited' may be enforced by an org policy — must NOT add a
+ // second false failure on top of the uniform-access finding.
publicAccessPrevention: 'inherited',
},
},
],
}),
});
- // both violations on the one bucket
- expect(failed.map((f) => f.title).join(' ')).toMatch(/Uniform bucket-level access/);
- expect(failed.map((f) => f.title).join(' ')).toMatch(/Public access prevention/);
+ expect(failed).toHaveLength(1);
+ expect(failed[0]!.title).toMatch(/Uniform bucket-level access/);
+ });
+
+ it('does not fail solely on publicAccessPrevention inherited (org policy may enforce)', async () => {
+ const { passed, failed } = await runCheck(storagePublicAccessCheck, {
+ fetch: () => ({
+ items: [
+ {
+ name: 'b1',
+ iamConfiguration: {
+ uniformBucketLevelAccess: { enabled: true },
+ publicAccessPrevention: 'inherited',
+ },
+ },
+ ],
+ }),
+ });
+ expect(failed).toHaveLength(0);
+ expect(passed).toHaveLength(1);
});
it('passes when all buckets are locked down', async () => {
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 2ef0da0f56..dc0434c952 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
@@ -33,42 +33,50 @@ export const cloudSqlBackupsCheck: IntegrationCheck = {
}
for (const projectId of projectIds) {
- const instances = await gcpListItems(
- ctx,
- `https://sqladmin.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/instances`,
- );
- if (instances.length === 0) continue;
+ 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) {
- // Read replicas / on-prem instances can't configure their own backups
- // (replicas are protected by the primary's backups) — don't fail them.
- if (
- inst.masterInstanceName ||
- (inst.instanceType && inst.instanceType !== 'CLOUD_SQL_INSTANCE')
- ) {
- continue;
- }
- const enabled = inst.settings?.backupConfiguration?.enabled === true;
- if (enabled) {
- ctx.pass({
- title: `Automated backups enabled: ${inst.name}`,
- description: `Cloud SQL instance "${inst.name}" has automated backups enabled.`,
- resourceType: 'gcp-cloud-sql-instance',
- resourceId: inst.name,
- evidence: { projectId, instance: inst.name },
- });
- } else {
- ctx.fail({
- title: `Automated backups disabled: ${inst.name}`,
- description: `Cloud SQL instance "${inst.name}" does not have automated backups enabled.`,
- resourceType: 'gcp-cloud-sql-instance',
- resourceId: inst.name,
- severity: 'medium',
- remediation:
- 'Enable automated backups (and point-in-time recovery) in the instance backup settings.',
- evidence: { projectId, instance: inst.name },
- });
+ for (const inst of instances) {
+ // Read replicas / on-prem instances can't configure their own backups
+ // (replicas are protected by the primary's backups) — don't fail them.
+ if (
+ inst.masterInstanceName ||
+ (inst.instanceType && inst.instanceType !== 'CLOUD_SQL_INSTANCE')
+ ) {
+ continue;
+ }
+ const enabled = inst.settings?.backupConfiguration?.enabled === true;
+ if (enabled) {
+ ctx.pass({
+ title: `Automated backups enabled: ${inst.name}`,
+ description: `Cloud SQL instance "${inst.name}" has automated backups enabled.`,
+ resourceType: 'gcp-cloud-sql-instance',
+ resourceId: `${projectId}/${inst.name}`,
+ evidence: { projectId, instance: inst.name },
+ });
+ } else {
+ ctx.fail({
+ title: `Automated backups disabled: ${inst.name}`,
+ description: `Cloud SQL instance "${inst.name}" does not have automated backups enabled.`,
+ resourceType: 'gcp-cloud-sql-instance',
+ resourceId: `${projectId}/${inst.name}`,
+ severity: 'medium',
+ remediation:
+ 'Enable automated backups (and point-in-time recovery) in the instance backup settings.',
+ evidence: { projectId, instance: inst.name },
+ });
+ }
}
+ } catch (err) {
+ ctx.warn(
+ `GCP Cloud SQL backups check: failed to evaluate project ${projectId} — skipping`,
+ { projectId, error: err instanceof Error ? err.message : String(err) },
+ );
+ continue;
}
}
},
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 8463b0b56f..65c34ade08 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
@@ -35,45 +35,54 @@ export const cloudSqlSslCheck: IntegrationCheck = {
}
for (const projectId of projectIds) {
- const instances = await gcpListItems(
- ctx,
- `https://sqladmin.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/instances`,
- );
- if (instances.length === 0) continue;
+ 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 ip = inst.settings?.ipConfiguration;
- // sslMode is authoritative when present; fall back to legacy requireSsl.
- const sslEnforced =
- typeof ip?.sslMode === 'string'
- ? SECURE_SSL_MODES.has(ip.sslMode)
- : ip?.requireSsl === true;
+ for (const inst of instances) {
+ const ip = inst.settings?.ipConfiguration;
+ // sslMode is authoritative when present; fall back to legacy requireSsl.
+ const sslEnforced =
+ typeof ip?.sslMode === 'string'
+ ? SECURE_SSL_MODES.has(ip.sslMode)
+ : ip?.requireSsl === true;
- if (sslEnforced) {
- ctx.pass({
- title: `SSL/TLS enforced: ${inst.name}`,
- description: `Cloud SQL instance "${inst.name}" requires encrypted connections.`,
- resourceType: 'gcp-cloud-sql-instance',
- resourceId: inst.name,
- evidence: {
- projectId,
- instance: inst.name,
- sslMode: ip?.sslMode ?? null,
- requireSsl: ip?.requireSsl ?? null,
- },
- });
- } else {
- ctx.fail({
- title: `SSL/TLS not enforced: ${inst.name}`,
- description: `Cloud SQL instance "${inst.name}" does not require SSL/TLS for connections.`,
- resourceType: 'gcp-cloud-sql-instance',
- resourceId: inst.name,
- severity: 'medium',
- remediation:
- 'Set the SSL mode to ENCRYPTED_ONLY (or require trusted client certificates) to enforce encrypted connections.',
- evidence: { projectId, instance: inst.name },
- });
+ if (sslEnforced) {
+ ctx.pass({
+ title: `SSL/TLS enforced: ${inst.name}`,
+ description: `Cloud SQL instance "${inst.name}" requires encrypted connections.`,
+ resourceType: 'gcp-cloud-sql-instance',
+ resourceId: `${projectId}/${inst.name}`,
+ evidence: {
+ projectId,
+ instance: inst.name,
+ sslMode: ip?.sslMode ?? null,
+ requireSsl: ip?.requireSsl ?? null,
+ },
+ });
+ } else {
+ ctx.fail({
+ title: `SSL/TLS not enforced: ${inst.name}`,
+ description: `Cloud SQL instance "${inst.name}" does not require SSL/TLS for connections.`,
+ resourceType: 'gcp-cloud-sql-instance',
+ resourceId: `${projectId}/${inst.name}`,
+ severity: 'medium',
+ remediation:
+ 'Set the SSL mode to ENCRYPTED_ONLY (or require trusted client certificates) to enforce encrypted connections.',
+ evidence: { projectId, instance: inst.name },
+ });
+ }
}
+ } catch (err) {
+ ctx.warn(
+ `GCP Cloud SQL SSL check: failed to evaluate project "${projectId}" — ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ );
+ continue;
}
}
},
diff --git a/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts b/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
index 7266eb0e72..d573b82b87 100644
--- a/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
+++ b/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
@@ -52,79 +52,89 @@ export const iamPrimitiveRolesCheck: IntegrationCheck = {
}
for (const projectId of projectIds) {
- const projectBindings = await getBindings(
- ctx,
- `v3/projects/${encodeURIComponent(projectId)}`,
- );
- if (projectBindings === null) continue; // can't read project policy → no assertion
-
- // Resolve the ancestry (folders/org) so inherited bindings are evaluated.
- let hierarchyFullyEvaluated = true;
- const scopes: Array<{ label: string; bindings: IamBinding[] }> = [
- { label: `Project "${projectId}"`, bindings: projectBindings },
- ];
try {
- const anc = await ctx.post<{
- ancestor?: Array<{ resourceId?: { type?: string; id?: string } }>;
- }>(`/v1/projects/${encodeURIComponent(projectId)}:getAncestry`, {});
- for (const a of anc.ancestor ?? []) {
- const type = a.resourceId?.type;
- const id = a.resourceId?.id;
- if (!id || type === 'project') continue;
- const resource =
- type === 'organization'
- ? `v3/organizations/${id}`
- : type === 'folder'
- ? `v3/folders/${id}`
- : null;
- if (!resource) continue;
- const bindings = await getBindings(ctx, resource);
- if (bindings === null) {
- hierarchyFullyEvaluated = false; // couldn't read this ancestor
- continue;
+ const projectBindings = await getBindings(
+ ctx,
+ `v3/projects/${encodeURIComponent(projectId)}`,
+ );
+ if (projectBindings === null) continue; // can't read project policy → no assertion
+
+ // Resolve the ancestry (folders/org) so inherited bindings are evaluated.
+ let hierarchyFullyEvaluated = true;
+ const scopes: Array<{ label: string; bindings: IamBinding[] }> = [
+ { label: `Project "${projectId}"`, bindings: projectBindings },
+ ];
+ try {
+ const anc = await ctx.post<{
+ ancestor?: Array<{ resourceId?: { type?: string; id?: string } }>;
+ }>(`/v1/projects/${encodeURIComponent(projectId)}:getAncestry`, {});
+ for (const a of anc.ancestor ?? []) {
+ const type = a.resourceId?.type;
+ const id = a.resourceId?.id;
+ if (!id || type === 'project') continue;
+ const resource =
+ type === 'organization'
+ ? `v3/organizations/${id}`
+ : type === 'folder'
+ ? `v3/folders/${id}`
+ : null;
+ if (!resource) continue;
+ const bindings = await getBindings(ctx, resource);
+ if (bindings === null) {
+ hierarchyFullyEvaluated = false; // couldn't read this ancestor
+ continue;
+ }
+ scopes.push({ label: `${type} ${id}`, bindings });
}
- scopes.push({ label: `${type} ${id}`, bindings });
+ } catch {
+ hierarchyFullyEvaluated = false;
}
- } catch {
- hierarchyFullyEvaluated = false;
- }
- let violations = 0;
- for (const scope of scopes) {
- for (const binding of scope.bindings) {
- const severity = PRIMITIVE_ROLES[binding.role];
- const members = binding.members ?? [];
- if (severity && members.length > 0) {
- violations++;
- ctx.fail({
- title: `Primitive role in use: ${binding.role}`,
- description: `${scope.label} grants the primitive role "${binding.role}" to ${members.length} member(s). Primitive roles violate least privilege.`,
+ let violations = 0;
+ for (const scope of scopes) {
+ for (const binding of scope.bindings) {
+ const severity = PRIMITIVE_ROLES[binding.role];
+ const members = binding.members ?? [];
+ if (severity && members.length > 0) {
+ violations++;
+ ctx.fail({
+ title: `Primitive role in use: ${binding.role}`,
+ description: `${scope.label} grants the primitive role "${binding.role}" to ${members.length} member(s). Primitive roles violate least privilege.`,
+ resourceType: 'gcp-project',
+ resourceId: projectId,
+ severity,
+ remediation: `Replace "${binding.role}" bindings with least-privilege predefined or custom roles.`,
+ evidence: { projectId, scope: scope.label, role: binding.role, memberCount: members.length },
+ });
+ }
+ }
+ }
+
+ if (violations === 0) {
+ if (hierarchyFullyEvaluated) {
+ ctx.pass({
+ title: 'No primitive owner/editor roles (project + inherited)',
+ description: `Project "${projectId}" and its inherited folders/organization have no primitive (owner/editor) role bindings.`,
resourceType: 'gcp-project',
resourceId: projectId,
- severity,
- remediation: `Replace "${binding.role}" bindings with least-privilege predefined or custom roles.`,
- evidence: { projectId, scope: scope.label, role: binding.role, memberCount: members.length },
+ evidence: { projectId, scope: 'project+inherited', inheritedBindingsEvaluated: true },
});
+ } else {
+ // Inherited bindings couldn't be fully read — don't satisfy the task
+ // on incomplete data.
+ ctx.log(
+ `GCP IAM: inherited bindings for "${projectId}" not fully readable; not asserting a pass`,
+ );
}
}
- }
-
- if (violations === 0) {
- if (hierarchyFullyEvaluated) {
- ctx.pass({
- title: 'No primitive owner/editor roles (project + inherited)',
- description: `Project "${projectId}" and its inherited folders/organization have no primitive (owner/editor) role bindings.`,
- resourceType: 'gcp-project',
- resourceId: projectId,
- evidence: { projectId, scope: 'project+inherited', inheritedBindingsEvaluated: true },
- });
- } else {
- // Inherited bindings couldn't be fully read — don't satisfy the task
- // on incomplete data.
- ctx.log(
- `GCP IAM: inherited bindings for "${projectId}" not fully readable; not asserting a pass`,
- );
- }
+ } catch (error) {
+ // One project's API error must not abort the whole check.
+ ctx.warn(
+ `GCP IAM: failed to evaluate primitive roles for "${projectId}": ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ );
+ continue;
}
}
},
diff --git a/packages/integration-platform/src/manifests/gcp/checks/shared.ts b/packages/integration-platform/src/manifests/gcp/checks/shared.ts
index 8f84306eae..f20a763777 100644
--- a/packages/integration-platform/src/manifests/gcp/checks/shared.ts
+++ b/packages/integration-platform/src/manifests/gcp/checks/shared.ts
@@ -9,8 +9,14 @@ import type { CheckContext } from '../../../types';
*/
export async function resolveGcpProjectIds(ctx: CheckContext): Promise {
const selected = ctx.variables.project_ids;
- if (Array.isArray(selected) && selected.length > 0) {
- return selected.filter((p): p is string => typeof p === 'string');
+ if (Array.isArray(selected)) {
+ // Sanitize: keep only non-empty, trimmed string ids. If nothing valid
+ // remains, fall through to discovery rather than returning [] and skipping.
+ const cleaned = selected
+ .filter((p): p is string => typeof p === 'string')
+ .map((p) => p.trim())
+ .filter((p) => p.length > 0);
+ if (cleaned.length > 0) return cleaned;
}
try {
@@ -61,6 +67,12 @@ export async function gcpListItems(
typeof data.nextPageToken === 'string' ? data.nextPageToken : undefined;
pages++;
} while (pageToken && pages < 50);
+ if (pageToken) {
+ ctx.warn('GCP list hit the page cap; results may be truncated', {
+ url,
+ pages,
+ });
+ }
return out;
}
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 865c8092ec..6e3439cf96 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
@@ -20,7 +20,7 @@ export const storagePublicAccessCheck: IntegrationCheck = {
id: 'gcp-storage-no-public-access',
name: 'Cloud Storage — no public access',
description:
- 'Verify Cloud Storage buckets enforce uniform bucket-level access and public access prevention.',
+ 'Verify Cloud Storage buckets enforce uniform bucket-level access so object permissions are managed through IAM rather than public ACLs.',
service: 'cloud-storage',
taskMapping: TASK_TEMPLATES.productionFirewallNopublicaccessControls,
@@ -32,54 +32,48 @@ export const storagePublicAccessCheck: IntegrationCheck = {
}
for (const projectId of projectIds) {
- 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
+ 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
- let violations = 0;
- for (const bucket of buckets) {
- const iam = bucket.iamConfiguration;
- if (iam?.uniformBucketLevelAccess?.enabled !== true) {
- violations++;
- ctx.fail({
- title: `Uniform bucket-level access disabled: ${bucket.name}`,
- description: `Bucket "${bucket.name}" allows fine-grained ACLs, which can expose individual objects publicly.`,
- resourceType: 'gcp-storage-bucket',
- resourceId: bucket.name,
- severity: 'medium',
- remediation:
- 'Enable uniform bucket-level access so permissions are managed exclusively through IAM.',
- evidence: { projectId, bucket: bucket.name },
- });
+ let violations = 0;
+ for (const bucket of buckets) {
+ const iam = bucket.iamConfiguration;
+ // Only uniform bucket-level access drives the public-exposure FAIL.
+ // publicAccessPrevention 'inherited'/undefined can come from an
+ // enforcing org policy, so it is not a reliable per-bucket signal
+ // and must not produce a false failure here.
+ if (iam?.uniformBucketLevelAccess?.enabled !== true) {
+ violations++;
+ ctx.fail({
+ title: `Uniform bucket-level access disabled: ${bucket.name}`,
+ description: `Bucket "${bucket.name}" allows fine-grained ACLs, which can expose individual objects publicly.`,
+ resourceType: 'gcp-storage-bucket',
+ resourceId: `${projectId}/${bucket.name}`,
+ severity: 'medium',
+ remediation:
+ 'Enable uniform bucket-level access so permissions are managed exclusively through IAM.',
+ evidence: { projectId, bucket: bucket.name },
+ });
+ }
}
- if (iam?.publicAccessPrevention !== 'enforced') {
- violations++;
- ctx.fail({
- title: `Public access prevention not enforced at the bucket level: ${bucket.name}`,
- description: `Bucket "${bucket.name}" does not set public access prevention to "enforced" at the bucket level (current: ${iam?.publicAccessPrevention ?? 'inherited'}). If an org policy enforces it, this inherits — verify the org policy or set it explicitly on the bucket.`,
- resourceType: 'gcp-storage-bucket',
- resourceId: bucket.name,
- severity: 'medium',
- remediation:
- 'Set public access prevention to "enforced" to block all public access regardless of IAM/ACLs.',
- evidence: {
- projectId,
- bucket: bucket.name,
- publicAccessPrevention: iam?.publicAccessPrevention ?? null,
- },
+
+ if (violations === 0) {
+ ctx.pass({
+ title: 'Cloud Storage not publicly accessible',
+ description: `All ${buckets.length} bucket(s) in "${projectId}" enforce uniform bucket-level access.`,
+ resourceType: 'gcp-project',
+ resourceId: projectId,
+ evidence: { projectId, bucketCount: buckets.length },
});
}
- }
-
- if (violations === 0) {
- ctx.pass({
- title: 'Cloud Storage not publicly accessible',
- description: `All ${buckets.length} bucket(s) in "${projectId}" enforce uniform access and public access prevention.`,
- resourceType: 'gcp-project',
- resourceId: projectId,
- evidence: { projectId, bucketCount: buckets.length },
+ } catch (err) {
+ ctx.warn('GCP storage check failed for project; skipping', {
+ projectId,
+ error: err instanceof Error ? err.message : String(err),
});
}
}
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 072c245f09..0def968bef 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
@@ -20,14 +20,19 @@ const SENSITIVE_PORTS: Array<{
];
/**
- * VPC firewall check (direct API, no SCC). Flags enabled INGRESS firewall rules
- * open to 0.0.0.0/0 that expose SSH (22), RDP (3389), or all ports/protocols.
+ * VPC firewall-rules check (direct API, no SCC). Flags enabled INGRESS VPC
+ * firewall rules open to 0.0.0.0/0 that expose SSH (22), RDP (3389), or all
+ * ports/protocols.
+ *
+ * Scope: this evaluates VPC firewall rules only (global/firewalls). It does NOT
+ * evaluate hierarchical (org/folder) or network firewall policies, so the pass
+ * evidence records `firewallPoliciesEvaluated: false` to avoid over-claiming.
*/
export const vpcOpenFirewallsCheck: IntegrationCheck = {
id: 'gcp-vpc-no-open-firewalls',
- name: 'VPC — no firewalls open to the internet',
+ name: 'VPC — no firewall rules open to the internet',
description:
- 'Flags enabled INGRESS firewall rules that allow 0.0.0.0/0 to SSH, RDP, or all ports.',
+ 'Flags enabled INGRESS VPC firewall rules that allow 0.0.0.0/0 to SSH, RDP, or all ports. Evaluates VPC firewall rules only (not hierarchical/network firewall policies).',
service: 'vpc-network',
taskMapping: TASK_TEMPLATES.productionFirewallNopublicaccessControls,
@@ -39,62 +44,75 @@ export const vpcOpenFirewallsCheck: IntegrationCheck = {
}
for (const projectId of projectIds) {
- const rules = await gcpListItems(
- ctx,
- `https://compute.googleapis.com/compute/v1/projects/${encodeURIComponent(projectId)}/global/firewalls`,
- );
- if (rules.length === 0) continue;
+ try {
+ const rules = await gcpListItems(
+ ctx,
+ `https://compute.googleapis.com/compute/v1/projects/${encodeURIComponent(projectId)}/global/firewalls`,
+ );
- let violations = 0;
- for (const rule of rules) {
- if (rule.disabled === true) continue;
- if (rule.direction && rule.direction !== 'INGRESS') continue;
- const srcs = rule.sourceRanges ?? [];
- const openRanges = srcs.filter((r) => r === '0.0.0.0/0' || r === '::/0');
- if (openRanges.length === 0) continue;
- const openLabel = openRanges.join(' / ');
+ let violations = 0;
+ for (const rule of rules) {
+ if (rule.disabled === true) continue;
+ if (rule.direction && rule.direction !== 'INGRESS') continue;
+ const srcs = rule.sourceRanges ?? [];
+ const openRanges = srcs.filter((r) => r === '0.0.0.0/0' || r === '::/0');
+ if (openRanges.length === 0) continue;
+ const openLabel = openRanges.join(' / ');
- const allowed = rule.allowed ?? [];
- if (allowed.some((a) => a.IPProtocol === 'all')) {
- violations++;
- ctx.fail({
- title: `Firewall open to internet (all ports): ${rule.name}`,
- description: `Firewall rule "${rule.name}" allows ALL protocols/ports from ${openLabel}.`,
- resourceType: 'gcp-firewall-rule',
- resourceId: rule.name,
- severity: 'critical',
- remediation: `Remove the public source range(s) (${openLabel}); restrict source ranges to known CIDRs and limit allowed protocols/ports to only what is required.`,
- evidence: { projectId, rule: rule.name, openRanges },
- });
- continue;
- }
-
- const tcpTuples = allowed.filter(
- (a) => a.IPProtocol === 'tcp' || a.IPProtocol === '6',
- );
- for (const { port, label, severity } of SENSITIVE_PORTS) {
- if (tcpTuples.some((t) => portsCover(t.ports, port))) {
+ const allowed = rule.allowed ?? [];
+ if (allowed.some((a) => a.IPProtocol === 'all')) {
violations++;
ctx.fail({
- title: `${label} open to internet: ${rule.name}`,
- description: `Firewall rule "${rule.name}" allows ${label} (port ${port}) from ${openLabel}.`,
+ title: `Firewall open to internet (all ports): ${rule.name}`,
+ description: `Firewall rule "${rule.name}" allows ALL protocols/ports from ${openLabel}.`,
resourceType: 'gcp-firewall-rule',
- resourceId: rule.name,
- severity,
- remediation: `Remove the public source range(s) (${openLabel}) for port ${port}; restrict ${label} access to a VPN, bastion, or known CIDR ranges.`,
- evidence: { projectId, rule: rule.name, port, openRanges },
+ resourceId: `${projectId}/${rule.name}`,
+ severity: 'critical',
+ remediation: `Remove the public source range(s) (${openLabel}); restrict source ranges to known CIDRs and limit allowed protocols/ports to only what is required.`,
+ evidence: { projectId, rule: rule.name, openRanges },
});
+ continue;
+ }
+
+ const tcpTuples = allowed.filter(
+ (a) => a.IPProtocol === 'tcp' || a.IPProtocol === '6',
+ );
+ for (const { port, label, severity } of SENSITIVE_PORTS) {
+ if (tcpTuples.some((t) => portsCover(t.ports, port))) {
+ violations++;
+ ctx.fail({
+ title: `${label} open to internet: ${rule.name}`,
+ description: `Firewall rule "${rule.name}" allows ${label} (port ${port}) from ${openLabel}.`,
+ resourceType: 'gcp-firewall-rule',
+ resourceId: `${projectId}/${rule.name}`,
+ severity,
+ remediation: `Remove the public source range(s) (${openLabel}) for port ${port}; restrict ${label} access to a VPN, bastion, or known CIDR ranges.`,
+ evidence: { projectId, rule: rule.name, port, openRanges },
+ });
+ }
}
}
- }
- if (violations === 0) {
- ctx.pass({
- title: 'No firewalls open to the internet',
- description: `No firewall rule in "${projectId}" exposes SSH/RDP/all-ports to 0.0.0.0/0 (${rules.length} rule(s) checked).`,
- resourceType: 'gcp-project',
- resourceId: projectId,
- evidence: { projectId, ruleCount: rules.length },
+ if (violations === 0) {
+ // Zero ingress rules = implied-deny = compliant, so a project with no
+ // firewall rules passes (ruleCount 0) rather than being skipped.
+ ctx.pass({
+ title: 'No VPC firewall rules open to the internet',
+ description: `No VPC firewall rule in "${projectId}" exposes SSH/RDP/all-ports to 0.0.0.0/0 (${rules.length} rule(s) checked). Scope: VPC firewall rules only — hierarchical/network firewall policies were not evaluated.`,
+ resourceType: 'gcp-project',
+ resourceId: projectId,
+ evidence: {
+ projectId,
+ ruleCount: rules.length,
+ scope: 'vpc-firewall-rules-only',
+ firewallPoliciesEvaluated: false,
+ },
+ });
+ }
+ } catch (err) {
+ ctx.warn('GCP VPC firewall check failed for project; skipping', {
+ projectId,
+ error: err instanceof Error ? err.message : String(err),
});
}
}
From 6ef7ad0705afc525e0337a5ecea689419cd3ca45 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Tue, 2 Jun 2026 09:31:23 -0400
Subject: [PATCH 06/13] fix(integration-platform): extend round-4 patterns to
sibling check files
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Proactive hardening from an adversarial audit of every cloud check file —
applies the same patterns cubic established (per-item resilience, fail-closed
on unverified reads, scoped resourceIds, no silent truncation) to the files it
hadn't deep-reviewed, so they don't surface as the next review round:
- AWS EC2 + RDS: wrap each region in try/catch so one opted-out / restricted /
throttled region no longer aborts the whole scan (matches the GCP per-project
fix). RDS instance/cluster resourceIds are now region-scoped
(`${region}/${id}`) — RDS identifiers are user-chosen and only unique per
region, so same-named DBs across regions previously collided.
- AWS CloudTrail: an otherwise-compliant trail whose GetTrailStatus can't be
read now emits a "could not verify" failure instead of nothing — consistent
with the error-reads-never-silent-pass rule applied to Azure monitor/sql/
entra-id; an unverified control must not be recorded as satisfied.
- GCP project auto-discovery: paginate via nextPageToken (bounded, warns at the
cap) instead of evaluating only the first 50 projects and silently dropping
the rest, which would yield false "all clean" evidence for unscanned projects.
Audit confirmed the remaining candidates were already handled (gcpListItems
paginates + warns; Azure SSE is always-on; diagnosticSettings don't paginate;
NSG loop has no per-item I/O). Tests: +1 discovery-pagination regression, +1
cloudtrail unverified-status regression updated. 157 pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../aws/checks/__tests__/aws-checks.test.ts | 9 +-
.../src/manifests/aws/checks/cloudtrail.ts | 22 +++-
.../src/manifests/aws/checks/ec2.ts | 54 +++++----
.../src/manifests/aws/checks/rds.ts | 107 +++++++++++-------
.../gcp/checks/__tests__/gcp-checks.test.ts | 25 ++++
.../src/manifests/gcp/checks/shared.ts | 32 +++++-
6 files changed, 171 insertions(+), 78 deletions(-)
diff --git a/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
index af1d7797d4..9b68fc5920 100644
--- a/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
@@ -224,12 +224,15 @@ describe('AWS CloudTrail evaluator', () => {
expect(out[0]!.severity).toBe('medium');
});
- it('emits nothing when an otherwise-compliant trail status is unreadable', () => {
+ it('fails "could not verify" when an otherwise-compliant trail status is unreadable', () => {
// multi-region + validated, but GetTrailStatus failed → loggingKnown=false.
- // We must not assert a false "not logging" failure on unverified data.
+ // Must not assert a false "not logging" failure, but also must not silently
+ // pass — emit a "could not verify" failure so the control isn't satisfied.
const out = evaluateCloudTrail([
{ name: 't1', multiRegion: true, logValidation: true, logging: false, loggingKnown: false },
]);
- expect(out).toHaveLength(0);
+ expect(out).toHaveLength(1);
+ expect(out[0]!.kind).toBe('fail');
+ expect(out[0]!.title).toMatch(/Could not verify/);
});
});
diff --git a/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts b/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
index 087667ed1c..460cc98497 100644
--- a/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
@@ -39,13 +39,27 @@ export function evaluateCloudTrail(trails: TrailInfo[]): CheckOutcome[] {
];
}
// No confirmed-good trail. If an otherwise-compliant (multi-region + validated)
- // candidate exists whose logging status could not be read, we cannot assert a
- // failure on unverified data — emit nothing rather than a false negative.
- const unverifiableCandidate = trails.some(
+ // candidate exists whose logging status could not be read, we must NOT record
+ // a clean run on unverified data (ERROR-READS-NEVER-SILENT-PASS) — but we also
+ // can't assert it is actively NOT logging. Emit a "could not verify" failure
+ // so the control isn't silently treated as satisfied.
+ const unverifiableCandidate = trails.find(
(t) => t.multiRegion && t.logValidation && t.loggingKnown === false,
);
if (unverifiableCandidate) {
- return [];
+ return [
+ {
+ kind: 'fail',
+ title: 'Could not verify CloudTrail logging status',
+ description: `Trail "${unverifiableCandidate.name}" is multi-region with log file validation, but its logging status (GetTrailStatus) could not be read, so active logging is unverified.`,
+ resourceType: 'aws-cloudtrail',
+ resourceId: unverifiableCandidate.name,
+ severity: 'medium',
+ remediation:
+ 'Grant cloudtrail:GetTrailStatus to the integration role so logging status can be verified, then re-run the check.',
+ evidence: { trail: unverifiableCandidate.name },
+ },
+ ];
}
if (trails.length === 0) {
return [
diff --git a/packages/integration-platform/src/manifests/aws/checks/ec2.ts b/packages/integration-platform/src/manifests/aws/checks/ec2.ts
index 274b4109ac..76171ebd7e 100644
--- a/packages/integration-platform/src/manifests/aws/checks/ec2.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/ec2.ts
@@ -96,30 +96,38 @@ export const ec2SecurityGroupsCheck: IntegrationCheck = {
}
const sgs: SgInfo[] = [];
for (const region of session.regions) {
- const ec2 = new EC2Client({ region, credentials: session.credentials });
- let token: string | undefined;
- do {
- const resp = await ec2.send(
- new DescribeSecurityGroupsCommand({ NextToken: token, MaxResults: 1000 }),
+ // Isolate per-region failures (opted-out/disabled regions, throttling)
+ // so one region's error doesn't abort scanning of the others.
+ try {
+ const ec2 = new EC2Client({ region, credentials: session.credentials });
+ let token: string | undefined;
+ do {
+ const resp = await ec2.send(
+ new DescribeSecurityGroupsCommand({ NextToken: token, MaxResults: 1000 }),
+ );
+ for (const sg of resp.SecurityGroups ?? []) {
+ sgs.push({
+ groupId: sg.GroupId ?? 'unknown',
+ groupName: sg.GroupName,
+ region,
+ permissions: (sg.IpPermissions ?? []).map((p) => ({
+ ipProtocol: p.IpProtocol ?? '-1',
+ fromPort: p.FromPort,
+ toPort: p.ToPort,
+ cidrs: [
+ ...(p.IpRanges ?? []).map((r) => r.CidrIp),
+ ...(p.Ipv6Ranges ?? []).map((r) => r.CidrIpv6),
+ ].filter((c): c is string => typeof c === 'string'),
+ })),
+ });
+ }
+ token = resp.NextToken;
+ } while (token);
+ } catch (err) {
+ ctx.log(
+ `EC2: could not list security groups in ${region}: ${err instanceof Error ? err.message : String(err)}`,
);
- for (const sg of resp.SecurityGroups ?? []) {
- sgs.push({
- groupId: sg.GroupId ?? 'unknown',
- groupName: sg.GroupName,
- region,
- permissions: (sg.IpPermissions ?? []).map((p) => ({
- ipProtocol: p.IpProtocol ?? '-1',
- fromPort: p.FromPort,
- toPort: p.ToPort,
- cidrs: [
- ...(p.IpRanges ?? []).map((r) => r.CidrIp),
- ...(p.Ipv6Ranges ?? []).map((r) => r.CidrIpv6),
- ].filter((c): c is string => typeof c === 'string'),
- })),
- });
- }
- token = resp.NextToken;
- } while (token);
+ }
}
if (sgs.length === 0) return;
emitOutcomes(ctx, evaluateSecurityGroups(sgs));
diff --git a/packages/integration-platform/src/manifests/aws/checks/rds.ts b/packages/integration-platform/src/manifests/aws/checks/rds.ts
index 79e20af5bf..1a053cba58 100644
--- a/packages/integration-platform/src/manifests/aws/checks/rds.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/rds.ts
@@ -43,7 +43,7 @@ export function evaluateRdsEncryption(instances: RdsInstanceInfo[]): CheckOutcom
title: `RDS storage encrypted: ${i.id}`,
description: `RDS instance "${i.id}" (${i.region}) has storage encryption enabled.`,
resourceType: 'aws-rds-instance',
- resourceId: i.id,
+ resourceId: `${i.region}/${i.id}`,
evidence: { instance: i.id, region: i.region },
}
: {
@@ -51,7 +51,7 @@ export function evaluateRdsEncryption(instances: RdsInstanceInfo[]): CheckOutcom
title: `RDS storage not encrypted: ${i.id}`,
description: `RDS instance "${i.id}" (${i.region}) does not have storage encryption enabled.`,
resourceType: 'aws-rds-instance',
- resourceId: i.id,
+ resourceId: `${i.region}/${i.id}`,
severity: 'high',
remediation:
'Enable storage encryption (encryption at rest must be set at creation; restore from an encrypted snapshot to remediate).',
@@ -72,7 +72,7 @@ export function evaluateRdsBackups(instances: RdsInstanceInfo[]): CheckOutcome[]
title: `RDS automated backups enabled: ${i.id}`,
description: `RDS instance "${i.id}" (${i.region}) retains backups for ${i.backupRetentionDays} day(s).`,
resourceType: 'aws-rds-instance',
- resourceId: i.id,
+ resourceId: `${i.region}/${i.id}`,
evidence: { instance: i.id, backupRetentionDays: i.backupRetentionDays },
}
: {
@@ -80,7 +80,7 @@ export function evaluateRdsBackups(instances: RdsInstanceInfo[]): CheckOutcome[]
title: `RDS automated backups disabled: ${i.id}`,
description: `RDS instance "${i.id}" (${i.region}) has automated backups disabled (retention 0).`,
resourceType: 'aws-rds-instance',
- resourceId: i.id,
+ resourceId: `${i.region}/${i.id}`,
severity: 'medium',
remediation: 'Set a backup retention period of at least 7 days.',
evidence: { instance: i.id },
@@ -96,7 +96,7 @@ export function evaluateRdsClusterEncryption(clusters: RdsClusterInfo[]): CheckO
title: `RDS cluster storage encrypted: ${c.id}`,
description: `RDS cluster "${c.id}" (${c.region}) has storage encryption enabled.`,
resourceType: 'aws-rds-cluster',
- resourceId: c.id,
+ resourceId: `${c.region}/${c.id}`,
evidence: { cluster: c.id, region: c.region },
}
: {
@@ -104,7 +104,7 @@ export function evaluateRdsClusterEncryption(clusters: RdsClusterInfo[]): CheckO
title: `RDS cluster storage not encrypted: ${c.id}`,
description: `RDS cluster "${c.id}" (${c.region}) does not have storage encryption enabled.`,
resourceType: 'aws-rds-cluster',
- resourceId: c.id,
+ resourceId: `${c.region}/${c.id}`,
severity: 'high',
remediation:
'Enable storage encryption (encryption at rest must be set at creation; restore from an encrypted snapshot to remediate).',
@@ -121,7 +121,7 @@ export function evaluateRdsClusterBackups(clusters: RdsClusterInfo[]): CheckOutc
title: `RDS cluster automated backups enabled: ${c.id}`,
description: `RDS cluster "${c.id}" (${c.region}) retains backups for ${c.backupRetentionDays} day(s).`,
resourceType: 'aws-rds-cluster',
- resourceId: c.id,
+ resourceId: `${c.region}/${c.id}`,
evidence: { cluster: c.id, backupRetentionDays: c.backupRetentionDays },
}
: {
@@ -129,7 +129,7 @@ export function evaluateRdsClusterBackups(clusters: RdsClusterInfo[]): CheckOutc
title: `RDS cluster automated backups disabled: ${c.id}`,
description: `RDS cluster "${c.id}" (${c.region}) has automated backups disabled (retention 0).`,
resourceType: 'aws-rds-cluster',
- resourceId: c.id,
+ resourceId: `${c.region}/${c.id}`,
severity: 'medium',
remediation: 'Set a backup retention period of at least 7 days.',
evidence: { cluster: c.id },
@@ -137,46 +137,65 @@ export function evaluateRdsClusterBackups(clusters: RdsClusterInfo[]): CheckOutc
);
}
-async function listRdsInstances(session: AwsSession): Promise {
+async function listRdsInstances(
+ session: AwsSession,
+ ctx: CheckContext,
+): Promise {
const out: RdsInstanceInfo[] = [];
for (const region of session.regions) {
- const rds = new RDSClient({ region, credentials: session.credentials });
- let marker: string | undefined;
- do {
- const resp = await rds.send(new DescribeDBInstancesCommand({ Marker: marker }));
- for (const db of resp.DBInstances ?? []) {
- out.push({
- id: db.DBInstanceIdentifier ?? 'unknown',
- region,
- encrypted: db.StorageEncrypted === true,
- backupRetentionDays: db.BackupRetentionPeriod ?? 0,
- engine: db.Engine ?? '',
- });
- }
- marker = resp.Marker;
- } while (marker);
+ // Isolate per-region failures so one bad region doesn't abort the rest.
+ try {
+ const rds = new RDSClient({ region, credentials: session.credentials });
+ let marker: string | undefined;
+ do {
+ const resp = await rds.send(new DescribeDBInstancesCommand({ Marker: marker }));
+ for (const db of resp.DBInstances ?? []) {
+ out.push({
+ id: db.DBInstanceIdentifier ?? 'unknown',
+ region,
+ encrypted: db.StorageEncrypted === true,
+ backupRetentionDays: db.BackupRetentionPeriod ?? 0,
+ engine: db.Engine ?? '',
+ });
+ }
+ marker = resp.Marker;
+ } while (marker);
+ } catch (err) {
+ ctx.log(
+ `RDS: could not list DB instances in ${region}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
}
return out;
}
-async function listRdsClusters(session: AwsSession): Promise {
+async function listRdsClusters(
+ session: AwsSession,
+ ctx: CheckContext,
+): Promise {
const out: RdsClusterInfo[] = [];
for (const region of session.regions) {
- const rds = new RDSClient({ region, credentials: session.credentials });
- let marker: string | undefined;
- do {
- const resp = await rds.send(new DescribeDBClustersCommand({ Marker: marker }));
- for (const cluster of resp.DBClusters ?? []) {
- out.push({
- id: cluster.DBClusterIdentifier ?? 'unknown',
- region,
- encrypted: cluster.StorageEncrypted === true,
- backupRetentionDays: cluster.BackupRetentionPeriod ?? 0,
- engine: cluster.Engine ?? '',
- });
- }
- marker = resp.Marker;
- } while (marker);
+ try {
+ const rds = new RDSClient({ region, credentials: session.credentials });
+ let marker: string | undefined;
+ do {
+ const resp = await rds.send(new DescribeDBClustersCommand({ Marker: marker }));
+ for (const cluster of resp.DBClusters ?? []) {
+ out.push({
+ id: cluster.DBClusterIdentifier ?? 'unknown',
+ region,
+ encrypted: cluster.StorageEncrypted === true,
+ backupRetentionDays: cluster.BackupRetentionPeriod ?? 0,
+ engine: cluster.Engine ?? '',
+ });
+ }
+ marker = resp.Marker;
+ } while (marker);
+ } catch (err) {
+ ctx.log(
+ `RDS: could not list DB clusters in ${region}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
}
return out;
}
@@ -196,8 +215,8 @@ export const rdsEncryptionCheck: IntegrationCheck = {
// Evaluate non-Aurora DB instances at the instance level and DB clusters
// (Aurora / Multi-AZ) at the cluster level — instance-level StorageEncrypted
// is unreliable for Aurora and produces false failures.
- const instances = await listRdsInstances(session);
- const clusters = await listRdsClusters(session);
+ const instances = await listRdsInstances(session, ctx);
+ const clusters = await listRdsClusters(session, ctx);
if (instances.length === 0 && clusters.length === 0) return;
emitOutcomes(ctx, evaluateRdsEncryption(instances));
emitOutcomes(ctx, evaluateRdsClusterEncryption(clusters));
@@ -219,8 +238,8 @@ export const rdsBackupsCheck: IntegrationCheck = {
// Evaluate non-Aurora DB instances at the instance level and DB clusters
// (Aurora / Multi-AZ) at the cluster level — instance-level
// BackupRetentionPeriod is unreliable for Aurora and produces false failures.
- const instances = await listRdsInstances(session);
- const clusters = await listRdsClusters(session);
+ const instances = await listRdsInstances(session, ctx);
+ const clusters = await listRdsClusters(session, ctx);
if (instances.length === 0 && clusters.length === 0) return;
emitOutcomes(ctx, evaluateRdsBackups(instances));
emitOutcomes(ctx, evaluateRdsClusterBackups(clusters));
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 4b73b4db8b..62763b2016 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
@@ -183,6 +183,31 @@ describe('GCP Cloud Storage public-access check', () => {
});
});
+describe('GCP project auto-discovery', () => {
+ it('paginates discovered projects (follows nextPageToken) so all are evaluated', async () => {
+ const secureBucket = {
+ items: [
+ { name: 'b', iamConfiguration: { uniformBucketLevelAccess: { enabled: true } } },
+ ],
+ };
+ const { passed } = await runCheck(storagePublicAccessCheck, {
+ variables: {}, // no project_ids → forces auto-discovery
+ fetch: (url) => {
+ if (url.includes('organizations:search')) return { organizations: [] };
+ // page 2 must be matched before the generic projects branch
+ if (url.includes('pageToken=tok2')) return { projects: [{ projectId: 'p2' }] };
+ if (url.includes('/v1/projects')) {
+ return { projects: [{ projectId: 'p1' }], nextPageToken: 'tok2' };
+ }
+ if (url.includes('storage/v1/b')) return secureBucket;
+ return {};
+ },
+ });
+ // both the first- and second-page projects were scanned
+ expect(passed.map((p) => p.resourceId).sort()).toEqual(['p1', 'p2']);
+ });
+});
+
describe('GCP VPC open-firewalls check', () => {
it('flags RDP (3389) open to 0.0.0.0/0 as critical', async () => {
const { failed } = await runCheck(vpcOpenFirewallsCheck, {
diff --git a/packages/integration-platform/src/manifests/gcp/checks/shared.ts b/packages/integration-platform/src/manifests/gcp/checks/shared.ts
index f20a763777..23221de326 100644
--- a/packages/integration-platform/src/manifests/gcp/checks/shared.ts
+++ b/packages/integration-platform/src/manifests/gcp/checks/shared.ts
@@ -30,10 +30,34 @@ export async function resolveGcpProjectIds(ctx: CheckContext): Promise
const filter = orgId
? `lifecycleState:ACTIVE AND parent.id:${orgId}`
: 'lifecycleState:ACTIVE';
- const data = await ctx.fetch<{ projects?: Array<{ projectId: string }> }>(
- `/v1/projects?filter=${encodeURIComponent(filter)}&pageSize=50`,
- );
- return (data.projects ?? []).map((p) => p.projectId).slice(0, 50);
+ // Page through all discoverable projects (bounded) rather than evaluating
+ // only the first page — silently dropping projects would produce false
+ // "all clean" evidence for the projects that were never scanned.
+ const projectIds: string[] = [];
+ let pageToken: string | undefined;
+ let pages = 0;
+ do {
+ const tokenParam = pageToken
+ ? `&pageToken=${encodeURIComponent(pageToken)}`
+ : '';
+ const data: {
+ projects?: Array<{ projectId: string }>;
+ nextPageToken?: string;
+ } = await ctx.fetch(
+ `/v1/projects?filter=${encodeURIComponent(filter)}&pageSize=100${tokenParam}`,
+ );
+ for (const p of data.projects ?? []) projectIds.push(p.projectId);
+ pageToken =
+ typeof data.nextPageToken === 'string' ? data.nextPageToken : undefined;
+ pages++;
+ } while (pageToken && pages < 20);
+ if (pageToken) {
+ ctx.warn(
+ 'GCP project auto-discovery hit the page cap; some projects may not be evaluated — set project_ids to scope explicitly',
+ { pages, discovered: projectIds.length },
+ );
+ }
+ return projectIds;
} catch (err) {
ctx.warn('GCP project auto-discovery failed; checks will be skipped', {
error: err instanceof Error ? err.message : String(err),
From 3ec918ad14a57b59cace4805673cbac303d14096 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Tue, 2 Jun 2026 10:30:54 -0400
Subject: [PATCH 07/13] fix(integration-platform): resolve 3 P1s from cubic
review of fix commits
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- GCP Cloud Storage public-access: uniform bucket-level access alone does NOT
prevent public access — a bucket is public if its IAM policy grants
allUsers/allAuthenticatedUsers. Now reads each bucket's IAM policy:
publicAccessPrevention 'enforced' passes definitively (no IAM read); a public
IAM member fails (high); an unreadable policy fails "could not verify" (never
a silent pass); UBLA-disabled remains a medium finding (object ACLs can't be
verified from the bucket policy). Keeps the round-4 fix of not failing on
publicAccessPrevention 'inherited' alone.
- AWS EC2 + RDS: a per-region read failure was logged but swallowed, so a total
(or partial) read failure could end with no findings — a silent clean run.
Now each failed region is surfaced as a medium "could not verify" failure
(EC2 inline; RDS via failUnverifiedRegions over both instance and cluster
scans), consistent with the error-reads-never-silent-pass rule. session.regions
is the customer-configured region set, so this only fires on genuine failures.
Tests: +3 storage regressions (allUsers→fail high, PAP-enforced→pass without IAM
read, IAM-read-error→could-not-verify); discovery test updated for per-bucket
resourceId. 160 pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../src/manifests/aws/checks/ec2.ts | 16 ++
.../src/manifests/aws/checks/rds.ts | 63 ++++++--
.../gcp/checks/__tests__/gcp-checks.test.ts | 92 +++++++++++-
.../gcp/checks/storage-public-access.ts | 139 +++++++++++++-----
4 files changed, 255 insertions(+), 55 deletions(-)
diff --git a/packages/integration-platform/src/manifests/aws/checks/ec2.ts b/packages/integration-platform/src/manifests/aws/checks/ec2.ts
index 76171ebd7e..642a47da81 100644
--- a/packages/integration-platform/src/manifests/aws/checks/ec2.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/ec2.ts
@@ -95,6 +95,7 @@ export const ec2SecurityGroupsCheck: IntegrationCheck = {
return;
}
const sgs: SgInfo[] = [];
+ const failedRegions: string[] = [];
for (const region of session.regions) {
// Isolate per-region failures (opted-out/disabled regions, throttling)
// so one region's error doesn't abort scanning of the others.
@@ -124,11 +125,26 @@ export const ec2SecurityGroupsCheck: IntegrationCheck = {
token = resp.NextToken;
} while (token);
} catch (err) {
+ failedRegions.push(region);
ctx.log(
`EC2: could not list security groups in ${region}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
+ // A region we couldn't read is unverified — surface it instead of letting a
+ // total/partial read failure end as a silent clean run (no findings).
+ if (failedRegions.length > 0) {
+ ctx.fail({
+ title: 'Could not verify security groups in some regions',
+ description: `Security groups could not be listed in: ${failedRegions.join(', ')}. Internet exposure in those regions is unverified.`,
+ resourceType: 'aws-security-group',
+ resourceId: `regions:${failedRegions.join(',')}`,
+ severity: 'medium',
+ remediation:
+ 'Ensure the integration role can call ec2:DescribeSecurityGroups in all enabled regions, then re-run the check.',
+ evidence: { failedRegions },
+ });
+ }
if (sgs.length === 0) return;
emitOutcomes(ctx, evaluateSecurityGroups(sgs));
},
diff --git a/packages/integration-platform/src/manifests/aws/checks/rds.ts b/packages/integration-platform/src/manifests/aws/checks/rds.ts
index 1a053cba58..b0a556738f 100644
--- a/packages/integration-platform/src/manifests/aws/checks/rds.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/rds.ts
@@ -137,11 +137,18 @@ export function evaluateRdsClusterBackups(clusters: RdsClusterInfo[]): CheckOutc
);
}
+interface RegionScan {
+ items: T[];
+ /** Regions whose listing call failed — their resources are unverified. */
+ failedRegions: string[];
+}
+
async function listRdsInstances(
session: AwsSession,
ctx: CheckContext,
-): Promise {
- const out: RdsInstanceInfo[] = [];
+): Promise> {
+ const items: RdsInstanceInfo[] = [];
+ const failedRegions: string[] = [];
for (const region of session.regions) {
// Isolate per-region failures so one bad region doesn't abort the rest.
try {
@@ -150,7 +157,7 @@ async function listRdsInstances(
do {
const resp = await rds.send(new DescribeDBInstancesCommand({ Marker: marker }));
for (const db of resp.DBInstances ?? []) {
- out.push({
+ items.push({
id: db.DBInstanceIdentifier ?? 'unknown',
region,
encrypted: db.StorageEncrypted === true,
@@ -161,19 +168,21 @@ async function listRdsInstances(
marker = resp.Marker;
} while (marker);
} catch (err) {
+ failedRegions.push(region);
ctx.log(
`RDS: could not list DB instances in ${region}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
- return out;
+ return { items, failedRegions };
}
async function listRdsClusters(
session: AwsSession,
ctx: CheckContext,
-): Promise {
- const out: RdsClusterInfo[] = [];
+): Promise> {
+ const items: RdsClusterInfo[] = [];
+ const failedRegions: string[] = [];
for (const region of session.regions) {
try {
const rds = new RDSClient({ region, credentials: session.credentials });
@@ -181,7 +190,7 @@ async function listRdsClusters(
do {
const resp = await rds.send(new DescribeDBClustersCommand({ Marker: marker }));
for (const cluster of resp.DBClusters ?? []) {
- out.push({
+ items.push({
id: cluster.DBClusterIdentifier ?? 'unknown',
region,
encrypted: cluster.StorageEncrypted === true,
@@ -192,12 +201,36 @@ async function listRdsClusters(
marker = resp.Marker;
} while (marker);
} catch (err) {
+ failedRegions.push(region);
ctx.log(
`RDS: could not list DB clusters in ${region}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
- return out;
+ return { items, failedRegions };
+}
+
+/**
+ * Emit a "could not verify" failure for regions whose RDS listing failed so a
+ * total/partial read failure isn't recorded as a silent clean run.
+ */
+function failUnverifiedRegions(
+ ctx: CheckContext,
+ failedRegions: string[],
+ what: string,
+): void {
+ if (failedRegions.length === 0) return;
+ const regions = [...new Set(failedRegions)];
+ ctx.fail({
+ title: `Could not verify RDS ${what} in some regions`,
+ description: `RDS resources could not be listed in: ${regions.join(', ')}, so ${what} in those regions is unverified.`,
+ resourceType: 'aws-rds',
+ resourceId: `regions:${regions.join(',')}`,
+ severity: 'medium',
+ remediation:
+ 'Ensure the integration role can describe RDS instances and clusters in all enabled regions, then re-run the check.',
+ evidence: { failedRegions: regions },
+ });
}
export const rdsEncryptionCheck: IntegrationCheck = {
@@ -217,9 +250,10 @@ export const rdsEncryptionCheck: IntegrationCheck = {
// is unreliable for Aurora and produces false failures.
const instances = await listRdsInstances(session, ctx);
const clusters = await listRdsClusters(session, ctx);
- if (instances.length === 0 && clusters.length === 0) return;
- emitOutcomes(ctx, evaluateRdsEncryption(instances));
- emitOutcomes(ctx, evaluateRdsClusterEncryption(clusters));
+ failUnverifiedRegions(ctx, [...instances.failedRegions, ...clusters.failedRegions], 'encryption');
+ if (instances.items.length === 0 && clusters.items.length === 0) return;
+ emitOutcomes(ctx, evaluateRdsEncryption(instances.items));
+ emitOutcomes(ctx, evaluateRdsClusterEncryption(clusters.items));
},
};
@@ -240,8 +274,9 @@ export const rdsBackupsCheck: IntegrationCheck = {
// BackupRetentionPeriod is unreliable for Aurora and produces false failures.
const instances = await listRdsInstances(session, ctx);
const clusters = await listRdsClusters(session, ctx);
- if (instances.length === 0 && clusters.length === 0) return;
- emitOutcomes(ctx, evaluateRdsBackups(instances));
- emitOutcomes(ctx, evaluateRdsClusterBackups(clusters));
+ failUnverifiedRegions(ctx, [...instances.failedRegions, ...clusters.failedRegions], 'backups');
+ if (instances.items.length === 0 && clusters.items.length === 0) return;
+ emitOutcomes(ctx, evaluateRdsBackups(instances.items));
+ emitOutcomes(ctx, evaluateRdsClusterBackups(clusters.items));
},
};
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 62763b2016..481682da4d 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
@@ -181,15 +181,83 @@ describe('GCP Cloud Storage public-access check', () => {
expect(passed).toHaveLength(0);
expect(failed).toHaveLength(0);
});
+
+ it('fails (high) a bucket whose IAM policy grants allUsers (UBLA alone is not enough)', async () => {
+ const { passed, failed } = await runCheck(storagePublicAccessCheck, {
+ fetch: (url) => {
+ if (url.includes('/iam')) {
+ return { bindings: [{ role: 'roles/storage.objectViewer', members: ['allUsers'] }] };
+ }
+ return {
+ items: [
+ {
+ name: 'b1',
+ iamConfiguration: {
+ uniformBucketLevelAccess: { enabled: true },
+ publicAccessPrevention: 'inherited',
+ },
+ },
+ ],
+ };
+ },
+ });
+ expect(passed).toHaveLength(0);
+ expect(failed).toHaveLength(1);
+ expect(failed[0]!.severity).toBe('high');
+ expect(failed[0]!.title).toMatch(/publicly accessible/);
+ });
+
+ it('passes a bucket with publicAccessPrevention enforced without reading IAM', async () => {
+ let iamRead = false;
+ const { passed, failed } = await runCheck(storagePublicAccessCheck, {
+ fetch: (url) => {
+ if (url.includes('/iam')) {
+ iamRead = true;
+ return { bindings: [{ role: 'roles/storage.objectViewer', members: ['allUsers'] }] };
+ }
+ return {
+ items: [
+ {
+ name: 'b1',
+ iamConfiguration: {
+ uniformBucketLevelAccess: { enabled: false },
+ publicAccessPrevention: 'enforced',
+ },
+ },
+ ],
+ };
+ },
+ });
+ expect(iamRead).toBe(false); // enforced is definitive; no IAM read needed
+ expect(failed).toHaveLength(0);
+ expect(passed).toHaveLength(1);
+ });
+
+ it('fails "could not verify" when a bucket IAM policy cannot be read', async () => {
+ const { passed, failed } = await runCheck(storagePublicAccessCheck, {
+ fetch: (url) => {
+ if (url.includes('/iam')) throw new Error('403 forbidden');
+ return {
+ items: [
+ {
+ name: 'b1',
+ iamConfiguration: {
+ uniformBucketLevelAccess: { enabled: true },
+ publicAccessPrevention: 'inherited',
+ },
+ },
+ ],
+ };
+ },
+ });
+ expect(passed).toHaveLength(0);
+ expect(failed).toHaveLength(1);
+ expect(failed[0]!.title).toMatch(/Could not verify/);
+ });
});
describe('GCP project auto-discovery', () => {
it('paginates discovered projects (follows nextPageToken) so all are evaluated', async () => {
- const secureBucket = {
- items: [
- { name: 'b', iamConfiguration: { uniformBucketLevelAccess: { enabled: true } } },
- ],
- };
const { passed } = await runCheck(storagePublicAccessCheck, {
variables: {}, // no project_ids → forces auto-discovery
fetch: (url) => {
@@ -199,12 +267,20 @@ describe('GCP project auto-discovery', () => {
if (url.includes('/v1/projects')) {
return { projects: [{ projectId: 'p1' }], nextPageToken: 'tok2' };
}
- if (url.includes('storage/v1/b')) return secureBucket;
+ // bucket IAM policy read (must precede the bucket-list branch)
+ if (url.includes('/iam')) return { bindings: [] };
+ if (url.includes('storage/v1/b')) {
+ return {
+ items: [
+ { name: 'b', iamConfiguration: { uniformBucketLevelAccess: { enabled: true } } },
+ ],
+ };
+ }
return {};
},
});
- // both the first- and second-page projects were scanned
- expect(passed.map((p) => p.resourceId).sort()).toEqual(['p1', 'p2']);
+ // both the first- and second-page projects were scanned (per-bucket resourceId)
+ expect(passed.map((p) => p.resourceId).sort()).toEqual(['p1/b', 'p2/b']);
});
});
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 6e3439cf96..88a06a77f6 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
@@ -11,16 +11,25 @@ interface Bucket {
};
}
+interface BucketIamPolicy {
+ bindings?: Array<{ role?: string; members?: string[] }>;
+}
+
+const PUBLIC_MEMBERS = new Set(['allUsers', 'allAuthenticatedUsers']);
+
/**
- * Cloud Storage public-access check (direct API, no SCC). Uses bucket metadata
- * only (no per-bucket IAM calls) to flag buckets that don't enforce uniform
- * bucket-level access or public access prevention.
+ * Cloud Storage public-access check (direct API, no SCC). A bucket is public if
+ * its IAM policy grants a role to `allUsers`/`allAuthenticatedUsers`, so uniform
+ * bucket-level access alone is NOT sufficient — we read each bucket's IAM
+ * policy. `publicAccessPrevention: 'enforced'` definitively blocks public access
+ * (regardless of IAM/ACLs) and is treated as compliant; 'inherited'/undefined is
+ * ambiguous (may be enforced by org policy) so it is not itself a failure.
*/
export const storagePublicAccessCheck: IntegrationCheck = {
id: 'gcp-storage-no-public-access',
name: 'Cloud Storage — no public access',
description:
- 'Verify Cloud Storage buckets enforce uniform bucket-level access so object permissions are managed through IAM rather than public ACLs.',
+ 'Verify Cloud Storage buckets are not granted to allUsers/allAuthenticatedUsers and enforce uniform bucket-level access.',
service: 'cloud-storage',
taskMapping: TASK_TEMPLATES.productionFirewallNopublicaccessControls,
@@ -39,36 +48,8 @@ export const storagePublicAccessCheck: IntegrationCheck = {
);
if (buckets.length === 0) continue; // nothing to evidence for this project
- let violations = 0;
for (const bucket of buckets) {
- const iam = bucket.iamConfiguration;
- // Only uniform bucket-level access drives the public-exposure FAIL.
- // publicAccessPrevention 'inherited'/undefined can come from an
- // enforcing org policy, so it is not a reliable per-bucket signal
- // and must not produce a false failure here.
- if (iam?.uniformBucketLevelAccess?.enabled !== true) {
- violations++;
- ctx.fail({
- title: `Uniform bucket-level access disabled: ${bucket.name}`,
- description: `Bucket "${bucket.name}" allows fine-grained ACLs, which can expose individual objects publicly.`,
- resourceType: 'gcp-storage-bucket',
- resourceId: `${projectId}/${bucket.name}`,
- severity: 'medium',
- remediation:
- 'Enable uniform bucket-level access so permissions are managed exclusively through IAM.',
- evidence: { projectId, bucket: bucket.name },
- });
- }
- }
-
- if (violations === 0) {
- ctx.pass({
- title: 'Cloud Storage not publicly accessible',
- description: `All ${buckets.length} bucket(s) in "${projectId}" enforce uniform bucket-level access.`,
- resourceType: 'gcp-project',
- resourceId: projectId,
- evidence: { projectId, bucketCount: buckets.length },
- });
+ await evaluateBucket(ctx, projectId, bucket);
}
} catch (err) {
ctx.warn('GCP storage check failed for project; skipping', {
@@ -79,3 +60,95 @@ export const storagePublicAccessCheck: IntegrationCheck = {
}
},
};
+
+async function evaluateBucket(
+ ctx: CheckContext,
+ projectId: string,
+ bucket: Bucket,
+): Promise {
+ const iam = bucket.iamConfiguration;
+ const resourceId = `${projectId}/${bucket.name}`;
+
+ // Public Access Prevention 'enforced' blocks all public access regardless of
+ // IAM bindings or ACLs — definitively compliant, no IAM read needed.
+ if (iam?.publicAccessPrevention === 'enforced') {
+ ctx.pass({
+ title: `Public access prevention enforced: ${bucket.name}`,
+ description: `Bucket "${bucket.name}" enforces public access prevention, which blocks all anonymous access.`,
+ resourceType: 'gcp-storage-bucket',
+ resourceId,
+ evidence: { projectId, bucket: bucket.name, publicAccessPrevention: 'enforced' },
+ });
+ return;
+ }
+
+ // Otherwise the authoritative signal is the bucket IAM policy: a binding to
+ // allUsers/allAuthenticatedUsers makes the bucket public. UBLA alone does not
+ // prevent this, so we must read the policy rather than infer from metadata.
+ let policy: BucketIamPolicy;
+ try {
+ policy = await ctx.fetch(
+ `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucket.name)}/iam`,
+ );
+ } catch (err) {
+ // Couldn't read the policy → unverified, never a silent pass.
+ ctx.fail({
+ title: `Could not verify public access: ${bucket.name}`,
+ description: `Bucket "${bucket.name}" IAM policy could not be read, so public access is unverified.`,
+ resourceType: 'gcp-storage-bucket',
+ resourceId,
+ severity: 'medium',
+ remediation:
+ 'Grant storage.buckets.getIamPolicy (e.g. roles/storage.legacyBucketReader or Viewer) to the connection, then re-run.',
+ evidence: {
+ projectId,
+ bucket: bucket.name,
+ error: err instanceof Error ? err.message : String(err),
+ },
+ });
+ return;
+ }
+
+ const publicMembers = (policy.bindings ?? [])
+ .flatMap((b) => b.members ?? [])
+ .filter((m) => PUBLIC_MEMBERS.has(m));
+
+ if (publicMembers.length > 0) {
+ ctx.fail({
+ title: `Bucket publicly accessible: ${bucket.name}`,
+ description: `Bucket "${bucket.name}" grants access to ${[...new Set(publicMembers)].join(', ')} via its IAM policy.`,
+ resourceType: 'gcp-storage-bucket',
+ resourceId,
+ severity: 'high',
+ remediation:
+ 'Remove allUsers/allAuthenticatedUsers from the bucket IAM policy and enable public access prevention.',
+ evidence: { projectId, bucket: bucket.name, publicMembers: [...new Set(publicMembers)] },
+ });
+ return;
+ }
+
+ if (iam?.uniformBucketLevelAccess?.enabled !== true) {
+ // No public IAM bindings, but fine-grained ACLs are enabled — individual
+ // objects can still be made public via ACLs, which can't be verified from
+ // the bucket policy. Flag it rather than pass on incomplete coverage.
+ ctx.fail({
+ title: `Uniform bucket-level access disabled: ${bucket.name}`,
+ description: `Bucket "${bucket.name}" allows fine-grained ACLs, so individual objects can be exposed publicly via ACLs (not covered by the bucket IAM policy).`,
+ resourceType: 'gcp-storage-bucket',
+ resourceId,
+ severity: 'medium',
+ remediation:
+ 'Enable uniform bucket-level access so permissions are managed exclusively through IAM.',
+ evidence: { projectId, bucket: bucket.name },
+ });
+ return;
+ }
+
+ ctx.pass({
+ title: `No public access: ${bucket.name}`,
+ description: `Bucket "${bucket.name}" has no allUsers/allAuthenticatedUsers IAM bindings and enforces uniform bucket-level access.`,
+ resourceType: 'gcp-storage-bucket',
+ resourceId,
+ evidence: { projectId, bucket: bucket.name },
+ });
+}
From 239aea4b131ac5492afc26c9fc23d2b5208753c9 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Tue, 2 Jun 2026 13:06:40 -0400
Subject: [PATCH 08/13] fix(integration-platform): resolve cubic findings on
latest commit (4 of 5)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reviewed the 5 findings from cubic's review of the merge commit; fixed the 4
genuine ones (the 5th is a deliberate design choice, see below):
- GCP project auto-discovery (P1): dropped the org/parent filter. A `parent.id`
filter without `parent.type` is ambiguous AND silently excludes folder-nested
projects — both drop projects that should be scanned. Now lists every active
accessible project (lifecycleState:ACTIVE), paginated; users scope via
project_ids. Removes the org-search round-trip entirely.
- AWS S3 (P2): indeterminate buckets (encryption / Block-Public-Access read
failed) were filtered out, so an all-unreadable account passed with no
findings. Both evaluators now emit a medium "could not verify" per
indeterminate bucket instead of dropping it — consistent with the
error-reads-never-silent-pass rule used for EC2/RDS/CloudTrail.
- Azure subscription auto-detect (P2): no longer falls back to the first
subscription regardless of state (could be Disabled/PastDue → failing calls);
selects an Enabled subscription or returns null so the check no-ops cleanly.
- ServiceCard (P2): URL-encode the dynamic path/query segments of the service
detail link.
Deferred (P3): the AWS STS assume-role "duplication" — the cited helper
(aws/helpers/aws-client.ts) is pre-existing, used by no check, and returns
pre-built service clients with a legacy access-key branch; the per-region
checks intentionally use their own raw-credential helper. Coupling them would
be worse, and independent copies are the agreed design.
Tests: +1 S3 all-indeterminate regression; encryption test updated. 161 pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../[slug]/components/ServiceCard.tsx | 4 +-
.../aws/checks/__tests__/aws-checks.test.ts | 18 +++++--
.../src/manifests/aws/checks/s3.ts | 49 ++++++++++++++-----
.../src/manifests/azure/checks/shared.ts | 6 ++-
.../src/manifests/gcp/checks/shared.ts | 21 +++-----
5 files changed, 67 insertions(+), 31 deletions(-)
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.tsx
index 99eddffd97..ddb9ec0d02 100644
--- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.tsx
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.tsx
@@ -125,8 +125,8 @@ export function ServiceCard({ service, connectionId, orgId, slug }: ServiceCardP
const taskCount = service.mappedTasks?.length ?? 0;
const href =
- `/${orgId}/integrations/${slug}/services/${service.id}` +
- (connectionId ? `?connectionId=${connectionId}` : '');
+ `/${encodeURIComponent(orgId)}/integrations/${encodeURIComponent(slug)}/services/${encodeURIComponent(service.id)}` +
+ (connectionId ? `?connectionId=${encodeURIComponent(connectionId)}` : '');
return (
{
- it('encryption: pass when encrypted, fail (high) when not, skip indeterminate', () => {
+ it('encryption: pass when encrypted, fail (high) when not, "could not verify" (medium) when indeterminate', () => {
const out = evaluateS3Encryption([
{ name: 'a', encrypted: true, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: null },
{ name: 'b', encrypted: false, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: null },
- // read error → indeterminate → excluded (no false high finding)
+ // read error → indeterminate → "could not verify" (not a false high, not silently dropped)
{ name: 'c', encrypted: false, encryptionDetermined: false, publicAccessDetermined: true, bucketBpa: null },
]);
- expect(out).toHaveLength(2);
+ expect(out).toHaveLength(3);
expect(out[0]!.kind).toBe('pass');
expect(out[1]!.kind).toBe('fail');
expect(out[1]!.severity).toBe('high');
+ expect(out[2]!.kind).toBe('fail');
+ expect(out[2]!.severity).toBe('medium');
+ expect(out[2]!.title).toMatch(/Could not verify/);
+ });
+
+ it('encryption: all-indeterminate buckets do not pass silently', () => {
+ const out = evaluateS3Encryption([
+ { name: 'x', encrypted: false, encryptionDetermined: false, publicAccessDetermined: true, bucketBpa: null },
+ ]);
+ expect(out).toHaveLength(1);
+ expect(out[0]!.kind).toBe('fail');
+ expect(out[0]!.severity).toBe('medium');
});
it('public access: bucket-level all-blocked passes, missing fails', () => {
diff --git a/packages/integration-platform/src/manifests/aws/checks/s3.ts b/packages/integration-platform/src/manifests/aws/checks/s3.ts
index f02c979cc2..dfc70569f9 100644
--- a/packages/integration-platform/src/manifests/aws/checks/s3.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/s3.ts
@@ -43,10 +43,24 @@ function isFullyBlocked(bucket: BpaFlags | null, account: BpaFlags | null): bool
}
export function evaluateS3Encryption(buckets: S3BucketInfo[]): CheckOutcome[] {
- return buckets
- .filter((b) => b.encryptionDetermined)
- .map((b) =>
- b.encrypted
+ return buckets.map((b): CheckOutcome => {
+ if (!b.encryptionDetermined) {
+ // Read failed → unverified. Don't assert a false "no encryption" (high),
+ // but don't silently drop it either (that would let an all-unreadable
+ // account pass with no findings).
+ return {
+ kind: 'fail',
+ title: `Could not verify encryption: ${b.name}`,
+ description: `Encryption status for bucket "${b.name}" could not be read, so it is unverified.`,
+ resourceType: 'aws-s3-bucket',
+ resourceId: b.name,
+ severity: 'medium',
+ remediation:
+ 'Grant s3:GetEncryptionConfiguration to the integration role so default encryption can be verified, then re-run.',
+ evidence: { bucket: b.name },
+ };
+ }
+ return b.encrypted
? {
kind: 'pass',
title: `Default encryption enabled: ${b.name}`,
@@ -64,18 +78,29 @@ export function evaluateS3Encryption(buckets: S3BucketInfo[]): CheckOutcome[] {
severity: 'high',
remediation: 'Enable default encryption (SSE-S3 or SSE-KMS) on the bucket.',
evidence: { bucket: b.name },
- },
- );
+ };
+ });
}
export function evaluateS3PublicAccess(
buckets: S3BucketInfo[],
accountBpa: BpaFlags | null,
): CheckOutcome[] {
- return buckets
- .filter((b) => b.publicAccessDetermined)
- .map((b) =>
- isFullyBlocked(b.bucketBpa, accountBpa)
+ return buckets.map((b): CheckOutcome => {
+ if (!b.publicAccessDetermined) {
+ return {
+ kind: 'fail',
+ title: `Could not verify public access: ${b.name}`,
+ description: `Block Public Access status for bucket "${b.name}" could not be read, so its public-access posture is unverified.`,
+ resourceType: 'aws-s3-bucket',
+ resourceId: b.name,
+ severity: 'medium',
+ remediation:
+ 'Grant s3:GetBucketPublicAccessBlock to the integration role so public-access settings can be verified, then re-run.',
+ evidence: { bucket: b.name },
+ };
+ }
+ return isFullyBlocked(b.bucketBpa, accountBpa)
? {
kind: 'pass',
title: `Public access blocked: ${b.name}`,
@@ -93,8 +118,8 @@ export function evaluateS3PublicAccess(
severity: 'high',
remediation: 'Enable all four S3 Block Public Access settings on the bucket (or account).',
evidence: { bucket: b.name },
- },
- );
+ };
+ });
}
async function gatherBuckets(
diff --git a/packages/integration-platform/src/manifests/azure/checks/shared.ts b/packages/integration-platform/src/manifests/azure/checks/shared.ts
index f99f725511..58de6f8965 100644
--- a/packages/integration-platform/src/manifests/azure/checks/shared.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/shared.ts
@@ -19,7 +19,11 @@ export async function resolveAzureSubscriptionId(
value?: Array<{ subscriptionId: string; state?: string }>;
}>(`${ARM}/subscriptions?api-version=2020-01-01`);
const subs = data.value ?? [];
- const active = subs.find((s) => s.state === 'Enabled') ?? subs[0];
+ // Only auto-select an Enabled subscription. Falling back to the first
+ // subscription regardless of state could pick a Disabled/PastDue one whose
+ // API calls fail; returning null instead makes the check no-op cleanly (the
+ // user can set subscription_id explicitly).
+ const active = subs.find((s) => s.state === 'Enabled');
return active?.subscriptionId ?? null;
} catch (err) {
ctx.warn(
diff --git a/packages/integration-platform/src/manifests/gcp/checks/shared.ts b/packages/integration-platform/src/manifests/gcp/checks/shared.ts
index 23221de326..3dd0416c6c 100644
--- a/packages/integration-platform/src/manifests/gcp/checks/shared.ts
+++ b/packages/integration-platform/src/manifests/gcp/checks/shared.ts
@@ -20,19 +20,14 @@ export async function resolveGcpProjectIds(ctx: CheckContext): Promise
}
try {
- const orgData = await ctx.fetch<{
- organizations?: Array<{ name: string; state?: string }>;
- }>('https://cloudresourcemanager.googleapis.com/v3/organizations:search');
- const activeOrg = (orgData.organizations ?? []).find(
- (o) => o.state === 'ACTIVE',
- );
- const orgId = activeOrg?.name?.replace('organizations/', '');
- const filter = orgId
- ? `lifecycleState:ACTIVE AND parent.id:${orgId}`
- : 'lifecycleState:ACTIVE';
- // Page through all discoverable projects (bounded) rather than evaluating
- // only the first page — silently dropping projects would produce false
- // "all clean" evidence for the projects that were never scanned.
+ // List every active project the connection can access. We intentionally do
+ // NOT scope by organization/parent: a `parent.id` filter without
+ // `parent.type` is ambiguous, AND parent-scoping silently excludes
+ // folder-nested projects, both of which would drop projects that should be
+ // evaluated. Users scope to a subset explicitly via the project_ids
+ // variable. Page through all results (bounded) rather than the first page —
+ // silently dropping projects would produce false "all clean" evidence.
+ const filter = 'lifecycleState:ACTIVE';
const projectIds: string[] = [];
let pageToken: string | undefined;
let pages = 0;
From b1b55791eddd69ecb15f6a30de15f060b0ac7080 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Tue, 2 Jun 2026 16:41:15 -0400
Subject: [PATCH 09/13] fix(integration-platform): resolve 4 cubic findings
(RBAC gate + read-error states)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ServiceDetailView (P2, security): the Cloud Tests scanning toggle had no RBAC
gate, so a view-only user could flip a setting the API restricts to
integration:update. Now gated with usePermissions().hasPermission('integration',
'update') — users without it see a read-only "Scanning on/off" status; the
toggle handler also early-returns without the permission.
- ServiceDetailView (P2, UX): status showed "Checking…" forever when the
provider has no active connection (null connectionId was treated as loading).
Added an explicit "Not connected" state and split loading/error/baseline.
- AWS KMS (P1): the rotation check returned with no findings when rotation
status was unreadable for all eligible keys — masking a permission gap as a
clean run. Now each eligible key emits an outcome; unreadable status →
medium "could not verify". Only no-ops when there are no eligible keys.
- AWS CloudTrail (P1): a failed DescribeTrails in every region produced an
empty trail list, which evaluated to a false high "No CloudTrail configured".
Now tracks failed regions and, when no trails were found AND a region failed,
reports a medium "could not verify" instead of a fabricated finding.
Tests: KMS test updated + all-unreadable regression; +1 KMS regression. 162 pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../components/ServiceDetailView.tsx | 36 ++++++++++++++-----
.../aws/checks/__tests__/aws-checks.test.ts | 18 ++++++++--
.../src/manifests/aws/checks/cloudtrail.ts | 19 ++++++++++
.../src/manifests/aws/checks/kms.ts | 35 +++++++++++++-----
4 files changed, 88 insertions(+), 20 deletions(-)
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx
index 4c3cbf3661..4add5d37aa 100644
--- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx
@@ -1,6 +1,7 @@
'use client';
import { useConnectionServices } from '@/hooks/use-integration-platform';
+import { usePermissions } from '@/hooks/use-permissions';
import { Breadcrumb, Stack } from '@trycompai/design-system';
import type {
ConnectionListItemResponse,
@@ -68,11 +69,15 @@ export function ServiceDetailView({
isLoading: servicesLoading,
error: servicesError,
} = useConnectionServices(effectiveConnectionId);
+ // Toggling a service calls PUT /connections/:id/services, which the API gates
+ // behind integration:update — gate the control the same way on the client.
+ const { hasPermission } = usePermissions();
+ const canUpdate = hasPermission('integration', 'update');
+
const liveService = connectionServices.find((s) => s.id === service.id);
const isEnabled = liveService?.enabled ?? false;
const isImplemented = service.implemented !== false;
- const servicesLoaded =
- Boolean(effectiveConnectionId) && !servicesLoading && !servicesError;
+ const hasConnection = Boolean(effectiveConnectionId);
// Only services present in the connection's live service list can be toggled.
// (e.g. AWS baseline services are always scanned and aren't in the toggle list.)
const isManageable = Boolean(liveService);
@@ -85,7 +90,7 @@ export function ServiceDetailView({
const mappedTasks = service.mappedTasks ?? [];
const handleToggle = async () => {
- if (!effectiveConnectionId || toggling || !liveService) return;
+ if (!effectiveConnectionId || toggling || !liveService || !canUpdate) return;
setToggling(true);
const next = !isEnabled;
try {
@@ -138,7 +143,23 @@ export function ServiceDetailView({
Couldn’t load connection
- ) : isManageable ? (
+ ) : !hasConnection ? (
+
+ Not connected
+
+ ) : servicesError ? (
+
+ Status unavailable
+
+ ) : servicesLoading ? (
+
+ Checking…
+
+ ) : !isManageable ? (
+
+ Always scanned
+
+ ) : canUpdate ? (
) : (
+ // Has the service but lacks integration:update → read-only status.
- {servicesLoaded
- ? 'Always scanned'
- : servicesError
- ? 'Status unavailable'
- : 'Checking…'}
+ {isEnabled ? 'Scanning on' : 'Scanning off'}
)}
diff --git a/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
index 12f9d5b105..b4b7860875 100644
--- a/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
@@ -191,18 +191,30 @@ describe('AWS RDS evaluators', () => {
});
describe('AWS KMS rotation evaluator', () => {
- it('evaluates only rotation-eligible keys with a known status', () => {
+ it('evaluates eligible keys; unreadable rotation status → could-not-verify (not dropped)', () => {
const out = evaluateKmsRotation([
{ keyId: 'sym-on', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: true, rotationEnabled: true },
{ keyId: 'sym-off', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: true, rotationEnabled: false },
// RSA/HMAC/etc. — not rotation-eligible → no finding
{ keyId: 'rsa', region: 'us-east-1', rotationEligible: false, rotationStatusKnown: false, rotationEnabled: false },
- // eligible but status unreadable → no fabricated finding
+ // eligible but status unreadable → "could not verify" (masking a permission gap as clean would be wrong)
{ keyId: 'unknown', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: false, rotationEnabled: false },
]);
- expect(out).toHaveLength(2);
+ expect(out).toHaveLength(3);
expect(out[0]!.kind).toBe('pass');
expect(out[1]!.kind).toBe('fail');
+ expect(out[2]!.kind).toBe('fail');
+ expect(out[2]!.severity).toBe('medium');
+ expect(out[2]!.title).toMatch(/Could not verify/);
+ });
+
+ it('does not pass silently when rotation status is unreadable for all eligible keys', () => {
+ const out = evaluateKmsRotation([
+ { keyId: 'k1', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: false, rotationEnabled: false },
+ ]);
+ expect(out).toHaveLength(1);
+ expect(out[0]!.kind).toBe('fail');
+ expect(out[0]!.title).toMatch(/Could not verify/);
});
});
diff --git a/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts b/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
index 460cc98497..387b469c08 100644
--- a/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
@@ -110,6 +110,7 @@ export const cloudTrailEnabledCheck: IntegrationCheck = {
// dedupe by TrailARN before evaluating.
const seenArns = new Set();
const trails: TrailInfo[] = [];
+ const failedRegions: string[] = [];
for (const region of session.regions) {
const ct = new CloudTrailClient({
@@ -122,6 +123,7 @@ export const cloudTrailEnabledCheck: IntegrationCheck = {
const resp = await ct.send(new DescribeTrailsCommand({}));
trailList = resp.trailList;
} catch (err) {
+ failedRegions.push(region);
ctx.log(
`CloudTrail: could not list trails in ${region}: ${err instanceof Error ? err.message : String(err)}`,
);
@@ -161,6 +163,23 @@ export const cloudTrailEnabledCheck: IntegrationCheck = {
}
}
+ // If we found no trails AND at least one region's DescribeTrails failed, we
+ // can't conclude "No CloudTrail configured" (that would be a false high on a
+ // permissions/transient error) — report it as unverified instead.
+ if (trails.length === 0 && failedRegions.length > 0) {
+ ctx.fail({
+ title: 'Could not verify CloudTrail configuration',
+ description: `CloudTrail trails could not be listed in: ${failedRegions.join(', ')}, so trail configuration is unverified.`,
+ resourceType: 'aws-cloudtrail',
+ resourceId: 'account',
+ severity: 'medium',
+ remediation:
+ 'Grant cloudtrail:DescribeTrails to the integration role in all enabled regions, then re-run the check.',
+ evidence: { failedRegions },
+ });
+ return;
+ }
+
emitOutcomes(ctx, evaluateCloudTrail(trails));
},
};
diff --git a/packages/integration-platform/src/manifests/aws/checks/kms.ts b/packages/integration-platform/src/manifests/aws/checks/kms.ts
index c83883ca79..74a2c58efe 100644
--- a/packages/integration-platform/src/manifests/aws/checks/kms.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/kms.ts
@@ -27,12 +27,29 @@ export interface KmsKeyInfo {
rotationEnabled: boolean;
}
-/** Only rotation-eligible keys with a known status are evaluated. */
+/**
+ * Every rotation-eligible key produces an outcome. A key whose rotation status
+ * couldn't be read is surfaced as "could not verify" (medium) rather than
+ * dropped — silently excluding it would let a permission gap pass as clean.
+ */
export function evaluateKmsRotation(keys: KmsKeyInfo[]): CheckOutcome[] {
return keys
- .filter((k) => k.rotationEligible && k.rotationStatusKnown)
- .map((k) =>
- k.rotationEnabled
+ .filter((k) => k.rotationEligible)
+ .map((k): CheckOutcome => {
+ if (!k.rotationStatusKnown) {
+ return {
+ kind: 'fail',
+ title: `Could not verify KMS key rotation: ${k.keyId}`,
+ description: `Rotation status for customer-managed KMS key "${k.keyId}" (${k.region}) could not be read, so rotation is unverified.`,
+ resourceType: 'aws-kms-key',
+ resourceId: k.keyId,
+ severity: 'medium',
+ remediation:
+ 'Grant kms:GetKeyRotationStatus to the integration role so rotation can be verified, then re-run.',
+ evidence: { keyId: k.keyId, region: k.region },
+ };
+ }
+ return k.rotationEnabled
? {
kind: 'pass',
title: `KMS key rotation enabled: ${k.keyId}`,
@@ -50,8 +67,8 @@ export function evaluateKmsRotation(keys: KmsKeyInfo[]): CheckOutcome[] {
severity: 'medium',
remediation: 'Enable automatic annual key rotation on the customer-managed KMS key.',
evidence: { keyId: k.keyId, region: k.region },
- },
- );
+ };
+ });
}
async function listKmsKeys(
@@ -121,8 +138,10 @@ export const kmsKeyRotationCheck: IntegrationCheck = {
return;
}
const keys = await listKmsKeys(ctx, session);
- // Nothing to evidence if there are no rotation-eligible keys.
- if (!keys.some((k) => k.rotationEligible && k.rotationStatusKnown)) return;
+ // Genuine no-op only when there are NO rotation-eligible keys at all. If
+ // eligible keys exist but their status is unreadable, evaluateKmsRotation
+ // emits "could not verify" rather than passing silently.
+ if (!keys.some((k) => k.rotationEligible)) return;
emitOutcomes(ctx, evaluateKmsRotation(keys));
},
};
From d59e4c7ca5f2b754fe553d92fca568e62cc1dace Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Tue, 2 Jun 2026 18:07:04 -0400
Subject: [PATCH 10/13] fix(integration-platform): never let a read failure end
as a silent/false verdict
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adversarial sweep of every check run()/helper against the verified engine
contract (an uncaught throw → status 'error' → task 'failed' [visible]; a run
that emits NO outcomes → task status UNCHANGED [silent/stale]). Fixes the real
silent-pass paths and converts read-error aborts into explicit "could not
verify" findings so a permission/transient failure can never read as compliant:
GCP (the genuine silent passes — per-project catch emitted ctx.warn + skipped,
so an all-projects-failed run left the task stale):
- vpc-open-firewalls, storage-public-access, cloud-sql-backups, cloud-sql-ssl,
iam-primitive-roles: per-project catch now emits a project-scoped "could not
verify" ctx.fail instead of warn-and-continue.
AWS:
- New resolveAwsSessionOrFail() wraps STS AssumeRole: an assume-role failure
now emits "could not assume AWS role" (could-not-verify) instead of aborting;
wired into all 6 AWS checks.
- Top-level list reads that previously threw uncaught now surface could-not-
verify: iam GetAccountSummary, kms ListKeys (per region), s3 ListBuckets
(both checks). kms DescribeKey failures already tracked as unreadable.
Azure:
- New armListAllOrFail() emits "could not verify " (and returns null)
when the primary ARM list throws; wired into storage (×3), sql (×3),
key-vault, network, and entra-id (role assignments + definitions). Each
caller now guards on null and stops rather than aborting the check.
Tests: +2 GCP regressions (storage/vpc project-read failure → could not verify,
not a silent pass). 164 pass. Build clean.
Pre-existing (NOT changed here — flagged separately, out of this PR's scope):
GcpProjectPicker RBAC gate, ActivitySection/provider.id URL encoding — they
live in pre-existing cloud-tests components this PR does not modify.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../src/manifests/aws/checks/cloudtrail.ts | 4 +-
.../src/manifests/aws/checks/ec2.ts | 4 +-
.../src/manifests/aws/checks/iam.ts | 27 +++++++--
.../src/manifests/aws/checks/kms.ts | 59 +++++++++++++++----
.../src/manifests/aws/checks/rds.ts | 6 +-
.../src/manifests/aws/checks/s3.ts | 42 +++++++++++--
.../src/manifests/aws/checks/shared.ts | 29 +++++++++
.../src/manifests/azure/checks/entra-id.ts | 9 ++-
.../src/manifests/azure/checks/key-vault.ts | 12 +++-
.../src/manifests/azure/checks/network.ts | 6 +-
.../src/manifests/azure/checks/shared.ts | 28 +++++++++
.../src/manifests/azure/checks/sql.ts | 10 +++-
.../src/manifests/azure/checks/storage.ts | 14 ++++-
.../gcp/checks/__tests__/gcp-checks.test.ts | 24 ++++++++
.../manifests/gcp/checks/cloud-sql-backups.ts | 16 +++--
.../src/manifests/gcp/checks/cloud-sql-ssl.ts | 17 ++++--
.../gcp/checks/iam-primitive-roles.ts | 22 +++++--
.../gcp/checks/storage-public-access.ts | 17 +++++-
.../gcp/checks/vpc-open-firewalls.ts | 18 +++++-
19 files changed, 302 insertions(+), 62 deletions(-)
diff --git a/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts b/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
index 387b469c08..abb277fdd1 100644
--- a/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
@@ -6,7 +6,7 @@ import {
} from '@aws-sdk/client-cloudtrail';
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, IntegrationCheck } from '../../../types';
-import { assumeAwsSession, type CheckOutcome, emitOutcomes } from './shared';
+import { resolveAwsSessionOrFail, type CheckOutcome, emitOutcomes } from './shared';
export interface TrailInfo {
name: string;
@@ -98,7 +98,7 @@ export const cloudTrailEnabledCheck: IntegrationCheck = {
service: 'cloudtrail',
taskMapping: TASK_TEMPLATES.monitoringAlerting,
run: async (ctx: CheckContext) => {
- const session = await assumeAwsSession(ctx);
+ const session = await resolveAwsSessionOrFail(ctx);
if (!session) {
ctx.log('AWS CloudTrail check: connection not configured — skipping');
return;
diff --git a/packages/integration-platform/src/manifests/aws/checks/ec2.ts b/packages/integration-platform/src/manifests/aws/checks/ec2.ts
index 642a47da81..86d817d565 100644
--- a/packages/integration-platform/src/manifests/aws/checks/ec2.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/ec2.ts
@@ -1,7 +1,7 @@
import { DescribeSecurityGroupsCommand, EC2Client } from '@aws-sdk/client-ec2';
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, FindingSeverity, IntegrationCheck } from '../../../types';
-import { assumeAwsSession, type CheckOutcome, emitOutcomes } from './shared';
+import { resolveAwsSessionOrFail, type CheckOutcome, emitOutcomes } from './shared';
export interface SgPermission {
ipProtocol: string;
@@ -89,7 +89,7 @@ export const ec2SecurityGroupsCheck: IntegrationCheck = {
service: 'ec2-vpc',
taskMapping: TASK_TEMPLATES.productionFirewallNopublicaccessControls,
run: async (ctx: CheckContext) => {
- const session = await assumeAwsSession(ctx);
+ const session = await resolveAwsSessionOrFail(ctx);
if (!session) {
ctx.log('AWS EC2 security-groups check: connection not configured — skipping');
return;
diff --git a/packages/integration-platform/src/manifests/aws/checks/iam.ts b/packages/integration-platform/src/manifests/aws/checks/iam.ts
index e417c381b8..17b4f81ef9 100644
--- a/packages/integration-platform/src/manifests/aws/checks/iam.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/iam.ts
@@ -5,7 +5,7 @@ import {
} from '@aws-sdk/client-iam';
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, IntegrationCheck } from '../../../types';
-import { assumeAwsSession, type CheckOutcome, emitOutcomes } from './shared';
+import { resolveAwsSessionOrFail, type CheckOutcome, emitOutcomes } from './shared';
export interface IamAccountData {
/** null = no password policy configured */
@@ -119,7 +119,7 @@ export const iamAccountSecurityCheck: IntegrationCheck = {
service: 'iam-analyzer',
taskMapping: TASK_TEMPLATES.rolebasedAccessControls,
run: async (ctx: CheckContext) => {
- const session = await assumeAwsSession(ctx);
+ const session = await resolveAwsSessionOrFail(ctx);
if (!session) {
ctx.log('AWS IAM check: connection not configured — skipping');
return;
@@ -139,8 +139,27 @@ export const iamAccountSecurityCheck: IntegrationCheck = {
if (!(err instanceof Error && /NoSuchEntity/i.test(err.name))) throw err;
}
- const summaryResp = await iam.send(new GetAccountSummaryCommand({}));
- const summary = (summaryResp.SummaryMap ?? {}) as Record;
+ let summary: Record;
+ try {
+ const summaryResp = await iam.send(new GetAccountSummaryCommand({}));
+ summary = (summaryResp.SummaryMap ?? {}) as Record;
+ } catch (err) {
+ // The account summary drives the root-MFA / root-access-key findings — if
+ // it can't be read, surface "could not verify" rather than aborting the
+ // check with a bare error (or omitting those critical findings).
+ ctx.fail({
+ title: 'Could not verify IAM account summary',
+ description:
+ 'The IAM account summary (root MFA, root access keys) could not be read, so account security is unverified.',
+ resourceType: 'aws-account',
+ resourceId: 'account',
+ severity: 'medium',
+ remediation:
+ 'Grant iam:GetAccountSummary to the integration role, then re-run the check.',
+ evidence: { error: err instanceof Error ? err.message : String(err) },
+ });
+ return;
+ }
emitOutcomes(ctx, evaluateIamAccount({ passwordPolicy, summary }));
},
diff --git a/packages/integration-platform/src/manifests/aws/checks/kms.ts b/packages/integration-platform/src/manifests/aws/checks/kms.ts
index 74a2c58efe..8a7f3a8d3a 100644
--- a/packages/integration-platform/src/manifests/aws/checks/kms.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/kms.ts
@@ -7,7 +7,7 @@ import {
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, IntegrationCheck } from '../../../types';
import {
- assumeAwsSession,
+ resolveAwsSessionOrFail,
type AwsSession,
type CheckOutcome,
emitOutcomes,
@@ -71,14 +71,22 @@ export function evaluateKmsRotation(keys: KmsKeyInfo[]): CheckOutcome[] {
});
}
+interface KmsKeyScan {
+ keys: KmsKeyInfo[];
+ /** Keys whose DescribeKey failed — eligibility couldn't be classified. */
+ unreadableKeyIds: string[];
+}
+
async function listKmsKeys(
ctx: CheckContext,
session: AwsSession,
-): Promise {
+): Promise {
const out: KmsKeyInfo[] = [];
+ const unreadableKeyIds: string[] = [];
for (const region of session.regions) {
const kms = new KMSClient({ region, credentials: session.credentials });
let marker: string | undefined;
+ try {
do {
const resp = await kms.send(new ListKeysCommand({ Marker: marker }));
for (const k of resp.Keys ?? []) {
@@ -88,7 +96,10 @@ async function listKmsKeys(
try {
meta = (await kms.send(new DescribeKeyCommand({ KeyId: keyId }))).KeyMetadata;
} catch (err) {
- // Skip this key rather than aborting the whole scan.
+ // Can't classify this key's eligibility — record it as unreadable so
+ // an all-unreadable account isn't reported as a clean run (a denied
+ // kms:DescribeKey would otherwise leave zero eligible keys silently).
+ unreadableKeyIds.push(keyId);
ctx.log(
`KMS: could not describe key ${keyId} in ${region}: ${err instanceof Error ? err.message : String(err)}`,
);
@@ -121,8 +132,16 @@ async function listKmsKeys(
}
marker = resp.NextMarker;
} while (marker);
+ } catch (err) {
+ // ListKeys failed for this region — record a region marker so run()
+ // surfaces "could not verify" instead of aborting / silently skipping it.
+ unreadableKeyIds.push(`region:${region}`);
+ ctx.log(
+ `KMS: could not list keys in ${region}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
}
- return out;
+ return { keys: out, unreadableKeyIds };
}
export const kmsKeyRotationCheck: IntegrationCheck = {
@@ -132,16 +151,34 @@ export const kmsKeyRotationCheck: IntegrationCheck = {
service: 'kms',
taskMapping: TASK_TEMPLATES.encryptionAtRest,
run: async (ctx: CheckContext) => {
- const session = await assumeAwsSession(ctx);
+ const session = await resolveAwsSessionOrFail(ctx);
if (!session) {
ctx.log('AWS KMS check: connection not configured — skipping');
return;
}
- const keys = await listKmsKeys(ctx, session);
- // Genuine no-op only when there are NO rotation-eligible keys at all. If
- // eligible keys exist but their status is unreadable, evaluateKmsRotation
- // emits "could not verify" rather than passing silently.
- if (!keys.some((k) => k.rotationEligible)) return;
- emitOutcomes(ctx, evaluateKmsRotation(keys));
+ const { keys, unreadableKeyIds } = await listKmsKeys(ctx, session);
+
+ // Keys whose metadata couldn't be read can't be classified — surface them
+ // so an all-unreadable account (e.g. kms:DescribeKey denied) isn't recorded
+ // as a clean run with no findings.
+ if (unreadableKeyIds.length > 0) {
+ ctx.fail({
+ title: 'Could not verify KMS keys',
+ description: `Key metadata could not be read for ${unreadableKeyIds.length} KMS key(s) (DescribeKey failed), so their rotation eligibility and status are unverified.`,
+ resourceType: 'aws-kms-key',
+ resourceId: 'account',
+ severity: 'medium',
+ remediation:
+ 'Grant kms:DescribeKey (and kms:GetKeyRotationStatus) to the integration role, then re-run the check.',
+ evidence: { unreadableKeyCount: unreadableKeyIds.length },
+ });
+ }
+
+ // Rotation-eligible keys each produce an outcome (incl. could-not-verify for
+ // unreadable rotation status). If there are none and nothing was unreadable,
+ // it's a genuine no-op (no rotation-eligible keys to evidence).
+ if (keys.some((k) => k.rotationEligible)) {
+ emitOutcomes(ctx, evaluateKmsRotation(keys));
+ }
},
};
diff --git a/packages/integration-platform/src/manifests/aws/checks/rds.ts b/packages/integration-platform/src/manifests/aws/checks/rds.ts
index b0a556738f..5727a441e7 100644
--- a/packages/integration-platform/src/manifests/aws/checks/rds.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/rds.ts
@@ -6,7 +6,7 @@ import {
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, IntegrationCheck } from '../../../types';
import {
- assumeAwsSession,
+ resolveAwsSessionOrFail,
type AwsSession,
type CheckOutcome,
emitOutcomes,
@@ -240,7 +240,7 @@ export const rdsEncryptionCheck: IntegrationCheck = {
service: 'rds',
taskMapping: TASK_TEMPLATES.encryptionAtRest,
run: async (ctx: CheckContext) => {
- const session = await assumeAwsSession(ctx);
+ const session = await resolveAwsSessionOrFail(ctx);
if (!session) {
ctx.log('AWS RDS encryption check: connection not configured — skipping');
return;
@@ -264,7 +264,7 @@ export const rdsBackupsCheck: IntegrationCheck = {
service: 'rds',
taskMapping: TASK_TEMPLATES.backupLogs,
run: async (ctx: CheckContext) => {
- const session = await assumeAwsSession(ctx);
+ const session = await resolveAwsSessionOrFail(ctx);
if (!session) {
ctx.log('AWS RDS backups check: connection not configured — skipping');
return;
diff --git a/packages/integration-platform/src/manifests/aws/checks/s3.ts b/packages/integration-platform/src/manifests/aws/checks/s3.ts
index dfc70569f9..cee65ba76c 100644
--- a/packages/integration-platform/src/manifests/aws/checks/s3.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/s3.ts
@@ -10,7 +10,7 @@ import {
} from '@aws-sdk/client-s3-control';
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, IntegrationCheck } from '../../../types';
-import { assumeAwsSession, type CheckOutcome, emitOutcomes } from './shared';
+import { resolveAwsSessionOrFail, type CheckOutcome, emitOutcomes } from './shared';
export interface BpaFlags {
blockPublicAcls: boolean;
@@ -199,7 +199,7 @@ export const s3EncryptionCheck: IntegrationCheck = {
service: 's3',
taskMapping: TASK_TEMPLATES.encryptionAtRest,
run: async (ctx: CheckContext) => {
- const session = await assumeAwsSession(ctx);
+ const session = await resolveAwsSessionOrFail(ctx);
if (!session) {
ctx.log('AWS S3 encryption check: connection not configured — skipping');
return;
@@ -209,7 +209,23 @@ export const s3EncryptionCheck: IntegrationCheck = {
credentials: session.credentials,
followRegionRedirects: true,
});
- const buckets = await gatherBuckets(s3, { encryption: true, publicAccess: false });
+ let buckets: S3BucketInfo[];
+ try {
+ buckets = await gatherBuckets(s3, { encryption: true, publicAccess: false });
+ } catch (err) {
+ ctx.fail({
+ title: 'Could not verify S3 encryption',
+ description:
+ 'S3 buckets could not be listed, so default encryption could not be verified.',
+ resourceType: 'aws-account',
+ resourceId: 'account',
+ severity: 'medium',
+ remediation:
+ 'Grant s3:ListAllMyBuckets (and s3:GetEncryptionConfiguration) to the integration role, then re-run the check.',
+ evidence: { error: err instanceof Error ? err.message : String(err) },
+ });
+ return;
+ }
if (buckets.length === 0) return;
emitOutcomes(ctx, evaluateS3Encryption(buckets));
},
@@ -222,7 +238,7 @@ export const s3PublicAccessCheck: IntegrationCheck = {
service: 's3',
taskMapping: TASK_TEMPLATES.productionFirewallNopublicaccessControls,
run: async (ctx: CheckContext) => {
- const session = await assumeAwsSession(ctx);
+ const session = await resolveAwsSessionOrFail(ctx);
if (!session) {
ctx.log('AWS S3 public-access check: connection not configured — skipping');
return;
@@ -260,7 +276,23 @@ export const s3PublicAccessCheck: IntegrationCheck = {
}
}
- const buckets = await gatherBuckets(s3, { encryption: false, publicAccess: true });
+ let buckets: S3BucketInfo[];
+ try {
+ buckets = await gatherBuckets(s3, { encryption: false, publicAccess: true });
+ } catch (err) {
+ ctx.fail({
+ title: 'Could not verify S3 public access',
+ description:
+ 'S3 buckets could not be listed, so Block Public Access could not be verified.',
+ resourceType: 'aws-account',
+ resourceId: 'account',
+ severity: 'medium',
+ remediation:
+ 'Grant s3:ListAllMyBuckets (and s3:GetBucketPublicAccessBlock) to the integration role, then re-run the check.',
+ evidence: { error: err instanceof Error ? err.message : String(err) },
+ });
+ return;
+ }
if (buckets.length === 0) return;
emitOutcomes(ctx, evaluateS3PublicAccess(buckets, accountBpa));
},
diff --git a/packages/integration-platform/src/manifests/aws/checks/shared.ts b/packages/integration-platform/src/manifests/aws/checks/shared.ts
index 8baa844773..cc5edc04a3 100644
--- a/packages/integration-platform/src/manifests/aws/checks/shared.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/shared.ts
@@ -54,6 +54,35 @@ export async function assumeAwsSession(
};
}
+/**
+ * Resolve an AWS session, distinguishing "connection not configured" (returns
+ * null silently — a legitimate no-op) from "assume-role failed" (e.g. denied,
+ * bad ARN/external ID, throttling). On an assume-role failure it emits a
+ * "could not verify" finding and returns null, so the failure surfaces as
+ * explicit evidence with remediation rather than as a bare check error (or a
+ * false non-compliant verdict). Use this instead of assumeAwsSession directly.
+ */
+export async function resolveAwsSessionOrFail(
+ ctx: CheckContext,
+): Promise {
+ try {
+ return await assumeAwsSession(ctx);
+ } catch (err) {
+ ctx.fail({
+ title: 'Could not assume AWS role',
+ description:
+ 'The cross-account IAM role could not be assumed, so this check could not be verified.',
+ resourceType: 'aws-account',
+ resourceId: 'account',
+ severity: 'medium',
+ remediation:
+ 'Verify the role ARN and external ID are correct and the role trust policy allows Comp to assume it, then re-run the check.',
+ evidence: { error: err instanceof Error ? err.message : String(err) },
+ });
+ return null;
+ }
+}
+
/** A provider-agnostic pass/fail outcome produced by a pure evaluator. */
export interface CheckOutcome {
kind: 'pass' | 'fail';
diff --git a/packages/integration-platform/src/manifests/azure/checks/entra-id.ts b/packages/integration-platform/src/manifests/azure/checks/entra-id.ts
index 943ef15cf0..5efacd5c2f 100644
--- a/packages/integration-platform/src/manifests/azure/checks/entra-id.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/entra-id.ts
@@ -1,6 +1,6 @@
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, IntegrationCheck } from '../../../types';
-import { ARM_BASE, armListAll, resolveAzureSubscriptionId } from './shared';
+import { ARM_BASE, armListAllOrFail, resolveAzureSubscriptionId } from './shared';
interface RoleAssignment {
properties: { roleDefinitionId: string; principalId: string; principalType: string };
@@ -72,15 +72,18 @@ export const rbacLeastPrivilegeCheck: IntegrationCheck = {
if (!sub) return;
const [assignments, definitions] = await Promise.all([
- armListAll(
+ armListAllOrFail(
ctx,
`${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01`,
+ { what: 'role assignments', resourceType: 'azure-subscription', subscriptionId: sub },
),
- armListAll(
+ armListAllOrFail(
ctx,
`${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.Authorization/roleDefinitions?api-version=2022-04-01`,
+ { what: 'role definitions', resourceType: 'azure-subscription', subscriptionId: sub },
),
]);
+ if (!assignments || !definitions) return;
const defMap = new Map(definitions.map((d) => [d.id, d]));
diff --git a/packages/integration-platform/src/manifests/azure/checks/key-vault.ts b/packages/integration-platform/src/manifests/azure/checks/key-vault.ts
index f680736e1d..7cf6ffc252 100644
--- a/packages/integration-platform/src/manifests/azure/checks/key-vault.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/key-vault.ts
@@ -1,6 +1,6 @@
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, FindingSeverity, IntegrationCheck } from '../../../types';
-import { ARM_BASE, armListAll, resolveAzureSubscriptionId } from './shared';
+import { ARM_BASE, armListAllOrFail, resolveAzureSubscriptionId } from './shared';
interface KeyVault {
id: string;
@@ -14,10 +14,14 @@ interface KeyVault {
};
}
-async function listVaults(ctx: CheckContext, sub: string): Promise {
- return armListAll(
+async function listVaults(
+ ctx: CheckContext,
+ sub: string,
+): Promise {
+ return armListAllOrFail(
ctx,
`${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.KeyVault/vaults?api-version=2023-07-01`,
+ { what: 'key vaults', resourceType: 'azure-key-vault', subscriptionId: sub },
);
}
@@ -33,6 +37,7 @@ export const keyVaultProtectionCheck: IntegrationCheck = {
const sub = await resolveAzureSubscriptionId(ctx);
if (!sub) return;
const vaults = await listVaults(ctx, sub);
+ if (!vaults) return;
if (vaults.length === 0) return;
for (const v of vaults) {
const p = v.properties ?? {};
@@ -92,6 +97,7 @@ export const keyVaultRbacCheck: IntegrationCheck = {
const sub = await resolveAzureSubscriptionId(ctx);
if (!sub) return;
const vaults = await listVaults(ctx, sub);
+ if (!vaults) return;
if (vaults.length === 0) return;
for (const v of vaults) {
if (v.properties?.enableRbacAuthorization) {
diff --git a/packages/integration-platform/src/manifests/azure/checks/network.ts b/packages/integration-platform/src/manifests/azure/checks/network.ts
index dc11960579..a8c568fabe 100644
--- a/packages/integration-platform/src/manifests/azure/checks/network.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/network.ts
@@ -1,6 +1,6 @@
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, FindingSeverity, IntegrationCheck } from '../../../types';
-import { ARM_BASE, armListAll, resolveAzureSubscriptionId } from './shared';
+import { ARM_BASE, armListAllOrFail, resolveAzureSubscriptionId } from './shared';
interface SecurityRule {
name: string;
@@ -81,10 +81,12 @@ export const nsgNoOpenPortsCheck: IntegrationCheck = {
run: async (ctx: CheckContext) => {
const sub = await resolveAzureSubscriptionId(ctx);
if (!sub) return;
- const nsgs = await armListAll(
+ const nsgs = await armListAllOrFail(
ctx,
`${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.Network/networkSecurityGroups?api-version=2023-11-01`,
+ { what: 'network security groups', resourceType: 'azure-nsg', subscriptionId: sub },
);
+ if (!nsgs) return;
if (nsgs.length === 0) return;
for (const nsg of nsgs) {
diff --git a/packages/integration-platform/src/manifests/azure/checks/shared.ts b/packages/integration-platform/src/manifests/azure/checks/shared.ts
index 58de6f8965..d309d7db2a 100644
--- a/packages/integration-platform/src/manifests/azure/checks/shared.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/shared.ts
@@ -65,4 +65,32 @@ export async function armListAll(
return out;
}
+/**
+ * Paginate an ARM list endpoint, emitting a "could not verify" finding (and
+ * returning null) if the read throws. Use this for a check's primary list so a
+ * permission/transient failure surfaces as explicit evidence with remediation
+ * rather than aborting the check with a bare error or a false verdict.
+ */
+export async function armListAllOrFail(
+ ctx: CheckContext,
+ url: string,
+ opts: { what: string; resourceType: string; subscriptionId: string },
+): Promise {
+ try {
+ return await armListAll(ctx, url);
+ } catch (err) {
+ ctx.fail({
+ title: `Could not verify ${opts.what}`,
+ description: `${opts.what} could not be listed from Azure, so this check is unverified.`,
+ resourceType: opts.resourceType,
+ resourceId: opts.subscriptionId,
+ severity: 'medium',
+ remediation:
+ 'Ensure the connection has Reader access to the subscription, then re-run the check.',
+ evidence: { error: err instanceof Error ? err.message : String(err) },
+ });
+ return null;
+ }
+}
+
export const ARM_BASE = ARM;
diff --git a/packages/integration-platform/src/manifests/azure/checks/sql.ts b/packages/integration-platform/src/manifests/azure/checks/sql.ts
index 48e0bfa532..e237de4165 100644
--- a/packages/integration-platform/src/manifests/azure/checks/sql.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/sql.ts
@@ -1,6 +1,6 @@
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, IntegrationCheck } from '../../../types';
-import { ARM_BASE, armListAll, resolveAzureSubscriptionId } from './shared';
+import { ARM_BASE, armListAll, armListAllOrFail, resolveAzureSubscriptionId } from './shared';
interface SqlServer {
id: string;
@@ -18,10 +18,11 @@ interface SqlFirewallRule {
async function listSqlServers(
ctx: CheckContext,
sub: string,
-): Promise {
- return armListAll(
+): Promise {
+ return armListAllOrFail(
ctx,
`${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.Sql/servers?api-version=2023-05-01-preview`,
+ { what: 'SQL servers', resourceType: 'azure-sql-server', subscriptionId: sub },
);
}
@@ -36,6 +37,7 @@ export const sqlTlsCheck: IntegrationCheck = {
const sub = await resolveAzureSubscriptionId(ctx);
if (!sub) return;
const servers = await listSqlServers(ctx, sub);
+ if (!servers) return;
if (servers.length === 0) return;
for (const s of servers) {
const tls = s.properties?.minimalTlsVersion;
@@ -76,6 +78,7 @@ export const sqlPublicAccessCheck: IntegrationCheck = {
const sub = await resolveAzureSubscriptionId(ctx);
if (!sub) return;
const servers = await listSqlServers(ctx, sub);
+ if (!servers) return;
if (servers.length === 0) return;
for (const s of servers) {
let violation: { title: string; severity: 'high' | 'critical' | 'medium'; detail: string } | null =
@@ -174,6 +177,7 @@ export const sqlAuditingCheck: IntegrationCheck = {
const sub = await resolveAzureSubscriptionId(ctx);
if (!sub) return;
const servers = await listSqlServers(ctx, sub);
+ if (!servers) return;
if (servers.length === 0) return;
for (const s of servers) {
const auditing = await ctx
diff --git a/packages/integration-platform/src/manifests/azure/checks/storage.ts b/packages/integration-platform/src/manifests/azure/checks/storage.ts
index 87e94b132d..ffff5bee2e 100644
--- a/packages/integration-platform/src/manifests/azure/checks/storage.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/storage.ts
@@ -1,6 +1,6 @@
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { CheckContext, IntegrationCheck } from '../../../types';
-import { ARM_BASE, armListAll, resolveAzureSubscriptionId } from './shared';
+import { ARM_BASE, armListAllOrFail, resolveAzureSubscriptionId } from './shared';
interface StorageAccount {
id: string;
@@ -23,10 +23,15 @@ interface StorageAccount {
async function listStorageAccounts(
ctx: CheckContext,
sub: string,
-): Promise {
- return armListAll(
+): Promise {
+ return armListAllOrFail(
ctx,
`${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.Storage/storageAccounts?api-version=2023-05-01`,
+ {
+ what: 'storage accounts',
+ resourceType: 'azure-storage-account',
+ subscriptionId: sub,
+ },
);
}
@@ -42,6 +47,7 @@ export const storageHttpsTlsCheck: IntegrationCheck = {
const sub = await resolveAzureSubscriptionId(ctx);
if (!sub) return;
const accounts = await listStorageAccounts(ctx, sub);
+ if (!accounts) return;
if (accounts.length === 0) return;
for (const a of accounts) {
const p = a.properties ?? {};
@@ -90,6 +96,7 @@ export const storagePublicAccessCheck: IntegrationCheck = {
const sub = await resolveAzureSubscriptionId(ctx);
if (!sub) return;
const accounts = await listStorageAccounts(ctx, sub);
+ if (!accounts) return;
if (accounts.length === 0) return;
for (const a of accounts) {
const p = a.properties ?? {};
@@ -143,6 +150,7 @@ export const storageEncryptionCheck: IntegrationCheck = {
const sub = await resolveAzureSubscriptionId(ctx);
if (!sub) return;
const accounts = await listStorageAccounts(ctx, sub);
+ if (!accounts) return;
if (accounts.length === 0) return;
for (const a of accounts) {
const enc = a.properties?.encryption?.services;
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 481682da4d..51d24da4e6 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
@@ -254,6 +254,19 @@ describe('GCP Cloud Storage public-access check', () => {
expect(failed).toHaveLength(1);
expect(failed[0]!.title).toMatch(/Could not verify/);
});
+
+ it('emits "could not verify" (not a silent pass) when the bucket list read fails', async () => {
+ const { passed, failed } = await runCheck(storagePublicAccessCheck, {
+ fetch: (url) => {
+ if (url.includes('storage/v1/b')) throw new Error('403 forbidden');
+ return {};
+ },
+ });
+ // A project read failure must surface a finding, not leave the task stale.
+ expect(passed).toHaveLength(0);
+ expect(failed).toHaveLength(1);
+ expect(failed[0]!.title).toMatch(/Could not verify Cloud Storage/);
+ });
});
describe('GCP project auto-discovery', () => {
@@ -301,6 +314,17 @@ describe('GCP VPC open-firewalls check', () => {
expect(failed[0]!.severity).toBe('critical');
});
+ it('emits "could not verify" (not a silent pass) when the firewall list read fails', async () => {
+ const { passed, failed } = await runCheck(vpcOpenFirewallsCheck, {
+ fetch: () => {
+ throw new Error('403 forbidden');
+ },
+ });
+ expect(passed).toHaveLength(0);
+ expect(failed).toHaveLength(1);
+ expect(failed[0]!.title).toMatch(/Could not verify VPC firewall rules/);
+ });
+
it('passes when no rule exposes sensitive ports (incl. disabled/egress/internal)', async () => {
const { passed, failed } = await runCheck(vpcOpenFirewallsCheck, {
fetch: () => ({
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 dc0434c952..a238ef5092 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
@@ -72,10 +72,18 @@ export const cloudSqlBackupsCheck: IntegrationCheck = {
}
}
} catch (err) {
- ctx.warn(
- `GCP Cloud SQL backups check: failed to evaluate project ${projectId} — skipping`,
- { projectId, error: err instanceof Error ? err.message : String(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).
+ ctx.fail({
+ title: `Could not verify Cloud SQL backups: ${projectId}`,
+ description: `Cloud SQL instances for project "${projectId}" could not be listed, so backup configuration is unverified.`,
+ resourceType: 'gcp-project',
+ resourceId: projectId,
+ severity: 'medium',
+ remediation:
+ 'Grant cloudsql.instances.list (e.g. roles/cloudsql.viewer) to the connection for this project, then re-run.',
+ evidence: { projectId, error: err instanceof Error ? err.message : String(err) },
+ });
continue;
}
}
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 65c34ade08..3eb02db44e 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
@@ -77,11 +77,18 @@ export const cloudSqlSslCheck: IntegrationCheck = {
}
}
} catch (err) {
- ctx.warn(
- `GCP Cloud SQL SSL check: failed to evaluate project "${projectId}" — ${
- err instanceof Error ? err.message : String(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).
+ ctx.fail({
+ title: `Could not verify Cloud SQL SSL: ${projectId}`,
+ description: `Cloud SQL instances for project "${projectId}" could not be listed, so SSL/TLS enforcement is unverified.`,
+ resourceType: 'gcp-project',
+ resourceId: projectId,
+ severity: 'medium',
+ remediation:
+ 'Grant cloudsql.instances.list (e.g. roles/cloudsql.viewer) to the connection for this project, then re-run.',
+ evidence: { projectId, error: err instanceof Error ? err.message : String(err) },
+ });
continue;
}
}
diff --git a/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts b/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
index d573b82b87..480d262cd6 100644
--- a/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
+++ b/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
@@ -128,12 +128,22 @@ export const iamPrimitiveRolesCheck: IntegrationCheck = {
}
}
} catch (error) {
- // One project's API error must not abort the whole check.
- ctx.warn(
- `GCP IAM: failed to evaluate primitive roles for "${projectId}": ${
- error instanceof Error ? error.message : String(error)
- }`,
- );
+ // One project's API error must not abort the whole check — but it is
+ // unverified, so emit a finding rather than warn-and-skip (an
+ // all-projects-failed run would otherwise leave the task stale).
+ ctx.fail({
+ title: `Could not verify IAM primitive roles: ${projectId}`,
+ description: `IAM policy for project "${projectId}" could not be read, so primitive-role usage is unverified.`,
+ resourceType: 'gcp-project',
+ resourceId: projectId,
+ severity: 'medium',
+ remediation:
+ 'Grant resourcemanager.projects.getIamPolicy (e.g. roles/iam.securityReviewer) to the connection for this project, then re-run.',
+ evidence: {
+ projectId,
+ error: error instanceof Error ? error.message : String(error),
+ },
+ });
continue;
}
}
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 88a06a77f6..339ffa6cfd 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
@@ -52,9 +52,20 @@ export const storagePublicAccessCheck: IntegrationCheck = {
await evaluateBucket(ctx, projectId, bucket);
}
} catch (err) {
- ctx.warn('GCP storage check failed for project; skipping', {
- projectId,
- error: err instanceof Error ? err.message : String(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).
+ ctx.fail({
+ title: `Could not verify Cloud Storage: ${projectId}`,
+ description: `Buckets for project "${projectId}" could not be listed, so public access is unverified.`,
+ resourceType: 'gcp-project',
+ resourceId: projectId,
+ severity: 'medium',
+ remediation:
+ 'Grant storage.buckets.list (e.g. roles/storage.legacyBucketReader or Viewer) to the connection for this project, then re-run.',
+ evidence: {
+ projectId,
+ error: err instanceof Error ? err.message : String(err),
+ },
});
}
}
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 0def968bef..70d4e8440d 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
@@ -110,9 +110,21 @@ export const vpcOpenFirewallsCheck: IntegrationCheck = {
});
}
} catch (err) {
- ctx.warn('GCP VPC firewall check failed for project; skipping', {
- projectId,
- error: err instanceof Error ? err.message : String(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).
+ ctx.fail({
+ title: `Could not verify VPC firewall rules: ${projectId}`,
+ description: `Firewall rules for project "${projectId}" could not be read, so internet exposure is unverified.`,
+ resourceType: 'gcp-project',
+ resourceId: projectId,
+ severity: 'medium',
+ remediation:
+ 'Grant compute.firewalls.list (e.g. roles/compute.viewer) to the connection for this project, then re-run.',
+ evidence: {
+ projectId,
+ error: err instanceof Error ? err.message : String(err),
+ },
});
}
}
From 4a6b64a8003f8c801a93279c0ae48a1465ea8a66 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Tue, 2 Jun 2026 18:21:40 -0400
Subject: [PATCH 11/13] fix(integration-platform): address cubic review of
d59e4c7ca (2 P2s)
- IAM: split evaluateIamAccount into evaluatePasswordPolicy + evaluateAccountSummary.
run() now emits the password-policy findings BEFORE the account-summary read, so
a GetAccountSummary failure surfaces "could not verify" without discarding the
already-obtained password-policy findings (independent evaluations).
- KMS: the "Could not verify KMS keys" finding now reports ListKeys (region) and
DescribeKey (key) failures distinctly, and the remediation includes kms:ListKeys.
Previously all entries were attributed to "DescribeKey failed".
Tests: +1 IAM regression (password-policy evaluation stands alone). 165 pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../aws/checks/__tests__/aws-checks.test.ts | 17 +++++++-
.../src/manifests/aws/checks/iam.ts | 43 ++++++++++++++-----
.../src/manifests/aws/checks/kms.ts | 24 ++++++++---
3 files changed, 66 insertions(+), 18 deletions(-)
diff --git a/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
index b4b7860875..13f4f5c96d 100644
--- a/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'bun:test';
import { evaluateCloudTrail } from '../cloudtrail';
import { evaluateSecurityGroups } from '../ec2';
-import { evaluateIamAccount } from '../iam';
+import {
+ evaluateAccountSummary,
+ evaluateIamAccount,
+ evaluatePasswordPolicy,
+} from '../iam';
import { evaluateKmsRotation } from '../kms';
import {
evaluateRdsBackups,
@@ -35,6 +39,17 @@ describe('AWS IAM account evaluator', () => {
});
expect(kinds(out)).toEqual(['pass', 'pass', 'pass']);
});
+
+ it('password-policy evaluation stands alone (preserved even if summary read fails)', () => {
+ // run() emits evaluatePasswordPolicy() before the summary fetch, so a
+ // summary failure can no longer discard the password-policy findings.
+ const out = evaluatePasswordPolicy(null);
+ expect(out).toHaveLength(1);
+ expect(out[0]!.kind).toBe('fail');
+ expect(out[0]!.title).toMatch(/password policy/i);
+ // and the summary evaluator is independent
+ expect(evaluateAccountSummary({ AccountMFAEnabled: 1, AccountAccessKeysPresent: 0 })).toHaveLength(2);
+ });
});
const ALL_BLOCKED = {
diff --git a/packages/integration-platform/src/manifests/aws/checks/iam.ts b/packages/integration-platform/src/manifests/aws/checks/iam.ts
index 17b4f81ef9..3d5e6539e4 100644
--- a/packages/integration-platform/src/manifests/aws/checks/iam.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/iam.ts
@@ -20,12 +20,13 @@ export interface IamAccountData {
summary: Record;
}
-/** Pure evaluation of IAM account-level posture (unit-tested without the SDK). */
-export function evaluateIamAccount(data: IamAccountData): CheckOutcome[] {
+/** Password-policy findings only (independent of the account summary). */
+export function evaluatePasswordPolicy(
+ pp: IamAccountData['passwordPolicy'],
+): CheckOutcome[] {
const out: CheckOutcome[] = [];
const id = 'account';
- const pp = data.passwordPolicy;
if (!pp) {
out.push({
kind: 'fail',
@@ -68,7 +69,17 @@ export function evaluateIamAccount(data: IamAccountData): CheckOutcome[] {
}
}
- if (data.summary.AccountMFAEnabled === 1) {
+ return out;
+}
+
+/** Root-account findings from the IAM account summary (MFA, access keys). */
+export function evaluateAccountSummary(
+ summary: Record,
+): CheckOutcome[] {
+ const out: CheckOutcome[] = [];
+ const id = 'account';
+
+ if (summary.AccountMFAEnabled === 1) {
out.push({
kind: 'pass',
title: 'Root account MFA enabled',
@@ -88,7 +99,7 @@ export function evaluateIamAccount(data: IamAccountData): CheckOutcome[] {
});
}
- if ((data.summary.AccountAccessKeysPresent ?? 0) > 0) {
+ if ((summary.AccountAccessKeysPresent ?? 0) > 0) {
out.push({
kind: 'fail',
title: 'Root account access keys present',
@@ -111,6 +122,14 @@ export function evaluateIamAccount(data: IamAccountData): CheckOutcome[] {
return out;
}
+/** Pure evaluation of IAM account-level posture (unit-tested without the SDK). */
+export function evaluateIamAccount(data: IamAccountData): CheckOutcome[] {
+ return [
+ ...evaluatePasswordPolicy(data.passwordPolicy),
+ ...evaluateAccountSummary(data.summary),
+ ];
+}
+
export const iamAccountSecurityCheck: IntegrationCheck = {
id: 'aws-iam-account-security',
name: 'IAM — password policy and root protections',
@@ -139,10 +158,15 @@ export const iamAccountSecurityCheck: IntegrationCheck = {
if (!(err instanceof Error && /NoSuchEntity/i.test(err.name))) throw err;
}
- let summary: Record;
+ // Password policy and account summary are independent — emit the
+ // password-policy findings now so they aren't lost if the summary read
+ // fails below.
+ emitOutcomes(ctx, evaluatePasswordPolicy(passwordPolicy));
+
try {
const summaryResp = await iam.send(new GetAccountSummaryCommand({}));
- summary = (summaryResp.SummaryMap ?? {}) as Record;
+ const summary = (summaryResp.SummaryMap ?? {}) as Record;
+ emitOutcomes(ctx, evaluateAccountSummary(summary));
} catch (err) {
// The account summary drives the root-MFA / root-access-key findings — if
// it can't be read, surface "could not verify" rather than aborting the
@@ -150,7 +174,7 @@ export const iamAccountSecurityCheck: IntegrationCheck = {
ctx.fail({
title: 'Could not verify IAM account summary',
description:
- 'The IAM account summary (root MFA, root access keys) could not be read, so account security is unverified.',
+ 'The IAM account summary (root MFA, root access keys) could not be read, so root-account security is unverified.',
resourceType: 'aws-account',
resourceId: 'account',
severity: 'medium',
@@ -158,9 +182,6 @@ export const iamAccountSecurityCheck: IntegrationCheck = {
'Grant iam:GetAccountSummary to the integration role, then re-run the check.',
evidence: { error: err instanceof Error ? err.message : String(err) },
});
- return;
}
-
- emitOutcomes(ctx, evaluateIamAccount({ passwordPolicy, summary }));
},
};
diff --git a/packages/integration-platform/src/manifests/aws/checks/kms.ts b/packages/integration-platform/src/manifests/aws/checks/kms.ts
index 8a7f3a8d3a..d380f48aa6 100644
--- a/packages/integration-platform/src/manifests/aws/checks/kms.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/kms.ts
@@ -158,19 +158,31 @@ export const kmsKeyRotationCheck: IntegrationCheck = {
}
const { keys, unreadableKeyIds } = await listKmsKeys(ctx, session);
- // Keys whose metadata couldn't be read can't be classified — surface them
- // so an all-unreadable account (e.g. kms:DescribeKey denied) isn't recorded
- // as a clean run with no findings.
+ // Keys/regions that couldn't be read can't be classified — surface them so
+ // an all-unreadable account (e.g. kms:ListKeys or kms:DescribeKey denied)
+ // isn't recorded as a clean run with no findings. Region markers
+ // ("region:") are ListKeys failures; the rest are DescribeKey failures.
if (unreadableKeyIds.length > 0) {
+ const failedRegions = unreadableKeyIds
+ .filter((k) => k.startsWith('region:'))
+ .map((k) => k.slice('region:'.length));
+ const failedKeyCount = unreadableKeyIds.length - failedRegions.length;
+ const parts: string[] = [];
+ if (failedRegions.length > 0) {
+ parts.push(`keys could not be listed in ${failedRegions.length} region(s) (${failedRegions.join(', ')})`);
+ }
+ if (failedKeyCount > 0) {
+ parts.push(`metadata could not be read for ${failedKeyCount} key(s)`);
+ }
ctx.fail({
title: 'Could not verify KMS keys',
- description: `Key metadata could not be read for ${unreadableKeyIds.length} KMS key(s) (DescribeKey failed), so their rotation eligibility and status are unverified.`,
+ description: `${parts.join('; ')} — rotation eligibility/status is unverified.`,
resourceType: 'aws-kms-key',
resourceId: 'account',
severity: 'medium',
remediation:
- 'Grant kms:DescribeKey (and kms:GetKeyRotationStatus) to the integration role, then re-run the check.',
- evidence: { unreadableKeyCount: unreadableKeyIds.length },
+ 'Grant kms:ListKeys, kms:DescribeKey, and kms:GetKeyRotationStatus to the integration role in all enabled regions, then re-run the check.',
+ evidence: { failedRegions, failedKeyCount },
});
}
From cdde66214e95259c4d6fe1b926ce976b6916c841 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Tue, 2 Jun 2026 19:48:40 -0400
Subject: [PATCH 12/13] fix(integration-platform): close GCP IAM silent pass,
harden CloudTrail region, tag per-service checks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses the cubic review of 63dab6d6c (and the prior d59e4c7ca review).
- gcp/iam-primitive-roles: a project whose own getIamPolicy read failed was
silently skipped (getBindings swallows the throw → null → `continue` with no
outcome), leaving the RBAC task stale-passing. Now fails closed with a "could
not verify" finding via a shared helper (reused by the outer per-project
catch). Real P1 (identified by cubic).
- aws/cloudtrail: query GetTrailStatus against the trail's home region instead
of the scan-region client. Hardening — the ARN-based read works cross-region
per AWS shadow-trail docs (and Prowler), and a failure was already caught as a
safe "could not verify"; home-region removes the ambiguity. (cubic finding;
the stated false-failure does not occur, but home-region is unambiguously
correct.)
- connections per-service task counts: buildServiceTaskMappings groups checks by
`service` === serviceId, but Vercel/Aikido/Google-Workspace checks were
untagged, so their service cards showed 0 evidence tasks. Tag those 7 checks
with their service id (every other multi-service provider already tags its
checks). Real P2 (identified by cubic).
- Add a manifest-integrity test: every check in a service-defining manifest must
be tagged with a real service id (prevents this regression recurring), plus a
GCP regression test for the project-policy read failure.
cubic's page.tsx connectionId finding is a false positive — ServiceDetailView
already validates the URL id against the provider's connections and falls back
to the active connection.
203 package tests pass; tsc build clean.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../aikido/checks/code-repository-scanning.ts | 1 +
.../aikido/checks/issue-count-threshold.ts | 1 +
.../aikido/checks/open-security-issues.ts | 1 +
.../src/manifests/aws/checks/cloudtrail.ts | 17 ++++++-
.../gcp/checks/__tests__/gcp-checks.test.ts | 16 ++++++
.../gcp/checks/iam-primitive-roles.ts | 50 +++++++++++++------
.../checks/employee-access.ts | 1 +
.../checks/two-factor-auth.ts | 1 +
.../vercel/checks/app-availability.ts | 1 +
.../vercel/checks/monitoring-alerting.ts | 1 +
.../__tests__/manifest-service-tags.test.ts | 34 +++++++++++++
11 files changed, 109 insertions(+), 15 deletions(-)
create mode 100644 packages/integration-platform/src/registry/__tests__/manifest-service-tags.test.ts
diff --git a/packages/integration-platform/src/manifests/aikido/checks/code-repository-scanning.ts b/packages/integration-platform/src/manifests/aikido/checks/code-repository-scanning.ts
index 64737a9e2c..f09da67991 100644
--- a/packages/integration-platform/src/manifests/aikido/checks/code-repository-scanning.ts
+++ b/packages/integration-platform/src/manifests/aikido/checks/code-repository-scanning.ts
@@ -30,6 +30,7 @@ export const codeRepositoryScanningCheck: IntegrationCheck = {
id: 'code_repository_scanning',
name: 'Code Repositories Actively Scanned',
description: 'Verify that all code repositories are being actively scanned for vulnerabilities',
+ service: 'vulnerability-scanning',
taskMapping: TASK_TEMPLATES.secureCode,
defaultSeverity: 'medium',
diff --git a/packages/integration-platform/src/manifests/aikido/checks/issue-count-threshold.ts b/packages/integration-platform/src/manifests/aikido/checks/issue-count-threshold.ts
index 055112091a..f2f4d54510 100644
--- a/packages/integration-platform/src/manifests/aikido/checks/issue-count-threshold.ts
+++ b/packages/integration-platform/src/manifests/aikido/checks/issue-count-threshold.ts
@@ -35,6 +35,7 @@ export const issueCountThresholdCheck: IntegrationCheck = {
id: 'issue_count_threshold',
name: 'Issue Count Within Threshold',
description: 'Verify that the total number of open security issues is within acceptable limits',
+ service: 'issue-tracking',
taskMapping: TASK_TEMPLATES.monitoringAlerting,
defaultSeverity: 'medium',
diff --git a/packages/integration-platform/src/manifests/aikido/checks/open-security-issues.ts b/packages/integration-platform/src/manifests/aikido/checks/open-security-issues.ts
index 63e65b6e3e..8780806e13 100644
--- a/packages/integration-platform/src/manifests/aikido/checks/open-security-issues.ts
+++ b/packages/integration-platform/src/manifests/aikido/checks/open-security-issues.ts
@@ -75,6 +75,7 @@ export const openSecurityIssuesCheck: IntegrationCheck = {
name: 'No Open Security Issues',
description:
'Verify that there are no open high or critical security vulnerabilities detected by Aikido',
+ service: 'issue-tracking',
taskMapping: TASK_TEMPLATES.secureCode,
defaultSeverity: 'high',
diff --git a/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts b/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
index abb277fdd1..6c80805d2d 100644
--- a/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
+++ b/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
@@ -143,8 +143,23 @@ export const cloudTrailEnabledCheck: IntegrationCheck = {
let loggingKnown = true;
// Logging status only matters for otherwise-compliant trails.
if (multiRegion && logValidation && t.TrailARN) {
+ // Query GetTrailStatus against the trail's home region. A multi-region
+ // trail is returned as a shadow in every scanned region; reusing the
+ // scan-region client when it differs from the home region can fail on
+ // some SDK paths and produce a false "could not verify". Reuse `ct`
+ // when the scan region already is the home region.
+ const homeRegion = t.HomeRegion ?? region;
+ const statusClient =
+ homeRegion === region
+ ? ct
+ : new CloudTrailClient({
+ region: homeRegion,
+ credentials: session.credentials,
+ });
try {
- const status = await ct.send(new GetTrailStatusCommand({ Name: t.TrailARN }));
+ const status = await statusClient.send(
+ new GetTrailStatusCommand({ Name: t.TrailARN }),
+ );
logging = status.IsLogging === true;
} catch (err) {
loggingKnown = false;
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 51d24da4e6..56801f4182 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
@@ -121,6 +121,22 @@ describe('GCP IAM primitive roles check', () => {
expect(passed).toHaveLength(0);
expect(failed).toHaveLength(0);
});
+
+ it('fails closed when the project IAM policy cannot be read', async () => {
+ // getBindings swallows the throw and returns null; the project read must
+ // surface a "could not verify" finding rather than silently skipping the
+ // project (which would leave the RBAC task stale-passing).
+ const { passed, failed } = await runCheck(iamPrimitiveRolesCheck, {
+ post: (url) => {
+ if (url.includes(':getIamPolicy')) throw new Error('403');
+ return {};
+ },
+ });
+ expect(passed).toHaveLength(0);
+ expect(failed).toHaveLength(1);
+ expect(failed[0]!.title).toMatch(/Could not verify IAM primitive roles/);
+ expect(failed[0]!.severity).toBe('medium');
+ });
});
describe('GCP Cloud Storage public-access check', () => {
diff --git a/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts b/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
index 480d262cd6..b559412db2 100644
--- a/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
+++ b/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
@@ -29,6 +29,35 @@ async function getBindings(
}
}
+/**
+ * Emit a fail-closed "could not verify" finding for a project whose IAM policy
+ * couldn't be read. Used for both the project-level read (where getBindings
+ * swallows the error and returns null) and the outer per-project catch, so an
+ * unreadable project is never silently skipped (which would leave the RBAC task
+ * stale-passing on unverified data).
+ */
+function failUnverifiedProject(
+ ctx: CheckContext,
+ projectId: string,
+ error?: unknown,
+): void {
+ ctx.fail({
+ title: `Could not verify IAM primitive roles: ${projectId}`,
+ description: `IAM policy for project "${projectId}" could not be read, so primitive-role usage is unverified.`,
+ resourceType: 'gcp-project',
+ resourceId: projectId,
+ severity: 'medium',
+ remediation:
+ 'Grant resourcemanager.projects.getIamPolicy (e.g. roles/iam.securityReviewer) to the connection for this project, then re-run.',
+ evidence: {
+ projectId,
+ ...(error !== undefined
+ ? { error: error instanceof Error ? error.message : String(error) }
+ : {}),
+ },
+ });
+}
+
/**
* IAM least-privilege check (direct API, no SCC). Evaluates primitive role
* bindings (roles/owner, roles/editor) on the project AND its inherited
@@ -57,7 +86,12 @@ export const iamPrimitiveRolesCheck: IntegrationCheck = {
ctx,
`v3/projects/${encodeURIComponent(projectId)}`,
);
- if (projectBindings === null) continue; // can't read project policy → no assertion
+ if (projectBindings === null) {
+ // Couldn't read the project's own IAM policy (getBindings swallowed
+ // the throw → null). Fail closed rather than silently skipping.
+ failUnverifiedProject(ctx, projectId);
+ continue;
+ }
// Resolve the ancestry (folders/org) so inherited bindings are evaluated.
let hierarchyFullyEvaluated = true;
@@ -131,19 +165,7 @@ export const iamPrimitiveRolesCheck: IntegrationCheck = {
// One project's API error must not abort the whole check — but it is
// unverified, so emit a finding rather than warn-and-skip (an
// all-projects-failed run would otherwise leave the task stale).
- ctx.fail({
- title: `Could not verify IAM primitive roles: ${projectId}`,
- description: `IAM policy for project "${projectId}" could not be read, so primitive-role usage is unverified.`,
- resourceType: 'gcp-project',
- resourceId: projectId,
- severity: 'medium',
- remediation:
- 'Grant resourcemanager.projects.getIamPolicy (e.g. roles/iam.securityReviewer) to the connection for this project, then re-run.',
- evidence: {
- projectId,
- error: error instanceof Error ? error.message : String(error),
- },
- });
+ failUnverifiedProject(ctx, projectId, error);
continue;
}
}
diff --git a/packages/integration-platform/src/manifests/google-workspace/checks/employee-access.ts b/packages/integration-platform/src/manifests/google-workspace/checks/employee-access.ts
index 4a771ab4bf..6f9c87fa61 100644
--- a/packages/integration-platform/src/manifests/google-workspace/checks/employee-access.ts
+++ b/packages/integration-platform/src/manifests/google-workspace/checks/employee-access.ts
@@ -21,6 +21,7 @@ export const employeeAccessCheck: IntegrationCheck = {
id: 'employee-access',
name: 'Employee Access Review',
description: 'Fetch all employees and their roles from Google Workspace for access review',
+ service: 'user-sync',
taskMapping: TASK_TEMPLATES.employeeAccess,
variables: [targetOrgUnitsVariable, includeSuspendedVariable],
diff --git a/packages/integration-platform/src/manifests/google-workspace/checks/two-factor-auth.ts b/packages/integration-platform/src/manifests/google-workspace/checks/two-factor-auth.ts
index bf4f169324..a0cf7bb59a 100644
--- a/packages/integration-platform/src/manifests/google-workspace/checks/two-factor-auth.ts
+++ b/packages/integration-platform/src/manifests/google-workspace/checks/two-factor-auth.ts
@@ -15,6 +15,7 @@ export const twoFactorAuthCheck: IntegrationCheck = {
id: 'two-factor-auth',
name: '2-Step Verification Enabled',
description: 'Verify all users have 2-Step Verification (2FA) enabled in Google Workspace',
+ service: 'mfa-compliance',
taskMapping: TASK_TEMPLATES.twoFactorAuth,
variables: [targetOrgUnitsVariable, includeSuspendedVariable],
diff --git a/packages/integration-platform/src/manifests/vercel/checks/app-availability.ts b/packages/integration-platform/src/manifests/vercel/checks/app-availability.ts
index 39f85dcffc..2298255bcd 100644
--- a/packages/integration-platform/src/manifests/vercel/checks/app-availability.ts
+++ b/packages/integration-platform/src/manifests/vercel/checks/app-availability.ts
@@ -23,6 +23,7 @@ export const appAvailabilityCheck: IntegrationCheck = {
id: 'app-availability',
name: 'App Availability',
description: 'Verify Vercel projects have active, healthy deployments',
+ service: 'monitoring',
taskMapping: TASK_TEMPLATES.appAvailability,
variables: [projectFilterModeVariable, filteredProjectsVariable],
diff --git a/packages/integration-platform/src/manifests/vercel/checks/monitoring-alerting.ts b/packages/integration-platform/src/manifests/vercel/checks/monitoring-alerting.ts
index 374cf4caad..1e98785a6a 100644
--- a/packages/integration-platform/src/manifests/vercel/checks/monitoring-alerting.ts
+++ b/packages/integration-platform/src/manifests/vercel/checks/monitoring-alerting.ts
@@ -24,6 +24,7 @@ export const monitoringAlertingCheck: IntegrationCheck = {
id: 'monitoring-alerting',
name: 'Monitoring & Alerting Review',
description: 'Verify webhooks and notifications are configured for deployment monitoring',
+ service: 'monitoring',
taskMapping: TASK_TEMPLATES.monitoringAlerting,
variables: [projectFilterModeVariable, filteredProjectsVariable],
diff --git a/packages/integration-platform/src/registry/__tests__/manifest-service-tags.test.ts b/packages/integration-platform/src/registry/__tests__/manifest-service-tags.test.ts
new file mode 100644
index 0000000000..63c32f8875
--- /dev/null
+++ b/packages/integration-platform/src/registry/__tests__/manifest-service-tags.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'bun:test';
+import { getAllManifests } from '../index';
+
+/**
+ * The connections controller derives per-service evidence-task counts with
+ * `buildServiceTaskMappings`, which groups checks to a service via
+ * `check.service === service.id`. If a manifest defines `services[]` but leaves
+ * a check untagged (or tagged with an id that isn't a real service), that
+ * check's task silently drops from every service card — exactly the regression
+ * cubic flagged for Vercel/Aikido/Google Workspace.
+ *
+ * Enforce the invariant so a future untagged check fails CI instead of shipping
+ * an empty/incorrect per-service task count.
+ */
+describe('manifest per-service task mapping integrity', () => {
+ const manifests = getAllManifests().filter(
+ (m) => (m.services?.length ?? 0) > 0 && (m.checks?.length ?? 0) > 0,
+ );
+
+ it('covers every service-defining manifest', () => {
+ // Guard against the registry import silently returning nothing.
+ expect(manifests.length).toBeGreaterThan(0);
+ });
+
+ for (const m of manifests) {
+ const serviceIds = new Set((m.services ?? []).map((s) => s.id));
+ for (const check of m.checks ?? []) {
+ it(`${m.id}: check "${check.id}" is tagged with a defined service id`, () => {
+ expect(check.service).toBeDefined();
+ expect(serviceIds.has(check.service as string)).toBe(true);
+ });
+ }
+ }
+});
From 05d1cce0a7ebc90f9564c82b7221941f5772d5a6 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Tue, 2 Jun 2026 20:11:38 -0400
Subject: [PATCH 13/13] fix(integration-platform): include out-of-scope role
defs in azure rbac wildcard scan
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses the cubic review of cdde66214.
- azure/entra-id (rbacLeastPrivilegeCheck): the wildcard custom-role scan only
iterated the subscription-scope `definitions` list, so a wildcard CustomRole
defined at a management-group/resource-group scope and assigned into the
subscription was missed — the privileged-assignment path already resolves
those out-of-scope defs (resolvedDefs) but the wildcard path ignored them.
Worst case: a role whose wildcard is a mid-path/dataActions-only action (not
high-privilege) is missed by BOTH paths. Now scans the union of `definitions`
+ `resolvedDefs` (deduped by id). Real P2 (identified by cubic).
- Add regression tests: MG-scoped wildcard role flagged; SQL firewall read
failure fails closed with a medium "Could not read SQL firewall rules" (guards
the earlier false-pass finding); off-host nextLink is not followed (guards the
earlier bearer-token-exposure finding).
Verified NOT bugs / dismissed (with evidence):
- gcp/shared.ts empty project_ids: cubic applied the cloud-security convention
([]=scan nothing) to integration-platform, where the convention is the
opposite ("leave empty = check all"); honoring [] here would create the exact
silent stale-pass the engine contract forbids. Left as-is.
- aws/shared.ts STS "duplication" (P3): the existing createAWSClients helper is
dead code (no callers), architecturally unfit for reuse, and reusing it would
regress the engine-contract-compliant error handling. Left as-is.
- page.tsx connectionId, azure sql firewall->[], azure nextLink token exposure,
ipv6 ::/0 across gcp/aws/azure, azure monitor partial-eval: all already fixed
in current code (re-verified).
206 package tests pass; tsc build clean.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../checks/__tests__/azure-checks.test.ts | 68 +++++++++++++++++++
.../src/manifests/azure/checks/entra-id.ts | 12 +++-
2 files changed, 79 insertions(+), 1 deletion(-)
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 8a7552a8ce..6da5f196b4 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
@@ -146,6 +146,21 @@ describe('Azure SQL checks', () => {
expect(passed).toHaveLength(1);
});
+ it('public-access fails closed (medium) when firewall rules cannot be read', async () => {
+ // A firewall read failure must NOT be coerced to "no public rules" (a false
+ // pass that hides exposure) — it must emit a "could not verify" finding.
+ const { passed, failed } = await run(sqlPublicAccessCheck, (url) => {
+ if (url.includes('/firewallRules')) throw new Error('403');
+ return { value: [{ ...server, properties: { publicNetworkAccess: 'Disabled' } }] };
+ });
+ expect(passed).toHaveLength(0);
+ expect(
+ failed.some(
+ (f) => /Could not read SQL firewall/.test(f.title) && f.severity === 'medium',
+ ),
+ ).toBe(true);
+ });
+
it('auditing fails when disabled, passes when enabled', async () => {
const bad = await run(sqlAuditingCheck, (url) =>
url.includes('/auditingSettings/default')
@@ -276,6 +291,32 @@ describe('Azure RBAC (entra) check', () => {
});
expect(passed).toHaveLength(1);
});
+
+ it('flags a wildcard custom role assigned from a management-group scope (resolved out-of-scope)', async () => {
+ // The role lives at an MG scope, so it is NOT in the subscription-scope
+ // roleDefinitions list — it's only resolved because an assignment references
+ // it. Its wildcard is a mid-path action (not high-privilege), so it is caught
+ // ONLY by the wildcard scan, which must include resolved out-of-scope defs.
+ const mgRoleId =
+ '/providers/Microsoft.Management/managementGroups/mg1/providers/Microsoft.Authorization/roleDefinitions/role-guid';
+ const { failed } = await run(rbacLeastPrivilegeCheck, (url) => {
+ if (url.includes('/managementGroups/')) {
+ return {
+ id: mgRoleId,
+ properties: {
+ roleName: 'MG Wildcard',
+ type: 'CustomRole',
+ permissions: [{ actions: ['Microsoft.Network/*/read'], dataActions: [] }],
+ },
+ };
+ }
+ if (url.includes('roleDefinitions')) return { value: [] };
+ return {
+ value: [{ properties: { roleDefinitionId: mgRoleId, principalId: 'p', principalType: 'User' } }],
+ };
+ });
+ expect(failed.some((f) => /Custom role with wildcard/.test(f.title))).toBe(true);
+ });
});
describe('Azure Monitor check', () => {
@@ -285,3 +326,30 @@ describe('Azure Monitor check', () => {
expect(failed).toHaveLength(2);
});
});
+
+describe('Azure ARM pagination safety', () => {
+ it('does not follow an off-host nextLink (no bearer token leaks to a foreign host)', async () => {
+ // A nextLink whose host is not management.azure.com must be rejected before
+ // the next fetch, else the OAuth bearer token would be sent to it. The
+ // classic prefix bypass "https://management.azure.com.evil.com/..." must NOT
+ // be treated as on-host.
+ const fetched: string[] = [];
+ await run(storageHttpsTlsCheck, (url) => {
+ fetched.push(url);
+ if (url.includes('/storageAccounts') && !url.includes('evil')) {
+ return {
+ value: [
+ {
+ id: 'sa1',
+ name: 'sa1',
+ properties: { supportsHttpsTrafficOnly: true, minimumTlsVersion: 'TLS1_2' },
+ },
+ ],
+ nextLink: 'https://management.azure.com.evil.com/next?api-version=2023-01-01',
+ };
+ }
+ return { value: [] };
+ });
+ expect(fetched.some((u) => u.includes('evil'))).toBe(false);
+ });
+});
diff --git a/packages/integration-platform/src/manifests/azure/checks/entra-id.ts b/packages/integration-platform/src/manifests/azure/checks/entra-id.ts
index 5efacd5c2f..62ad42bb9a 100644
--- a/packages/integration-platform/src/manifests/azure/checks/entra-id.ts
+++ b/packages/integration-platform/src/manifests/azure/checks/entra-id.ts
@@ -175,7 +175,17 @@ export const rbacLeastPrivilegeCheck: IntegrationCheck = {
});
}
- const wildcardRoles = definitions.filter(
+ // Inspect every role definition actually seen — the subscription-scope list
+ // PLUS any out-of-scope definitions resolved from assignments above (e.g.
+ // custom roles defined at a management group and assigned into this
+ // subscription). Filtering only the subscription-scope `definitions` would
+ // miss assigned MG/RG-scoped wildcard custom roles entirely. Dedupe by id.
+ const allDefs = new Map(
+ definitions.map((d) => [d.id, d]),
+ );
+ for (const [id, def] of resolvedDefs) allDefs.set(id, def);
+
+ const wildcardRoles = [...allDefs.values()].filter(
(d) =>
d.properties.type === 'CustomRole' &&
d.properties.permissions.some(