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/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 ? ( + + ) : ( + + {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 ? ( - - ) : ( - Not added - )} -
- ); - })} + {mappedTasks.map((mappedTask) => ( + + ))}
); 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..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 @@ -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,84 @@ 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) { - const { services } = useConnectionServices(connectionId); - +/** + * 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, 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; - const showToggle = isImplemented && isConnected && onToggle; + // 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 = + `/${encodeURIComponent(orgId)}/integrations/${encodeURIComponent(slug)}/services/${encodeURIComponent(service.id)}` + + (connectionId ? `?connectionId=${encodeURIComponent(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 && ( - - )} + {scanningLabel} + + {taskCount > 0 && ( + + {taskCount} evidence task{taskCount === 1 ? '' : 's'} + + )} +
-
+ + ); } 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]/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]/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..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,20 +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'; - -interface TaskApiResponse { - data: Array<{ - id: string; - title: string; - description: string; - taskTemplateId: string | null; - }>; -} +import { loadIntegrationPageData } from './lib/load-integration-page-data'; interface PageProps { params: Promise<{ orgId: string; slug: string }>; @@ -28,28 +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 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)); - return ( ; +} + +interface TaskTemplate { + id: string; + taskId: string; + name: string; + description: string; +} + +interface ServiceDetailViewProps { + provider: IntegrationProviderResponse; + service: ServiceMeta; + connections: ConnectionListItemResponse[]; + connectionId: string | null; + connectionsErrored: boolean; + taskTemplates: TaskTemplate[]; + tasksErrored: boolean; + orgId: string; + slug: string; +} + +export function ServiceDetailView({ + provider, + service, + connections, + connectionId, + connectionsErrored, + taskTemplates, + tasksErrored, + orgId, + slug, +}: ServiceDetailViewProps) { + // Resolve the connection this service belongs to (URL param, else first active). + const effectiveConnectionId = useMemo(() => { + // 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', + ); + return active?.id ?? null; + }, [connectionId, connections]); + + const { + services: connectionServices, + updateServices, + 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 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); + 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 || !liveService || !canUpdate) 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. +

+
+ {connectionsErrored ? ( + + Couldn’t load connection + + ) : !hasConnection ? ( + + Not connected + + ) : servicesError ? ( + + Status unavailable + + ) : servicesLoading ? ( + + Checking… + + ) : !isManageable ? ( + + Always scanned + + ) : canUpdate ? ( + + ) : ( + // Has the service but lacks integration:update → read-only status. + + {isEnabled ? 'Scanning on' : 'Scanning off'} + + )} +
+
+ + {/* 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) => ( + + ))} +
+ )} +
+
+ ); +} 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..d22d1141e2 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/page.tsx @@ -0,0 +1,49 @@ +import { PageLayout } from '@trycompai/design-system'; +import { redirect } from 'next/navigation'; +import { loadIntegrationPageData } from '../../lib/load-integration-page-data'; +import { ServiceDetailView } from './components/ServiceDetailView'; + +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 { + provider, + providerErrored, + connections, + connectionsErrored, + taskTemplates, + tasksErrored, + } = await loadIntegrationPageData(slug); + + if (!provider || providerErrored) { + redirect(`/${orgId}/integrations`); + } + + const service = (provider.services ?? []).find((s) => s.id === serviceId); + if (!service) { + redirect(`/${orgId}/integrations/${slug}`); + } + + return ( + + + + ); +} diff --git a/bun.lock b/bun.lock index e5cfd7955e..c9e5bf0d62 100644 --- a/bun.lock +++ b/bun.lock @@ -633,7 +633,12 @@ "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-s3-control": "^3.943.0", "@aws-sdk/client-securityhub": "^3.943.0", "@aws-sdk/client-sts": "^3.943.0", "zod": "^4.0.0", @@ -961,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=="], @@ -1033,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=="], @@ -2407,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=="], @@ -6757,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=="], @@ -7231,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=="], @@ -8423,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=="], @@ -9513,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=="], @@ -10231,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=="], @@ -10357,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 2369797cc0..c0558bc442 100644 --- a/packages/integration-platform/package.json +++ b/packages/integration-platform/package.json @@ -39,7 +39,12 @@ }, "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-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/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/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/__tests__/aws-checks.test.ts b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts new file mode 100644 index 0000000000..13f4f5c96d --- /dev/null +++ b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it } from 'bun:test'; +import { evaluateCloudTrail } from '../cloudtrail'; +import { evaluateSecurityGroups } from '../ec2'; +import { + evaluateAccountSummary, + evaluateIamAccount, + evaluatePasswordPolicy, +} from '../iam'; +import { evaluateKmsRotation } from '../kms'; +import { + evaluateRdsBackups, + evaluateRdsClusterBackups, + evaluateRdsClusterEncryption, + 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']); + }); + + 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 = { + blockPublicAcls: true, + ignorePublicAcls: true, + blockPublicPolicy: true, + restrictPublicBuckets: true, +}; + +describe('AWS S3 evaluators', () => { + 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 → "could not verify" (not a false high, not silently dropped) + { name: 'c', encrypted: false, encryptionDetermined: false, publicAccessDetermined: true, bucketBpa: null }, + ]); + 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', () => { + const out = evaluateS3PublicAccess( + [ + { name: 'a', encrypted: false, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: ALL_BLOCKED }, + { name: 'b', encrypted: false, encryptionDetermined: true, publicAccessDetermined: true, 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, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: null }], + ALL_BLOCKED, + ); + expect(out[0]!.kind).toBe('pass'); + }); +}); + +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 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'] }] }, + ]); + 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'); + }); + + 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', () => { + it('encryption: pass when encrypted, fail (high) when not', () => { + const out = evaluateRdsEncryption([ + { 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, skip Aurora (cluster-level)', () => { + const out = evaluateRdsBackups([ + { 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']); // 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', () => { + 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 → "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(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/); + }); +}); + +describe('AWS CloudTrail evaluator', () => { + 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'); + 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, logging: true }, + ]); + expect(out[0]!.kind).toBe('fail'); + expect(out[0]!.severity).toBe('medium'); + }); + + it('fails "could not verify" when an otherwise-compliant trail status is unreadable', () => { + // multi-region + validated, but GetTrailStatus failed → loggingKnown=false. + // 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(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 new file mode 100644 index 0000000000..6c80805d2d --- /dev/null +++ b/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts @@ -0,0 +1,200 @@ +import { + CloudTrailClient, + DescribeTrailsCommand, + type DescribeTrailsCommandOutput, + GetTrailStatusCommand, +} from '@aws-sdk/client-cloudtrail'; +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { resolveAwsSessionOrFail, type CheckOutcome, emitOutcomes } from './shared'; + +export interface TrailInfo { + name: string; + multiRegion: boolean; + 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 && t.loggingKnown !== false, + ); + if (good) { + return [ + { + kind: 'pass', + 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 }, + }, + ]; + } + // No confirmed-good trail. If an otherwise-compliant (multi-region + validated) + // 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 [ + { + 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 [ + { + 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 multi-region, actively logging, AND has log file validation enabled.', + resourceType: 'aws-cloudtrail', + resourceId: 'account', + severity: 'medium', + remediation: + 'Ensure a CloudTrail trail is multi-region, logging is started, and log file validation is enabled.', + evidence: { trails: trails.map((t) => t.name) }, + }, + ]; +} + +export const cloudTrailEnabledCheck: IntegrationCheck = { + id: 'aws-cloudtrail-enabled', + name: 'CloudTrail — multi-region trail logging with validation', + description: + 'Verify a multi-region CloudTrail trail is actively logging with log file validation.', + service: 'cloudtrail', + taskMapping: TASK_TEMPLATES.monitoringAlerting, + run: async (ctx: CheckContext) => { + const session = await resolveAwsSessionOrFail(ctx); + if (!session) { + ctx.log('AWS CloudTrail check: connection not configured — skipping'); + return; + } + + // 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[] = []; + const failedRegions: string[] = []; + + 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) { + failedRegions.push(region); + 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) { + // 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 statusClient.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, + }); + } + } + + // 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/ec2.ts b/packages/integration-platform/src/manifests/aws/checks/ec2.ts new file mode 100644 index 0000000000..86d817d565 --- /dev/null +++ b/packages/integration-platform/src/manifests/aws/checks/ec2.ts @@ -0,0 +1,151 @@ +import { DescribeSecurityGroupsCommand, EC2Client } from '@aws-sdk/client-ec2'; +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, FindingSeverity, IntegrationCheck } from '../../../types'; +import { resolveAwsSessionOrFail, 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') && !perm.cidrs.includes('::/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; + } + // 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; + 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 resolveAwsSessionOrFail(ctx); + if (!session) { + ctx.log('AWS EC2 security-groups check: connection not configured — skipping'); + 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. + 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) { + 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/iam.ts b/packages/integration-platform/src/manifests/aws/checks/iam.ts new file mode 100644 index 0000000000..3d5e6539e4 --- /dev/null +++ b/packages/integration-platform/src/manifests/aws/checks/iam.ts @@ -0,0 +1,187 @@ +import { + GetAccountPasswordPolicyCommand, + GetAccountSummaryCommand, + IAMClient, +} from '@aws-sdk/client-iam'; +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { resolveAwsSessionOrFail, 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; +} + +/** Password-policy findings only (independent of the account summary). */ +export function evaluatePasswordPolicy( + pp: IamAccountData['passwordPolicy'], +): CheckOutcome[] { + const out: CheckOutcome[] = []; + const id = 'account'; + + 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 }, + }); + } + } + + 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', + 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 ((summary.AccountAccessKeysPresent ?? 0) > 0) { + out.push({ + kind: 'fail', + title: 'Root account access keys present', + description: 'The root account has access keys (active or inactive), 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; +} + +/** 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', + 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 resolveAwsSessionOrFail(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) { + // 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; + } + + // 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({})); + 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 + // 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 root-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) }, + }); + } + }, +}; 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..d380f48aa6 --- /dev/null +++ b/packages/integration-platform/src/manifests/aws/checks/kms.ts @@ -0,0 +1,196 @@ +import { + DescribeKeyCommand, + GetKeyRotationStatusCommand, + KMSClient, + ListKeysCommand, +} from '@aws-sdk/client-kms'; +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { + resolveAwsSessionOrFail, + type AwsSession, + type CheckOutcome, + emitOutcomes, +} from './shared'; + +export interface KmsKeyInfo { + keyId: string; + region: string; + /** + * 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; +} + +/** + * 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) + .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}`, + 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 }, + }; + }); +} + +interface KmsKeyScan { + keys: KmsKeyInfo[]; + /** Keys whose DescribeKey failed — eligibility couldn't be classified. */ + unreadableKeyIds: string[]; +} + +async function listKmsKeys( + ctx: CheckContext, + session: AwsSession, +): 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 ?? []) { + const keyId = k.KeyId; + if (!keyId) continue; + let meta; + try { + meta = (await kms.send(new DescribeKeyCommand({ KeyId: keyId }))).KeyMetadata; + } catch (err) { + // 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)}`, + ); + continue; + } + // 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; + let rotationStatusKnown = false; + if (rotationEligible) { + try { + const rot = await kms.send(new GetKeyRotationStatusCommand({ KeyId: keyId })); + rotationEnabled = rot.KeyRotationEnabled === true; + 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, rotationEligible, rotationStatusKnown, rotationEnabled }); + } + 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 { keys: out, unreadableKeyIds }; +} + +export const kmsKeyRotationCheck: IntegrationCheck = { + id: 'aws-kms-key-rotation', + name: 'KMS — customer key rotation enabled', + description: 'Verify rotation-eligible customer-managed KMS keys have automatic rotation enabled.', + service: 'kms', + taskMapping: TASK_TEMPLATES.encryptionAtRest, + run: async (ctx: CheckContext) => { + const session = await resolveAwsSessionOrFail(ctx); + if (!session) { + ctx.log('AWS KMS check: connection not configured — skipping'); + return; + } + const { keys, unreadableKeyIds } = await listKmsKeys(ctx, session); + + // 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: `${parts.join('; ')} — rotation eligibility/status is unverified.`, + resourceType: 'aws-kms-key', + resourceId: 'account', + severity: 'medium', + remediation: + 'Grant kms:ListKeys, kms:DescribeKey, and kms:GetKeyRotationStatus to the integration role in all enabled regions, then re-run the check.', + evidence: { failedRegions, failedKeyCount }, + }); + } + + // 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 new file mode 100644 index 0000000000..5727a441e7 --- /dev/null +++ b/packages/integration-platform/src/manifests/aws/checks/rds.ts @@ -0,0 +1,282 @@ +import { + DescribeDBClustersCommand, + DescribeDBInstancesCommand, + RDSClient, +} from '@aws-sdk/client-rds'; +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { + resolveAwsSessionOrFail, + type AwsSession, + type CheckOutcome, + emitOutcomes, +} from './shared'; + +export interface RdsInstanceInfo { + id: string; + region: string; + encrypted: boolean; + backupRetentionDays: number; + /** e.g. 'postgres', 'mysql', 'aurora-mysql' — Aurora backups are cluster-level */ + 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 + // 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', + title: `RDS storage encrypted: ${i.id}`, + description: `RDS instance "${i.id}" (${i.region}) has storage encryption enabled.`, + resourceType: 'aws-rds-instance', + resourceId: `${i.region}/${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.region}/${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 + // 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', + 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.region}/${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.region}/${i.id}`, + severity: 'medium', + remediation: 'Set a backup retention period of at least 7 days.', + evidence: { instance: i.id }, + }, + ); +} + +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.region}/${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.region}/${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.region}/${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.region}/${c.id}`, + severity: 'medium', + remediation: 'Set a backup retention period of at least 7 days.', + evidence: { cluster: c.id }, + }, + ); +} + +interface RegionScan { + items: T[]; + /** Regions whose listing call failed — their resources are unverified. */ + failedRegions: string[]; +} + +async function listRdsInstances( + session: AwsSession, + ctx: CheckContext, +): 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 { + 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 ?? []) { + items.push({ + id: db.DBInstanceIdentifier ?? 'unknown', + region, + encrypted: db.StorageEncrypted === true, + backupRetentionDays: db.BackupRetentionPeriod ?? 0, + engine: db.Engine ?? '', + }); + } + 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 { items, failedRegions }; +} + +async function listRdsClusters( + session: AwsSession, + ctx: CheckContext, +): Promise> { + const items: RdsClusterInfo[] = []; + const failedRegions: string[] = []; + for (const region of session.regions) { + 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 ?? []) { + items.push({ + id: cluster.DBClusterIdentifier ?? 'unknown', + region, + encrypted: cluster.StorageEncrypted === true, + backupRetentionDays: cluster.BackupRetentionPeriod ?? 0, + engine: cluster.Engine ?? '', + }); + } + 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 { 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 = { + 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 resolveAwsSessionOrFail(ctx); + if (!session) { + 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, ctx); + const clusters = await listRdsClusters(session, ctx); + 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)); + }, +}; + +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 resolveAwsSessionOrFail(ctx); + if (!session) { + 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, ctx); + const clusters = await listRdsClusters(session, ctx); + 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/aws/checks/s3.ts b/packages/integration-platform/src/manifests/aws/checks/s3.ts new file mode 100644 index 0000000000..cee65ba76c --- /dev/null +++ b/packages/integration-platform/src/manifests/aws/checks/s3.ts @@ -0,0 +1,299 @@ +import { + GetBucketEncryptionCommand, + GetPublicAccessBlockCommand, + 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 { resolveAwsSessionOrFail, type CheckOutcome, emitOutcomes } from './shared'; + +export interface BpaFlags { + blockPublicAcls: boolean; + ignorePublicAcls: boolean; + blockPublicPolicy: boolean; + restrictPublicBuckets: boolean; +} + +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; + /** false when bucket-level Block Public Access couldn't be read (error) → excluded from eval */ + publicAccessDetermined: boolean; +} + +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[] { + 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}`, + 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[], + accountBpa: BpaFlags | null, +): CheckOutcome[] { + 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}`, + 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 }, + } + : { + kind: 'fail', + title: `Public access not fully blocked: ${b.name}`, + 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', + 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 encryptionDetermined = true; + let bucketBpa: BpaFlags | null = null; + let publicAccessDetermined = true; + + if (opts.encryption) { + try { + const enc = await s3.send(new GetBucketEncryptionCommand({ Bucket: name })); + encrypted = (enc.ServerSideEncryptionConfiguration?.Rules?.length ?? 0) > 0; + } 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) { + try { + const pab = await s3.send(new GetPublicAccessBlockCommand({ Bucket: name })); + const c = pab.PublicAccessBlockConfiguration; + bucketBpa = { + blockPublicAcls: Boolean(c?.BlockPublicAcls), + ignorePublicAcls: Boolean(c?.IgnorePublicAcls), + blockPublicPolicy: Boolean(c?.BlockPublicPolicy), + restrictPublicBuckets: Boolean(c?.RestrictPublicBuckets), + }; + } 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, publicAccessDetermined }); + } + 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', + description: 'Verify all S3 buckets have default server-side encryption enabled.', + service: 's3', + taskMapping: TASK_TEMPLATES.encryptionAtRest, + run: async (ctx: CheckContext) => { + const session = await resolveAwsSessionOrFail(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, + followRegionRedirects: true, + }); + 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)); + }, +}; + +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 (account or bucket level).', + service: 's3', + taskMapping: TASK_TEMPLATES.productionFirewallNopublicaccessControls, + run: async (ctx: CheckContext) => { + const session = await resolveAwsSessionOrFail(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, + 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`, + ); + } + } + + 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)); + }, +}; \ No newline at end of file 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..cc5edc04a3 --- /dev/null +++ b/packages/integration-platform/src/manifests/aws/checks/shared.ts @@ -0,0 +1,121 @@ +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] + : [] + ).filter((r) => r.trim().length > 0); + + 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, + }; +} + +/** + * 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'; + 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..6da5f196b4 --- /dev/null +++ b/packages/integration-platform/src/manifests/azure/checks/__tests__/azure-checks.test.ts @@ -0,0 +1,355 @@ +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("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, + 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 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); + }); + + 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('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') + ? { 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); + }); + + 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); + }); + + 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', () => { + 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('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')) { + return { value: [{ id: 'reader', properties: { roleName: 'Reader', type: 'BuiltInRole', permissions: [] } }] }; + } + return { value: [{ properties: { roleDefinitionId: 'reader', principalId: 'p', principalType: 'User' } }] }; + }); + 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', () => { + 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); + }); +}); + +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 new file mode 100644 index 0000000000..62ad42bb9a --- /dev/null +++ b/packages/integration-platform/src/manifests/azure/checks/entra-id.ts @@ -0,0 +1,221 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { ARM_BASE, armListAllOrFail, 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[]; dataActions?: string[] }>; + }; +} + +// Secondary, name-based fallback only. Permission-based classification +// (see actionIsHighPrivilege / defIsPrivileged) is the primary signal. +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', +]); + +// 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 { + 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 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 { + const permissionPrivileged = def.properties.permissions.some((perm) => + (perm.actions ?? []).some(actionIsHighPrivilege), + ); + if (permissionPrivileged) return true; + return PRIVILEGED_ROLES.has(def.properties.roleName); +} + +/** + * 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([ + armListAllOrFail( + ctx, + `${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01`, + { what: 'role assignments', resourceType: 'azure-subscription', subscriptionId: sub }, + ), + 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])); + + // 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({ + 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 }, + }); + } + + // 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( + (perm) => + (perm.actions ?? []).some(isWildcardAction) || + (perm.dataActions ?? []).some(isWildcardAction), + ), + ); + 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..7cf6ffc252 --- /dev/null +++ b/packages/integration-platform/src/manifests/azure/checks/key-vault.ts @@ -0,0 +1,125 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, FindingSeverity, IntegrationCheck } from '../../../types'; +import { ARM_BASE, armListAllOrFail, 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 armListAllOrFail( + ctx, + `${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.KeyVault/vaults?api-version=2023-07-01`, + { what: 'key vaults', resourceType: 'azure-key-vault', subscriptionId: sub }, + ); +} + +/** 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) return; + if (vaults.length === 0) return; + for (const v of vaults) { + const p = v.properties ?? {}; + const issues: string[] = []; + let severity: FindingSeverity = 'medium'; + if (p.enableSoftDelete === false) { + issues.push('soft delete disabled'); + severity = 'high'; + } + if (!p.enablePurgeProtection) issues.push('purge protection disabled'); + const isPublic = + p.publicNetworkAccess !== 'Disabled' && + (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) return; + 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..c226963cca --- /dev/null +++ b/packages/integration-platform/src/manifests/azure/checks/monitor.ts @@ -0,0 +1,147 @@ +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; + logs?: Array<{ enabled?: boolean }>; + }; +} + +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 }, + }); + } + } 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 + .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) && + (s.properties?.logs ?? []).some((l) => l.enabled), + ); + 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: {}, + }); + } + } 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) { + 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..a8c568fabe --- /dev/null +++ b/packages/integration-platform/src/manifests/azure/checks/network.ts @@ -0,0 +1,141 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, FindingSeverity, IntegrationCheck } from '../../../types'; +import { ARM_BASE, armListAllOrFail, 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 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 { + 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)); +} + +/** + * 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; + } + 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 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) { + 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); + // 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: 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' }, + ]; + 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..d309d7db2a --- /dev/null +++ b/packages/integration-platform/src/manifests/azure/checks/shared.ts @@ -0,0 +1,96 @@ +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.trim().length > 0) { + return configured.trim(); + } + try { + const data = await ctx.fetch<{ + value?: Array<{ subscriptionId: string; state?: string }>; + }>(`${ARM}/subscriptions?api-version=2020-01-01`); + const subs = data.value ?? []; + // 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( + 'Failed to auto-detect Azure subscription; set subscription_id manually', + { error: err instanceof Error ? err.message : String(err) }, + ); + 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; + // 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) { + ctx.warn('Azure ARM list hit the page cap; results may be truncated', { + url, + pages, + }); + } + 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 new file mode 100644 index 0000000000..e237de4165 --- /dev/null +++ b/packages/integration-platform/src/manifests/azure/checks/sql.ts @@ -0,0 +1,224 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { ARM_BASE, armListAll, armListAllOrFail, 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 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 }, + ); +} + +/** 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) return; + if (servers.length === 0) return; + for (const s of servers) { + const tls = s.properties?.minimalTlsVersion; + // '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'}).`, + 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) return; + 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', + }; + } + + // 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(() => null); + + 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) { + 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 if (rules === null) { + // 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}`, + 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) return; + 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) { + // 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}`, + 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..ffff5bee2e --- /dev/null +++ b/packages/integration-platform/src/manifests/azure/checks/storage.ts @@ -0,0 +1,180 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { ARM_BASE, armListAllOrFail, 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 armListAllOrFail( + ctx, + `${ARM_BASE}/subscriptions/${sub}/providers/Microsoft.Storage/storageAccounts?api-version=2023-05-01`, + { + what: 'storage accounts', + resourceType: 'azure-storage-account', + subscriptionId: sub, + }, + ); +} + +/** 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) return; + 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) return; + if (accounts.length === 0) return; + for (const a of accounts) { + const p = a.properties ?? {}; + const publicBlob = p.allowBlobPublicAccess === true; + // 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 = + !networkRestricted && + (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) return; + 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..56801f4182 --- /dev/null +++ b/packages/integration-platform/src/manifests/gcp/checks/__tests__/gcp-checks.test.ts @@ -0,0 +1,446 @@ +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); + }); + + 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); + }); + + 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', () => { + it('fails a bucket with uniform bucket-level access disabled', async () => { + const { failed } = await runCheck(storagePublicAccessCheck, { + fetch: () => ({ + items: [ + { + 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', + }, + }, + ], + }), + }); + 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 () => { + 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); + }); + + 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/); + }); + + 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', () => { + it('paginates discovered projects (follows nextPageToken) so all are evaluated', async () => { + 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' }; + } + // 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 (per-bucket resourceId) + expect(passed.map((p) => p.resourceId).sort()).toEqual(['p1/b', 'p2/b']); + }); +}); + +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('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: () => ({ + 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'); + }); + + 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', () => { + 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); + }); + + 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)', () => { + 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..a238ef5092 --- /dev/null +++ b/packages/integration-platform/src/manifests/gcp/checks/cloud-sql-backups.ts @@ -0,0 +1,91 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +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 }; + }; +} + +/** + * 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) { + 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: `${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) { + // 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 new file mode 100644 index 0000000000..3eb02db44e --- /dev/null +++ b/packages/integration-platform/src/manifests/gcp/checks/cloud-sql-ssl.ts @@ -0,0 +1,96 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { gcpListItems, 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) { + 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; + + 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) { + // 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 new file mode 100644 index 0000000000..b559412db2 --- /dev/null +++ b/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts @@ -0,0 +1,173 @@ +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. */ +const PRIMITIVE_ROLES: Record = { + 'roles/owner': 'high', + 'roles/editor': 'medium', +}; + +interface IamBinding { + role: string; + 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; + } +} + +/** + * 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 + * 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 and their inherited folders/organization.', + 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) { + try { + const projectBindings = await getBindings( + ctx, + `v3/projects/${encodeURIComponent(projectId)}`, + ); + 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; + 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 }, + }); + } + } + } + + 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 — but it is + // unverified, so emit a finding rather than warn-and-skip (an + // all-projects-failed run would otherwise leave the task stale). + failUnverifiedProject(ctx, projectId, error); + continue; + } + } + }, +}; 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..3dd0416c6c --- /dev/null +++ b/packages/integration-platform/src/manifests/gcp/checks/shared.ts @@ -0,0 +1,116 @@ +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)) { + // 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 { + // 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; + 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), + }); + 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); + if (pageToken) { + ctx.warn('GCP list hit the page cap; results may be truncated', { + url, + pages, + }); + } + return out; +} + +/** + * 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..339ffa6cfd --- /dev/null +++ b/packages/integration-platform/src/manifests/gcp/checks/storage-public-access.ts @@ -0,0 +1,165 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { gcpListItems, resolveGcpProjectIds } from './shared'; + +interface Bucket { + name: string; + location?: string; + iamConfiguration?: { + uniformBucketLevelAccess?: { enabled?: boolean }; + publicAccessPrevention?: string; + }; +} + +interface BucketIamPolicy { + bindings?: Array<{ role?: string; members?: string[] }>; +} + +const PUBLIC_MEMBERS = new Set(['allUsers', 'allAuthenticatedUsers']); + +/** + * 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 are not granted to allUsers/allAuthenticatedUsers and enforce uniform bucket-level access.', + 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) { + try { + const buckets = await gcpListItems( + ctx, + `https://storage.googleapis.com/storage/v1/b?project=${encodeURIComponent(projectId)}`, + ); + if (buckets.length === 0) continue; // nothing to evidence for this project + + for (const bucket of buckets) { + await evaluateBucket(ctx, projectId, bucket); + } + } catch (err) { + // Unverified project → emit a finding, not a warn-and-skip, so an + // all-projects-failed run doesn't leave the task stale (silent pass). + 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), + }, + }); + } + } + }, +}; + +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 }, + }); +} 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..70d4e8440d --- /dev/null +++ b/packages/integration-platform/src/manifests/gcp/checks/vpc-open-firewalls.ts @@ -0,0 +1,132 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, FindingSeverity, IntegrationCheck } from '../../../types'; +import { gcpListItems, 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-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 firewall rules open to the internet', + description: + '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, + + 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) { + 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(' / '); + + 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: `${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) { + // 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) { + // 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), + }, + }); + } + } + }, +}; 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, + ], }; 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); + }); + } + } +});