diff --git a/apps/api/src/cloud-security/azure-remediation.service.ts b/apps/api/src/cloud-security/azure-remediation.service.ts
index 846d037d56..07ae2e2815 100644
--- a/apps/api/src/cloud-security/azure-remediation.service.ts
+++ b/apps/api/src/cloud-security/azure-remediation.service.ts
@@ -643,9 +643,12 @@ export class AzureRemediationService {
connectionId,
{
tokenUrl: oauthConfig.tokenUrl,
+ refreshUrl: oauthConfig.refreshUrl,
clientId: oauthCreds.clientId,
clientSecret: oauthCreds.clientSecret,
clientAuthMethod: oauthConfig.clientAuthMethod,
+ scope: oauthCreds.scopes.join(' '),
+ tokenParams: oauthConfig.tokenParams,
},
);
if (token) return token;
diff --git a/apps/api/src/cloud-security/cloud-security.service.ts b/apps/api/src/cloud-security/cloud-security.service.ts
index 39d700a767..38de61ce74 100644
--- a/apps/api/src/cloud-security/cloud-security.service.ts
+++ b/apps/api/src/cloud-security/cloud-security.service.ts
@@ -113,9 +113,12 @@ export class CloudSecurityService {
const accessToken =
await this.credentialVaultService.getValidAccessToken(connectionId, {
tokenUrl: oauthConfig.tokenUrl,
+ refreshUrl: oauthConfig.refreshUrl,
clientId: oauthCreds.clientId,
clientSecret: oauthCreds.clientSecret,
clientAuthMethod: oauthConfig.clientAuthMethod,
+ scope: oauthCreds.scopes.join(' '),
+ tokenParams: oauthConfig.tokenParams,
});
if (!accessToken) {
diff --git a/apps/api/src/cloud-security/gcp-remediation.service.ts b/apps/api/src/cloud-security/gcp-remediation.service.ts
index 079d439c5d..fd722bdc27 100644
--- a/apps/api/src/cloud-security/gcp-remediation.service.ts
+++ b/apps/api/src/cloud-security/gcp-remediation.service.ts
@@ -677,9 +677,12 @@ export class GcpRemediationService {
connectionId,
{
tokenUrl: oauthConfig.tokenUrl,
+ refreshUrl: oauthConfig.refreshUrl,
clientId: oauthCreds.clientId,
clientSecret: oauthCreds.clientSecret,
clientAuthMethod: oauthConfig.clientAuthMethod,
+ scope: oauthCreds.scopes.join(' '),
+ tokenParams: oauthConfig.tokenParams,
},
);
if (token) return token;
diff --git a/apps/api/src/integration-platform/controllers/checks.controller.ts b/apps/api/src/integration-platform/controllers/checks.controller.ts
index 5376d67d91..e66413e3a1 100644
--- a/apps/api/src/integration-platform/controllers/checks.controller.ts
+++ b/apps/api/src/integration-platform/controllers/checks.controller.ts
@@ -30,9 +30,10 @@ import {
import { ConnectionRepository } from '../repositories/connection.repository';
import { ConnectionService } from '../services/connection.service';
import { CredentialVaultService } from '../services/credential-vault.service';
+import { OAuthCredentialsService } from '../services/oauth-credentials.service';
import { ProviderRepository } from '../repositories/provider.repository';
import { CheckRunRepository } from '../repositories/check-run.repository';
-import { getStringValue, toStringCredentials } from '../utils/credential-utils';
+import { getStringValue } from '../utils/credential-utils';
// Class (not interface) so @nestjs/swagger emits a body schema, plus a
// class-validator decorator so the ValidationPipe whitelist accepts the field.
@@ -67,6 +68,7 @@ export class ChecksController {
private readonly connectionRepository: ConnectionRepository,
private readonly providerRepository: ProviderRepository,
private readonly credentialVaultService: CredentialVaultService,
+ private readonly oauthCredentialsService: OAuthCredentialsService,
private readonly checkRunRepository: CheckRunRepository,
private readonly connectionService: ConnectionService,
) {}
@@ -247,6 +249,48 @@ export class ChecksController {
`Running checks for connection ${connectionId} (${provider.slug})${body.checkId ? ` - check: ${body.checkId}` : ''}`,
);
+ let accessToken = getStringValue(credentials.access_token);
+ let onTokenRefresh: (() => Promise) | undefined;
+ if (manifest.auth.type === 'oauth2') {
+ const oauthConfig = manifest.auth.config;
+ const supportsRefresh = oauthConfig.supportsRefreshToken !== false;
+
+ if (supportsRefresh) {
+ const oauthCredentials =
+ await this.oauthCredentialsService.getCredentials(
+ provider.slug,
+ organizationId,
+ );
+
+ if (oauthCredentials) {
+ const refreshConfig = {
+ tokenUrl: oauthConfig.tokenUrl,
+ refreshUrl: oauthConfig.refreshUrl,
+ clientId: oauthCredentials.clientId,
+ clientSecret: oauthCredentials.clientSecret,
+ clientAuthMethod: oauthConfig.clientAuthMethod,
+ scope: oauthCredentials.scopes.join(' '),
+ tokenParams: oauthConfig.tokenParams,
+ };
+
+ const validAccessToken =
+ await this.credentialVaultService.getValidAccessToken(
+ connectionId,
+ refreshConfig,
+ );
+ if (validAccessToken) {
+ accessToken = validAccessToken;
+ }
+
+ onTokenRefresh = () =>
+ this.credentialVaultService.refreshOAuthTokens(
+ connectionId,
+ refreshConfig,
+ );
+ }
+ }
+ }
+
// Create a check run record
const checkRun = await this.checkRunRepository.create({
connectionId,
@@ -256,16 +300,18 @@ export class ChecksController {
try {
// Run checks
- const accessToken = getStringValue(credentials.access_token);
- const stringCredentials = toStringCredentials(credentials);
const result = await runAllChecks({
manifest,
accessToken,
- credentials: stringCredentials,
+ // Pass decrypted credentials through unchanged. Collapsing array fields
+ // here (e.g. AWS `regions`) made custom-auth checks see no regions and
+ // skip with "connection not configured".
+ credentials,
variables,
connectionId,
organizationId: connection.organizationId,
checkId: body.checkId,
+ onTokenRefresh,
logger: {
info: (msg, data) => this.logger.log(msg, data),
warn: (msg, data) => this.logger.warn(msg, data),
diff --git a/apps/api/src/integration-platform/controllers/connections.controller.spec.ts b/apps/api/src/integration-platform/controllers/connections.controller.spec.ts
index 31da8f9477..dad16fffbb 100644
--- a/apps/api/src/integration-platform/controllers/connections.controller.spec.ts
+++ b/apps/api/src/integration-platform/controllers/connections.controller.spec.ts
@@ -8,6 +8,7 @@ import { CredentialVaultService } from '../services/credential-vault.service';
import { OAuthCredentialsService } from '../services/oauth-credentials.service';
import { AutoCheckRunnerService } from '../services/auto-check-runner.service';
import { ProviderRepository } from '../repositories/provider.repository';
+import { ConnectionRepository } from '../repositories/connection.repository';
jest.mock('../../auth/auth.server', () => ({
auth: { api: { getSession: jest.fn() } },
@@ -20,6 +21,12 @@ jest.mock('@trycompai/auth', () => ({
BUILT_IN_ROLE_PERMISSIONS: {},
}));
+jest.mock('@db', () => ({
+ db: {
+ integrationProvider: { findUnique: jest.fn() },
+ },
+}));
+
jest.mock('@trycompai/integration-platform', () => ({
getManifest: jest.fn(),
getAllManifests: jest.fn(),
@@ -49,6 +56,7 @@ describe('ConnectionsController', () => {
const mockConnectionService = {
getOrganizationConnections: jest.fn(),
getConnection: jest.fn(),
+ getConnectionForOrg: jest.fn(),
createConnection: jest.fn(),
activateConnection: jest.fn(),
pauseConnection: jest.fn(),
@@ -78,6 +86,10 @@ describe('ConnectionsController', () => {
upsert: jest.fn(),
};
+ const mockConnectionRepository = {
+ update: jest.fn(),
+ };
+
const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
beforeEach(async () => {
@@ -98,6 +110,7 @@ describe('ConnectionsController', () => {
useValue: mockAutoCheckRunnerService,
},
{ provide: ProviderRepository, useValue: mockProviderRepository },
+ { provide: ConnectionRepository, useValue: mockConnectionRepository },
],
})
.overrideGuard(HybridAuthGuard)
@@ -110,6 +123,14 @@ describe('ConnectionsController', () => {
jest.clearAllMocks();
mockAutoCheckRunnerService.tryAutoRunChecks.mockResolvedValue(false);
+ mockConnectionService.getConnectionForOrg.mockResolvedValue({
+ id: 'conn_1',
+ organizationId: 'org_1',
+ status: 'active',
+ provider: { slug: 'datadog' },
+ metadata: {},
+ variables: {},
+ });
});
describe('listProviders', () => {
@@ -233,13 +254,14 @@ describe('ConnectionsController', () => {
createdAt: new Date(),
updatedAt: new Date(),
};
- mockConnectionService.getConnection.mockResolvedValue(connection);
+ mockConnectionService.getConnectionForOrg.mockResolvedValue(connection);
mockedGetManifest.mockReturnValue(undefined as never);
- const result = await controller.getConnection('conn_1');
+ const result = await controller.getConnection('conn_1', 'org_1');
- expect(mockConnectionService.getConnection).toHaveBeenCalledWith(
+ expect(mockConnectionService.getConnectionForOrg).toHaveBeenCalledWith(
'conn_1',
+ 'org_1',
);
expect(result.id).toBe('conn_1');
expect(result.providerSlug).toBe('github');
@@ -318,18 +340,18 @@ describe('ConnectionsController', () => {
describe('testConnection', () => {
it('should throw NOT_FOUND when provider slug is missing', async () => {
- mockConnectionService.getConnection.mockResolvedValue({
+ mockConnectionService.getConnectionForOrg.mockResolvedValue({
id: 'conn_1',
provider: undefined,
});
- await expect(controller.testConnection('conn_1')).rejects.toThrow(
- HttpException,
- );
+ await expect(
+ controller.testConnection('conn_1', 'org_1'),
+ ).rejects.toThrow(HttpException);
});
it('should throw BAD_REQUEST when no credentials found', async () => {
- mockConnectionService.getConnection.mockResolvedValue({
+ mockConnectionService.getConnectionForOrg.mockResolvedValue({
id: 'conn_1',
provider: { slug: 'datadog' },
});
@@ -337,13 +359,13 @@ describe('ConnectionsController', () => {
null,
);
- await expect(controller.testConnection('conn_1')).rejects.toThrow(
- HttpException,
- );
+ await expect(
+ controller.testConnection('conn_1', 'org_1'),
+ ).rejects.toThrow(HttpException);
});
it('should activate connection when no handler is defined', async () => {
- mockConnectionService.getConnection.mockResolvedValue({
+ mockConnectionService.getConnectionForOrg.mockResolvedValue({
id: 'conn_1',
provider: { slug: 'custom-provider' },
});
@@ -356,7 +378,7 @@ describe('ConnectionsController', () => {
} as never);
mockConnectionService.activateConnection.mockResolvedValue(undefined);
- const result = await controller.testConnection('conn_1');
+ const result = await controller.testConnection('conn_1', 'org_1');
expect(mockConnectionService.activateConnection).toHaveBeenCalledWith(
'conn_1',
@@ -372,7 +394,7 @@ describe('ConnectionsController', () => {
status: 'paused',
});
- const result = await controller.pauseConnection('conn_1');
+ const result = await controller.pauseConnection('conn_1', 'org_1');
expect(mockConnectionService.pauseConnection).toHaveBeenCalledWith(
'conn_1',
@@ -388,7 +410,7 @@ describe('ConnectionsController', () => {
status: 'active',
});
- const result = await controller.resumeConnection('conn_1');
+ const result = await controller.resumeConnection('conn_1', 'org_1');
expect(mockConnectionService.activateConnection).toHaveBeenCalledWith(
'conn_1',
@@ -404,7 +426,7 @@ describe('ConnectionsController', () => {
status: 'disconnected',
});
- const result = await controller.disconnectConnection('conn_1');
+ const result = await controller.disconnectConnection('conn_1', 'org_1');
expect(mockConnectionService.disconnectConnection).toHaveBeenCalledWith(
'conn_1',
@@ -417,7 +439,7 @@ describe('ConnectionsController', () => {
it('should call service.deleteConnection', async () => {
mockConnectionService.deleteConnection.mockResolvedValue(undefined);
- const result = await controller.deleteConnection('conn_1');
+ const result = await controller.deleteConnection('conn_1', 'org_1');
expect(mockConnectionService.deleteConnection).toHaveBeenCalledWith(
'conn_1',
@@ -428,7 +450,7 @@ describe('ConnectionsController', () => {
describe('updateConnection', () => {
it('should merge metadata and update', async () => {
- mockConnectionService.getConnection.mockResolvedValue({
+ mockConnectionService.getConnectionForOrg.mockResolvedValue({
id: 'conn_1',
organizationId: 'org_1',
metadata: { existing: 'value' },
@@ -451,11 +473,9 @@ describe('ConnectionsController', () => {
});
it('should throw FORBIDDEN when org does not match', async () => {
- mockConnectionService.getConnection.mockResolvedValue({
- id: 'conn_1',
- organizationId: 'org_other',
- metadata: {},
- });
+ mockConnectionService.getConnectionForOrg.mockRejectedValue(
+ new HttpException('Connection not found', HttpStatus.NOT_FOUND),
+ );
await expect(
controller.updateConnection('conn_1', 'org_1', {
@@ -467,11 +487,9 @@ describe('ConnectionsController', () => {
describe('ensureValidCredentials', () => {
it('should throw NOT_FOUND when org does not match', async () => {
- mockConnectionService.getConnection.mockResolvedValue({
- id: 'conn_1',
- organizationId: 'org_other',
- status: 'active',
- });
+ mockConnectionService.getConnectionForOrg.mockRejectedValue(
+ new HttpException('Connection not found', HttpStatus.NOT_FOUND),
+ );
await expect(
controller.ensureValidCredentials('conn_1', 'org_1'),
@@ -479,7 +497,7 @@ describe('ConnectionsController', () => {
});
it('should throw BAD_REQUEST when connection is not active', async () => {
- mockConnectionService.getConnection.mockResolvedValue({
+ mockConnectionService.getConnectionForOrg.mockResolvedValue({
id: 'conn_1',
organizationId: 'org_1',
status: 'paused',
@@ -491,7 +509,7 @@ describe('ConnectionsController', () => {
});
it('should return credentials for api_key auth', async () => {
- mockConnectionService.getConnection.mockResolvedValue({
+ mockConnectionService.getConnectionForOrg.mockResolvedValue({
id: 'conn_1',
organizationId: 'org_1',
status: 'active',
@@ -509,15 +527,68 @@ describe('ConnectionsController', () => {
expect(result.success).toBe(true);
expect(result.credentials).toEqual({ api_key: 'test-key' });
});
+
+ it('force refreshes OAuth credentials even when stored expiry is not due', async () => {
+ mockConnectionService.getConnectionForOrg.mockResolvedValue({
+ id: 'conn_1',
+ organizationId: 'org_1',
+ status: 'active',
+ provider: { slug: 'gcp' },
+ });
+ mockedGetManifest.mockReturnValue({
+ auth: {
+ type: 'oauth2',
+ config: {
+ tokenUrl: 'https://oauth2.googleapis.com/token',
+ refreshUrl: undefined,
+ clientAuthMethod: 'body',
+ supportsRefreshToken: true,
+ tokenParams: undefined,
+ },
+ },
+ } as never);
+ mockCredentialVaultService.needsRefresh.mockResolvedValue(false);
+ mockOAuthCredentialsService.getCredentials.mockResolvedValue({
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ scopes: ['https://www.googleapis.com/auth/cloud-platform'],
+ });
+ mockCredentialVaultService.refreshOAuthTokens.mockResolvedValue(
+ 'fresh-token',
+ );
+ mockCredentialVaultService.getDecryptedCredentials.mockResolvedValue({
+ access_token: 'fresh-token',
+ });
+
+ const result = await controller.ensureValidCredentials(
+ 'conn_1',
+ 'org_1',
+ {
+ forceRefresh: true,
+ },
+ );
+
+ expect(mockCredentialVaultService.needsRefresh).not.toHaveBeenCalled();
+ expect(
+ mockCredentialVaultService.refreshOAuthTokens,
+ ).toHaveBeenCalledWith('conn_1', {
+ tokenUrl: 'https://oauth2.googleapis.com/token',
+ refreshUrl: undefined,
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ clientAuthMethod: 'body',
+ scope: 'https://www.googleapis.com/auth/cloud-platform',
+ tokenParams: undefined,
+ });
+ expect(result.credentials).toEqual({ access_token: 'fresh-token' });
+ });
});
describe('updateCredentials', () => {
it('should throw NOT_FOUND when org does not match', async () => {
- mockConnectionService.getConnection.mockResolvedValue({
- id: 'conn_1',
- organizationId: 'org_other',
- provider: { slug: 'datadog' },
- });
+ mockConnectionService.getConnectionForOrg.mockRejectedValue(
+ new HttpException('Connection not found', HttpStatus.NOT_FOUND),
+ );
await expect(
controller.updateCredentials('conn_1', 'org_1', {
@@ -527,7 +598,7 @@ describe('ConnectionsController', () => {
});
it('should throw BAD_REQUEST for OAuth integrations', async () => {
- mockConnectionService.getConnection.mockResolvedValue({
+ mockConnectionService.getConnectionForOrg.mockResolvedValue({
id: 'conn_1',
organizationId: 'org_1',
provider: { slug: 'github' },
@@ -544,7 +615,7 @@ describe('ConnectionsController', () => {
});
it('should merge and store credentials', async () => {
- mockConnectionService.getConnection.mockResolvedValue({
+ mockConnectionService.getConnectionForOrg.mockResolvedValue({
id: 'conn_1',
organizationId: 'org_1',
status: 'active',
@@ -573,7 +644,7 @@ describe('ConnectionsController', () => {
});
it('should activate connection if it was in error state', async () => {
- mockConnectionService.getConnection.mockResolvedValue({
+ mockConnectionService.getConnectionForOrg.mockResolvedValue({
id: 'conn_1',
organizationId: 'org_1',
status: 'error',
diff --git a/apps/api/src/integration-platform/controllers/connections.controller.ts b/apps/api/src/integration-platform/controllers/connections.controller.ts
index 641b610937..359ec36b11 100644
--- a/apps/api/src/integration-platform/controllers/connections.controller.ts
+++ b/apps/api/src/integration-platform/controllers/connections.controller.ts
@@ -21,7 +21,13 @@ import {
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
-import { IsArray, IsObject, IsOptional, IsString } from 'class-validator';
+import {
+ IsArray,
+ IsBoolean,
+ IsObject,
+ IsOptional,
+ IsString,
+} from 'class-validator';
import { db } from '@db';
import {
AssumeRoleCommand,
@@ -55,6 +61,7 @@ import {
parseAwsRoleArn,
validateAwsPartitionConfig,
} from '../../cloud-security/aws-partition.utils';
+import { getProviderSummary } from '../utils/provider-summary';
// Class (not interface) so @nestjs/swagger can introspect it — interfaces are
// erased at runtime and produce an empty OpenAPI body schema, which means MCP
@@ -184,6 +191,17 @@ class UpdateConnectionCredentialsDto {
credentials!: Record;
}
+class EnsureValidCredentialsDto {
+ @ApiPropertyOptional({
+ description:
+ 'Force an OAuth token refresh even when the stored expiry has not been reached. Use after a provider returns 401.',
+ default: false,
+ })
+ @IsOptional()
+ @IsBoolean()
+ forceRefresh?: boolean;
+}
+
const hasCredentialValue = (value?: string | string[]): boolean => {
if (Array.isArray(value)) {
return value.length > 0;
@@ -305,11 +323,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 +439,7 @@ export class ConnectionsController {
description: s.description,
enabledByDefault: s.enabledByDefault ?? true,
implemented: s.implemented ?? true,
+ mappedTasks: this.buildServiceTaskMappings(manifest.checks, s.id),
})) ?? [],
};
}
@@ -414,20 +456,24 @@ export class ConnectionsController {
return connections
.filter((c) => c.status !== 'disconnected')
- .map((c) => ({
- id: c.id,
- providerId: c.providerId,
- providerSlug: (c as any).provider?.slug,
- providerName: (c as any).provider?.name,
- status: c.status,
- authStrategy: c.authStrategy,
- lastSyncAt: c.lastSyncAt,
- nextSyncAt: c.nextSyncAt,
- errorMessage: c.errorMessage,
- variables: c.variables,
- metadata: c.metadata,
- createdAt: c.createdAt,
- }));
+ .map((c) => {
+ const provider = getProviderSummary(c);
+
+ return {
+ id: c.id,
+ providerId: c.providerId,
+ providerSlug: provider?.slug,
+ providerName: provider?.name,
+ status: c.status,
+ authStrategy: c.authStrategy,
+ lastSyncAt: c.lastSyncAt,
+ nextSyncAt: c.nextSyncAt,
+ errorMessage: c.errorMessage,
+ variables: c.variables,
+ metadata: c.metadata,
+ createdAt: c.createdAt,
+ };
+ });
}
/**
@@ -444,8 +490,7 @@ export class ConnectionsController {
id,
organizationId,
);
- const providerSlug = (connection as { provider?: { slug: string } })
- .provider?.slug;
+ const providerSlug = getProviderSummary(connection)?.slug;
// Get credential fields for custom auth integrations
let credentialFields: Array<{
@@ -865,7 +910,7 @@ export class ConnectionsController {
id,
organizationId,
);
- const providerSlug = (connection as any).provider?.slug;
+ const providerSlug = getProviderSummary(connection)?.slug;
if (!providerSlug) {
throw new HttpException(
@@ -1048,10 +1093,12 @@ export class ConnectionsController {
*/
@Post(':id/ensure-valid-credentials')
@ApiOperation({ summary: 'Ensure valid credentials for a connection' })
+ @ApiBody({ type: EnsureValidCredentialsDto, required: false })
@RequirePermission('integration', 'update')
async ensureValidCredentials(
@Param('id') id: string,
@OrganizationId() organizationId: string,
+ @Body() body?: EnsureValidCredentialsDto,
) {
const connection = await this.connectionService.getConnectionForOrg(
id,
@@ -1065,8 +1112,7 @@ export class ConnectionsController {
);
}
- const providerSlug = (connection as { provider?: { slug: string } })
- .provider?.slug;
+ const providerSlug = getProviderSummary(connection)?.slug;
if (!providerSlug) {
throw new HttpException(
'Provider not found for connection',
@@ -1090,11 +1136,15 @@ export class ConnectionsController {
const supportsRefresh = oauthConfig.supportsRefreshToken !== false;
if (supportsRefresh) {
- const needsRefresh = await this.credentialVaultService.needsRefresh(id);
+ const forceRefresh = body?.forceRefresh === true;
+ const needsRefresh =
+ forceRefresh || (await this.credentialVaultService.needsRefresh(id));
if (needsRefresh) {
this.logger.log(
- `Token needs refresh for connection ${id}, attempting refresh...`,
+ forceRefresh
+ ? `Force refreshing token for connection ${id}...`
+ : `Token needs refresh for connection ${id}, attempting refresh...`,
);
const oauthCredentials =
@@ -1118,6 +1168,8 @@ export class ConnectionsController {
clientId: oauthCredentials.clientId,
clientSecret: oauthCredentials.clientSecret,
clientAuthMethod: oauthConfig.clientAuthMethod,
+ scope: oauthCredentials.scopes.join(' '),
+ tokenParams: oauthConfig.tokenParams,
},
);
@@ -1293,8 +1345,7 @@ export class ConnectionsController {
organizationId,
);
- const providerSlug = (connection as { provider?: { slug: string } })
- .provider?.slug;
+ const providerSlug = getProviderSummary(connection)?.slug;
if (!providerSlug) {
throw new HttpException(
'Provider not found for connection',
diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts
index c018449fee..cd52b77103 100644
--- a/apps/api/src/integration-platform/controllers/sync.controller.ts
+++ b/apps/api/src/integration-platform/controllers/sync.controller.ts
@@ -187,6 +187,8 @@ export class SyncController {
clientId: oauthCredentials.clientId,
clientSecret: oauthCredentials.clientSecret,
clientAuthMethod: oauthConfig.clientAuthMethod,
+ scope: oauthCredentials.scopes.join(' '),
+ tokenParams: oauthConfig.tokenParams,
},
);
if (newToken) {
@@ -684,9 +686,12 @@ export class SyncController {
connectionId,
{
tokenUrl: oauthConfig.tokenUrl,
+ refreshUrl: oauthConfig.refreshUrl,
clientId: oauthCredentials.clientId,
clientSecret: oauthCredentials.clientSecret,
clientAuthMethod: oauthConfig.clientAuthMethod,
+ scope: oauthCredentials.scopes.join(' '),
+ tokenParams: oauthConfig.tokenParams,
},
);
if (newToken) {
@@ -1799,6 +1804,8 @@ export class SyncController {
clientId: oauthCredentials.clientId,
clientSecret: oauthCredentials.clientSecret,
clientAuthMethod: oauthConfig.clientAuthMethod,
+ scope: oauthCredentials.scopes.join(' '),
+ tokenParams: oauthConfig.tokenParams,
},
);
if (newToken) {
@@ -1867,7 +1874,7 @@ export class SyncController {
// `SyncDefinition` (from @trycompai/integration-platform) doesn't
// declare `isDirectorySource`, but the underlying Prisma JSON value
// may carry it. Structural cast lets us read the optional flag
- // without an `as any`, with a safe `?? false` fallback.
+ // with a safe `?? false` fallback.
isDirectorySource:
(syncDefinition as { isDirectorySource?: boolean })
.isDirectorySource ?? false,
diff --git a/apps/api/src/integration-platform/controllers/task-integrations.controller.ts b/apps/api/src/integration-platform/controllers/task-integrations.controller.ts
index b1f1922042..9d758739df 100644
--- a/apps/api/src/integration-platform/controllers/task-integrations.controller.ts
+++ b/apps/api/src/integration-platform/controllers/task-integrations.controller.ts
@@ -35,8 +35,9 @@ import { CheckRunRepository } from '../repositories/check-run.repository';
import { CredentialVaultService } from '../services/credential-vault.service';
import { OAuthCredentialsService } from '../services/oauth-credentials.service';
import { TaskIntegrationChecksService } from '../services/task-integration-checks.service';
-import { getStringValue, toStringCredentials } from '../utils/credential-utils';
+import { getStringValue } from '../utils/credential-utils';
import { isCheckDisabledForTask } from '../utils/disabled-task-checks';
+import { getProviderSummary } from '../utils/provider-summary';
import { db } from '@db';
import type { Prisma } from '@db';
@@ -411,6 +412,8 @@ export class TaskIntegrationsController {
clientId: oauthCredentials.clientId,
clientSecret: oauthCredentials.clientSecret,
clientAuthMethod: oauthConfig.clientAuthMethod,
+ scope: oauthCredentials.scopes.join(' '),
+ tokenParams: oauthConfig.tokenParams,
},
);
};
@@ -429,11 +432,13 @@ export class TaskIntegrationsController {
try {
// Run the specific check
const accessToken = getStringValue(credentials.access_token);
- const stringCredentials = toStringCredentials(credentials);
const result = await runAllChecks({
manifest,
accessToken,
- credentials: stringCredentials,
+ // Pass decrypted credentials through unchanged. Collapsing array fields
+ // here (e.g. AWS `regions`) made custom-auth checks see no regions and
+ // skip with "connection not configured".
+ credentials,
variables,
connectionId,
organizationId,
@@ -642,37 +647,41 @@ export class TaskIntegrationsController {
);
return {
- runs: runs.map((run) => ({
- id: run.id,
- checkId: run.checkId,
- checkName: run.checkName,
- status: run.status,
- startedAt: run.startedAt,
- completedAt: run.completedAt,
- durationMs: run.durationMs,
- totalChecked: run.totalChecked,
- passedCount: run.passedCount,
- failedCount: run.failedCount,
- errorMessage: run.errorMessage,
- logs: run.logs,
- provider: {
- slug: (run.connection as any).provider?.slug,
- name: (run.connection as any).provider?.name,
- },
- results: run.results.map((r) => ({
- id: r.id,
- passed: r.passed,
- resourceType: r.resourceType,
- resourceId: r.resourceId,
- title: r.title,
- description: r.description,
- severity: r.severity,
- remediation: r.remediation,
- evidence: r.evidence,
- collectedAt: r.collectedAt,
- })),
- createdAt: run.createdAt,
- })),
+ runs: runs.map((run) => {
+ const provider = getProviderSummary(run.connection);
+
+ return {
+ id: run.id,
+ checkId: run.checkId,
+ checkName: run.checkName,
+ status: run.status,
+ startedAt: run.startedAt,
+ completedAt: run.completedAt,
+ durationMs: run.durationMs,
+ totalChecked: run.totalChecked,
+ passedCount: run.passedCount,
+ failedCount: run.failedCount,
+ errorMessage: run.errorMessage,
+ logs: run.logs,
+ provider: {
+ slug: provider?.slug,
+ name: provider?.name,
+ },
+ results: run.results.map((r) => ({
+ id: r.id,
+ passed: r.passed,
+ resourceType: r.resourceType,
+ resourceId: r.resourceId,
+ title: r.title,
+ description: r.description,
+ severity: r.severity,
+ remediation: r.remediation,
+ evidence: r.evidence,
+ collectedAt: r.collectedAt,
+ })),
+ createdAt: run.createdAt,
+ };
+ }),
};
}
}
diff --git a/apps/api/src/integration-platform/services/credential-vault.service.spec.ts b/apps/api/src/integration-platform/services/credential-vault.service.spec.ts
new file mode 100644
index 0000000000..b2024f726a
--- /dev/null
+++ b/apps/api/src/integration-platform/services/credential-vault.service.spec.ts
@@ -0,0 +1,70 @@
+jest.mock('@db', () => ({
+ db: {},
+}));
+
+import { CredentialVaultService } from './credential-vault.service';
+import type { CredentialRepository } from '../repositories/credential.repository';
+import type { ConnectionRepository } from '../repositories/connection.repository';
+
+describe('CredentialVaultService', () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('does not let tokenParams override reserved OAuth refresh parameters', async () => {
+ const credentialRepository = {} as unknown as CredentialRepository;
+ const connectionRepository = {} as unknown as ConnectionRepository;
+ const service = new CredentialVaultService(
+ credentialRepository,
+ connectionRepository,
+ );
+
+ jest.spyOn(service, 'getRefreshToken').mockResolvedValue('real-refresh');
+ jest.spyOn(service, 'storeOAuthTokens').mockResolvedValue(undefined);
+
+ let requestBody: BodyInit | null | undefined;
+ jest.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
+ requestBody = init?.body;
+ return new Response(
+ JSON.stringify({
+ access_token: 'new-token',
+ expires_in: 3600,
+ }),
+ {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ },
+ );
+ });
+
+ const token = await service.refreshOAuthTokens('conn_1', {
+ tokenUrl: 'https://oauth.example.com/token',
+ clientId: 'real-client',
+ clientSecret: 'real-secret',
+ clientAuthMethod: 'body',
+ scope: 'real-scope',
+ tokenParams: {
+ grant_type: 'client_credentials',
+ refresh_token: 'wrong-refresh',
+ client_id: 'wrong-client',
+ client_secret: 'wrong-secret',
+ scope: 'wrong-scope',
+ audience: 'https://api.example.com',
+ },
+ });
+
+ expect(token).toBe('new-token');
+
+ if (typeof requestBody !== 'string') {
+ throw new Error('Expected OAuth refresh body to be serialized');
+ }
+
+ const params = new URLSearchParams(requestBody);
+ expect(params.get('grant_type')).toBe('refresh_token');
+ expect(params.get('refresh_token')).toBe('real-refresh');
+ expect(params.get('client_id')).toBe('real-client');
+ expect(params.get('client_secret')).toBe('real-secret');
+ expect(params.get('scope')).toBe('real-scope');
+ expect(params.get('audience')).toBe('https://api.example.com');
+ });
+});
diff --git a/apps/api/src/integration-platform/services/credential-vault.service.ts b/apps/api/src/integration-platform/services/credential-vault.service.ts
index 09901b12a5..ef227d8e26 100644
--- a/apps/api/src/integration-platform/services/credential-vault.service.ts
+++ b/apps/api/src/integration-platform/services/credential-vault.service.ts
@@ -15,6 +15,13 @@ const KEY_LENGTH = 32;
// Refresh tokens 5 minutes before expiry to avoid race conditions
const REFRESH_BUFFER_SECONDS = 300;
+const RESERVED_REFRESH_TOKEN_PARAMS = new Set([
+ 'grant_type',
+ 'refresh_token',
+ 'client_id',
+ 'client_secret',
+ 'scope',
+]);
export interface EncryptedData {
encrypted: string;
@@ -37,6 +44,8 @@ export interface TokenRefreshConfig {
clientId: string;
clientSecret: string;
clientAuthMethod?: 'body' | 'header';
+ scope?: string;
+ tokenParams?: Record;
/** If provider has a separate refresh URL (rare) */
refreshUrl?: string;
}
@@ -324,6 +333,22 @@ export class CredentialVaultService {
refresh_token: refreshToken,
});
+ if (config.scope) {
+ body.set('scope', config.scope);
+ }
+
+ if (config.tokenParams) {
+ for (const [key, value] of Object.entries(config.tokenParams)) {
+ if (RESERVED_REFRESH_TOKEN_PARAMS.has(key)) {
+ this.logger.warn(
+ `Ignoring reserved OAuth refresh token parameter: ${key}`,
+ );
+ continue;
+ }
+ body.set(key, value);
+ }
+ }
+
const headers: Record = {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
diff --git a/apps/api/src/integration-platform/utils/credential-utils.ts b/apps/api/src/integration-platform/utils/credential-utils.ts
index f73a0d6426..0b2027b332 100644
--- a/apps/api/src/integration-platform/utils/credential-utils.ts
+++ b/apps/api/src/integration-platform/utils/credential-utils.ts
@@ -14,22 +14,3 @@ export function getStringValue(value?: string | string[]): string | undefined {
}
return value;
}
-
-/**
- * Normalizes credentials from Record to Record
- * by extracting the first value from arrays
- * @param credentials - The credentials object with potential array values
- * @returns A normalized credentials object with only string values
- */
-export function toStringCredentials(
- credentials: Record,
-): Record {
- const normalized: Record = {};
- for (const [key, value] of Object.entries(credentials)) {
- const stringValue = getStringValue(value);
- if (typeof stringValue === 'string' && stringValue.length > 0) {
- normalized[key] = stringValue;
- }
- }
- return normalized;
-}
diff --git a/apps/api/src/integration-platform/utils/provider-summary.ts b/apps/api/src/integration-platform/utils/provider-summary.ts
new file mode 100644
index 0000000000..9561434014
--- /dev/null
+++ b/apps/api/src/integration-platform/utils/provider-summary.ts
@@ -0,0 +1,28 @@
+export interface ProviderSummary {
+ slug?: string;
+ name?: string;
+}
+
+export function getProviderSummary(
+ value: unknown,
+): ProviderSummary | undefined {
+ if (!value || typeof value !== 'object' || !('provider' in value)) {
+ return undefined;
+ }
+
+ const provider = (value as { provider?: unknown }).provider;
+ if (!provider || typeof provider !== 'object') {
+ return undefined;
+ }
+
+ const slug =
+ 'slug' in provider && typeof provider.slug === 'string'
+ ? provider.slug
+ : undefined;
+ const name =
+ 'name' in provider && typeof provider.name === 'string'
+ ? provider.name
+ : undefined;
+
+ return { slug, name };
+}
diff --git a/apps/api/src/trigger/integration-platform/ensure-valid-credentials.spec.ts b/apps/api/src/trigger/integration-platform/ensure-valid-credentials.spec.ts
new file mode 100644
index 0000000000..a7f56207d1
--- /dev/null
+++ b/apps/api/src/trigger/integration-platform/ensure-valid-credentials.spec.ts
@@ -0,0 +1,46 @@
+import { requestValidCredentials } from './ensure-valid-credentials';
+
+function createAbortError(): Error {
+ const error = new Error('Aborted');
+ error.name = 'AbortError';
+ return error;
+}
+
+describe('requestValidCredentials', () => {
+ const originalServiceToken = process.env.SERVICE_TOKEN_TRIGGER;
+
+ afterEach(() => {
+ process.env.SERVICE_TOKEN_TRIGGER = originalServiceToken;
+ jest.restoreAllMocks();
+ jest.useRealTimers();
+ });
+
+ it('times out requests that do not respond', async () => {
+ jest.useFakeTimers();
+ process.env.SERVICE_TOKEN_TRIGGER = 'service-token';
+
+ jest.spyOn(globalThis, 'fetch').mockImplementation(
+ (_input, init) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener(
+ 'abort',
+ () => reject(createAbortError()),
+ { once: true },
+ );
+ }),
+ );
+
+ const resultPromise = requestValidCredentials({
+ apiUrl: 'https://api.example.com',
+ connectionId: 'conn_1',
+ organizationId: 'org_1',
+ });
+
+ jest.advanceTimersByTime(30_000);
+
+ await expect(resultPromise).resolves.toEqual({
+ success: false,
+ error: 'Timed out after 30000ms while requesting valid credentials',
+ });
+ });
+});
diff --git a/apps/api/src/trigger/integration-platform/ensure-valid-credentials.ts b/apps/api/src/trigger/integration-platform/ensure-valid-credentials.ts
new file mode 100644
index 0000000000..f939533890
--- /dev/null
+++ b/apps/api/src/trigger/integration-platform/ensure-valid-credentials.ts
@@ -0,0 +1,112 @@
+export type IntegrationCredentialValues = Record;
+
+export interface ValidCredentialsResult {
+ success: boolean;
+ credentials?: IntegrationCredentialValues;
+ error?: string;
+ status?: number;
+}
+
+const VALID_CREDENTIALS_REQUEST_TIMEOUT_MS = 30_000;
+
+function getErrorMessage(value: unknown): string | undefined {
+ if (value && typeof value === 'object' && 'message' in value) {
+ const message = (value as { message?: unknown }).message;
+ return typeof message === 'string' ? message : undefined;
+ }
+ return undefined;
+}
+
+function isAbortError(error: unknown): boolean {
+ return (
+ !!error &&
+ typeof error === 'object' &&
+ 'name' in error &&
+ error.name === 'AbortError'
+ );
+}
+
+export function getAccessToken(
+ credentials: IntegrationCredentialValues,
+): string | undefined {
+ const value = credentials.access_token;
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
+}
+
+export async function requestValidCredentials(params: {
+ apiUrl: string;
+ connectionId: string;
+ organizationId: string;
+ forceRefresh?: boolean;
+}): Promise {
+ const serviceToken = process.env.SERVICE_TOKEN_TRIGGER;
+ if (!serviceToken) {
+ return {
+ success: false,
+ error: 'SERVICE_TOKEN_TRIGGER is not configured',
+ };
+ }
+
+ const abortController = new AbortController();
+ const timeoutId = setTimeout(() => {
+ abortController.abort();
+ }, VALID_CREDENTIALS_REQUEST_TIMEOUT_MS);
+
+ try {
+ const response = await fetch(
+ `${params.apiUrl}/v1/integrations/connections/${params.connectionId}/ensure-valid-credentials`,
+ {
+ method: 'POST',
+ signal: abortController.signal,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-service-token': serviceToken,
+ 'x-organization-id': params.organizationId,
+ },
+ body: JSON.stringify({ forceRefresh: params.forceRefresh === true }),
+ },
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null);
+ return {
+ success: false,
+ status: response.status,
+ error:
+ getErrorMessage(errorData) ||
+ `Failed to get valid credentials: ${response.status}`,
+ };
+ }
+
+ const result = (await response.json()) as {
+ success: boolean;
+ credentials?: IntegrationCredentialValues;
+ };
+
+ if (!result.success || !result.credentials) {
+ return {
+ success: false,
+ error: 'Valid credentials response did not include credentials',
+ };
+ }
+
+ return {
+ success: true,
+ credentials: result.credentials,
+ };
+ } catch (error) {
+ if (isAbortError(error) || abortController.signal.aborted) {
+ return {
+ success: false,
+ error: `Timed out after ${VALID_CREDENTIALS_REQUEST_TIMEOUT_MS}ms while requesting valid credentials`,
+ };
+ }
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ } finally {
+ clearTimeout(timeoutId);
+ }
+}
diff --git a/apps/api/src/trigger/integration-platform/run-connection-checks.ts b/apps/api/src/trigger/integration-platform/run-connection-checks.ts
index 9a28d861bf..7523b63b9a 100644
--- a/apps/api/src/trigger/integration-platform/run-connection-checks.ts
+++ b/apps/api/src/trigger/integration-platform/run-connection-checks.ts
@@ -1,6 +1,11 @@
import { getManifest, runAllChecks } from '@trycompai/integration-platform';
import { db } from '@db';
import { logger, tags, task } from '@trigger.dev/sdk';
+import {
+ getAccessToken,
+ requestValidCredentials,
+ type IntegrationCredentialValues,
+} from './ensure-valid-credentials';
/**
* Trigger task that runs all checks for a connection.
@@ -89,45 +94,43 @@ export const runConnectionChecks = task({
// Ensure we have valid credentials
const apiUrl = process.env.BASE_URL || 'http://localhost:3333';
- let credentials: Record;
+ let credentials: IntegrationCredentialValues;
- try {
- logger.info('Ensuring valid credentials...');
- const response = await fetch(
- `${apiUrl}/v1/integrations/connections/${connectionId}/ensure-valid-credentials`,
- {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'x-service-token': process.env.SERVICE_TOKEN_TRIGGER!,
- 'x-organization-id': organizationId,
- },
- },
- );
+ logger.info('Ensuring valid credentials...');
+ const credentialsResult = await requestValidCredentials({
+ apiUrl,
+ connectionId,
+ organizationId,
+ });
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- const errorMessage =
- (errorData as { message?: string }).message ||
- `Failed to get valid credentials: ${response.status}`;
- logger.error(errorMessage);
- return { success: false, error: errorMessage };
- }
+ if (!credentialsResult.success || !credentialsResult.credentials) {
+ const errorMessage =
+ credentialsResult.error || 'Failed to validate credentials';
+ logger.error(errorMessage);
+ return { success: false, error: errorMessage };
+ }
+ credentials = credentialsResult.credentials;
- const result = (await response.json()) as {
- success: boolean;
- credentials: Record;
- };
- credentials = result.credentials;
- } catch (error) {
- logger.error('Failed to ensure valid credentials', {
- error: error instanceof Error ? error.message : String(error),
+ const handleTokenRefresh = async (): Promise => {
+ logger.info('Force refreshing OAuth credentials after provider 401...');
+ const refreshResult = await requestValidCredentials({
+ apiUrl,
+ connectionId,
+ organizationId,
+ forceRefresh: true,
});
- return { success: false, error: 'Failed to validate credentials' };
- }
+
+ if (!refreshResult.success || !refreshResult.credentials) {
+ logger.error(refreshResult.error || 'Forced token refresh failed');
+ return null;
+ }
+
+ credentials = refreshResult.credentials;
+ return getAccessToken(credentials) ?? null;
+ };
// Validate credentials based on auth type
- if (manifest.auth.type === 'oauth2' && !credentials.access_token) {
+ if (manifest.auth.type === 'oauth2' && !getAccessToken(credentials)) {
logger.error(
`No OAuth access token found for connection: ${connectionId}`,
);
@@ -168,11 +171,13 @@ export const runConnectionChecks = task({
// Run all checks
const result = await runAllChecks({
manifest,
- accessToken: credentials.access_token ?? undefined,
+ accessToken: getAccessToken(credentials),
credentials,
variables,
connectionId,
organizationId,
+ onTokenRefresh:
+ manifest.auth.type === 'oauth2' ? handleTokenRefresh : undefined,
logger: {
info: (msg, data) => logger.info(msg, data),
warn: (msg, data) => logger.warn(msg, data),
diff --git a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts
index e2c4b404a5..8d697fe3dc 100644
--- a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts
+++ b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts
@@ -5,6 +5,11 @@ import { triggerEmail } from '../../email/trigger-email';
import { TaskStatusChangedEmail } from '../../email/templates/task-status-changed';
import { isUserUnsubscribed } from '@trycompai/email';
import { parseDisabledTaskChecks } from '../../integration-platform/utils/disabled-task-checks';
+import {
+ getAccessToken,
+ requestValidCredentials,
+ type IntegrationCredentialValues,
+} from './ensure-valid-credentials';
/**
* Send email notifications for task status change
@@ -214,59 +219,57 @@ export const runTaskIntegrationChecks = task({
// Ensure we have valid credentials (refresh OAuth tokens if needed)
const apiUrl = process.env.BASE_URL || 'http://localhost:3333';
- let credentials: Record;
+ let credentials: IntegrationCredentialValues;
- try {
- logger.info('Ensuring valid credentials (refreshing if needed)...');
- const response = await fetch(
- `${apiUrl}/v1/integrations/connections/${connectionId}/ensure-valid-credentials`,
- {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'x-service-token': process.env.SERVICE_TOKEN_TRIGGER!,
- 'x-organization-id': organizationId,
- },
- },
- );
+ logger.info('Ensuring valid credentials (refreshing if needed)...');
+ const credentialsResult = await requestValidCredentials({
+ apiUrl,
+ connectionId,
+ organizationId,
+ });
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- const errorMessage =
- (errorData as { message?: string }).message ||
- `Failed to get valid credentials: ${response.status}`;
- logger.error(errorMessage);
-
- // If unauthorized, mark connection as error
- if (response.status === 401) {
- await db.integrationConnection.update({
- where: { id: connectionId },
- data: {
- status: 'error',
- errorMessage:
- 'OAuth token expired. Please reconnect the integration.',
- },
- });
- }
+ if (!credentialsResult.success || !credentialsResult.credentials) {
+ const errorMessage =
+ credentialsResult.error || 'Failed to validate credentials';
+ logger.error(errorMessage);
- return { success: false, error: errorMessage };
+ // If unauthorized, mark connection as error
+ if (credentialsResult.status === 401) {
+ await db.integrationConnection.update({
+ where: { id: connectionId },
+ data: {
+ status: 'error',
+ errorMessage:
+ 'OAuth token expired. Please reconnect the integration.',
+ },
+ });
}
- const result = (await response.json()) as {
- success: boolean;
- credentials: Record;
- };
- credentials = result.credentials;
- logger.info('Credentials validated successfully');
- } catch (error) {
- logger.error('Failed to ensure valid credentials', {
- error: error instanceof Error ? error.message : String(error),
- });
- return { success: false, error: 'Failed to validate credentials' };
+ return { success: false, error: errorMessage };
}
+ credentials = credentialsResult.credentials;
+ logger.info('Credentials validated successfully');
+
+ const handleTokenRefresh = async (): Promise => {
+ logger.info('Force refreshing OAuth credentials after provider 401...');
+ const refreshResult = await requestValidCredentials({
+ apiUrl,
+ connectionId,
+ organizationId,
+ forceRefresh: true,
+ });
+
+ if (!refreshResult.success || !refreshResult.credentials) {
+ logger.error(refreshResult.error || 'Forced token refresh failed');
+ return null;
+ }
+
+ credentials = refreshResult.credentials;
+ return getAccessToken(credentials) ?? null;
+ };
// Validate credentials based on auth type
- if (manifest.auth.type === 'oauth2' && !credentials.access_token) {
+ if (manifest.auth.type === 'oauth2' && !getAccessToken(credentials)) {
logger.error(
`No OAuth access token found for connection: ${connectionId}`,
);
@@ -326,12 +329,14 @@ export const runTaskIntegrationChecks = task({
for (const checkId of effectiveCheckIds) {
const result = await runAllChecks({
manifest,
- accessToken: credentials.access_token ?? undefined,
+ accessToken: getAccessToken(credentials),
credentials,
variables,
connectionId,
organizationId,
checkId, // Run specific check
+ onTokenRefresh:
+ manifest.auth.type === 'oauth2' ? handleTokenRefresh : undefined,
logger: {
info: (msg, data) => logger.info(msg, data),
warn: (msg, data) => logger.warn(msg, data),
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/EvidenceTaskRow.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/EvidenceTaskRow.tsx
new file mode 100644
index 0000000000..79bf79ff07
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/EvidenceTaskRow.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import { Button } from '@trycompai/design-system';
+import { ArrowRight } from '@trycompai/design-system/icons';
+import Link from 'next/link';
+
+/** The resolved live task for a mapped template, when it exists in the org. */
+export interface EvidenceTaskRowTask {
+ taskId: string;
+ name: string;
+ description: string;
+}
+
+interface EvidenceTaskRowProps {
+ /** Name shown when the template has no live task in this org. */
+ fallbackName: string;
+ task?: EvidenceTaskRowTask;
+ orgId: string;
+ /** Action label when the task exists (e.g. 'Open', 'View task'). */
+ buttonLabel?: string;
+ /** When the tasks fetch failed, distinguish "couldn't load" from "not added". */
+ tasksErrored?: boolean;
+}
+
+/**
+ * A single evidence-task row: task name + description with an "open" action, or
+ * a not-added / load-error fallback. Shared by the integration detail page and
+ * the per-service detail page so the row markup has one source of truth.
+ */
+export function EvidenceTaskRow({
+ fallbackName,
+ task,
+ orgId,
+ buttonLabel = 'Open',
+ tasksErrored = false,
+}: EvidenceTaskRowProps) {
+ return (
+
+
+
{task?.name ?? fallbackName}
+
+ {task?.description ||
+ 'Mapped to this template, but the task is not in this organization yet.'}
+
+
+
+ {task ? (
+
}
+ iconRight={
}
+ >
+ {buttonLabel}
+
+ ) : (
+
+ {tasksErrored ? 'Couldn’t load tasks' : 'Not added'}
+
+ )}
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/IntegrationEvidenceTasks.test.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/IntegrationEvidenceTasks.test.tsx
new file mode 100644
index 0000000000..2037e8be2a
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/IntegrationEvidenceTasks.test.tsx
@@ -0,0 +1,54 @@
+import type { IntegrationProvider } from '@/hooks/use-integration-platform';
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { IntegrationEvidenceTasks } from './IntegrationEvidenceTasks';
+
+function makeProvider(
+ mappedTasks: Array<{ id: string; name: string }>,
+): IntegrationProvider {
+ // Only `mappedTasks` is read by the component; cast the minimal shape.
+ return { mappedTasks } as unknown as IntegrationProvider;
+}
+
+const MAPPED = [
+ { id: 'tmpl-encryption', name: 'Encryption at Rest' },
+ { id: 'tmpl-firewall', name: 'Production Firewall & No-Public-Access Controls' },
+];
+
+describe('IntegrationEvidenceTasks — hides evidence tasks not added to the org', () => {
+ it('shows only tasks that exist in the org, with a matching count and no "Not added"', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Encryption at Rest')).toBeInTheDocument();
+ expect(
+ screen.queryByText('Production Firewall & No-Public-Access Controls'),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText('Not added')).not.toBeInTheDocument();
+ // Count badge reflects added-only.
+ expect(screen.getByText('1')).toBeInTheDocument();
+ });
+
+ it('renders nothing when none of the mapped tasks exist in the org', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+});
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..bca8e9921c 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;
@@ -34,8 +32,11 @@ export function IntegrationEvidenceTasks({
() => new Map(taskTemplates.map((task) => [task.id, task])),
[taskTemplates],
);
+ // Hide template mappings with no live task in this org — they would render as
+ // "Not added" and prompt customers to ask why. Show only tasks they have.
+ const visibleTasks = mappedTasks.filter((m) => taskByTemplateId.has(m.id));
- if (mappedTasks.length === 0) {
+ if (visibleTasks.length === 0) {
return null;
}
@@ -50,40 +51,20 @@ export function IntegrationEvidenceTasks({
- {mappedTasks.length}
+ {visibleTasks.length}
- {mappedTasks.map((mappedTask) => {
- const task = taskByTemplateId.get(mappedTask.id);
-
- return (
-
-
-
{task?.name ?? mappedTask.name}
-
- {task?.description ||
- 'This task is mapped to the integration template, but is not available in this organization yet.'}
-
-
-
- {task ? (
-
}
- iconRight={
}
- >
- Open
-
- ) : (
-
Not added
- )}
-
- );
- })}
+ {visibleTasks.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..ac63829327 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,9 @@ export function ProviderDetailView({
services={services}
connectionServices={connectionServices}
connectionId={selectedConnection?.id ?? null}
- onToggle={handleToggleService}
- togglingService={togglingService}
+ orgId={orgId}
+ slug={provider.id}
+ taskTemplates={taskTemplates}
/>
)}
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.test.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.test.tsx
new file mode 100644
index 0000000000..15b027355a
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ServiceCard.test.tsx
@@ -0,0 +1,54 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('@/hooks/use-integration-platform', () => ({
+ useConnectionServices: () => ({ services: [], isLoading: false, error: null }),
+}));
+
+import { ServiceCard } from './ServiceCard';
+
+const service = {
+ id: 's3',
+ name: 'S3 Bucket Security',
+ description: 'Public access blocks, default encryption, and versioning checks',
+ mappedTasks: [
+ { id: 'tmpl-encryption', name: 'Encryption at Rest' },
+ { id: 'tmpl-firewall', name: 'Production Firewall' },
+ ],
+};
+
+describe('ServiceCard — evidence task count', () => {
+ it('counts only tasks added to the org when addedTemplateIds is provided', () => {
+ render(
+ (['tmpl-encryption'])}
+ />,
+ );
+ expect(screen.getByText('1 evidence task')).toBeInTheDocument();
+ expect(screen.queryByText('2 evidence tasks')).not.toBeInTheDocument();
+ });
+
+ it('falls back to all mapped tasks when addedTemplateIds is absent', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('2 evidence tasks')).toBeInTheDocument();
+ });
+
+ it('hides the count entirely when none of the mapped tasks are added', () => {
+ render(
+ ()}
+ />,
+ );
+ expect(screen.queryByText(/evidence task/)).not.toBeInTheDocument();
+ });
+});
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..65c87d7554 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,100 @@ 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;
+ /**
+ * Template ids that have a live task in this org. When provided, the evidence
+ * count shows only added tasks — matching the service detail page, which
+ * hides mappings the org hasn't added. Falls back to all mapped tasks when
+ * absent.
+ */
+ addedTemplateIds?: Set;
}
+/**
+ * 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,
- isConnected,
- onToggle,
- toggling,
+ orgId,
+ slug,
+ addedTemplateIds,
}: ServiceCardProps) {
- const { services } = useConnectionServices(connectionId);
-
+ const { services, isLoading, error } = useConnectionServices(connectionId);
const isImplemented = service.implemented !== false;
const liveService = services.find((s) => s.id === service.id);
+ const inServiceList = Boolean(liveService);
const isEnabled = liveService?.enabled ?? false;
- 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 mappedTasks = service.mappedTasks ?? [];
+ const taskCount = addedTemplateIds
+ ? mappedTasks.filter((m) => addedTemplateIds.has(m.id)).length
+ : mappedTasks.length;
+
+ 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 && (
-
-
+
+
);
}
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/services-grid.test.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/services-grid.test.tsx
new file mode 100644
index 0000000000..f220488ee4
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/services-grid.test.tsx
@@ -0,0 +1,43 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('@/hooks/use-integration-platform', () => ({
+ useConnectionServices: () => ({ services: [], isLoading: false, error: null }),
+}));
+
+import { ServicesGrid } from './services-grid';
+
+const services = [
+ {
+ id: 's3',
+ name: 'S3 Bucket Security',
+ description: 'Public access blocks, default encryption, and versioning checks',
+ mappedTasks: [
+ { id: 'tmpl-a', name: 'Encryption at Rest' },
+ { id: 'tmpl-b', name: 'Production Firewall' },
+ ],
+ },
+];
+
+describe('ServicesGrid — evidence task counts', () => {
+ it('falls back to total mapped tasks when taskTemplates is not provided', () => {
+ render(
+
,
+ );
+ // taskTemplates omitted → addedTemplateIds undefined → card counts all mapped.
+ expect(screen.getByText('2 evidence tasks')).toBeInTheDocument();
+ });
+
+ it('counts only added tasks when taskTemplates is provided', () => {
+ render(
+
,
+ );
+ expect(screen.getByText('1 evidence task')).toBeInTheDocument();
+ });
+});
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..15154e487e 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,43 +2,41 @@
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,
+ taskTemplates,
}: {
- 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;
+ /** Org task templates (live tasks). Used to count only added evidence tasks. */
+ taskTemplates?: Array<{ id: 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],
+ // Template ids the org actually has a live task for — so each card counts
+ // only added evidence tasks (matching the service detail page). Stays
+ // undefined when taskTemplates isn't provided so ServiceCard falls back to
+ // counting all mapped tasks; an explicit empty array still means "none added"
+ // (count 0), matching the detail page for an org with no live tasks.
+ const addedTemplateIds = useMemo(
+ () => (taskTemplates ? new Set(taskTemplates.map((t) => t.id)) : undefined),
+ [taskTemplates],
);
const displayedServices = useMemo(
@@ -47,9 +45,9 @@ export function ServicesGrid({
manifestServices: services,
connectionServices,
search,
- tailEnabledIds,
+ tailEnabledIds: [],
}),
- [services, connectionServices, search, tailEnabledIds],
+ [services, connectionServices, search],
);
return (
@@ -75,9 +73,9 @@ export function ServicesGrid({
key={service.id}
service={service}
connectionId={connectionId}
- isConnected
- onToggle={handleToggle}
- toggling={togglingService === service.id}
+ orgId={orgId}
+ slug={slug}
+ addedTemplateIds={addedTemplateIds}
/>
))}
{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 (
({
+ usePermissions: () => ({ hasPermission: () => true }),
+}));
+vi.mock('@/hooks/use-integration-platform', () => ({
+ useConnectionServices: () => ({
+ services: [],
+ updateServices: vi.fn(),
+ isLoading: false,
+ error: null,
+ }),
+}));
+
+import type {
+ ConnectionListItemResponse,
+ IntegrationProviderResponse,
+} from '@trycompai/integration-platform';
+import { ServiceDetailView } from './ServiceDetailView';
+
+const FIREWALL = 'Production Firewall & No-Public-Access Controls';
+
+const baseProps = {
+ provider: {
+ id: 'aws',
+ name: 'Amazon Web Services',
+ } as unknown as IntegrationProviderResponse,
+ service: {
+ id: 's3',
+ name: 'S3 Bucket Security',
+ description: 'Public access blocks, default encryption, and versioning checks',
+ mappedTasks: [
+ { id: 'tmpl-encryption', name: 'Encryption at Rest' },
+ { id: 'tmpl-firewall', name: FIREWALL },
+ ],
+ },
+ connections: [] as ConnectionListItemResponse[],
+ connectionId: null,
+ connectionsErrored: false,
+ taskTemplates: [
+ {
+ id: 'tmpl-encryption',
+ taskId: 'task-1',
+ name: 'Encryption at Rest',
+ description: 'd',
+ },
+ ],
+ tasksErrored: false,
+ orgId: 'org-1',
+ slug: 'aws',
+};
+
+describe('ServiceDetailView — Evidence provided hides tasks not added to the org', () => {
+ it('shows only the org task and never renders "Not added"', () => {
+ render();
+ expect(screen.getByText('Encryption at Rest')).toBeInTheDocument();
+ expect(screen.queryByText(FIREWALL)).not.toBeInTheDocument();
+ expect(screen.queryByText('Not added')).not.toBeInTheDocument();
+ });
+
+ it('shows all mapped tasks (with a load-error notice) when the tasks fetch errored', () => {
+ render();
+ // On a fetch error we can't distinguish added from not-added, so show all
+ // and surface "Couldn't load" rather than a misleading "Not added".
+ expect(screen.getByText('Encryption at Rest')).toBeInTheDocument();
+ expect(screen.getByText(FIREWALL)).toBeInTheDocument();
+ expect(screen.queryByText('Not added')).not.toBeInTheDocument();
+ expect(screen.getAllByText('Couldn’t load tasks').length).toBeGreaterThan(0);
+ });
+});
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx
new file mode 100644
index 0000000000..32a30df0c7
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/services/[serviceId]/components/ServiceDetailView.tsx
@@ -0,0 +1,233 @@
+'use client';
+
+import { useConnectionServices } from '@/hooks/use-integration-platform';
+import { usePermissions } from '@/hooks/use-permissions';
+import { Breadcrumb, Stack } from '@trycompai/design-system';
+import type {
+ ConnectionListItemResponse,
+ IntegrationProviderResponse,
+} from '@trycompai/integration-platform';
+import Link from 'next/link';
+import { useMemo, useState } from 'react';
+import { toast } from 'sonner';
+import { EvidenceTaskRow } from '../../../components/EvidenceTaskRow';
+
+interface ServiceMeta {
+ id: string;
+ name: string;
+ description: string;
+ implemented?: boolean;
+ mappedTasks?: Array<{ id: string; name: string }>;
+}
+
+interface TaskTemplate {
+ id: string;
+ taskId: string;
+ name: string;
+ description: string;
+}
+
+interface ServiceDetailViewProps {
+ provider: IntegrationProviderResponse;
+ service: ServiceMeta;
+ connections: ConnectionListItemResponse[];
+ connectionId: string | null;
+ 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 ?? [];
+ // Only surface evidence tasks that actually exist in this org. A template
+ // mapping with no live task would render as "Not added", which prompts
+ // customers to ask why it isn't added. On a tasks-fetch error we can't tell
+ // added from not-added, so show everything (the row renders "Couldn't load").
+ const visibleTasks = tasksErrored
+ ? mappedTasks
+ : mappedTasks.filter((m) => taskByTemplateId.has(m.id));
+
+ 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.
+
+
+
+ {visibleTasks.length}
+
+
+
+
+ {visibleTasks.length === 0 ? (
+
+ This service doesn't map to any evidence task yet.
+
+ ) : (
+
+ {visibleTasks.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/apps/app/src/components/integrations/ConnectionVariablesForm.test.tsx b/apps/app/src/components/integrations/ConnectionVariablesForm.test.tsx
new file mode 100644
index 0000000000..99c00a4947
--- /dev/null
+++ b/apps/app/src/components/integrations/ConnectionVariablesForm.test.tsx
@@ -0,0 +1,173 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+// Stub the design-system Select family so we can assert how its overlay is configured.
+// The affected modal is a legacy Radix dialog while DS Select is Base UI; rendering
+// the popup inline keeps it inside the dialog focus boundary.
+vi.mock('@trycompai/design-system', () => ({
+ Input: (props: Record) => ,
+ Label: ({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) => (
+
+ ),
+ Spinner: () => ,
+ Select: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ SelectTrigger: ({ children, id }: { children: React.ReactNode; id?: string }) => (
+ {children}
+ ),
+ SelectValue: ({ placeholder }: { placeholder?: string }) => {placeholder},
+ SelectContent: ({
+ children,
+ portal,
+ alignItemWithTrigger,
+ style,
+ }: {
+ children: React.ReactNode;
+ portal?: boolean;
+ alignItemWithTrigger?: boolean;
+ style?: React.CSSProperties;
+ }) => (
+
+ {children}
+
+ ),
+ SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => (
+
+ {children}
+
+ ),
+}));
+
+// The multi-select field pulls in @trycompai/ui MultipleSelector — not relevant
+// to these select/boolean dropdown tests, so stub it out.
+vi.mock('./ConnectionVariableMultiSelect', () => ({
+ ConnectionVariableMultiSelect: () => ,
+}));
+
+import {
+ ConnectionVariablesFields,
+ type ConnectionVariable,
+ type ConnectionVariableSelectContentOptions,
+} from './ConnectionVariablesForm';
+
+const noop = () => {};
+
+function renderFields(
+ variables: ConnectionVariable[],
+ selectContentOptions?: ConnectionVariableSelectContentOptions,
+) {
+ return render(
+ ,
+ );
+}
+
+describe('ConnectionVariablesFields dropdown clickability inside a modal', () => {
+ const modalSelectContentOptions = {
+ portal: false,
+ alignItemWithTrigger: false,
+ } satisfies ConnectionVariableSelectContentOptions;
+
+ it('renders a select-type dropdown popup inline when the legacy modal opts in', () => {
+ renderFields(
+ [
+ {
+ id: 'alert_severity_threshold',
+ label: 'Fail on open alerts at severity',
+ type: 'select',
+ required: false,
+ options: [
+ { value: 'low', label: 'Low' },
+ { value: 'critical', label: 'Critical' },
+ ],
+ },
+ ],
+ modalSelectContentOptions,
+ );
+
+ const content = screen.getByTestId('select-content');
+ expect(content).toHaveAttribute('data-portal', 'false');
+ expect(content).toHaveAttribute('data-align-item-with-trigger', 'false');
+ expect(content).toHaveStyle({ pointerEvents: 'auto' });
+ expect(screen.getByText('Low')).toBeInTheDocument();
+ expect(screen.getByText('Critical')).toBeInTheDocument();
+ });
+
+ it('renders a boolean-type dropdown popup inline when the legacy modal opts in', () => {
+ renderFields(
+ [
+ {
+ id: 'enabled',
+ label: 'Enabled',
+ type: 'boolean',
+ required: false,
+ default: false,
+ },
+ ],
+ modalSelectContentOptions,
+ );
+
+ const content = screen.getByTestId('select-content');
+ expect(content).toHaveAttribute('data-portal', 'false');
+ expect(content).toHaveAttribute('data-align-item-with-trigger', 'false');
+ expect(content).toHaveStyle({ pointerEvents: 'auto' });
+ expect(screen.getByText('Yes')).toBeInTheDocument();
+ expect(screen.getByText('No')).toBeInTheDocument();
+ });
+
+ it('applies modal overlay settings to every dropdown for mixed select + boolean fields', () => {
+ renderFields(
+ [
+ {
+ id: 'mode',
+ label: 'Mode',
+ type: 'select',
+ required: false,
+ options: [{ value: 'all', label: 'All' }],
+ },
+ { id: 'enabled', label: 'Enabled', type: 'boolean', required: false },
+ ],
+ modalSelectContentOptions,
+ );
+
+ const contents = screen.getAllByTestId('select-content');
+ expect(contents).toHaveLength(2);
+ for (const content of contents) {
+ expect(content).toHaveAttribute('data-portal', 'false');
+ expect(content).toHaveAttribute('data-align-item-with-trigger', 'false');
+ expect(content).toHaveStyle({ pointerEvents: 'auto' });
+ }
+ });
+
+ it('keeps the default DS portal behavior unless a caller opts into modal-safe rendering', () => {
+ renderFields([
+ {
+ id: 'mode',
+ label: 'Mode',
+ type: 'select',
+ required: false,
+ options: [{ value: 'all', label: 'All' }],
+ },
+ ]);
+
+ const content = screen.getByTestId('select-content');
+ expect(content).not.toHaveAttribute('data-portal');
+ expect(content).not.toHaveAttribute('data-align-item-with-trigger');
+ expect(content).toHaveStyle({ pointerEvents: 'auto' });
+ });
+});
diff --git a/apps/app/src/components/integrations/ConnectionVariablesForm.tsx b/apps/app/src/components/integrations/ConnectionVariablesForm.tsx
index 91dbda6403..78766302ca 100644
--- a/apps/app/src/components/integrations/ConnectionVariablesForm.tsx
+++ b/apps/app/src/components/integrations/ConnectionVariablesForm.tsx
@@ -10,7 +10,7 @@ import {
SelectValue,
Spinner,
} from '@trycompai/design-system';
-import type { Dispatch, SetStateAction } from 'react';
+import type { ComponentProps, Dispatch, SetStateAction } from 'react';
import { ConnectionVariableMultiSelect } from './ConnectionVariableMultiSelect';
export interface ConnectionVariable {
@@ -28,6 +28,11 @@ export interface ConnectionVariable {
export type VariableValue = string | number | boolean | string[];
+export type ConnectionVariableSelectContentOptions = Pick<
+ ComponentProps,
+ 'portal' | 'alignItemWithTrigger'
+>;
+
interface ConnectionVariablesFieldsProps {
variables: ConnectionVariable[];
variableValues: Record;
@@ -35,6 +40,7 @@ interface ConnectionVariablesFieldsProps {
dynamicOptions: Record;
loadingOptions: Record;
fetchOptions: (variableId: string) => void;
+ selectContentOptions?: ConnectionVariableSelectContentOptions;
}
export const normalizeVariableValue = (
@@ -87,6 +93,7 @@ export function ConnectionVariablesFields({
dynamicOptions,
loadingOptions,
fetchOptions,
+ selectContentOptions,
}: ConnectionVariablesFieldsProps) {
const syncModeVariable = variables.find((variable) => variable.id === 'sync_user_filter_mode');
const hasSyncModeVariable = Boolean(syncModeVariable);
@@ -170,7 +177,17 @@ export function ConnectionVariablesFields({
-
+ {/*
+ The DS Select is built on @base-ui/react and portals its popup to document.body.
+ This form is rendered inside a Radix (@trycompai/ui) modal Dialog (ManageIntegrationDialog),
+ and Radix's modal sets `body { pointer-events: none }`. The portaled popup inherits that,
+ so its options are unclickable and the open is cancelled on mouseup ("insta-closes").
+ ManageIntegrationDialog passes portal={false} plus alignItemWithTrigger={false} so the
+ popup stays inside the dialog focus boundary and uses normal absolute anchored positioning.
+ pointer-events:auto keeps the popup interactive if a modal body lock is active. Harmless in
+ the design-system Sheet consumer (AccountSettingsSheet), which does not lock body events.
+ */}
+
{isLoadingOptions ? (
@@ -203,7 +220,8 @@ export function ConnectionVariablesFields({
-
+ {/* pointer-events:auto so the popup is clickable inside the Radix modal's body lock (see note above). */}
+
Yes
No
diff --git a/apps/app/src/components/integrations/ManageIntegrationDialog.tsx b/apps/app/src/components/integrations/ManageIntegrationDialog.tsx
index 7845d8327d..6dc8f114f0 100644
--- a/apps/app/src/components/integrations/ManageIntegrationDialog.tsx
+++ b/apps/app/src/components/integrations/ManageIntegrationDialog.tsx
@@ -5,6 +5,7 @@ import {
normalizeVariableValue,
validateTargetRepos,
type ConnectionVariable,
+ type ConnectionVariableSelectContentOptions,
} from '@/components/integrations/ConnectionVariablesForm';
import {
useIntegrationConnections,
@@ -92,6 +93,11 @@ interface ManageIntegrationDialogProps {
onSaved?: () => void;
}
+const LEGACY_MODAL_SELECT_CONTENT_OPTIONS = {
+ portal: false,
+ alignItemWithTrigger: false,
+} satisfies ConnectionVariableSelectContentOptions;
+
export function ManageIntegrationDialog({
open,
onOpenChange,
@@ -471,6 +477,7 @@ function ConfigurationContent({
dynamicOptions={dynamicOptions}
loadingOptions={loadingDynamicOptions}
fetchOptions={fetchDynamicOptions}
+ selectContentOptions={LEGACY_MODAL_SELECT_CONTENT_OPTIONS}
/>
);
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..19259d9698
--- /dev/null
+++ b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts
@@ -0,0 +1,375 @@
+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';
+import { resolveAwsCredentialInputs } from '../shared';
+
+const kinds = (os: { kind: string }[]) => os.map((o) => o.kind);
+
+describe('AWS credential resolution (regions shape)', () => {
+ const base = { roleArn: 'arn:aws:iam::123456789012:role/x', externalId: 'eid' };
+
+ it('honors a multi-element regions array (the normal stored shape)', () => {
+ const r = resolveAwsCredentialInputs({
+ ...base,
+ regions: ['us-east-1', 'us-west-2'],
+ });
+ expect(r).not.toBeNull();
+ expect(r!.regions).toEqual(['us-east-1', 'us-west-2']);
+ });
+
+ it('accepts a single region string (resilient to an upstream collapse)', () => {
+ const r = resolveAwsCredentialInputs({ ...base, regions: 'us-east-1' });
+ expect(r).not.toBeNull();
+ expect(r!.regions).toEqual(['us-east-1']);
+ });
+
+ it('accepts the legacy singular `region` key', () => {
+ const r = resolveAwsCredentialInputs({ ...base, region: 'eu-west-1' });
+ expect(r!.regions).toEqual(['eu-west-1']);
+ });
+
+ it('returns null when regions resolve to empty (not configured)', () => {
+ expect(resolveAwsCredentialInputs({ ...base, regions: [] })).toBeNull();
+ expect(resolveAwsCredentialInputs({ ...base, regions: [' '] })).toBeNull();
+ });
+
+ it('returns null when roleArn or externalId is missing', () => {
+ expect(
+ resolveAwsCredentialInputs({ externalId: 'eid', regions: ['us-east-1'] }),
+ ).toBeNull();
+ expect(
+ resolveAwsCredentialInputs({ roleArn: base.roleArn, regions: ['us-east-1'] }),
+ ).toBeNull();
+ });
+});
+
+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/);
+ });
+});
+
+describe('IAM/CloudTrail outcomes carry evidence (so the UI shows "View Evidence")', () => {
+ const hasEvidence = (o: { evidence?: Record }) =>
+ !!o.evidence && Object.keys(o.evidence).length > 0;
+
+ it('every password-policy outcome has evidence (none / weak / strong)', () => {
+ expect(evaluatePasswordPolicy(null).every(hasEvidence)).toBe(true);
+ expect(
+ evaluatePasswordPolicy({ MinimumPasswordLength: 8 }).every(hasEvidence),
+ ).toBe(true);
+ expect(
+ evaluatePasswordPolicy({
+ MinimumPasswordLength: 14,
+ RequireSymbols: true,
+ RequireNumbers: true,
+ RequireUppercaseCharacters: true,
+ RequireLowercaseCharacters: true,
+ }).every(hasEvidence),
+ ).toBe(true);
+ });
+
+ it('every root-account-summary outcome has evidence (both states)', () => {
+ expect(
+ evaluateAccountSummary({
+ AccountMFAEnabled: 0,
+ AccountAccessKeysPresent: 1,
+ }).every(hasEvidence),
+ ).toBe(true);
+ expect(
+ evaluateAccountSummary({
+ AccountMFAEnabled: 1,
+ AccountAccessKeysPresent: 0,
+ }).every(hasEvidence),
+ ).toBe(true);
+ });
+
+ it('the "No CloudTrail configured" outcome has evidence', () => {
+ const out = evaluateCloudTrail([]);
+ expect(out).toHaveLength(1);
+ expect(out[0]!.title).toMatch(/No CloudTrail configured/);
+ expect(hasEvidence(out[0]!)).toBe(true);
+ });
+
+ it('pass/fail evidence carries the determining value (S3 encryption, KMS rotation)', () => {
+ const enc = evaluateS3Encryption([
+ { name: 'enc', encrypted: true, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: null },
+ { name: 'plain', encrypted: false, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: null },
+ ]);
+ expect(enc[0]!.evidence?.encrypted).toBe(true);
+ expect(enc[1]!.evidence?.encrypted).toBe(false);
+
+ const rot = evaluateKmsRotation([
+ { keyId: 'on', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: true, rotationEnabled: true },
+ { keyId: 'off', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: true, rotationEnabled: false },
+ ]);
+ expect(rot[0]!.evidence?.rotationEnabled).toBe(true);
+ expect(rot[1]!.evidence?.rotationEnabled).toBe(false);
+ });
+});
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..436832d583
--- /dev/null
+++ b/packages/integration-platform/src/manifests/aws/checks/cloudtrail.ts
@@ -0,0 +1,208 @@
+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, multiRegion: good.multiRegion, logging: good.logging, logValidation: good.logValidation },
+ },
+ ];
+ }
+ // 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.',
+ evidence: { trailsFound: 0 },
+ },
+ ];
+ }
+ 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) => ({
+ name: t.name,
+ multiRegion: t.multiRegion,
+ logging: t.logging,
+ logValidation: t.logValidation,
+ })),
+ },
+ },
+ ];
+}
+
+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..10368f9182
--- /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, groupName: sg.groupName, region: sg.region, ipProtocol: perm.ipProtocol, cidrs: perm.cidrs },
+ });
+ 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, ipProtocol: perm.ipProtocol, cidrs: perm.cidrs },
+ });
+ }
+ }
+ }
+ 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, groupName: sg.groupName, region: sg.region, inboundRuleCount: sg.permissions.length, internetExposedSensitivePorts: false },
+ });
+ }
+ }
+ 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..813727f457
--- /dev/null
+++ b/packages/integration-platform/src/manifests/aws/checks/iam.ts
@@ -0,0 +1,194 @@
+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).',
+ evidence: { passwordPolicyConfigured: false },
+ });
+ } 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,
+ evidence: { accountMFAEnabled: true },
+ });
+ } 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.',
+ evidence: { accountMFAEnabled: false },
+ });
+ }
+
+ 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.',
+ evidence: {
+ accountAccessKeysPresent: summary.AccountAccessKeysPresent ?? 0,
+ },
+ });
+ } else {
+ out.push({
+ kind: 'pass',
+ title: 'No root account access keys',
+ description: 'The root account has no access keys.',
+ resourceType: 'aws-account',
+ resourceId: id,
+ evidence: { accountAccessKeysPresent: 0 },
+ });
+ }
+
+ 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..bfb38f5014
--- /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, rotationStatusKnown: false },
+ };
+ }
+ 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, rotationEnabled: true },
+ }
+ : {
+ 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, rotationEnabled: false },
+ };
+ });
+}
+
+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..b4ca418632
--- /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, encrypted: true },
+ }
+ : {
+ 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, encrypted: false },
+ },
+ );
+}
+
+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, region: i.region, backupRetentionDays: i.backupRetentionDays },
+ },
+ );
+}
+
+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, encrypted: true },
+ }
+ : {
+ 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, encrypted: false },
+ },
+ );
+}
+
+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, region: c.region, backupRetentionDays: c.backupRetentionDays },
+ },
+ );
+}
+
+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..6038f09865
--- /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, encryptionDetermined: false },
+ };
+ }
+ 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, encrypted: true },
+ }
+ : {
+ 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, encrypted: false },
+ };
+ });
+}
+
+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, publicAccessDetermined: false },
+ };
+ }
+ 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, bucketBpa: b.bucketBpa, accountBpa },
+ }
+ : {
+ 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, bucketBpa: b.bucketBpa, accountBpa },
+ };
+ });
+}
+
+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..8a7e48e6eb
--- /dev/null
+++ b/packages/integration-platform/src/manifests/aws/checks/shared.ts
@@ -0,0 +1,227 @@
+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[];
+}
+
+export interface AwsCredentialInputs {
+ roleArn: string;
+ externalId: string;
+ regions: string[];
+}
+
+/**
+ * Resolve role ARN, external ID, and regions from raw connection credentials.
+ * Returns null when any is missing (treated as "connection not configured").
+ *
+ * `regions` is normally a string[]; a single region string (or the legacy
+ * singular `region` key) is also accepted, so a value that was collapsed to a
+ * scalar upstream still yields a usable region instead of silently resolving to
+ * "not configured".
+ */
+export function resolveAwsCredentialInputs(
+ credentials: Record,
+): AwsCredentialInputs | null {
+ const roleArn =
+ typeof credentials.roleArn === 'string' ? credentials.roleArn : '';
+ const externalId =
+ typeof credentials.externalId === 'string' ? credentials.externalId : '';
+ const rawRegions = credentials.regions;
+ const regions = (
+ Array.isArray(rawRegions)
+ ? rawRegions.filter((r): r is string => typeof r === 'string')
+ : typeof rawRegions === 'string'
+ ? [rawRegions]
+ : typeof credentials.region === 'string'
+ ? [credentials.region]
+ : []
+ ).filter((r) => r.trim().length > 0);
+
+ if (!roleArn || !externalId || regions.length === 0) return null;
+ return { roleArn, externalId, regions };
+}
+
+type AwsPartition = 'aws' | 'aws-us-gov';
+
+function awsPartitionForRegion(region: string): AwsPartition {
+ return region.startsWith('us-gov-') ? 'aws-us-gov' : 'aws';
+}
+
+/** Comp's dedicated roleAssumer role ARN for the partition (set per environment). */
+function awsRoleAssumerArn(partition: AwsPartition): string | undefined {
+ return partition === 'aws-us-gov'
+ ? process.env.SECURITY_HUB_GOVCLOUD_ROLE_ASSUMER_ARN
+ : process.env.SECURITY_HUB_ROLE_ASSUMER_ARN;
+}
+
+/**
+ * Base credentials for hop 1. Commercial AWS uses the task role's default
+ * provider chain (undefined); GovCloud uses explicit access keys when set.
+ */
+function awsBaseCredentials(
+ partition: AwsPartition,
+): { accessKeyId: string; secretAccessKey: string } | undefined {
+ if (partition !== 'aws-us-gov') return undefined;
+ const accessKeyId = process.env.SECURITY_HUB_GOVCLOUD_ACCESS_KEY_ID;
+ const secretAccessKey = process.env.SECURITY_HUB_GOVCLOUD_SECRET_ACCESS_KEY;
+ if (!accessKeyId || !secretAccessKey) return undefined;
+ return { accessKeyId, secretAccessKey };
+}
+
+/**
+ * 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).
+ *
+ * Uses the SAME two-hop chain as Cloud Tests (independent copy): the customer's
+ * role trust policy whitelists Comp's dedicated roleAssumer role, NOT the raw
+ * task role — so we must (1) assume the roleAssumer from the task/base creds,
+ * then (2) assume the customer role with the roleAssumer creds + external ID. A
+ * single direct hop fails with "not authorized to perform sts:AssumeRole".
+ */
+export async function assumeAwsSession(
+ ctx: CheckContext,
+): Promise {
+ const inputs = resolveAwsCredentialInputs(
+ ctx.credentials as Record,
+ );
+ if (!inputs) return null;
+ const { roleArn, externalId, regions } = inputs;
+
+ // IAM is global — assume once in the first region; the creds work everywhere.
+ const region = regions[0];
+ const partition = awsPartitionForRegion(region);
+
+ const roleAssumerArn = awsRoleAssumerArn(partition);
+ if (!roleAssumerArn) {
+ const envName =
+ partition === 'aws-us-gov'
+ ? 'SECURITY_HUB_GOVCLOUD_ROLE_ASSUMER_ARN'
+ : 'SECURITY_HUB_ROLE_ASSUMER_ARN';
+ throw new Error(`Missing ${envName} (Comp roleAssumer ARN).`);
+ }
+
+ // Hop 1: task/base creds -> Comp roleAssumer.
+ const baseSts = new STSClient({
+ region,
+ credentials: awsBaseCredentials(partition),
+ });
+ const assumerResp = await baseSts.send(
+ new AssumeRoleCommand({
+ RoleArn: roleAssumerArn,
+ RoleSessionName: 'CompRoleAssumer',
+ DurationSeconds: 3600,
+ }),
+ );
+ const assumer = assumerResp.Credentials;
+ if (
+ !assumer?.AccessKeyId ||
+ !assumer.SecretAccessKey ||
+ !assumer.SessionToken
+ ) {
+ return null;
+ }
+
+ // Hop 2: roleAssumer -> customer role (trust policy whitelists the roleAssumer
+ // ARN + external ID).
+ const assumerSts = new STSClient({
+ region,
+ credentials: {
+ accessKeyId: assumer.AccessKeyId,
+ secretAccessKey: assumer.SecretAccessKey,
+ sessionToken: assumer.SessionToken,
+ },
+ });
+ const res = await assumerSts.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..7af63eef6e
--- /dev/null
+++ b/packages/integration-platform/src/manifests/azure/checks/entra-id.ts
@@ -0,0 +1,240 @@
+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: {
+ privilegedCount: privileged.length,
+ threshold: 5,
+ principalIds: privileged.map((a) => a.properties.principalId),
+ principalTypes: privileged.map((a) => a.properties.principalType),
+ },
+ });
+ }
+
+ 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,
+ principalIds: spPrivileged.map((a) => a.properties.principalId),
+ },
+ });
+ }
+
+ // 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++;
+ const wildcardActions = role.properties.permissions.flatMap((perm) =>
+ [...(perm.actions ?? []), ...(perm.dataActions ?? [])].filter(
+ isWildcardAction,
+ ),
+ );
+ 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, wildcardActions },
+ });
+ }
+
+ 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,
+ threshold: 5,
+ wildcardCustomRoles: wildcardRoles.length,
+ privilegedServicePrincipals: spPrivileged.length,
+ assignmentsEvaluated: assignments.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..4015c0c995
--- /dev/null
+++ b/packages/integration-platform/src/manifests/azure/checks/key-vault.ts
@@ -0,0 +1,133 @@
+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,
+ enableSoftDelete: p.enableSoftDelete,
+ enablePurgeProtection: p.enablePurgeProtection,
+ publicNetworkAccess: p.publicNetworkAccess ?? null,
+ },
+ });
+ }
+ }
+ },
+};
+
+/** 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, enableRbacAuthorization: true },
+ });
+ } 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,
+ enableRbacAuthorization: v.properties?.enableRbacAuthorization ?? false,
+ },
+ });
+ }
+ }
+ },
+};
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..515a6a113c
--- /dev/null
+++ b/packages/integration-platform/src/manifests/azure/checks/monitor.ts
@@ -0,0 +1,167 @@
+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.map((r) => r.op),
+ configuredOperations: [...ops],
+ },
+ });
+ }
+ } 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 ?? [];
+ // A setting only exports when it targets a destination AND has at least
+ // one enabled log category. Reuse this for both the verdict and the
+ // evidence so the destination flags reflect what actually exports (a
+ // destination whose logs are disabled must not be reported as exporting).
+ const hasEnabledLogs = (s: DiagnosticSetting) =>
+ (s.properties?.logs ?? []).some((l) => l.enabled);
+ const hasExport = settings.some(
+ (s) =>
+ (s.properties?.workspaceId ||
+ s.properties?.storageAccountId ||
+ s.properties?.eventHubAuthorizationRuleId) &&
+ hasEnabledLogs(s),
+ );
+ if (hasExport) {
+ ctx.pass({
+ title: 'Diagnostic log export configured',
+ description: 'Subscription activity logs are exported.',
+ resourceType: 'azure-subscription',
+ resourceId: sub,
+ evidence: {
+ settingsCount: settings.length,
+ exportsToLogAnalytics: settings.some(
+ (s) => !!s.properties?.workspaceId && hasEnabledLogs(s),
+ ),
+ exportsToStorage: settings.some(
+ (s) => !!s.properties?.storageAccountId && hasEnabledLogs(s),
+ ),
+ exportsToEventHub: settings.some(
+ (s) => !!s.properties?.eventHubAuthorizationRuleId && hasEnabledLogs(s),
+ ),
+ },
+ });
+ } 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: { diagnosticSettingsFound: settings.length },
+ });
+ }
+ } 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..b84f62129d
--- /dev/null
+++ b/packages/integration-platform/src/manifests/azure/checks/network.ts
@@ -0,0 +1,153 @@
+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,
+ exposure: c.label,
+ sources: ruleSources(rule),
+ ports,
+ protocol: rule.properties.protocol ?? '*',
+ },
+ });
+ }
+ }
+ }
+
+ 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,
+ inboundAllowRulesEvaluated: inbound.length,
+ totalRules: (nsg.properties.securityRules ?? []).length,
+ },
+ });
+ }
+ }
+ },
+};
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..035c3d3a96
--- /dev/null
+++ b/packages/integration-platform/src/manifests/azure/checks/sql.ts
@@ -0,0 +1,233 @@
+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,
+ reason: violation.detail,
+ firewallRuleCount: rules?.length ?? 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,
+ publicNetworkAccess: s.properties?.publicNetworkAccess ?? null,
+ 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, state: auditing.properties?.state ?? null },
+ });
+ } 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..f1058c7df4
--- /dev/null
+++ b/packages/integration-platform/src/manifests/azure/checks/storage.ts
@@ -0,0 +1,189 @@
+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,
+ supportsHttpsTrafficOnly: p.supportsHttpsTrafficOnly,
+ 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,
+ allowBlobPublicAccess: p.allowBlobPublicAccess,
+ publicNetworkAccess: p.publicNetworkAccess ?? null,
+ networkDefaultAction: p.networkAcls?.defaultAction ?? null,
+ },
+ });
+ }
+ }
+ },
+};
+
+/** 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, blobEnabled: blobOk, fileEnabled: fileOk },
+ });
+ } 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..2e6ce89b8b
--- /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, region: inst.region ?? null, backupsEnabled: true },
+ });
+ } 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, region: inst.region ?? null, backupsEnabled: false },
+ });
+ }
+ }
+ } 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..a195d1dd1b
--- /dev/null
+++ b/packages/integration-platform/src/manifests/gcp/checks/cloud-sql-ssl.ts
@@ -0,0 +1,101 @@
+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,
+ sslMode: ip?.sslMode ?? null,
+ requireSsl: ip?.requireSsl ?? null,
+ },
+ });
+ }
+ }
+ } 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..5a65da5ff7
--- /dev/null
+++ b/packages/integration-platform/src/manifests/gcp/checks/iam-primitive-roles.ts
@@ -0,0 +1,179 @@
+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,
+ scopesEvaluated: scopes.length,
+ primitiveRoleBindingsFound: 0,
+ },
+ });
+ } 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..eefd6b6685
--- /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, uniformBucketLevelAccess: false, publicMembers: 0 },
+ });
+ 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, publicMembers: 0, uniformBucketLevelAccess: true },
+ });
+}
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);
+ });
+ }
+ }
+});
diff --git a/packages/integration-platform/src/runtime/check-context.ts b/packages/integration-platform/src/runtime/check-context.ts
index 16a773f68c..27527a67d2 100644
--- a/packages/integration-platform/src/runtime/check-context.ts
+++ b/packages/integration-platform/src/runtime/check-context.ts
@@ -10,7 +10,13 @@ export interface CheckContextOptions {
manifest: IntegrationManifest;
/** Access token for OAuth integrations. Optional for custom auth types (e.g., AWS IAM). */
accessToken?: string;
- credentials: Record;
+ /**
+ * Credential values. Custom-auth providers can legitimately hold array
+ * fields (e.g. AWS `regions: string[]`), so values are `string | string[]`.
+ * Do NOT collapse arrays to a single value before passing them in — checks
+ * like the AWS ones read `regions` as an array.
+ */
+ credentials: Record;
variables?: CheckVariableValues;
connectionId: string;
organizationId: string;
@@ -92,6 +98,17 @@ export function createCheckContext(options: CheckContextOptions): {
} = options;
let currentAccessToken = initialAccessToken ?? '';
+
+ // Read a credential as a single string. Custom-auth credentials can be
+ // arrays (e.g. AWS regions); the string-based auth schemes (api key, basic)
+ // need a scalar, so collapse to the first element here — never upstream,
+ // where it would destroy multi-value fields for the checks that need them.
+ const credString = (key: string): string => {
+ const v = credentials[key];
+ if (Array.isArray(v)) return v[0] ?? '';
+ return v ?? '';
+ };
+
const findings: CheckResult['findings'] = [];
const passingResults: CheckResult['passingResults'] = [];
const logs: CheckResult['logs'] = [];
@@ -135,7 +152,7 @@ export function createCheckContext(options: CheckContextOptions): {
// API Key: Add to header if configured
if (manifest.auth.type === 'api_key' && manifest.auth.config.in === 'header') {
- const apiKey = credentials[manifest.auth.config.name] || credentials.api_key || '';
+ const apiKey = credString(manifest.auth.config.name) || credString('api_key');
const value = manifest.auth.config.prefix
? `${manifest.auth.config.prefix}${apiKey}`
: apiKey;
@@ -144,8 +161,8 @@ export function createCheckContext(options: CheckContextOptions): {
// Basic Auth: Encode username:password
if (manifest.auth.type === 'basic') {
- const username = credentials[manifest.auth.config.usernameField || 'username'] || '';
- const password = credentials[manifest.auth.config.passwordField || 'password'] || '';
+ const username = credString(manifest.auth.config.usernameField || 'username');
+ const password = credString(manifest.auth.config.passwordField || 'password');
const encoded = Buffer.from(`${username}:${password}`).toString('base64');
headers['Authorization'] = `Basic ${encoded}`;
}
@@ -227,7 +244,7 @@ export function createCheckContext(options: CheckContextOptions): {
// API Key in query param
if (manifest.auth.type === 'api_key' && manifest.auth.config.in === 'query') {
- const apiKey = credentials[manifest.auth.config.name] || credentials.api_key || '';
+ const apiKey = credString(manifest.auth.config.name) || credString('api_key');
const value = manifest.auth.config.prefix
? `${manifest.auth.config.prefix}${apiKey}`
: apiKey;
diff --git a/packages/integration-platform/src/types.ts b/packages/integration-platform/src/types.ts
index 9217d9d9a1..a2929b5099 100644
--- a/packages/integration-platform/src/types.ts
+++ b/packages/integration-platform/src/types.ts
@@ -268,8 +268,13 @@ export interface CheckContext {
/** The OAuth access token (for oauth2 auth). Empty for custom auth types like AWS. */
accessToken: string;
- /** All credentials as key-value pairs (form fields for custom auth, or token data for OAuth) */
- credentials: Record;
+ /**
+ * All credentials as key-value pairs (form fields for custom auth, or token
+ * data for OAuth). Custom-auth fields can be arrays (e.g. AWS `regions`), so
+ * values are `string | string[]` — read array fields directly and use a
+ * scalar coercion only where a single string is required.
+ */
+ credentials: Record;
/** User-configured variables for this integration */
variables: CheckVariableValues;