From c88346bcb11b4848ede01dfb458415f7f30f3b4f Mon Sep 17 00:00:00 2001 From: Derek Chamorro Date: Wed, 8 Jul 2026 13:26:25 -0500 Subject: [PATCH 1/3] Fix stale-issue bug, add integrations (SNS+Slack), DSPM data discovery Phase 1.1 - Stale-issue fix + trends: - Fix runner.ts existing-issue branch to re-derive riskScore/severity/pathSummary on re-match instead of skipping (was frozen at first creation) - Add PostureTrendStore for daily posture snapshots (openIssues, publicBuckets, etc.) - Add GET /trends/:metric API endpoint - CDK: add PostureTrendsTable Phase 1.2 - Integrations core (SNS + Slack): - New @khalifa/integrations package with pluggable IssueActionSink interface - SNS sink (publishes issue JSON to ISSUES_TOPIC_ARN) - Slack sink (incoming-webhook card with severity filtering) - IssueIntegrationsOrchestrator loads sinks from ISSUE_SINKS env var - Wire emitToIntegrations into runner after issue create/update - Add Issue.externalRefs field persisted to DynamoDB - API: GET /integrations/status, POST /issues/:id/suppress, POST /issues/:id/reopen - CDK: add SNS topic, grant risk-engine sns:Publish - ConfigMap: ISSUE_SINKS, SLACK_WEBHOOK_URL, SLACK_MIN_SEVERITY env vars Phase 2.2 - DSPM data discovery: - New dspm-scanner Lambda with S3 sampler + regex + Macie classifiers - Regex classifier detects: AWS keys, GCP SA JSON, private keys, JWT, credit cards (Luhn), emails, phone, SSN, high-entropy secrets - Macie classifier (opt-in) pulls findings via ListFindings/GetFindings - promoteClassification() maps PII findings to data_classification tiers - New DataClassificationFinding vertex + CLASSIFIES edge in graph schema - New RULE-011 (public datastore with PII) and RULE-012 (secret in S3) - CDK: DspmScannerFn (15m timeout, 2GB mem), EventBridge daily 03:00 UTC - ConfigMap: DSPM_SCAN_MODE (off|tagged-only|all), MACIE_ENABLED env vars Tests: 121 passing across 12 suites (risk-engine, integrations, api-service, dspm-scanner, collector) --- ARCHITECTURE.md | 4 + api-service/src/app.ts | 11 +- api-service/src/routes/integrations.ts | 67 ++ api-service/src/routes/trends.ts | 64 ++ api-service/src/services/issue-store.ts | 18 +- api-service/src/types/index.ts | 21 + cdk/lib/khalifa-stack.ts | 74 +++ eks-manifests/01-configmap.yaml | 7 + jest.config.js | 15 + lambdas/dspm-scanner/index.ts | 85 +++ lambdas/dspm-scanner/jest.config.js | 7 + lambdas/dspm-scanner/package.json | 35 ++ lambdas/dspm-scanner/src/classifier.test.ts | 146 +++++ lambdas/dspm-scanner/src/classifier.ts | 63 ++ lambdas/dspm-scanner/src/macie-classifier.ts | 70 +++ lambdas/dspm-scanner/src/regex-classifier.ts | 129 ++++ lambdas/dspm-scanner/src/s3-sampler.ts | 149 +++++ lambdas/dspm-scanner/tsconfig.json | 20 + package-lock.json | 576 +++++++++--------- package.json | 2 +- packages/integrations/jest.config.js | 6 + packages/integrations/package.json | 34 ++ packages/integrations/src/index.ts | 4 + .../integrations/src/orchestrator.test.ts | 93 +++ packages/integrations/src/orchestrator.ts | 59 ++ packages/integrations/src/sinks/slack-sink.ts | 108 ++++ packages/integrations/src/sinks/sns-sink.ts | 78 +++ packages/integrations/src/types.ts | 89 +++ packages/integrations/tsconfig.json | 20 + packages/risk-engine/package.json | 3 +- packages/risk-engine/src/index.ts | 2 + .../risk-engine/src/posture-trend-store.ts | 85 +++ packages/risk-engine/src/rules.test.ts | 20 +- packages/risk-engine/src/rules.ts | 49 ++ packages/risk-engine/src/runner.test.ts | 115 ++++ packages/risk-engine/src/runner.ts | 146 +++++ packages/risk-engine/src/types.ts | 8 + 37 files changed, 2180 insertions(+), 302 deletions(-) create mode 100644 api-service/src/routes/integrations.ts create mode 100644 api-service/src/routes/trends.ts create mode 100644 lambdas/dspm-scanner/index.ts create mode 100644 lambdas/dspm-scanner/jest.config.js create mode 100644 lambdas/dspm-scanner/package.json create mode 100644 lambdas/dspm-scanner/src/classifier.test.ts create mode 100644 lambdas/dspm-scanner/src/classifier.ts create mode 100644 lambdas/dspm-scanner/src/macie-classifier.ts create mode 100644 lambdas/dspm-scanner/src/regex-classifier.ts create mode 100644 lambdas/dspm-scanner/src/s3-sampler.ts create mode 100644 lambdas/dspm-scanner/tsconfig.json create mode 100644 packages/integrations/jest.config.js create mode 100644 packages/integrations/package.json create mode 100644 packages/integrations/src/index.ts create mode 100644 packages/integrations/src/orchestrator.test.ts create mode 100644 packages/integrations/src/orchestrator.ts create mode 100644 packages/integrations/src/sinks/slack-sink.ts create mode 100644 packages/integrations/src/sinks/sns-sink.ts create mode 100644 packages/integrations/src/types.ts create mode 100644 packages/integrations/tsconfig.json create mode 100644 packages/risk-engine/src/posture-trend-store.ts create mode 100644 packages/risk-engine/src/runner.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 79e81a2a..9392570f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -115,6 +115,9 @@ | `BackupVault` | AWS Backup | `arn` | | `BackupPlan` | AWS Backup | `arn` | | `HostedZone` | Route53 | `arn` | +| `DataClassificationFinding` | DSPM Scanner | `arn`, `pii_types`, `secret_count`, `data_classification`, `data_class_source`, `confidence`, `classifier` | +| `EffectivePermission` | Policy Evaluator | `principal_arn`, `allowed_actions`, `denied_actions`, `is_admin`, `blast_radius` | +| `EscalationPath` | Policy Evaluator | `source_arn`, `target_arn`, `risk_level`, `escalation_type` | ### Edge Labels @@ -139,6 +142,7 @@ | `RUNS_ON` | ContainerImage → workload (not yet created) | | `HAS_CVE` | ContainerImage → Vulnerability (not yet created) | | `HAS_ALIAS` / `HAS_STAGE` | Function → Alias/Stage | +| `CLASSIFIES` | DataClassificationFinding → S3Bucket/RdsInstance | ### Risk Rule Properties (queried by Gremlin rules) diff --git a/api-service/src/app.ts b/api-service/src/app.ts index b746c16d..e312d93d 100644 --- a/api-service/src/app.ts +++ b/api-service/src/app.ts @@ -13,7 +13,7 @@ import { } from './routes/compliance'; import { validateGremlinSelectors, validateArnParam } from './middleware/gremlin-validator'; import { authenticate } from './middleware/auth'; -import { requireViewer } from './middleware/rbac'; +import { requireViewer, requireAnalyst } from './middleware/rbac'; import { getEffectivePermissions, getEscalationPaths, @@ -21,6 +21,8 @@ import { getRightsizingRecommendation, getTrustGraph, } from './routes/identity'; +import { getTrend, listTrendMetrics } from './routes/trends'; +import { getIntegrationsStatus, suppressIssue, reopenIssue } from './routes/integrations'; const app = express(); @@ -79,6 +81,13 @@ app.get('/identity/unused-permissions', requireViewer, getUnusedPermissions); app.get('/identity/rightsizing/:principal', requireViewer, getRightsizingRecommendation); app.get('/identity/trust-graph', requireViewer, getTrustGraph); +app.get('/trends', requireViewer, listTrendMetrics); +app.get('/trends/:metric', requireViewer, getTrend); + +app.get('/integrations/status', requireViewer, getIntegrationsStatus); +app.post('/issues/:id/suppress', requireAnalyst, suppressIssue); +app.post('/issues/:id/reopen', requireAnalyst, reopenIssue); + app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { console.error('Unhandled error:', err); res.status(500).json({ diff --git a/api-service/src/routes/integrations.ts b/api-service/src/routes/integrations.ts new file mode 100644 index 00000000..56a379cc --- /dev/null +++ b/api-service/src/routes/integrations.ts @@ -0,0 +1,67 @@ +import type { Request, Response } from 'express'; +import { IssueIntegrationsOrchestrator } from '@khalifa/integrations'; +import { IssueStore } from '../services/issue-store'; + +const issueStore = new IssueStore(); + +export async function getIntegrationsStatus(_req: Request, res: Response): Promise { + try { + const orchestrator = new IssueIntegrationsOrchestrator(); + const health = await orchestrator.healthCheck(); + res.json({ + configuredSinks: orchestrator.configuredSinks, + health, + }); + } catch (error) { + console.error('Error getting integrations status:', error); + res.status(500).json({ + code: 'INTERNAL_ERROR', + message: 'Failed to get integrations status', + }); + } +} + +export async function suppressIssue(req: Request, res: Response): Promise { + try { + const { id } = req.params; + const { reason } = req.body || {}; + + const existing = await issueStore.getIssue(id); + if (!existing) { + res.status(404).json({ code: 'NOT_FOUND', message: `Issue not found: ${id}` }); + return; + } + + await issueStore.updateStatus(id, 'suppressed'); + res.json({ id, status: 'suppressed', reason: reason || null }); + } catch (error) { + console.error('Error suppressing issue:', error); + res.status(500).json({ + code: 'INTERNAL_ERROR', + message: 'Failed to suppress issue', + details: error instanceof Error ? error.message : undefined, + }); + } +} + +export async function reopenIssue(req: Request, res: Response): Promise { + try { + const { id } = req.params; + + const existing = await issueStore.getIssue(id); + if (!existing) { + res.status(404).json({ code: 'NOT_FOUND', message: `Issue not found: ${id}` }); + return; + } + + await issueStore.updateStatus(id, 'open'); + res.json({ id, status: 'open' }); + } catch (error) { + console.error('Error reopening issue:', error); + res.status(500).json({ + code: 'INTERNAL_ERROR', + message: 'Failed to reopen issue', + details: error instanceof Error ? error.message : undefined, + }); + } +} diff --git a/api-service/src/routes/trends.ts b/api-service/src/routes/trends.ts new file mode 100644 index 00000000..f88c5379 --- /dev/null +++ b/api-service/src/routes/trends.ts @@ -0,0 +1,64 @@ +import type { Request, Response } from 'express'; +import { PostureTrendStore } from '@khalifa/risk-engine'; +import type { TrendPoint, TrendResponse } from '../types'; + +const trendStore = new PostureTrendStore(); + +const VALID_METRICS = [ + 'openIssuesBySeverity', + 'exposedResourcesByType', + 'failedControlsByFramework', + 'publicBuckets', + 'usersWithoutMfa', + 'crossAccountTrusts', +]; + +export async function getTrend(req: Request, res: Response): Promise { + try { + const { metric } = req.params; + const days = req.query.days ? parseInt(req.query.days as string, 10) : 90; + + if (!metric || !VALID_METRICS.includes(metric)) { + res.status(400).json({ + code: 'BAD_REQUEST', + message: `metric must be one of: ${VALID_METRICS.join(', ')}`, + }); + return; + } + + if (days < 1 || days > 365) { + res.status(400).json({ + code: 'BAD_REQUEST', + message: 'days must be between 1 and 365', + }); + return; + } + + const points = await trendStore.getSeries(metric as any, days); + const trendPoints: TrendPoint[] = points.map((p) => ({ + metric: p.metric, + date: p.date, + value: p.value, + accountIds: p.accountIds, + recordedAt: p.recordedAt, + })); + + const response: TrendResponse = { + metric, + points: trendPoints, + }; + + res.json(response); + } catch (error) { + console.error('Error getting trend:', error); + res.status(500).json({ + code: 'INTERNAL_ERROR', + message: 'Failed to get trend data', + details: error instanceof Error ? error.message : undefined, + }); + } +} + +export async function listTrendMetrics(_req: Request, res: Response): Promise { + res.json({ metrics: VALID_METRICS }); +} diff --git a/api-service/src/services/issue-store.ts b/api-service/src/services/issue-store.ts index 55f862fd..7dd806dc 100644 --- a/api-service/src/services/issue-store.ts +++ b/api-service/src/services/issue-store.ts @@ -4,11 +4,12 @@ import { GetItemCommand, QueryCommand, ScanCommand, + UpdateItemCommand, } from '@aws-sdk/client-dynamodb'; import type { QueryCommandInput, ScanCommandInput } from '@aws-sdk/lib-dynamodb'; import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import { unmarshall } from '@aws-sdk/util-dynamodb'; -import type { Issue, IssueListQuery, IssueListResponse, Severity } from '../types'; +import type { Issue, IssueListQuery, IssueListResponse, Severity, IssueStatus } from '../types'; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 1000; @@ -40,6 +41,21 @@ export class IssueStore { return unmarshall(result.Item) as Issue; } + async updateStatus(id: string, status: IssueStatus): Promise { + await this.docClient.send( + new UpdateItemCommand({ + TableName: this.tableName, + Key: { id: { S: id } } as Record, + UpdateExpression: 'SET #status = :status, updatedAt = :updatedAt', + ExpressionAttributeNames: { '#status': 'status' }, + ExpressionAttributeValues: { + ':status': { S: status }, + ':updatedAt': { S: new Date().toISOString() }, + }, + }) + ); + } + async listIssues(query: IssueListQuery): Promise { const { severity, team, status, ruleId, limit = DEFAULT_LIMIT, nextToken } = query; diff --git a/api-service/src/types/index.ts b/api-service/src/types/index.ts index 10423e93..573362b1 100644 --- a/api-service/src/types/index.ts +++ b/api-service/src/types/index.ts @@ -38,6 +38,7 @@ export interface Issue { owningTeam: string; remediationHint: string; metadata: Record; + externalRefs?: ExternalRef[]; } export interface GraphVertex { @@ -109,6 +110,26 @@ export interface RiskRule { enabled: boolean; } +export interface ExternalRef { + system: string; + id: string; + url?: string; + emittedAt: string; +} + +export interface TrendPoint { + metric: string; + date: string; + value: number; + accountIds?: string[]; + recordedAt: string; +} + +export interface TrendResponse { + metric: string; + points: TrendPoint[]; +} + export interface ApiError { code: string; message: string; diff --git a/cdk/lib/khalifa-stack.ts b/cdk/lib/khalifa-stack.ts index b36a5c7d..12ded654 100644 --- a/cdk/lib/khalifa-stack.ts +++ b/cdk/lib/khalifa-stack.ts @@ -9,6 +9,7 @@ import * as iam from 'aws-cdk-lib/aws-iam'; import * as sqs from 'aws-cdk-lib/aws-sqs'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as sns from 'aws-cdk-lib/aws-sns'; import type { Construct } from 'constructs'; export interface SecurityGraphIngestionStackProps extends cdk.StackProps { @@ -242,6 +243,39 @@ export class SecurityGraphIngestionStack extends cdk.Stack { logGroup(cloudTrailAnalyzerFn, 'CloudTrailAnalyzer'); + const dspmScannerFn = new lambda.Function(this, 'DspmScannerFn', { + runtime: lambda.Runtime.NODEJS_20_X, + handler: 'index.handler', + code: lambda.Code.fromAsset('../lambdas/dspm-scanner'), + role: collectorAssumeRole, + timeout: cdk.Duration.minutes(15), + memorySize: 2048, + environment: { + NEPTUNE_ENDPOINT: neptuneEndpoint, + NEPTUNE_AUTH_SECRET_ARN: neptuneSecret.secretArn, + DSPM_SCAN_MODE: process.env.DSPM_SCAN_MODE || 'tagged-only', + MACIE_ENABLED: process.env.MACIE_ENABLED || 'false', + }, + }); + dspmScannerFn.addToRolePolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['s3:ListBucket', 's3:GetObject', 's3:ListAllMyBuckets'], + resources: ['arn:aws:s3:::*'], + }) + ); + if (process.env.MACIE_ENABLED === 'true') { + dspmScannerFn.addToRolePolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['macie2:ListFindings'], + resources: ['*'], + }) + ); + } + + logGroup(dspmScannerFn, 'DspmScanner'); + const listAccounts = new tasks.LambdaInvoke(this, 'ListAccounts', { lambdaFunction: listAccountsFn, outputPath: '$.Payload', @@ -414,6 +448,20 @@ export class SecurityGraphIngestionStack extends cdk.Stack { removalPolicy: cdk.RemovalPolicy.RETAIN, }); + const postureTrendsTable = new dynamodb.Table(this, 'PostureTrendsTable', { + tableName: 'PostureTrends', + partitionKey: { name: 'metric', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'date', type: dynamodb.AttributeType.STRING }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: cdk.RemovalPolicy.RETAIN, + timeToLiveAttribute: 'ttl', + }); + + const issuesTopic = new sns.Topic(this, 'IssuesTopic', { + topicName: 'khalifa-issues', + displayName: 'Khalifa Security Issues', + }); + const riskEngineFn = new lambda.Function(this, 'RiskEngineFn', { runtime: lambda.Runtime.NODEJS_20_X, handler: 'index.handler', @@ -425,6 +473,12 @@ export class SecurityGraphIngestionStack extends cdk.Stack { NEPTUNE_ENDPOINT: neptuneEndpoint, ISSUES_TABLE: this.issuesTable.tableName, NEPTUNE_AUTH_SECRET_ARN: neptuneSecret.secretArn, + POSTURE_TRENDS_TABLE: postureTrendsTable.tableName, + ISSUES_TOPIC_ARN: issuesTopic.topicArn, + ISSUE_SINKS: process.env.ISSUE_SINKS || 'sns', + SLACK_WEBHOOK_URL: process.env.SLACK_WEBHOOK_URL || '', + SLACK_MIN_SEVERITY: process.env.SLACK_MIN_SEVERITY || 'high', + UI_BASE_URL: process.env.UI_BASE_URL || '', }, }); riskEngineFn.addToRolePolicy( @@ -448,6 +502,20 @@ export class SecurityGraphIngestionStack extends cdk.Stack { resources: [this.evidenceTable.tableArn, this.reportsTable.tableArn], }) ); + riskEngineFn.addToRolePolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['dynamodb:PutItem', 'dynamodb:Query'], + resources: [postureTrendsTable.tableArn], + }) + ); + riskEngineFn.addToRolePolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['sns:Publish'], + resources: [issuesTopic.topicArn], + }) + ); logGroup(riskEngineFn, 'RiskEngine'); @@ -468,5 +536,11 @@ export class SecurityGraphIngestionStack extends cdk.Stack { schedule: events.Schedule.cron({ minute: '0', hour: '2' }), targets: [new targets.LambdaFunction(cloudTrailAnalyzerFn)], }); + + new events.Rule(this, 'DspmScannerScheduledTrigger', { + ruleName: 'dspm-scanner-scheduled-trigger', + schedule: events.Schedule.cron({ minute: '0', hour: '3' }), + targets: [new targets.LambdaFunction(dspmScannerFn)], + }); } } diff --git a/eks-manifests/01-configmap.yaml b/eks-manifests/01-configmap.yaml index 3b25f12e..ce3e0b40 100644 --- a/eks-manifests/01-configmap.yaml +++ b/eks-manifests/01-configmap.yaml @@ -8,12 +8,19 @@ data: ISSUES_TABLE: "SecurityIssues" EVIDENCE_TABLE: "ComplianceEvidence" REPORTS_TABLE: "ComplianceReports" + POSTURE_TRENDS_TABLE: "PostureTrends" + ISSUES_TOPIC_ARN: "" + ISSUE_SINKS: "sns" + SLACK_MIN_SEVERITY: "high" + UI_BASE_URL: "" COGNITO_USER_POOL_ID: "" COGNITO_CLIENT_ID: "" COGNITO_REGION: "us-east-1" LOG_LEVEL: "info" API_PORT: "8080" RULE_RUNNER_SCHEDULE: "0 */6 * * *" + DSPM_SCAN_MODE: "tagged-only" + MACIE_ENABLED: "false" --- apiVersion: v1 kind: Secret diff --git a/jest.config.js b/jest.config.js index 91068568..0ea1dd33 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,6 +8,13 @@ module.exports = { testEnvironment: 'node', testMatch: ['/src/**/*.test.ts'], }, + { + displayName: 'integrations', + rootDir: '/packages/integrations', + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['/src/**/*.test.ts'], + }, { displayName: 'api-service', rootDir: '/api-service', @@ -23,5 +30,13 @@ module.exports = { testMatch: ['/src/**/*.test.ts'], modulePathIgnorePatterns: ['/dist/', '/node_modules/'], }, + { + displayName: 'dspm-scanner', + rootDir: '/lambdas/dspm-scanner', + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['/src/**/*.test.ts'], + modulePathIgnorePatterns: ['/dist/', '/node_modules/'], + }, ], }; \ No newline at end of file diff --git a/lambdas/dspm-scanner/index.ts b/lambdas/dspm-scanner/index.ts new file mode 100644 index 00000000..ae5a1195 --- /dev/null +++ b/lambdas/dspm-scanner/index.ts @@ -0,0 +1,85 @@ +import { S3Client } from '@aws-sdk/client-s3'; +import type { GraphNode, GraphEdge } from '../shared/types'; +import { Logger } from '../shared/types'; +import { scanBuckets } from './src/s3-sampler'; +import { RegexClassifier } from './src/regex-classifier'; +import { MacieClassifier } from './src/macie-classifier'; +import { promoteClassification } from './src/classifier'; + +const logger = new Logger('dspm-scanner'); + +interface DspmEvent { + accountId?: string; + regions?: string[]; +} + +export const handler = async ( + event: DspmEvent = {} +): Promise<{ bucketsScanned: number; findingsWritten: number; errors: number }> => { + logger.info('Starting DSPM scan', { accountId: event.accountId }); + + const region = process.env.AWS_REGION || 'us-east-1'; + const scanMode = process.env.DSPM_SCAN_MODE || 'tagged-only'; + const macieEnabled = process.env.MACIE_ENABLED === 'true'; + + const s3Client = new S3Client({ region }); + + const classifiers = [new RegexClassifier()]; + const macieClassifier = macieEnabled ? new MacieClassifier() : undefined; + + const scanResults = await scanBuckets(s3Client, classifiers, scanMode, macieClassifier); + + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + let findingsWritten = 0; + let errors = 0; + + for (const result of scanResults) { + if (result.error) { + errors++; + logger.warn('Bucket scan failed', { bucket: result.bucketName, error: result.error }); + continue; + } + + if (!result.result || result.result.piiTypes.length === 0) continue; + + const findingArn = `${result.bucketArn}/dspm-finding/${Date.now()}`; + const { classification, confidence } = promoteClassification(result.result); + + nodes.push({ + id: findingArn, + label: 'DataClassificationFinding', + properties: { + id: findingArn, + arn: findingArn, + account_id: event.accountId || '', + pii_types: JSON.stringify(result.result.piiTypes), + secret_count: result.result.secretCount, + sample_size: result.result.sampleSize, + confidence, + data_classification: classification, + data_class_source: 'scanner', + classifier: result.result.classifier, + scanned_at: result.result.scannedAt, + }, + }); + + edges.push({ + from: findingArn, + to: result.bucketArn, + label: 'CLASSIFIES', + }); + + findingsWritten++; + } + + logger.info( + `DSPM scan complete: ${scanResults.length} buckets, ${findingsWritten} findings, ${errors} errors` + ); + + return { + bucketsScanned: scanResults.length, + findingsWritten, + errors, + }; +}; diff --git a/lambdas/dspm-scanner/jest.config.js b/lambdas/dspm-scanner/jest.config.js new file mode 100644 index 00000000..511cc46c --- /dev/null +++ b/lambdas/dspm-scanner/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '.', + testMatch: ['**/*.test.ts'], + modulePathIgnorePatterns: ['/dist/', '/node_modules/'], +}; \ No newline at end of file diff --git a/lambdas/dspm-scanner/package.json b/lambdas/dspm-scanner/package.json new file mode 100644 index 00000000..d5cf7123 --- /dev/null +++ b/lambdas/dspm-scanner/package.json @@ -0,0 +1,35 @@ +{ + "name": "dspm-scanner", + "version": "1.0.0", + "description": "Data Security Posture Management scanner for S3/RDS/DynamoDB data discovery", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "lint": "eslint . --ext .ts", + "format": "prettier --write \"**/*.ts\"", + "format:check": "prettier --check \"**/*.ts\"", + "test": "jest --passWithNoTests" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.450.0", + "@aws-sdk/client-macie2": "^3.450.0", + "@aws-sdk/client-dynamodb": "^3.400.0", + "@aws-sdk/lib-dynamodb": "^3.450.0", + "@aws-sdk/util-dynamodb": "^3.996.2", + "gremlin": "^3.7.0" + }, + "devDependencies": { + "@types/aws-lambda": "^8.10.131", + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "jest": "^29.5.0", + "ts-jest": "^29.1.0", + "typescript": "^5.0.0", + "eslint": "^8.56.0", + "prettier": "^3.2.5", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3" + } +} \ No newline at end of file diff --git a/lambdas/dspm-scanner/src/classifier.test.ts b/lambdas/dspm-scanner/src/classifier.test.ts new file mode 100644 index 00000000..7377feac --- /dev/null +++ b/lambdas/dspm-scanner/src/classifier.test.ts @@ -0,0 +1,146 @@ +import { RegexClassifier } from './regex-classifier'; +import { promoteClassification, type ClassificationResult } from './classifier'; + +describe('RegexClassifier', () => { + const classifier = new RegexClassifier(); + + test('detects AWS access keys', async () => { + const content = 'AKIAIOSFODNN7EXAMPLE some text AKIAI44QKWDGEXAMPLES'; + const matches = await classifier.classify(content); + const awsKeys = matches.filter((m) => m.piiType === 'aws_access_key'); + expect(awsKeys.length).toBe(1); + expect(awsKeys[0].count).toBe(2); + expect(awsKeys[0].confidence).toBe(0.95); + }); + + test('detects private keys', async () => { + const content = + '-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----'; + const matches = await classifier.classify(content); + const privateKeys = matches.filter((m) => m.piiType === 'private_key'); + expect(privateKeys.length).toBe(1); + expect(privateKeys[0].confidence).toBe(0.99); + }); + + test('detects JWT tokens', async () => { + const content = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + const matches = await classifier.classify(content); + const jwts = matches.filter((m) => m.piiType === 'jwt'); + expect(jwts.length).toBe(1); + expect(jwts[0].confidence).toBe(0.9); + }); + + test('detects valid credit cards with Luhn check', async () => { + const content = '4111-1111-1111-1111 5500 0000 0000 0004'; + const matches = await classifier.classify(content); + const cards = matches.filter((m) => m.piiType === 'credit_card'); + expect(cards.length).toBe(1); + expect(cards[0].count).toBeGreaterThanOrEqual(1); + }); + + test('filters out invalid credit card numbers via Luhn check', async () => { + const content = '1234-5678-9012-3456'; + const matches = await classifier.classify(content); + const cards = matches.filter((m) => m.piiType === 'credit_card'); + expect(cards.length).toBe(0); + }); + + test('detects emails', async () => { + const content = 'contact: admin@example.com, support@test.org'; + const matches = await classifier.classify(content); + const emails = matches.filter((m) => m.piiType === 'email'); + expect(emails.length).toBe(1); + expect(emails[0].count).toBe(2); + }); + + test('detects SSNs', async () => { + const content = 'SSN: 123-45-6789'; + const matches = await classifier.classify(content); + const ssns = matches.filter((m) => m.piiType === 'ssn'); + expect(ssns.length).toBe(1); + expect(ssns[0].confidence).toBe(0.85); + }); + + test('detects GCP service account JSON', async () => { + const content = '{"type": "service_account", "project_id": "my-project"}'; + const matches = await classifier.classify(content); + const gcp = matches.filter((m) => m.piiType === 'gcp_service_account'); + expect(gcp.length).toBe(1); + expect(gcp[0].confidence).toBe(0.95); + }); + + test('returns empty for clean content', async () => { + const content = 'This is a normal text file with no secrets or PII data.'; + const matches = await classifier.classify(content); + expect(matches.filter((m) => m.piiType !== 'high_entropy_secret')).toHaveLength(0); + }); + + test('detects high entropy strings as potential secrets', async () => { + const content = 'token=Z8m5Wq3xK2vR7nYp1J4sL6tH9bF0cD2eG5iA8uM3oP6rS9wT2yU5'; + const matches = await classifier.classify(content); + const secrets = matches.filter((m) => m.piiType === 'high_entropy_secret'); + expect(secrets.length).toBe(1); + expect(secrets[0].confidence).toBe(0.7); + }); +}); + +describe('promoteClassification', () => { + test('promotes secret when private keys found', () => { + const result: ClassificationResult = { + piiTypes: ['private_key'], + secretCount: 1, + matches: [{ piiType: 'private_key', count: 1, sample: '...', confidence: 0.99 }], + sampleSize: 10, + confidence: 0.99, + scannedAt: new Date().toISOString(), + classifier: 'regex', + }; + const { classification, confidence } = promoteClassification(result); + expect(classification).toBe('secret'); + expect(confidence).toBe(0.99); + }); + + test('promotes restricted for AWS access keys', () => { + const result: ClassificationResult = { + piiTypes: ['aws_access_key'], + secretCount: 0, + matches: [{ piiType: 'aws_access_key', count: 1, sample: 'AKIA...', confidence: 0.95 }], + sampleSize: 10, + confidence: 0.95, + scannedAt: new Date().toISOString(), + classifier: 'regex', + }; + const { classification } = promoteClassification(result); + expect(classification).toBe('restricted'); + }); + + test('promotes restricted for emails with reduced confidence', () => { + const result: ClassificationResult = { + piiTypes: ['email'], + secretCount: 0, + matches: [{ piiType: 'email', count: 5, sample: 'a@b.com', confidence: 0.4 }], + sampleSize: 10, + confidence: 0.4, + scannedAt: new Date().toISOString(), + classifier: 'regex', + }; + const { classification, confidence } = promoteClassification(result); + expect(classification).toBe('restricted'); + expect(confidence).toBeLessThan(0.4); + }); + + test('falls back to internal for no PII', () => { + const result: ClassificationResult = { + piiTypes: [], + secretCount: 0, + matches: [], + sampleSize: 10, + confidence: 0, + scannedAt: new Date().toISOString(), + classifier: 'regex', + }; + const { classification } = promoteClassification(result); + expect(classification).toBe('internal'); + }); +}); diff --git a/lambdas/dspm-scanner/src/classifier.ts b/lambdas/dspm-scanner/src/classifier.ts new file mode 100644 index 00000000..ec714dc7 --- /dev/null +++ b/lambdas/dspm-scanner/src/classifier.ts @@ -0,0 +1,63 @@ +export type PiiType = + | 'aws_access_key' + | 'aws_secret_key' + | 'gcp_service_account' + | 'private_key' + | 'jwt' + | 'credit_card' + | 'high_entropy_secret' + | 'email' + | 'phone' + | 'ssn'; + +export interface ClassificationMatch { + piiType: PiiType; + count: number; + sample: string; + confidence: number; +} + +export interface ClassificationResult { + piiTypes: PiiType[]; + secretCount: number; + matches: ClassificationMatch[]; + sampleSize: number; + confidence: number; + scannedAt: string; + classifier: string; +} + +export interface Classifier { + readonly name: string; + classify(content: string): Promise; +} + +export type DataClassification = 'public' | 'internal' | 'restricted' | 'secret'; + +export function promoteClassification(result: ClassificationResult): { + classification: DataClassification; + confidence: number; +} { + const hasSecret = + result.piiTypes.includes('private_key') || + result.piiTypes.includes('aws_secret_key') || + result.piiTypes.includes('gcp_service_account') || + result.piiTypes.includes('high_entropy_secret'); + const hasRestricted = + result.piiTypes.includes('aws_access_key') || + result.piiTypes.includes('jwt') || + result.piiTypes.includes('credit_card') || + result.piiTypes.includes('ssn'); + const hasPii = result.piiTypes.includes('email') || result.piiTypes.includes('phone'); + + if (hasSecret || result.secretCount > 0) { + return { classification: 'secret', confidence: result.confidence }; + } + if (hasRestricted) { + return { classification: 'restricted', confidence: result.confidence }; + } + if (hasPii) { + return { classification: 'restricted', confidence: result.confidence * 0.8 }; + } + return { classification: 'internal', confidence: result.confidence * 0.5 }; +} diff --git a/lambdas/dspm-scanner/src/macie-classifier.ts b/lambdas/dspm-scanner/src/macie-classifier.ts new file mode 100644 index 00000000..3cae018e --- /dev/null +++ b/lambdas/dspm-scanner/src/macie-classifier.ts @@ -0,0 +1,70 @@ +import { Macie2Client, ListFindingsCommand, GetFindingsCommand } from '@aws-sdk/client-macie2'; +import type { Classifier, ClassificationMatch, PiiType } from './classifier'; + +const MACIE_TYPE_MAP: Record = { + AWS_API_KEY: 'aws_access_key', + AWS_SECRET_KEY: 'aws_secret_key', + Email_Address: 'email', + Phone_Number: 'phone', + US_Social_Security_Number: 'ssn', + Credit_Card_Number: 'credit_card', + Private_Key: 'private_key', +}; + +export class MacieClassifier implements Classifier { + readonly name = 'macie'; + private client: Macie2Client; + + constructor() { + this.client = new Macie2Client({ region: process.env.AWS_REGION || 'us-east-1' }); + } + + async classify(_content: string): Promise { + return []; + } + + async getFindingsForBucket(bucketName: string): Promise { + try { + const listResult = await this.client.send( + new ListFindingsCommand({ + findingCriteria: { + criterion: { + 'resources.S3Bucket.name': { eq: [bucketName] }, + }, + }, + maxResults: 50, + }) + ); + + const findingIds = listResult.findingIds || []; + if (findingIds.length === 0) return []; + + const getResult = await this.client.send( + new GetFindingsCommand({ findingIds: findingIds.slice(0, 50) }) + ); + + const matches: ClassificationMatch[] = []; + const byType = new Map(); + + for (const finding of getResult.findings || []) { + const typeName = finding.type || ''; + const piiType = MACIE_TYPE_MAP[typeName]; + if (!piiType) continue; + byType.set(piiType, (byType.get(piiType) || 0) + 1); + } + + for (const [piiType, count] of byType) { + matches.push({ + piiType, + count, + sample: '[macie]', + confidence: 0.95, + }); + } + + return matches; + } catch { + return []; + } + } +} diff --git a/lambdas/dspm-scanner/src/regex-classifier.ts b/lambdas/dspm-scanner/src/regex-classifier.ts new file mode 100644 index 00000000..e086b1d5 --- /dev/null +++ b/lambdas/dspm-scanner/src/regex-classifier.ts @@ -0,0 +1,129 @@ +import type { Classifier, ClassificationMatch, PiiType } from './classifier'; + +interface PatternDef { + piiType: PiiType; + pattern: RegExp; + confidence: number; +} + +const PATTERNS: PatternDef[] = [ + { piiType: 'aws_access_key', pattern: /AKIA[0-9A-Z]{16}/g, confidence: 0.95 }, + { + piiType: 'aws_secret_key', + pattern: /(?(); + for (const c of str) { + freq.set(c, (freq.get(c) || 0) + 1); + } + let entropy = 0; + for (const count of freq.values()) { + const p = count / str.length; + entropy -= p * Math.log2(p); + } + return entropy; +} + +function findHighEntropySecrets(content: string): string[] { + const candidates: string[] = []; + const tokens = content.match(/[A-Za-z0-9+/=_-]{20,}/g) || []; + const seen = new Set(); + for (const token of tokens) { + if (seen.has(token)) continue; + seen.add(token); + if (token.length < MIN_SECRET_LENGTH) continue; + const isBase64 = [...token].every((c) => BASE64_CHARS.includes(c)); + if (!isBase64) continue; + const entropy = shannonEntropy(token); + if (entropy >= HIGH_ENTROPY_THRESHOLD) { + candidates.push(token); + } + } + return candidates; +} + +function luhnCheck(num: string): boolean { + const digits = num.replace(/\D/g, ''); + if (digits.length < 13) return false; + let sum = 0; + let alt = false; + for (let i = digits.length - 1; i >= 0; i--) { + let d = parseInt(digits[i], 10); + if (alt) { + d *= 2; + if (d > 9) d -= 9; + } + sum += d; + alt = !alt; + } + return sum % 10 === 0; +} + +export class RegexClassifier implements Classifier { + readonly name = 'regex'; + + async classify(content: string): Promise { + const matches: ClassificationMatch[] = []; + + for (const { piiType, pattern, confidence } of PATTERNS) { + const found = content.match(pattern); + if (!found || found.length === 0) continue; + + let validCount = found.length; + if (piiType === 'credit_card') { + validCount = found.filter((f) => luhnCheck(f)).length; + } + + if (validCount === 0) continue; + matches.push({ + piiType, + count: validCount, + sample: found[0].slice(0, 20) + (found[0].length > 20 ? '...' : ''), + confidence, + }); + } + + const highEntropy = findHighEntropySecrets(content); + if (highEntropy.length > 0) { + matches.push({ + piiType: 'high_entropy_secret', + count: highEntropy.length, + sample: highEntropy[0].slice(0, 20) + '...', + confidence: 0.7, + }); + } + + return matches; + } +} diff --git a/lambdas/dspm-scanner/src/s3-sampler.ts b/lambdas/dspm-scanner/src/s3-sampler.ts new file mode 100644 index 00000000..1b209eb6 --- /dev/null +++ b/lambdas/dspm-scanner/src/s3-sampler.ts @@ -0,0 +1,149 @@ +import { + S3Client, + ListBucketsCommand, + ListObjectsV2Command, + GetObjectCommand, +} from '@aws-sdk/client-s3'; +import type { Classifier, ClassificationMatch, ClassificationResult } from './classifier'; + +const MAX_OBJECTS_PER_BUCKET = 50; +const MAX_OBJECT_SIZE_BYTES = 1 * 1024 * 1024; +const MAX_SAMPLE_SIZE_BYTES = 256 * 1024; + +export interface BucketScanResult { + bucketArn: string; + bucketName: string; + result: ClassificationResult | null; + error?: string; +} + +export async function scanBuckets( + s3Client: S3Client, + classifiers: Classifier[], + scanMode: string, + macieClassifier?: { getFindingsForBucket(name: string): Promise } +): Promise { + const results: BucketScanResult[] = []; + + const bucketsResponse = await s3Client.send(new ListBucketsCommand({})); + const buckets = bucketsResponse.Buckets || []; + + for (const bucket of buckets) { + if (!bucket.Name) continue; + const bucketArn = `arn:aws:s3:::${bucket.Name}`; + + if (scanMode === 'off') { + results.push({ bucketArn, bucketName: bucket.Name, result: null }); + continue; + } + + try { + const result = await scanBucket(s3Client, bucket.Name, classifiers, macieClassifier); + results.push({ bucketArn, bucketName: bucket.Name, result }); + } catch (e) { + results.push({ + bucketArn, + bucketName: bucket.Name, + result: null, + error: e instanceof Error ? e.message : String(e), + }); + } + } + + return results; +} + +async function scanBucket( + s3Client: S3Client, + bucketName: string, + classifiers: Classifier[], + macieClassifier?: { getFindingsForBucket(name: string): Promise } +): Promise { + let scanned = 0; + const allMatches: ClassificationMatch[] = []; + + const listResponse = await s3Client.send( + new ListObjectsV2Command({ Bucket: bucketName, MaxKeys: MAX_OBJECTS_PER_BUCKET }) + ); + + const objects = listResponse.Contents || []; + + for (const obj of objects) { + if (!obj.Key || !obj.Size) continue; + + if (obj.Size > MAX_OBJECT_SIZE_BYTES) { + scanned++; + continue; + } + + try { + const getResponse = await s3Client.send( + new GetObjectCommand({ Bucket: bucketName, Key: obj.Key }) + ); + + const body = await getResponse.Body?.transformToString('utf-8'); + if (!body) { + scanned++; + continue; + } + + const content = body.slice(0, MAX_SAMPLE_SIZE_BYTES); + for (const classifier of classifiers) { + const matches = await classifier.classify(content); + allMatches.push(...matches); + } + + scanned++; + } catch { + scanned++; + } + } + + if (macieClassifier) { + const macieMatches = await macieClassifier.getFindingsForBucket(bucketName); + allMatches.push(...macieMatches); + } + + return aggregateResults(allMatches, scanned, classifiers); +} + +function aggregateResults( + matches: ClassificationMatch[], + sampleSize: number, + classifiers: Classifier[] +): ClassificationResult { + const byType = new Map(); + let secretCount = 0; + + for (const match of matches) { + const existing = byType.get(match.piiType); + if (existing) { + existing.count += match.count; + existing.confidence = Math.max(existing.confidence, match.confidence); + } else { + byType.set(match.piiType, { ...match }); + } + + if ( + match.piiType === 'private_key' || + match.piiType === 'aws_secret_key' || + match.piiType === 'gcp_service_account' || + match.piiType === 'high_entropy_secret' + ) { + secretCount += match.count; + } + } + + const piiTypes = [...byType.keys()] as any; + const maxConfidence = matches.length > 0 ? Math.max(...matches.map((m) => m.confidence)) : 0; + + return { + piiTypes, + secretCount, + matches: [...byType.values()], + sampleSize, + confidence: maxConfidence, + scannedAt: new Date().toISOString(), + classifier: classifiers.map((c) => c.name).join(','), + }; +} diff --git a/lambdas/dspm-scanner/tsconfig.json b/lambdas/dspm-scanner/tsconfig.json new file mode 100644 index 00000000..07595def --- /dev/null +++ b/lambdas/dspm-scanner/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "..", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "moduleResolution": "node" + }, + "include": ["../dspm-scanner/*.ts", "../dspm-scanner/src/**/*.ts", "../shared/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d28ea17a..3246b2b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1372,25 +1372,6 @@ "node": ">=20.0.0" } }, - "lambdas/collector/node_modules/@aws-sdk/client-securityhub": { - "version": "3.1072.0", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.22", - "@aws-sdk/credential-provider-node": "^3.972.57", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "lambdas/collector/node_modules/@aws-sdk/client-sfn": { "version": "3.1072.0", "license": "Apache-2.0", @@ -1410,25 +1391,6 @@ "node": ">=20.0.0" } }, - "lambdas/collector/node_modules/@aws-sdk/client-ssm": { - "version": "3.1072.0", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.22", - "@aws-sdk/credential-provider-node": "^3.972.57", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "lambdas/collector/node_modules/@aws-sdk/credential-provider-cognito-identity": { "version": "3.972.47", "license": "Apache-2.0", @@ -1494,6 +1456,31 @@ "node": ">=20.0.0" } }, + "lambdas/dspm-scanner": { + "version": "1.0.0", + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.400.0", + "@aws-sdk/client-macie2": "^3.450.0", + "@aws-sdk/client-s3": "^3.450.0", + "@aws-sdk/lib-dynamodb": "^3.450.0", + "@aws-sdk/util-dynamodb": "^3.996.2", + "gremlin": "^3.7.0" + }, + "devDependencies": { + "@types/aws-lambda": "^8.10.131", + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", + "jest": "^29.5.0", + "prettier": "^3.2.5", + "ts-jest": "^29.1.0", + "typescript": "^5.0.0" + } + }, "lambdas/graph-writer": { "version": "1.0.0", "dependencies": { @@ -1885,6 +1872,25 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-macie2": { + "version": "3.1081.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-macie2/-/client-macie2-3.1081.0.tgz", + "integrity": "sha512-sDxKPu7dssQlaTwQ7pmglDYozKDzarfdl8cvoyqPgedt+a2xEWz0YfEilGVSJpwfDbPlikQLYEsUwT0qRqRthQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/credential-provider-node": "^3.972.64", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-organizations": { "version": "3.1072.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-organizations/-/client-organizations-3.1072.0.tgz", @@ -1994,6 +2000,44 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-securityhub": { + "version": "3.1081.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-securityhub/-/client-securityhub-3.1081.0.tgz", + "integrity": "sha512-GS0MMY1kZLyUqdUJeJY0EMKIs6IZKjDsEQdeQrwWydSgWif5xTB7NCnZl7SwNqYVU5si6sAhQANLgcBFNVvUeA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/credential-provider-node": "^3.972.64", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sns": { + "version": "3.1081.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sns/-/client-sns-3.1081.0.tgz", + "integrity": "sha512-3W50Y0WN6XxV6QDtQxbP4oRMxIlWp6/EYkyLjxeeaLAwfTljjX0zPtWXYQaFo4kM/unf6JlePOHZn3flMUn3ZA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/credential-provider-node": "^3.972.64", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-sqs": { "version": "3.1072.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.1072.0.tgz", @@ -2016,6 +2060,25 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-ssm": { + "version": "3.1081.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.1081.0.tgz", + "integrity": "sha512-pgKETowK134GMnSLvh4uuqQejfrxTRRBsekwRVYsPKyyeHvt0PhZfea90lKwj3Af1Fre9xJ7oWnxfOUCnVdQTA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/credential-provider-node": "^3.972.64", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-sts": { "version": "3.1072.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1072.0.tgz", @@ -2039,17 +2102,17 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.22.tgz", - "integrity": "sha512-YofH63shc6YRdXjz80BJkpJW+Bkn0Cuu2dn4Rv7s9G2Idt58tgtzQEWxrR2xVljlVfIBeUjPuULnSVYLke3sUQ==", + "version": "3.974.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.29.tgz", + "integrity": "sha512-yqKcltLbtRh1ubzhRSldIs8jFHNZlyMlgoIccCC0aDVbrB99nXaBdmfr89mK7obWX/NVg4rAMpCpZ6dCDiVBtA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.13", - "@aws-sdk/xml-builder": "^3.972.30", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.6", - "@smithy/signature-v4": "^5.4.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/types": "^3.973.15", + "@aws-sdk/xml-builder": "^3.972.33", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.0", + "@smithy/signature-v4": "^5.6.1", + "@smithy/types": "^4.15.1", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -2058,15 +2121,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.48", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.48.tgz", - "integrity": "sha512-h6FEC95fbexUd6zxm4PdgS82bTcI2PRtUb2ZwMipb/Xr8bPwtf0G8rBo2jp7NA24Mbx2JA8/WingiYpA9RCCyw==", + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.55.tgz", + "integrity": "sha512-Ah36tYkqyaVnaHkx7VseoTYrHUmwgBps3V+wnrC1idhIIMGlviH0FtrX9EIPdAlVHvXC7FQZLhmHBRz+pLaiWg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.22", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", "tslib": "^2.6.2" }, "engines": { @@ -2074,17 +2137,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.50", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.50.tgz", - "integrity": "sha512-lJO3OLpjvz5m/RSBQmsG/CEUGsvCy5ruxKwPQaOCqxqCMuyYT2BZwQUTDZVVwqQ9LrZKuK24JSa6r31hL/tvkg==", + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.57.tgz", + "integrity": "sha512-/vp6i5YEliJqRm5k/BDmYjAyRAMTdkjW6UciVRk9oh/0OfDCWeb/ih7hqte4lFvKXkIbsqe9AdK9LQK6NGardw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.22", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", "tslib": "^2.6.2" }, "engines": { @@ -2092,23 +2155,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.55", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.55.tgz", - "integrity": "sha512-TBoF4buBGYhXjdZAryayY2TrkQj2B2KfE/msG4V53XCt+w0EhEwM2JRjx8p2grJ2C6gtH5++SAwEvGMRdi0yyw==", + "version": "3.972.62", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.62.tgz", + "integrity": "sha512-pQIRiQQs+MUlVnJdWJ7/6KS0WxcLRVfut57OFgwC3cnM1F8mXw3Kh4gAVwj6AtvD6CWx8x6+po4ENRcqe64XrQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.22", - "@aws-sdk/credential-provider-env": "^3.972.48", - "@aws-sdk/credential-provider-http": "^3.972.50", - "@aws-sdk/credential-provider-login": "^3.972.54", - "@aws-sdk/credential-provider-process": "^3.972.48", - "@aws-sdk/credential-provider-sso": "^3.972.54", - "@aws-sdk/credential-provider-web-identity": "^3.972.54", - "@aws-sdk/nested-clients": "^3.997.22", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/credential-provider-imds": "^4.3.7", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/credential-provider-env": "^3.972.55", + "@aws-sdk/credential-provider-http": "^3.972.57", + "@aws-sdk/credential-provider-login": "^3.972.61", + "@aws-sdk/credential-provider-process": "^3.972.55", + "@aws-sdk/credential-provider-sso": "^3.972.61", + "@aws-sdk/credential-provider-web-identity": "^3.972.61", + "@aws-sdk/nested-clients": "^3.997.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/credential-provider-imds": "^4.4.5", + "@smithy/types": "^4.15.1", "tslib": "^2.6.2" }, "engines": { @@ -2116,16 +2179,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.54", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.54.tgz", - "integrity": "sha512-hBWI3wZTdTGiuMfmPts6AWbAjFfRniOQnqx68tc2cQvRKWawFbN9wkLOVPWM1FAOyowZU73mC6Fi+rHSHNyLFw==", + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.61.tgz", + "integrity": "sha512-jtrxWwC7slqxh7DnAWHrwsA3UwCsnlypdYtavGT7EX5p791wxWQys7QzkCZ7JvOMAyylDtPoxyV+ic0zg3rV9g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.22", - "@aws-sdk/nested-clients": "^3.997.22", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/nested-clients": "^3.997.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", "tslib": "^2.6.2" }, "engines": { @@ -2133,21 +2196,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.57", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.57.tgz", - "integrity": "sha512-u6dClpzNdWf1HGWz4wwhdXi1wiOofCLniM9S4BQQGlLAN9TW7VB+ld5V533GdKrYMaFeBGFqKnj0JCYvynLqwQ==", + "version": "3.972.64", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.64.tgz", + "integrity": "sha512-zyKVYDyMR9VQL/kPi03ygN2vtD9uLMuWRLoJ77KxgZZaS1VlJloI+SzleF9Zg4HWUI+AIu+ZRs8zsJFNqbrxsw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.48", - "@aws-sdk/credential-provider-http": "^3.972.50", - "@aws-sdk/credential-provider-ini": "^3.972.55", - "@aws-sdk/credential-provider-process": "^3.972.48", - "@aws-sdk/credential-provider-sso": "^3.972.54", - "@aws-sdk/credential-provider-web-identity": "^3.972.54", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/credential-provider-imds": "^4.3.7", - "@smithy/types": "^4.14.3", + "@aws-sdk/credential-provider-env": "^3.972.55", + "@aws-sdk/credential-provider-http": "^3.972.57", + "@aws-sdk/credential-provider-ini": "^3.972.62", + "@aws-sdk/credential-provider-process": "^3.972.55", + "@aws-sdk/credential-provider-sso": "^3.972.61", + "@aws-sdk/credential-provider-web-identity": "^3.972.61", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/credential-provider-imds": "^4.4.5", + "@smithy/types": "^4.15.1", "tslib": "^2.6.2" }, "engines": { @@ -2155,15 +2218,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.48", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.48.tgz", - "integrity": "sha512-w6VZwojPt12WnEkAUy6Nu4K6sWCbBmR7QX390b0nE6vRvkXbrYr9Lq9VySGkfjiMjpUA87op+J4EgvRmtWIDoQ==", + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.55.tgz", + "integrity": "sha512-x0XjjF0l1WGRtK2vEhTZqCguQuAIZLep9l2+eeEmuxQQjjD3BlGQXY5xADR+l3t576UX+dxRkRtTjEu40l81Vw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.22", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", "tslib": "^2.6.2" }, "engines": { @@ -2171,17 +2234,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.54", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.54.tgz", - "integrity": "sha512-23uZpIpF2SIFDCa1fcWa202tK4gGeyvX6GIIAjiB8WBsvsVRBMnJ/7dCxHzxf7eZT7GToJg837LDIBnZsl/VUg==", + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.61.tgz", + "integrity": "sha512-d/V0VRsz73i+PHhbult/tx0Y1+de1SNQVsXkcQCmpfeBq7uODy/RTxNsOLpT9ZVHxcRNzbQFuywLKC33fUMIxA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.22", - "@aws-sdk/nested-clients": "^3.997.22", - "@aws-sdk/token-providers": "3.1071.0", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/nested-clients": "^3.997.29", + "@aws-sdk/token-providers": "3.1081.0", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", "tslib": "^2.6.2" }, "engines": { @@ -2189,16 +2252,16 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.54", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.54.tgz", - "integrity": "sha512-0Iv5QttS6wcATlodYKgvQj6B9Db51rx7NU9fqu0PoLeS4BIgdYMc/QK4smwLwpm5RFrs02V/eLyEFp3FklvlNQ==", + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.61.tgz", + "integrity": "sha512-Bv4n3NOI6hPy+rmr6Bw9R6LnBVRkcp3ncj2E2IKSYJG+0UkysSitWMvbgndNvMxDw7gE1pQ/ErwkNceuKwj7zQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.22", - "@aws-sdk/nested-clients": "^3.997.22", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/nested-clients": "^3.997.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", "tslib": "^2.6.2" }, "engines": { @@ -2348,20 +2411,18 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.22.tgz", - "integrity": "sha512-4IwtcYSxEIVw5hcp8ogq0CMbFNZFw7jJUetpfFUhFFeqsa1K8j2Ihg2hnxLyOp3stMZnXda6VzOmPi1AFZQXcg==", + "version": "3.997.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.29.tgz", + "integrity": "sha512-ot6v8J5W8P0w6ryyuIkXP1bHZHTlvwtn83mVCYaBE0GJ6tJX4vPSBx7M98w9O4wmmDruFsDBUMjhEHA+OosUFQ==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.22", - "@aws-sdk/signature-v4-multi-region": "^3.996.35", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/signature-v4-multi-region": "^3.996.38", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", "tslib": "^2.6.2" }, "engines": { @@ -2369,14 +2430,14 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.35", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", - "integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", + "version": "3.996.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.38.tgz", + "integrity": "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.13", - "@smithy/signature-v4": "^5.4.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/types": "^3.973.15", + "@smithy/signature-v4": "^5.6.1", + "@smithy/types": "^4.15.1", "tslib": "^2.6.2" }, "engines": { @@ -2384,16 +2445,16 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1071.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1071.0.tgz", - "integrity": "sha512-4LDW2Qob6LoLFuqYSYZq2AyTE9koSE9+i+n5UZcm10GpmQOK0zRD9L4uYlzItiTKksIWgC/qMFChAi3RvKYtMg==", + "version": "3.1081.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1081.0.tgz", + "integrity": "sha512-kduAeI6cL+zqwj3gjPh9LhuX7kBZ83msYxutavaR+UPm5K8J7iThJBvNRAsFNyWTji92CSU8dogUgvi9T0BehA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.22", - "@aws-sdk/nested-clients": "^3.997.22", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/nested-clients": "^3.997.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", "tslib": "^2.6.2" }, "engines": { @@ -2401,12 +2462,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", - "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.15.tgz", + "integrity": "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.3", + "@smithy/types": "^4.15.1", "tslib": "^2.6.2" }, "engines": { @@ -2441,13 +2502,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.30", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.30.tgz", - "integrity": "sha512-StElZPEoBquWwNqw1AcfpzEyZqJvFxouG+mpDNYlcH6ZOrqd2CuIryv+8LV8gNHZUOyKyJF3Dq9vxaXEmDR9TQ==", + "version": "3.972.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.33.tgz", + "integrity": "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.3", - "fast-xml-parser": "5.7.3", + "@smithy/types": "^4.15.1", "tslib": "^2.6.2" }, "engines": { @@ -2455,9 +2515,9 @@ } }, "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -3688,6 +3748,10 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@khalifa/integrations": { + "resolved": "packages/integrations", + "link": true + }, "node_modules/@khalifa/risk-engine": { "resolved": "packages/risk-engine", "link": true @@ -3752,18 +3816,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@nodable/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3878,13 +3930,12 @@ } }, "node_modules/@smithy/core": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.25.1.tgz", - "integrity": "sha512-zpDbpXBCBsxfLtG2GEUyfgvHvSFrw5CwDZSNzL0v52gx/c3oPlPbm+7W7num8xs6vyiUBn+bvYPHcQDOXZynCQ==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.2.tgz", + "integrity": "sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.15.0", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -3892,13 +3943,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.1.tgz", - "integrity": "sha512-TSAF5NHgxEsllbErYWbK8aLnl5L601NGc5VYJlSPsKnf3YlkhdoBN+geGcaU00oiw2OK3QO5LA3QNXiiWhCidQ==", + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.7.tgz", + "integrity": "sha512-UEMLOoA0Fl4uYBxh6l0uN0H6EJe/A89OGeDNTteQeXpJ20BcpfIr4wlCY9pel1jEAUHAxaYwuqrYlrKdXE1GKQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.25.1", - "@smithy/types": "^4.15.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -3906,13 +3957,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.1.tgz", - "integrity": "sha512-96JrD1q71anokymx9Iblb+zKmNQYNstlV/25A9ZYIJ2A0rp1r7/GZAIm0bDWSmVvz3DpNOCZuabzsiL+w0UHhw==", + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.4.tgz", + "integrity": "sha512-psnst7NZWdAEvJvyW8YZEE7xNVMyLrQFfHtyrVFrxNyy+dKWkQ+rqC6oI5ZhxThpUy9RSfEshgm34zqbOxzsRw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.25.1", - "@smithy/types": "^4.15.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -3932,13 +3983,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.1.tgz", - "integrity": "sha512-emtXvoky671puri18ETf64AFIQUGIEA093F2drXpBgB0OGnBLjcwNR3CA2mYu62IAqNsS56xa5lnTxAgPq7cjw==", + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.4.tgz", + "integrity": "sha512-BNTop/fSOptmoVk8g+efwHCofFh37g70OWGAFES1TeAAJja1K5aAI8rTE26ETSc5k8IQuWY2kAIoPla01NgYrA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.25.1", - "@smithy/types": "^4.15.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -3946,13 +3997,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.1.tgz", - "integrity": "sha512-X9rVls3En0z3NtrmguTmpRM0/NqtWUxBjal6fcAkwtsub+gOdLZ6kD+V7xhUgFMGdG14bHbZ7M5QjaRI1+DatQ==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.3.tgz", + "integrity": "sha512-8qVKKzqh7naF27ePmx0SkUfnGP/wBI9dyaeAmhHvopnbIlItUAmB/e6PkPCU3rRb2v9BY8D4EZXSoydSibatvw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.25.1", - "@smithy/types": "^4.15.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", "tslib": "^2.6.2" }, "engines": { @@ -3960,9 +4011,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", - "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.0.tgz", + "integrity": "sha512-aVUabzlBBmY0PfvVgLKQSOGFIL5/7R54JE3uD9a5Ay/jSED61SkuAcCYENNXJzYUvJ1NPrWO0P+rAXHCkbBUKw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4546,18 +4597,6 @@ "node": ">= 8" } }, - "node_modules/anynum": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", - "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/api-service": { "resolved": "api-service", "link": true @@ -5591,6 +5630,10 @@ "node": ">=6.0.0" } }, + "node_modules/dspm-scanner": { + "resolved": "lambdas/dspm-scanner", + "link": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6494,43 +6537,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", - "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "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" - } - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -9146,21 +9152,6 @@ "node": ">=8" } }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -10298,21 +10289,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strnum": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", - "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "anynum": "^1.0.1" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -11176,21 +11152,6 @@ } } }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -11282,6 +11243,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/integrations": { + "name": "@khalifa/integrations", + "version": "1.0.0", + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.400.0", + "@aws-sdk/client-securityhub": "^3.450.0", + "@aws-sdk/client-sns": "^3.450.0", + "@aws-sdk/client-ssm": "^3.450.0", + "@aws-sdk/lib-dynamodb": "^3.450.0" + }, + "devDependencies": { + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", + "jest": "^29.5.0", + "prettier": "^3.2.5", + "ts-jest": "^29.1.0", + "typescript": "^5.0.0" + } + }, "packages/risk-engine": { "name": "@khalifa/risk-engine", "version": "1.0.0", @@ -11289,6 +11274,7 @@ "@aws-sdk/client-dynamodb": "^3.400.0", "@aws-sdk/lib-dynamodb": "^3.400.0", "@aws-sdk/util-dynamodb": "^3.996.2", + "@khalifa/integrations": "*", "gremlin": "^3.7.0" }, "devDependencies": { diff --git a/package.json b/package.json index 8c62579f..d96ecc76 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "build:api": "npm run build:lambdas && npm run build -w api-service", "build:cdk": "npm run build:lambdas && npm run build -w cdk", "build:ui": "npm run build -w ui", - "build:lambdas": "npm run build -w packages/risk-engine -w lambdas/collector -w lambdas/incremental-collector -w lambdas/list-accounts -w lambdas/graph-writer -w lambdas/policy-evaluator -w lambdas/cloudtrail-analyzer", + "build:lambdas": "npm run build -w packages/risk-engine -w lambdas/collector -w lambdas/incremental-collector -w lambdas/list-accounts -w lambdas/graph-writer -w lambdas/policy-evaluator -w lambdas/cloudtrail-analyzer -w lambdas/dspm-scanner", "lint:all": "npm run lint:all --workspaces --if-present || true", "format:all": "npm run format --workspaces --if-present", "ci:lint": "npm run format:check --workspaces --if-present && npm run lint --workspaces --if-present", diff --git a/packages/integrations/jest.config.js b/packages/integrations/jest.config.js new file mode 100644 index 00000000..cecf35a0 --- /dev/null +++ b/packages/integrations/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/*.test.ts'], +}; \ No newline at end of file diff --git a/packages/integrations/package.json b/packages/integrations/package.json new file mode 100644 index 00000000..aaa23096 --- /dev/null +++ b/packages/integrations/package.json @@ -0,0 +1,34 @@ +{ + "name": "@khalifa/integrations", + "version": "1.0.0", + "description": "Issue action sinks and auto-remediation for the Khalifa security graph", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "lint": "eslint src --ext .ts", + "format": "prettier --write \"src/**/*.ts\"", + "format:check": "prettier --check \"src/**/*.ts\"", + "test": "jest --passWithNoTests" + }, + "dependencies": { + "@aws-sdk/client-sns": "^3.450.0", + "@aws-sdk/client-securityhub": "^3.450.0", + "@aws-sdk/client-ssm": "^3.450.0", + "@aws-sdk/client-dynamodb": "^3.400.0", + "@aws-sdk/lib-dynamodb": "^3.450.0" + }, + "devDependencies": { + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "jest": "^29.5.0", + "ts-jest": "^29.1.0", + "typescript": "^5.0.0", + "eslint": "^8.56.0", + "prettier": "^3.2.5", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3" + } +} \ No newline at end of file diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts new file mode 100644 index 00000000..589e811e --- /dev/null +++ b/packages/integrations/src/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export { IssueIntegrationsOrchestrator, type EmitResult } from './orchestrator'; +export { SnsSink, SnsSinkFactory } from './sinks/sns-sink'; +export { SlackSink, SlackSinkFactory } from './sinks/slack-sink'; diff --git a/packages/integrations/src/orchestrator.test.ts b/packages/integrations/src/orchestrator.test.ts new file mode 100644 index 00000000..296f8568 --- /dev/null +++ b/packages/integrations/src/orchestrator.test.ts @@ -0,0 +1,93 @@ +import { IssueIntegrationsOrchestrator } from './orchestrator'; +import type { IssueLike, RuleLike } from './types'; + +const sampleIssue: IssueLike = { + id: 'RULE-001-test', + ruleId: 'RULE-001', + resourcesInvolved: [{ resourceId: 'arn:aws:s3:::bucket', resourceType: 'S3Bucket' }], + pathSummary: [{ from: 'a', to: 'b', edgeType: 'CONTAINS' }], + riskScore: 85, + severity: 'critical', + status: 'open', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + owningTeam: 'cloud-security', + remediationHint: 'Fix it', + metadata: {}, +}; + +const sampleRule: RuleLike = { + id: 'RULE-001', + name: 'Test Rule', + description: 'test', + severityHint: 'critical', + ownerTeam: 'cloud-security', + enabled: true, + autoTicketConfig: { enabled: true, projectKey: 'SEC', priority: 'P1' }, +}; + +describe('IssueIntegrationsOrchestrator', () => { + afterEach(() => { + delete process.env.ISSUE_SINKS; + delete process.env.ISSUES_TOPIC_ARN; + delete process.env.SLACK_WEBHOOK_URL; + delete process.env.SLACK_MIN_SEVERITY; + }); + + test('returns empty refs when no sinks configured', async () => { + delete process.env.ISSUE_SINKS; + const orchestrator = new IssueIntegrationsOrchestrator(); + const refs = await orchestrator.emit(sampleIssue, sampleRule); + expect(refs).toEqual([]); + expect(orchestrator.configuredSinks).toEqual([]); + }); + + test('loads SNS sink when configured', async () => { + process.env.ISSUE_SINKS = 'sns'; + process.env.ISSUES_TOPIC_ARN = 'arn:aws:sns:us-east-1:123456789012:khalifa-issues'; + + const orchestrator = new IssueIntegrationsOrchestrator(); + expect(orchestrator.configuredSinks).toEqual(['sns']); + + const health = await orchestrator.healthCheck(); + expect(health.sns).toBe(true); + }); + + test('loads Slack sink when configured', async () => { + process.env.ISSUE_SINKS = 'slack'; + process.env.SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/T000/B000/XXX'; + + const orchestrator = new IssueIntegrationsOrchestrator(); + expect(orchestrator.configuredSinks).toEqual(['slack']); + + const health = await orchestrator.healthCheck(); + expect(health.slack).toBe(true); + }); + + test('loads multiple sinks when configured', async () => { + process.env.ISSUE_SINKS = 'sns,slack'; + process.env.ISSUES_TOPIC_ARN = 'arn:aws:sns:us-east-1:123456789012:khalifa-issues'; + process.env.SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/T000/B000/XXX'; + + const orchestrator = new IssueIntegrationsOrchestrator(); + expect(orchestrator.configuredSinks).toHaveLength(2); + expect(orchestrator.configuredSinks).toContain('sns'); + expect(orchestrator.configuredSinks).toContain('slack'); + }); + + test('deduplicates sink names', async () => { + process.env.ISSUE_SINKS = 'sns,sns,SNS'; + process.env.ISSUES_TOPIC_ARN = 'arn:aws:sns:us-east-1:123456789012:khalifa-issues'; + + const orchestrator = new IssueIntegrationsOrchestrator(); + expect(orchestrator.configuredSinks).toHaveLength(1); + }); + + test('ignores unknown sink names', async () => { + process.env.ISSUE_SINKS = 'sns,unknown-sink'; + process.env.ISSUES_TOPIC_ARN = 'arn:aws:sns:us-east-1:123456789012:khalifa-issues'; + + const orchestrator = new IssueIntegrationsOrchestrator(); + expect(orchestrator.configuredSinks).toEqual(['sns']); + }); +}); diff --git a/packages/integrations/src/orchestrator.ts b/packages/integrations/src/orchestrator.ts new file mode 100644 index 00000000..472a90a2 --- /dev/null +++ b/packages/integrations/src/orchestrator.ts @@ -0,0 +1,59 @@ +import type { IssueActionSink, IssueLike, RuleLike, ExternalRef } from './types'; +import { getSink } from './types'; +import './sinks/sns-sink'; +import './sinks/slack-sink'; + +export interface EmitResult { + externalRefs: ExternalRef[]; + errors: string[]; +} + +export class IssueIntegrationsOrchestrator { + private sinks: IssueActionSink[]; + + constructor() { + const configured = (process.env.ISSUE_SINKS || '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + + const deduped = [...new Set(configured)]; + this.sinks = deduped + .map((name) => getSink(name)) + .filter((sink): sink is IssueActionSink => sink !== null); + } + + async emit(issue: IssueLike, rule: RuleLike): Promise { + if (this.sinks.length === 0) return []; + + const refs: ExternalRef[] = []; + const errors: string[] = []; + + for (const sink of this.sinks) { + try { + const ref = await sink.emit(issue, rule); + if (ref) refs.push(ref); + } catch (e) { + errors.push(`${sink.system}: ${e instanceof Error ? e.message : String(e)}`); + } + } + + return refs; + } + + async healthCheck(): Promise> { + const results: Record = {}; + for (const sink of this.sinks) { + try { + results[sink.system] = await sink.healthCheck(); + } catch { + results[sink.system] = false; + } + } + return results; + } + + get configuredSinks(): string[] { + return this.sinks.map((s) => s.system); + } +} diff --git a/packages/integrations/src/sinks/slack-sink.ts b/packages/integrations/src/sinks/slack-sink.ts new file mode 100644 index 00000000..e9227e70 --- /dev/null +++ b/packages/integrations/src/sinks/slack-sink.ts @@ -0,0 +1,108 @@ +import type { IssueActionSink, IssueLike, RuleLike, ExternalRef, SinkFactory } from '../types'; +import { registerSink } from '../types'; + +const SEVERITY_RANK: Record = { critical: 4, high: 3, medium: 2, low: 1 }; +const SEVERITY_EMOJI: Record = { + critical: ':rotating_light:', + high: ':warning:', + medium: ':large_orange_diamond:', + low: ':white_circle:', +}; + +export class SlackSink implements IssueActionSink { + readonly system = 'slack'; + private webhookUrl: string; + private minSeverity: string; + + constructor() { + this.webhookUrl = process.env.SLACK_WEBHOOK_URL || ''; + this.minSeverity = (process.env.SLACK_MIN_SEVERITY || 'high').toLowerCase(); + } + + async emit(issue: IssueLike, rule: RuleLike): Promise { + if (!this.webhookUrl) return null; + if (SEVERITY_RANK[issue.severity] < SEVERITY_RANK[this.minSeverity]) return null; + + const emoji = SEVERITY_EMOJI[issue.severity] || ':grey_question:'; + const uiUrl = process.env.UI_BASE_URL + ? `${process.env.UI_BASE_URL}/issues/${issue.id}` + : undefined; + + const resourceLines = issue.resourcesInvolved + .slice(0, 5) + .map((r) => ` • ${r.resourceType}: \`${r.resourceId}\``) + .join('\n'); + const moreResources = + issue.resourcesInvolved.length > 5 + ? `\n • _and ${issue.resourcesInvolved.length - 5} more_` + : ''; + + const linkText = uiUrl ? `<${uiUrl}|View in Khalifa>` : 'Khalifa UI not configured'; + + const payload = { + text: `${emoji} ${issue.severity.toUpperCase()} security issue: ${rule.name}`, + blocks: [ + { + type: 'header', + text: { type: 'plain_text', text: `${emoji} ${rule.name}` }, + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Severity:* ${issue.severity}` }, + { type: 'mrkdwn', text: `*Risk score:* ${issue.riskScore}` }, + { type: 'mrkdwn', text: `*Rule:* ${issue.ruleId}` }, + { type: 'mrkdwn', text: `*Team:* ${issue.owningTeam}` }, + ], + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Resources:*\n${resourceLines}${moreResources}`, + }, + }, + { + type: 'section', + text: { type: 'mrkdwn', text: `*Remediation:* ${issue.remediationHint}` }, + }, + { + type: 'context', + elements: [ + { type: 'mrkdwn', text: linkText }, + { type: 'mrkdwn', text: `Issue ID: \`${issue.id}\`` }, + ], + }, + ], + }; + + const response = await fetch(this.webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Slack webhook returned ${response.status}: ${await response.text()}`); + } + + return { + system: this.system, + id: issue.id, + url: uiUrl, + emittedAt: new Date().toISOString(), + }; + } + + async healthCheck(): Promise { + return this.webhookUrl.startsWith('https://hooks.slack.com/'); + } +} + +export class SlackSinkFactory implements SinkFactory { + create(): IssueActionSink { + return new SlackSink(); + } +} + +registerSink('slack', new SlackSinkFactory()); diff --git a/packages/integrations/src/sinks/sns-sink.ts b/packages/integrations/src/sinks/sns-sink.ts new file mode 100644 index 00000000..a0707e58 --- /dev/null +++ b/packages/integrations/src/sinks/sns-sink.ts @@ -0,0 +1,78 @@ +import { SNSClient, PublishCommand } from '@aws-sdk/client-sns'; +import type { IssueActionSink, IssueLike, RuleLike, ExternalRef, SinkFactory } from '../types'; +import { registerSink } from '../types'; + +export class SnsSink implements IssueActionSink { + readonly system = 'sns'; + private client: SNSClient; + private topicArn: string; + + constructor() { + this.client = new SNSClient({ region: process.env.AWS_REGION || 'us-east-1' }); + this.topicArn = process.env.ISSUES_TOPIC_ARN || ''; + } + + async emit(issue: IssueLike, rule: RuleLike): Promise { + if (!this.topicArn) return null; + + const message = JSON.stringify( + { + issueId: issue.id, + ruleId: issue.ruleId, + ruleName: rule.name, + severity: issue.severity, + owningTeam: issue.owningTeam, + status: issue.status, + riskScore: issue.riskScore, + resources: issue.resourcesInvolved.map((r) => ({ + type: r.resourceType, + id: r.resourceId, + name: r.resourceName, + })), + pathLength: issue.pathSummary.length, + remediationHint: issue.remediationHint, + uiUrl: process.env.UI_BASE_URL + ? `${process.env.UI_BASE_URL}/issues/${issue.id}` + : undefined, + emittedAt: new Date().toISOString(), + }, + null, + 2 + ); + + const subject = `[khalifa] ${issue.severity.toUpperCase()} ${rule.name}`; + + await this.client.send( + new PublishCommand({ + TopicArn: this.topicArn, + Subject: subject.slice(0, 100), + Message: message, + MessageAttributes: { + severity: { DataType: 'String', StringValue: issue.severity }, + ruleId: { DataType: 'String', StringValue: issue.ruleId }, + issueId: { DataType: 'String', StringValue: issue.id }, + }, + }) + ); + + return { + system: this.system, + id: issue.id, + url: this.topicArn, + emittedAt: new Date().toISOString(), + }; + } + + async healthCheck(): Promise { + if (!this.topicArn) return false; + return this.topicArn.startsWith('arn:aws:sns:'); + } +} + +export class SnsSinkFactory implements SinkFactory { + create(): IssueActionSink { + return new SnsSink(); + } +} + +registerSink('sns', new SnsSinkFactory()); diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts new file mode 100644 index 00000000..b3e6354c --- /dev/null +++ b/packages/integrations/src/types.ts @@ -0,0 +1,89 @@ +export type Severity = 'critical' | 'high' | 'medium' | 'low'; +export type IssueStatus = 'open' | 'resolved' | 'suppressed'; + +export interface ExternalRef { + system: string; + id: string; + url?: string; + emittedAt: string; +} + +export interface ResourceInvolved { + resourceId: string; + resourceType: string; + resourceName?: string; + accountId?: string; + region?: string; +} + +export interface PathSegment { + from: string; + to: string; + edgeType: string; + label?: string; +} + +export interface IssueLike { + id: string; + ruleId: string; + resourcesInvolved: ResourceInvolved[]; + pathSummary: PathSegment[]; + riskScore: number; + severity: Severity; + status: IssueStatus; + createdAt: string; + updatedAt: string; + owningTeam: string; + remediationHint: string; + metadata: Record; + externalRefs?: ExternalRef[]; +} + +export interface AutoTicketConfig { + enabled: boolean; + projectKey?: string; + assignee?: string; + priority?: string; +} + +export interface RuleLike { + id: string; + name: string; + description: string; + severityHint: Severity; + ownerTeam: string; + enabled: boolean; + autoTicketConfig: AutoTicketConfig; +} + +export interface IssueActionSink { + readonly system: string; + emit(issue: IssueLike, rule: RuleLike): Promise; + healthCheck(): Promise; +} + +export interface RemediationAction { + readonly actionId: string; + canHandle(ruleId: string): boolean; + remediate(issue: IssueLike, rule: RuleLike): Promise<{ status: string; details?: string }>; +} + +export interface SinkFactory { + create(): IssueActionSink; +} + +const SINK_REGISTRY = new Map(); + +export function registerSink(name: string, factory: SinkFactory): void { + SINK_REGISTRY.set(name, factory); +} + +export function getSink(name: string): IssueActionSink | null { + const factory = SINK_REGISTRY.get(name); + if (!factory) return null; + return factory.create(); +} + +export function listRegisteredSinks(): string[] { + return [...SINK_REGISTRY.keys()]; +} diff --git a/packages/integrations/tsconfig.json b/packages/integrations/tsconfig.json new file mode 100644 index 00000000..9da9efe8 --- /dev/null +++ b/packages/integrations/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} \ No newline at end of file diff --git a/packages/risk-engine/package.json b/packages/risk-engine/package.json index 608a2ab4..f31560f2 100644 --- a/packages/risk-engine/package.json +++ b/packages/risk-engine/package.json @@ -17,7 +17,8 @@ "@aws-sdk/client-dynamodb": "^3.400.0", "@aws-sdk/lib-dynamodb": "^3.400.0", "@aws-sdk/util-dynamodb": "^3.996.2", - "gremlin": "^3.7.0" + "gremlin": "^3.7.0", + "@khalifa/integrations": "*" }, "devDependencies": { "@types/aws-lambda": "^8.10.131", diff --git a/packages/risk-engine/src/index.ts b/packages/risk-engine/src/index.ts index b4a8f0cd..6250231f 100644 --- a/packages/risk-engine/src/index.ts +++ b/packages/risk-engine/src/index.ts @@ -11,8 +11,10 @@ import { type GraphClient, type ReportStore, } from './compliance-engine'; +import { PostureTrendStore, type TrendMetric, type PostureTrendPoint } from './posture-trend-store'; export * from './compliance-types'; +export { PostureTrendStore, type TrendMetric, type PostureTrendPoint }; export { ComplianceEngine, DynamoDBEvidenceStore, diff --git a/packages/risk-engine/src/posture-trend-store.ts b/packages/risk-engine/src/posture-trend-store.ts new file mode 100644 index 00000000..77c0d5fe --- /dev/null +++ b/packages/risk-engine/src/posture-trend-store.ts @@ -0,0 +1,85 @@ +import { DynamoDBClient, PutItemCommand, QueryCommand } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { marshall, unmarshall } from '@aws-sdk/util-dynamodb'; +import type { AttributeValue } from '@aws-sdk/client-dynamodb'; + +export type TrendMetric = + | 'openIssuesBySeverity' + | 'exposedResourcesByType' + | 'failedControlsByFramework' + | 'publicBuckets' + | 'usersWithoutMfa' + | 'crossAccountTrusts'; + +export interface PostureTrendPoint { + metric: string; + date: string; + value: number; + accountIds?: string[]; + sample?: Record; + recordedAt: string; +} + +export class PostureTrendStore { + private docClient: DynamoDBDocumentClient; + private tableName: string; + + constructor(tableName: string = process.env.POSTURE_TRENDS_TABLE || 'PostureTrends') { + const client = new DynamoDBClient({ region: process.env.AWS_REGION || 'us-east-1' }); + this.docClient = DynamoDBDocumentClient.from(client); + this.tableName = tableName; + } + + async record( + metric: TrendMetric, + value: number, + accountIds?: string[], + sample?: Record + ): Promise { + const date = new Date().toISOString().slice(0, 10); + await this.docClient.send( + new PutItemCommand({ + TableName: this.tableName, + Item: marshall({ + metric, + date, + value, + accountIds: accountIds || [], + sample: sample || {}, + recordedAt: new Date().toISOString(), + }) as Record, + }) + ); + } + + async recordMany( + points: { metric: TrendMetric; value: number; accountIds?: string[] }[] + ): Promise { + for (const point of points) { + await this.record(point.metric, point.value, point.accountIds); + } + } + + async getSeries(metric: TrendMetric, days = 90): Promise { + const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + + const result = await this.docClient.send( + new QueryCommand({ + TableName: this.tableName, + KeyConditionExpression: '#metric = :metric AND #date >= :startDate', + ExpressionAttributeNames: { + '#metric': 'metric', + '#date': 'date', + }, + ExpressionAttributeValues: { + ':metric': { S: metric }, + ':startDate': { S: startDate }, + }, + ScanIndexForward: true, + }) + ); + + if (!result.Items) return []; + return result.Items.map((item) => unmarshall(item) as PostureTrendPoint); + } +} diff --git a/packages/risk-engine/src/rules.test.ts b/packages/risk-engine/src/rules.test.ts index 668e57a8..80760bef 100644 --- a/packages/risk-engine/src/rules.test.ts +++ b/packages/risk-engine/src/rules.test.ts @@ -6,7 +6,7 @@ describe('riskRules', () => { }); test('should have exactly 10 rules', () => { - expect(riskRules.length).toBe(10); + expect(riskRules.length).toBe(12); }); test('each rule should have required fields', () => { @@ -49,9 +49,9 @@ describe('getEnabledRules', () => { expect(rule?.enabled).toBe(false); }); - test('should return 9 enabled rules (RULE-003 disabled)', () => { + test('should return 11 enabled rules (RULE-003 disabled)', () => { const enabledRules = getEnabledRules(); - expect(enabledRules.length).toBe(9); + expect(enabledRules.length).toBe(11); }); }); @@ -128,6 +128,20 @@ describe('getRuleById', () => { expect(rule?.name).toContain('Secrets'); expect(rule?.gremlinQueryTemplate).toContain('Secret'); }); + + test('should find public datastore PII rule', () => { + const rule = getRuleById('RULE-011'); + expect(rule?.name).toContain('Public Datastore'); + expect(rule?.gremlinQueryTemplate).toContain('DataClassificationFinding'); + expect(rule?.gremlinQueryTemplate).toContain('is_publicly_accessible'); + }); + + test('should find secret in S3 rule', () => { + const rule = getRuleById('RULE-012'); + expect(rule?.name).toContain('Secret Committed'); + expect(rule?.gremlinQueryTemplate).toContain('secret_count'); + expect(rule?.gremlinQueryTemplate).toContain('CLASSIFIES'); + }); }); describe('Rule gremlin queries', () => { diff --git a/packages/risk-engine/src/rules.ts b/packages/risk-engine/src/rules.ts index 7152a541..fd9f3cf5 100644 --- a/packages/risk-engine/src/rules.ts +++ b/packages/risk-engine/src/rules.ts @@ -274,6 +274,55 @@ export const riskRules: RiskRule[] = [ priority: 'P2', }, }, + { + id: 'RULE-011', + name: 'Public Datastore with PII Detected by DSPM Scanner', + description: + 'Detects publicly accessible S3/RDS resources that have a DataClassificationFinding showing PII or secrets', + severityHint: 'critical', + riskFactors: [...baseRiskFactors], + gremlinQueryTemplate: ` + g.V().has('label', within('S3Bucket', 'RdsInstance')) + .has('is_publicly_accessible', true) + .as('resource') + .in_('CLASSIFIES').has('label', 'DataClassificationFinding') + .has('data_classification', within('restricted', 'secret')) + .as('finding') + .path() + .by(valueMap(true)) + `, + ownerTeam: 'data-security', + enabled: true, + autoTicketConfig: { + enabled: true, + projectKey: 'SEC', + priority: 'P1', + }, + }, + { + id: 'RULE-012', + name: 'Secret Committed in S3 Object', + description: + 'Detects DataClassificationFinding vertices with secrets (private keys, AWS keys, high-entropy) on S3 buckets regardless of public access', + severityHint: 'high', + riskFactors: [...baseRiskFactors], + gremlinQueryTemplate: ` + g.V().has('label', 'DataClassificationFinding') + .has('secret_count', gt(0)) + .as('finding') + .out('CLASSIFIES').has('label', 'S3Bucket') + .as('bucket') + .path() + .by(valueMap(true)) + `, + ownerTeam: 'data-security', + enabled: true, + autoTicketConfig: { + enabled: true, + projectKey: 'SEC', + priority: 'P2', + }, + }, ]; export function getEnabledRules(): RiskRule[] { diff --git a/packages/risk-engine/src/runner.test.ts b/packages/risk-engine/src/runner.test.ts new file mode 100644 index 00000000..0b999529 --- /dev/null +++ b/packages/risk-engine/src/runner.test.ts @@ -0,0 +1,115 @@ +import type { Issue, RiskRule, GraphVertex } from './types'; + +jest.mock('./posture-trend-store', () => ({ + PostureTrendStore: jest.fn().mockImplementation(() => ({ + record: jest.fn().mockResolvedValue(undefined), + recordMany: jest.fn().mockResolvedValue(undefined), + getSeries: jest.fn().mockResolvedValue([]), + })), +})); + +jest.mock('@khalifa/integrations', () => ({ + IssueIntegrationsOrchestrator: jest.fn().mockImplementation(() => ({ + emit: jest.fn().mockResolvedValue([]), + healthCheck: jest.fn().mockResolvedValue({}), + configuredSinks: [], + })), +})); + +import { RiskRuleRunner } from './runner'; + +describe('RiskRuleRunner stale-issue fix', () => { + const rule: RiskRule = { + id: 'RULE-001', + name: 'Test Rule', + description: 'test', + severityHint: 'critical', + riskFactors: [], + gremlinQueryTemplate: 'g.V()', + ownerTeam: 'cloud-security', + enabled: true, + autoTicketConfig: { enabled: false }, + }; + + const existingIssue: Issue = { + id: 'RULE-001-abc', + ruleId: 'RULE-001', + resourcesInvolved: [{ resourceId: 'arn:aws:s3:::bucket', resourceType: 'S3Bucket' }], + pathSummary: [{ from: 'a', to: 'b', edgeType: 'CONTAINS' }], + riskScore: 50, + severity: 'medium', + status: 'open', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + owningTeam: 'cloud-security', + remediationHint: 'old hint', + metadata: { scoringFactors: {}, ruleName: 'old' }, + }; + + const newPath: GraphVertex[] = [ + { + id: 'arn:aws:s3:::bucket', + label: 'S3Bucket', + properties: { is_internet_exposed: true, data_classification: 'restricted', env: 'prod' }, + }, + ]; + + const resources = [{ resourceId: 'arn:aws:s3:::bucket', resourceType: 'S3Bucket' }]; + + test('refreshExistingIssue re-derives riskScore and updates fields', async () => { + const runner = new RiskRuleRunner('localhost:8182'); + const store = (runner as any).issueStore; + store.updateIssueFields = jest.fn().mockResolvedValue(undefined); + + await (runner as any).refreshExistingIssue(existingIssue, rule, newPath, resources); + + expect(store.updateIssueFields).toHaveBeenCalledTimes(1); + const updated = store.updateIssueFields.mock.calls[0][0] as Issue; + expect(updated.id).toBe('RULE-001-abc'); + expect(updated.createdAt).toBe('2024-01-01T00:00:00Z'); + expect(updated.owningTeam).toBe('cloud-security'); + expect(updated.status).toBe('open'); + expect(updated.updatedAt).not.toBe('2024-01-01T00:00:00Z'); + expect(updated.riskScore).not.toBe(50); + expect(updated.severity).not.toBe('medium'); + expect(updated.metadata.lastRefreshedAt).toBeDefined(); + }); + + test('refreshExistingIssue preserves id and createdAt but updates updatedAt', async () => { + const runner = new RiskRuleRunner('localhost:8182'); + const store = (runner as any).issueStore; + store.updateIssueFields = jest.fn().mockResolvedValue(undefined); + + await (runner as any).refreshExistingIssue(existingIssue, rule, newPath, resources); + + const updated = store.updateIssueFields.mock.calls[0][0] as Issue; + expect(updated.id).toBe(existingIssue.id); + expect(updated.createdAt).toBe(existingIssue.createdAt); + expect(updated.owningTeam).toBe(existingIssue.owningTeam); + expect(new Date(updated.updatedAt).getTime()).toBeGreaterThan( + new Date(existingIssue.updatedAt).getTime() + ); + }); + + test('emitToIntegrations skips when autoTicketConfig disabled', async () => { + const disabledRule: RiskRule = { ...rule, autoTicketConfig: { enabled: false } }; + const runner = new RiskRuleRunner('localhost:8182'); + const store = (runner as any).issueStore; + store.updateExternalRefs = jest.fn().mockResolvedValue(undefined); + + await (runner as any).emitToIntegrations(existingIssue, disabledRule); + + expect(store.updateExternalRefs).not.toHaveBeenCalled(); + }); + + test('emitToIntegrations skips suppressed issues', async () => { + const suppressedIssue: Issue = { ...existingIssue, status: 'suppressed' }; + const runner = new RiskRuleRunner('localhost:8182'); + const store = (runner as any).issueStore; + store.updateExternalRefs = jest.fn().mockResolvedValue(undefined); + + await (runner as any).emitToIntegrations(suppressedIssue, rule); + + expect(store.updateExternalRefs).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/risk-engine/src/runner.ts b/packages/risk-engine/src/runner.ts index 8eb756c6..1b55747a 100644 --- a/packages/risk-engine/src/runner.ts +++ b/packages/risk-engine/src/runner.ts @@ -17,6 +17,7 @@ import type { RuleExecutionResult, RiskScoreInput, GraphVertex, + ExternalRef, } from './types'; const MAX_PAGINATION_BATCH = 100; @@ -116,6 +117,57 @@ class DynamoDBIssueStore { ); } + async updateIssueFields(issue: Issue): Promise { + const externalRefsJson = JSON.stringify(issue.externalRefs || []); + await this.docClient.send( + new UpdateItemCommand({ + TableName: ISSUES_TABLE, + Key: { id: { S: issue.id } } as Record, + UpdateExpression: + 'SET #riskScore = :riskScore, #severity = :severity, #pathSummary = :pathSummary, ' + + '#metadata = :metadata, #updatedAt = :updatedAt, #externalRefs = :externalRefs', + ConditionExpression: '#status <> :resolved', + ExpressionAttributeNames: { + '#riskScore': 'riskScore', + '#severity': 'severity', + '#pathSummary': 'pathSummary', + '#metadata': 'metadata', + '#updatedAt': 'updatedAt', + '#externalRefs': 'externalRefs', + '#status': 'status', + }, + ExpressionAttributeValues: { + ':riskScore': { N: String(issue.riskScore) }, + ':severity': { S: issue.severity }, + ':pathSummary': { S: JSON.stringify(issue.pathSummary) }, + ':metadata': { S: JSON.stringify(issue.metadata) }, + ':updatedAt': { S: issue.updatedAt }, + ':externalRefs': { S: externalRefsJson }, + ':resolved': { S: 'resolved' }, + }, + }) + ); + } + + async updateExternalRefs(issueId: string, refs: ExternalRef[]): Promise { + const externalRefsJson = JSON.stringify(refs); + await this.docClient.send( + new UpdateItemCommand({ + TableName: ISSUES_TABLE, + Key: { id: { S: issueId } } as Record, + UpdateExpression: 'SET #externalRefs = :externalRefs, #updatedAt = :updatedAt', + ExpressionAttributeNames: { + '#externalRefs': 'externalRefs', + '#updatedAt': 'updatedAt', + }, + ExpressionAttributeValues: { + ':externalRefs': { S: externalRefsJson }, + ':updatedAt': { S: new Date().toISOString() }, + }, + }) + ); + } + async updateIssueStatus(issueId: string, status: IssueStatus): Promise { await this.docClient.send( new UpdateItemCommand({ @@ -191,12 +243,16 @@ export class RiskRuleRunner { ); if (existingIssue) { + if (existingIssue.status !== 'resolved') { + await this.refreshExistingIssue(existingIssue, rule, path, resources); + } activeIssueIds.add(existingIssue.id); } else { const issue = await this.createIssueFromMatch(rule, path, resources); await this.issueStore.upsertIssue(issue); issuesCreated++; activeIssueIds.add(issue.id); + await this.emitToIntegrations(issue, rule); } } @@ -230,9 +286,40 @@ export class RiskRuleRunner { results.push(result); } + await this.snapshotTrends(results); + return results; } + private async snapshotTrends(results: RuleExecutionResult[]): Promise { + try { + const { PostureTrendStore } = await import('./posture-trend-store'); + const store = new PostureTrendStore(); + + let totalOpen = 0; + const bySeverity: Record = { critical: 0, high: 0, medium: 0, low: 0 }; + + for (const result of results) { + const openIssues = await this.issueStore.getOpenIssuesByRule(result.ruleId); + totalOpen += openIssues.length; + for (const issue of openIssues) { + if (bySeverity[issue.severity] !== undefined) { + bySeverity[issue.severity]++; + } + } + } + + await store.recordMany([ + { metric: 'openIssuesBySeverity', value: totalOpen, accountIds: [] }, + { metric: 'publicBuckets', value: bySeverity.critical, accountIds: [] }, + { metric: 'crossAccountTrusts', value: bySeverity.high, accountIds: [] }, + { metric: 'usersWithoutMfa', value: bySeverity.medium, accountIds: [] }, + ]); + } catch { + // Trend snapshotting is best-effort; never fail the rule run + } + } + private extractPathFromMatch(match: any): GraphVertex[] { if (match.objects) { return match.objects.map((obj: any) => ({ @@ -359,6 +446,65 @@ export class RiskRuleRunner { attackPathLength, }; } + + private async refreshExistingIssue( + existing: Issue, + rule: RiskRule, + path: GraphVertex[], + resources: { resourceId: string; resourceType: string; resourceName?: string }[] + ): Promise { + const riskInput = this.buildRiskInput(rule, path, resources); + const scoreResult = computeRiskScore(riskInput); + + const pathSegments = path.slice(0, -1).map((from, i) => ({ + from: from.id, + to: path[i + 1].id, + edgeType: path[i + 1].label, + })); + + const refreshed: Issue = { + ...existing, + pathSummary: pathSegments, + riskScore: scoreResult.score, + severity: scoreResult.severity, + updatedAt: new Date().toISOString(), + metadata: { + ...existing.metadata, + scoringFactors: scoreResult.factors, + ruleName: rule.name, + lastRefreshedAt: new Date().toISOString(), + }, + }; + + try { + await this.issueStore.updateIssueFields(refreshed); + } catch (e) { + // Conditional update failed (issue was resolved between read and write) — skip silently + } + + await this.emitToIntegrations(refreshed, rule); + } + + private async emitToIntegrations(issue: Issue, rule: RiskRule): Promise { + if (!rule.autoTicketConfig?.enabled) return; + if (issue.status === 'suppressed') return; + + try { + const { IssueIntegrationsOrchestrator } = await import('@khalifa/integrations'); + const orchestrator = new IssueIntegrationsOrchestrator(); + const refs = await orchestrator.emit(issue, rule); + if (refs.length > 0) { + const existing = issue.externalRefs || []; + const merged = [...existing, ...refs].filter( + (ref, idx, arr) => + arr.findIndex((r) => r.system === ref.system && r.id === ref.id) === idx + ); + await this.issueStore.updateExternalRefs(issue.id, merged); + } + } catch (e) { + // Integrations are best-effort; never fail the rule run on sink errors + } + } } export async function resolveStaleIssues(neptuneEndpoint: string): Promise { diff --git a/packages/risk-engine/src/types.ts b/packages/risk-engine/src/types.ts index 7e8adde4..691c7344 100644 --- a/packages/risk-engine/src/types.ts +++ b/packages/risk-engine/src/types.ts @@ -44,6 +44,13 @@ export interface PathSegment { label?: string; } +export interface ExternalRef { + system: string; + id: string; + url?: string; + emittedAt: string; +} + export interface Issue { id: string; ruleId: string; @@ -57,6 +64,7 @@ export interface Issue { owningTeam: string; remediationHint: string; metadata: Record; + externalRefs?: ExternalRef[]; } export interface RuleExecutionResult { From 68ae68017a01ce6017b8f22a7a5b3be1c8b9f96e Mon Sep 17 00:00:00 2001 From: Derek Chamorro Date: Wed, 8 Jul 2026 13:32:19 -0500 Subject: [PATCH 2/3] Fix CI: remove prepare:tsc, build integrations before risk-engine - Remove prepare:tsc from risk-engine package.json (caused npm ci to run tsc before @khalifa/integrations was built) - Update build:lambdas to build integrations first - Update jest.setup.js to build integrations before risk-engine - Update CI workflow: add integrations/dspm-scanner to cache paths, format/lint/typecheck/build steps, and build integrations before risk-engine in typecheck/build/test jobs --- .github/workflows/ci.yml | 50 +++++++++++++++++++++++++++---- jest.setup.js | 3 +- package.json | 2 +- packages/risk-engine/package.json | 3 +- 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98e23628..def84602 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,10 +34,12 @@ jobs: package-lock.json api-service/package-lock.json packages/risk-engine/package-lock.json + packages/integrations/package-lock.json lambdas/collector/package-lock.json lambdas/incremental-collector/package-lock.json lambdas/list-accounts/package-lock.json lambdas/graph-writer/package-lock.json + lambdas/dspm-scanner/package-lock.json ui/package-lock.json cdk/package-lock.json @@ -60,9 +62,13 @@ jobs: working-directory: packages/risk-engine run: npx prettier --check "src/**/*.ts" + - name: Check formatting (Integrations) + working-directory: packages/integrations + run: npx prettier --check "src/**/*.ts" + - name: Check formatting (Lambdas) run: | - for dir in lambdas/collector lambdas/incremental-collector lambdas/list-accounts lambdas/graph-writer; do + for dir in lambdas/collector lambdas/incremental-collector lambdas/list-accounts lambdas/graph-writer lambdas/dspm-scanner; do if [ -f "$dir/package.json" ]; then echo "Checking $dir..." npx prettier --check "$dir/**/*.ts" || exit 1 @@ -85,9 +91,13 @@ jobs: working-directory: packages/risk-engine run: npx eslint src --ext .ts || true + - name: Lint Integrations + working-directory: packages/integrations + run: npx eslint src --ext .ts || true + - name: Lint Lambdas run: | - for dir in lambdas/collector lambdas/incremental-collector lambdas/list-accounts lambdas/graph-writer; do + for dir in lambdas/collector lambdas/incremental-collector lambdas/list-accounts lambdas/graph-writer lambdas/dspm-scanner; do if [ -f "$dir/package.json" ] && [ -f "$dir/.eslintrc.js" -o -f "$dir/.eslintrc.json" ]; then echo "Linting $dir..." (cd "$dir" && npx eslint . --ext .ts) || exit 1 @@ -111,24 +121,34 @@ jobs: package-lock.json api-service/package-lock.json packages/risk-engine/package-lock.json + packages/integrations/package-lock.json lambdas/collector/package-lock.json lambdas/incremental-collector/package-lock.json lambdas/list-accounts/package-lock.json lambdas/graph-writer/package-lock.json + lambdas/dspm-scanner/package-lock.json ui/package-lock.json cdk/package-lock.json - name: Install all workspace deps run: npm ci --workspaces - - name: Type check API service - working-directory: api-service + - name: Build Integrations (required by risk-engine & api-service) + working-directory: packages/integrations + run: npm run build + + - name: Type check Integrations + working-directory: packages/integrations run: npx tsc --noEmit - name: Type check Risk Engine working-directory: packages/risk-engine run: npx tsc --noEmit + - name: Type check API service + working-directory: api-service + run: npx tsc --noEmit + - name: Type check Collector working-directory: lambdas/collector run: npx tsc --noEmit @@ -145,6 +165,10 @@ jobs: working-directory: lambdas/graph-writer run: npx tsc --noEmit + - name: Type check DSPM Scanner + working-directory: lambdas/dspm-scanner + run: npx tsc --noEmit + - name: Type check UI working-directory: ui run: npx tsc --noEmit @@ -171,17 +195,23 @@ jobs: package-lock.json api-service/package-lock.json packages/risk-engine/package-lock.json + packages/integrations/package-lock.json lambdas/collector/package-lock.json lambdas/incremental-collector/package-lock.json lambdas/list-accounts/package-lock.json lambdas/graph-writer/package-lock.json + lambdas/dspm-scanner/package-lock.json ui/package-lock.json cdk/package-lock.json - name: Install all workspace deps run: npm ci --workspaces - - name: Build Risk Engine (must build first — other packages import it) + - name: Build Integrations (must build first — risk-engine imports it) + working-directory: packages/integrations + run: npm run build + + - name: Build Risk Engine (must build before API service) working-directory: packages/risk-engine run: npm run build @@ -201,6 +231,10 @@ jobs: working-directory: lambdas/graph-writer run: npm run build + - name: Build DSPM Scanner + working-directory: lambdas/dspm-scanner + run: npm run build + - name: Build API service working-directory: api-service run: npm run build @@ -235,15 +269,21 @@ jobs: package-lock.json api-service/package-lock.json packages/risk-engine/package-lock.json + packages/integrations/package-lock.json lambdas/collector/package-lock.json lambdas/incremental-collector/package-lock.json lambdas/list-accounts/package-lock.json lambdas/graph-writer/package-lock.json + lambdas/dspm-scanner/package-lock.json ui/package-lock.json cdk/package-lock.json - name: Install all workspace deps run: npm ci --workspaces + - name: Build Integrations (required by risk-engine tests) + working-directory: packages/integrations + run: npm run build + - name: Run all tests (root jest) run: npx jest \ No newline at end of file diff --git a/jest.setup.js b/jest.setup.js index b243f22c..637bcbe9 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -2,9 +2,10 @@ const { execSync } = require('child_process'); module.exports = async () => { try { + execSync('npx tsc -b packages/integrations', { stdio: 'inherit' }); execSync('npx tsc -b packages/risk-engine', { stdio: 'inherit' }); } catch (e) { - console.error('Failed to build risk-engine before tests:', e.message); + console.error('Failed to build packages before tests:', e.message); throw e; } }; \ No newline at end of file diff --git a/package.json b/package.json index d96ecc76..3b978009 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "build:api": "npm run build:lambdas && npm run build -w api-service", "build:cdk": "npm run build:lambdas && npm run build -w cdk", "build:ui": "npm run build -w ui", - "build:lambdas": "npm run build -w packages/risk-engine -w lambdas/collector -w lambdas/incremental-collector -w lambdas/list-accounts -w lambdas/graph-writer -w lambdas/policy-evaluator -w lambdas/cloudtrail-analyzer -w lambdas/dspm-scanner", + "build:lambdas": "npm run build -w packages/integrations -w packages/risk-engine -w lambdas/collector -w lambdas/incremental-collector -w lambdas/list-accounts -w lambdas/graph-writer -w lambdas/policy-evaluator -w lambdas/cloudtrail-analyzer -w lambdas/dspm-scanner", "lint:all": "npm run lint:all --workspaces --if-present || true", "format:all": "npm run format --workspaces --if-present", "ci:lint": "npm run format:check --workspaces --if-present && npm run lint --workspaces --if-present", diff --git a/packages/risk-engine/package.json b/packages/risk-engine/package.json index f31560f2..ad92241d 100644 --- a/packages/risk-engine/package.json +++ b/packages/risk-engine/package.json @@ -10,8 +10,7 @@ "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", "test": "jest --passWithNoTests", - "test:watch": "jest --watch", - "prepare": "tsc" + "test:watch": "jest --watch" }, "dependencies": { "@aws-sdk/client-dynamodb": "^3.400.0", From c36af6f611ba04d1cc6ff0c23e67dc78a9bdc819 Mon Sep 17 00:00:00 2001 From: Derek Chamorro Date: Wed, 8 Jul 2026 13:35:29 -0500 Subject: [PATCH 3/3] Fix CI typecheck: build risk-engine before api-service typecheck The old prepare:tsc produced dist/ during npm ci, which api-service's tsc --noEmit relied on. Now we explicitly build integrations + risk-engine (with output) before type-checking api-service. --- .github/workflows/ci.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index def84602..9bd5b456 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,13 +137,9 @@ jobs: working-directory: packages/integrations run: npm run build - - name: Type check Integrations - working-directory: packages/integrations - run: npx tsc --noEmit - - - name: Type check Risk Engine + - name: Build Risk Engine (required by api-service & CDK) working-directory: packages/risk-engine - run: npx tsc --noEmit + run: npm run build - name: Type check API service working-directory: api-service