diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 9b57bf73..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "root": true, - "env": { - "node": true, - "es2021": true - }, - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "prettier" - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module", - "project": ["./tsconfig.json", "./*/tsconfig.json"] - }, - "plugins": ["@typescript-eslint", "prettier"], - "rules": { - "prettier/prettier": "error", - "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], - "@typescript-eslint/no-non-null-assertion": "warn", - "no-console": "off" - }, - "ignorePatterns": ["dist/", "node_modules/", "*.js", "*.d.ts"] -} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1bde7bb6..06838d11 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,7 +43,7 @@ jobs: cache: 'npm' cache-dependency-path: | api-service/package-lock.json - lambdas/risk-engine/package-lock.json + packages/risk-engine/package-lock.json lambdas/collector/package-lock.json lambdas/incremental-collector/package-lock.json lambdas/list-accounts/package-lock.json @@ -56,7 +56,7 @@ jobs: run: npm ci - name: Install risk-engine deps - working-directory: lambdas/risk-engine + working-directory: packages/risk-engine run: npm ci - name: Install collector deps @@ -98,7 +98,7 @@ jobs: run: npm run build - name: Build Risk Engine - working-directory: lambdas/risk-engine + working-directory: packages/risk-engine run: npm run build - name: Build Collector @@ -142,10 +142,10 @@ jobs: tar -C dist -czf "dist/api-service_${version}.tar.gz" api-service # Risk Engine Lambda - mkdir -p dist/lambdas/risk-engine - cp -r lambdas/risk-engine/dist dist/lambdas/risk-engine/ - cp lambdas/risk-engine/package.json lambdas/risk-engine/package-lock.json dist/lambdas/risk-engine/ - tar -C dist -czf "dist/risk-engine_${version}.tar.gz" lambdas/risk-engine + mkdir -p dist/packages/risk-engine + cp -r packages/risk-engine/dist dist/packages/risk-engine/ + cp packages/risk-engine/package.json packages/risk-engine/package-lock.json dist/packages/risk-engine/ + tar -C dist -czf "dist/risk-engine_${version}.tar.gz" packages/risk-engine # Collector Lambda mkdir -p dist/lambdas/collector diff --git a/.gitignore b/.gitignore index c1d2c1b4..49c539af 100644 --- a/.gitignore +++ b/.gitignore @@ -46,8 +46,14 @@ temp/ # CloudWatch awslogs/ +# Local planning +plan.md + # Compiled JS — keep source .ts, ignore .js (but allow config) *.js +*.d.ts +*.d.ts.map +*.js.map !jest.config.js !jest.setup.js !prettier.config.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4e896226..79e81a2a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -3,60 +3,62 @@ ## Data Flow ``` - AWS Org (multi-account) - │ - ▼ - ┌──────────────────────────────────────────────┐ - │ Step Functions State Machine (every 2h) │ - │ + EventBridge → SQS → Incremental Updates │ - └─────────────┬──────────────────────────────┘ - ▼ - ┌──────────────────────────────────────────────┐ - │ Collector Lambda (per account, per region) │ - │ - STS AssumeRole → SecurityGraphCollectorRole│ - │ - 25+ AWS service APIs │ - │ - Tag ingestion via ResourceGroupsTaggingAPI │ - │ - Internet exposure derivation │ - │ - Cross-account trust analysis │ - └─────────────┬──────────────────────────────┘ - ▼ - ┌──────────────────────────────────────────────┐ - │ Graph Writer Lambda │ - │ → Neptune (Gremlin) │ - └─────────────┬──────────────────────────────┘ - ▼ - ┌──────────────────────────────────────────────┐ - │ Neptune Security Graph │ - │ - 50+ vertex labels │ - │ - 30+ edge labels │ - │ - Tag-derived properties (env, data_class) │ - └────────┬─────────────────────┬───────────────┘ - ▼ ▼ - ┌──────────────────────┐ ┌────────────────────────┐ - │ Risk Engine Lambda │ │ Compliance Evaluators │ - │ (every 1h) │ │ (CIS/SOC2/ISO27001) │ - │ - 10 Gremlin rules │ │ - 124 controls │ - │ - Risk scoring │ │ - DynamoDB persistence │ - └──────────┬───────────┘ └────────────┬───────────┘ - ▼ ▼ - ┌──────────────────────┐ ┌────────────────────────┐ - │ DynamoDB: │ │ DynamoDB: │ - │ SecurityIssues │ │ ComplianceEvidence │ - │ │ │ ComplianceReports │ - └──────────┬───────────┘ └────────────┬───────────┘ - ▼ ▼ - ┌─────────────────────────────────────────────────┐ - │ api-service (Express on EKS) │ - │ - JWT/Cognito auth (jose) │ - │ - RBAC: Viewer/Analyst/Admin via Cognito groups │ - │ - Gremlin query parameterization │ - └─────────────┬───────────────────────────────────┘ - ▼ - ┌─────────────────────────────────────────────────┐ - │ ui (Next.js) │ - │ - /issues, /attack-paths, /compliance/[framework]│ - │ - Cognito OIDC bearer tokens │ - └─────────────────────────────────────────────────┘ + AWS Org (multi-account) + │ + ▼ + ┌──────────────────────────────────────────────────────┐ + │ Step Functions State Machine (every 2h) │ + │ + EventBridge → SQS → Incremental Updates │ + └───────────────────────────┬──────────────────────────┘ + ▼ + ┌──────────────────────────────────────────────────────┐ + │ Collector Lambda (per account, per region) │ + │ - STS AssumeRole → SecurityGraphCollectorRole │ + │ - 30+ AWS service APIs │ + │ - Tag ingestion via ResourceGroupsTaggingAPI │ + │ - Internet exposure derivation │ + │ - Cross-account trust analysis │ + └───────────────────────────┬──────────────────────────┘ + ▼ + ┌──────────────────────────────────────────────────────┐ + │ Graph Writer Lambda │ + │ → Neptune (Gremlin) │ + └───────────────────────────┬──────────────────────────┘ + ▼ + ┌──────────────────────────────────────────────────────┐ + │ Neptune Security Graph │ + │ - 50+ vertex labels │ + │ - 30+ edge labels │ + │ - Tag-derived properties (env, data_class) │ + └────────────┬────────────────────────────┬────────────┘ + ▼ ▼ + ┌────────────────────────┐ ┌──────────────────────────┐ + │ Risk Engine Lambda │ │ Compliance Evaluators │ + │ (every 1h) │ │ (CIS/SOC2/ISO27001) │ + │ - 10 Gremlin rules │ │ - 117 controls │ + │ - Risk scoring │ │ - DynamoDB persistence │ + └────────────┬───────────┘ └─────────────┬────────────┘ + ▼ ▼ + ┌────────────────────────┐ ┌──────────────────────────┐ + │ DynamoDB: │ │ DynamoDB: │ + │ SecurityIssues │ │ ComplianceEvidence │ + │ │ │ ComplianceReports │ + └────────────────────────┘ └──────────────────────────┘ + │ │ + └─────────────┬──────────────┘ + ▼ + ┌──────────────────────────────────────────────────────┐ + │ api-service (Express on EKS) │ + │ - JWT/Cognito auth (jose) │ + │ - RBAC: Viewer/Analyst/Admin via Cognito groups │ + │ - Gremlin query parameterization │ + └──────────────────────────┬───────────────────────────┘ + ▼ + ┌──────────────────────────────────────────────────────┐ + │ ui (Next.js) │ + │ - /issues, /attack-paths, /compliance/[framework] │ + │ - Cognito OIDC bearer tokens │ + └──────────────────────────────────────────────────────┘ ``` ## Graph Schema @@ -71,7 +73,7 @@ | `Ec2Instance` | EC2 | `arn`, `account_id`, `instance_type`, `state`, `public_ip` | | `NetworkInterface` | EC2 | `arn`, `subnet_id`, `vpc_id` | | `SecurityGroup` | EC2 | `arn`, `vpc_id`, `is_world_open` | -| `SecurityGroupRule` | EC2 | `protocol`, `port_range`, `cidr_block` | +| `SecurityGroupRule` | EC2 | `protocol`, `port_from`, `port_to`, `port_range`, `cidr_block` | | `Vpc` | EC2 | `arn`, `cidr_block`, `flow_logs_enabled` | | `Subnet` | EC2 | `arn`, `vpc_id`, `is_public` | | `InternetGateway` | EC2 | `arn` | @@ -83,9 +85,11 @@ | `IamUser` | IAM | `arn`, `password_enabled`, `mfa_enabled` | | `IamRole` | IAM | `arn`, `has_cross_account_trust`, `is_admin_role` | | `IamPolicy` | IAM | `arn` | +| `IamPolicyDocument` | IAM | `arn`, `policy_arn`, `document_json`, `policy_type` | +| `IamPolicyStatement` | IAM | `arn`, `effect`, `actions`, `resources`, `has_wildcard_resource`, `has_wildcard_action` | | `AccountPasswordPolicy` | IAM | `min_password_length`, etc. | | `KmsKey` | KMS | `arn`, `key_state` | -| `RdsInstance` | RDS | `arn`, `publicly_accessible`, `storage_encrypted` | +| `RdsInstance` | RDS | `arn`, `publicly_accessible`, `is_publicly_accessible`, `storage_encrypted` | | `EksCluster` | EKS | `arn`, `version` | | `EcrRepository` | ECR | `arn`, `name` | | `ContainerImage` | ECR | `arn`, `digest`, `tag` | @@ -96,7 +100,7 @@ | `ConfigRule` | Config | `arn` | | `GuardDutyDetector` | GuardDuty | `arn`, `status` | | `AccessAnalyzer` | IAM Access Analyzer | `arn` | -| `LambdaFunction` | Lambda | `arn`, `runtime`, `role` | +| `LambdaFunction` | Lambda | `arn`, `runtime`, `role`, `is_in_vpc`, `has_internet_access` | | `LambdaAlias` | Lambda | `arn` | | `ApiGateway` | API Gateway | `arn`, `endpoint_type` | | `ApiGatewayStage` | API Gateway | `arn` | @@ -117,35 +121,41 @@ | Edge | Meaning | |------|---------| | `EXPOSES` | Internet → IGW/LB/ExternalAccount | -| `CONTAINS` | VPC → Subnet/Instance; Account → Resource | -| `ATTACHED_TO` | ENI → Instance; IGW → VPC | +| `CONTAINS` | VPC → Subnet/Instance; Account → Resource; Policy → Statement | +| `ATTACHED_TO` | ENI → Instance; IGW → VPC; IAMPrincipal → Policy | | `PROTECTS` | SecurityGroup → NetworkInterface | -| `PART_OF` | Rule → SecurityGroup | -| `ALLOWS_INGRESS` | SG rule → SG (legacy compatibility) | -| `HAS_IAM_ROLE` | Instance → IAMRole | -| `ALLOWS_ACCESS_TO` | IAMRole → Resource | -| `CONTAINS` | (also: Policy → Statement) | -| `TRUSTS` | IAMRole → ExternalAccount | +| `PART_OF` | SecurityGroupRule → SecurityGroup | +| `HAS_IAM_ROLE` | EC2/Lambda → IAMRole | +| `GRANTS` | IamPolicyStatement → Resource (non-wildcard) | +| `TRUSTS` | IAMRole → ExternalAccount/Principal | | `OWNS` | Account root → Resource | -| `HAS_FINDING` | Account → SecurityHub finding | +| `HAS_FINDING` | Account → SecurityHub/AccessAnalyzer finding | | `HAS_STATUS` | CloudTrail → CloudTrailStatus | | `IN_SUBNET` | LB/NatGW → Subnet | | `HAS_ENDPOINT` | VPC → VPC Endpoint | | `HAS_NACL` / `HAS_ROUTE_TABLE` | VPC → NACL/RouteTable | +| `MEMBER_OF` | IamUser → IamGroup | | `BELONGS_TO` | ContainerImage → EcrRepository | -| `RUNS_ON` | ContainerImage → workload (Phase 1) | +| `RUNS_ON` | ContainerImage → workload (not yet created) | +| `HAS_CVE` | ContainerImage → Vulnerability (not yet created) | | `HAS_ALIAS` / `HAS_STAGE` | Function → Alias/Stage | ### Risk Rule Properties (queried by Gremlin rules) | Property | Type | Source | |----------|------|--------| -| `is_internet_exposed` | boolean | Collector derivation | +| `is_internet_exposed` | boolean | Collector exposure derivation | +| `is_publicly_accessible` | boolean | S3/RDS direct fetch | +| `is_in_vpc` | boolean | Lambda VPC config | +| `has_internet_access` | boolean | Lambda VPC config (inverse) | | `crown_jewel` | boolean | `crown_jewel` tag | | `data_classification` | string | `data_classification` tag | | `env` | string | `env` or `environment` tag | -| `is_publicly_accessible` | boolean | S3/RDS direct fetch | | `has_cross_account_trust` | boolean | IAM trust analysis | +| `has_wildcard_resource` | boolean | IAM policy statement analysis | +| `has_wildcard_action` | boolean | IAM policy statement analysis | +| `port_from` / `port_to` | number | Security group rule | +| `flow_logs_enabled` | boolean | EC2 DescribeFlowLogs | ## Authentication diff --git a/api-service/package.json b/api-service/package.json index 48944b68..63c4150b 100644 --- a/api-service/package.json +++ b/api-service/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@aws-sdk/client-dynamodb": "^3.450.0", + "@aws-sdk/client-secrets-manager": "^3.1073.0", "@aws-sdk/lib-dynamodb": "^3.450.0", "@aws-sdk/util-dynamodb": "^3.450.0", "@khalifa/risk-engine": "*", diff --git a/api-service/src/app.ts b/api-service/src/app.ts index 24523c4c..b746c16d 100644 --- a/api-service/src/app.ts +++ b/api-service/src/app.ts @@ -26,6 +26,25 @@ const app = express(); app.use(express.json()); +app.use((_req: Request, res: Response, next: NextFunction) => { + const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const origin = _req.headers.origin; + if (origin && (allowedOrigins.length === 0 || allowedOrigins.includes(origin))) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + } + if (_req.method === 'OPTIONS') { + res.status(204).end(); + return; + } + next(); +}); + app.get('/health', (_req: Request, res: Response) => { res.json({ status: 'healthy', timestamp: new Date().toISOString() }); }); diff --git a/api-service/src/middleware/gremlin-validator.ts b/api-service/src/middleware/gremlin-validator.ts index 18740b71..a7d0f48d 100644 --- a/api-service/src/middleware/gremlin-validator.ts +++ b/api-service/src/middleware/gremlin-validator.ts @@ -1,11 +1,20 @@ import type { Request, Response, NextFunction } from 'express'; const LABEL_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/; +const ARN_PATTERN = /^arn:aws[a-z-]*:[a-z]+:[a-z0-9-]+:\d{12}:.+$|^\d{12}$|^\*$/; -const QUERY_PARAMS_TO_VALIDATE = ['fromSelector', 'toSelector', 'label', 'property']; +const LABEL_PARAMS = [ + 'fromSelector', + 'toSelector', + 'label', + 'property', + 'escalationType', + 'riskLevel', +]; +const ARN_PARAMS = ['principal', 'sourceAccount', 'targetRole']; export function validateGremlinSelectors(req: Request, res: Response, next: NextFunction): void { - for (const param of QUERY_PARAMS_TO_VALIDATE) { + for (const param of LABEL_PARAMS) { const value = req.query[param]; if (value === undefined) continue; const candidates = Array.isArray(value) ? value : [value]; @@ -20,6 +29,23 @@ export function validateGremlinSelectors(req: Request, res: Response, next: Next } } } + + for (const param of ARN_PARAMS) { + const value = req.query[param]; + if (value === undefined) continue; + const candidates = Array.isArray(value) ? value : [value]; + for (const candidate of candidates) { + if (typeof candidate !== 'string') continue; + if (!ARN_PATTERN.test(candidate)) { + res.status(400).json({ + code: 'INVALID_ARN_PARAM', + message: `Query parameter '${param}' must be a valid AWS ARN, account ID, or '*'`, + }); + return; + } + } + } + next(); } diff --git a/api-service/src/services/neptune-client-lite.ts b/api-service/src/services/neptune-client-lite.ts deleted file mode 100644 index f7db75e0..00000000 --- a/api-service/src/services/neptune-client-lite.ts +++ /dev/null @@ -1,59 +0,0 @@ -import Gremlin from 'gremlin'; - -export interface NeptuneConfig { - endpoint: string; - port?: number; - timeout?: number; -} - -export class NeptuneClient { - private client: Gremlin.driver.Client | null = null; - private config: NeptuneConfig; - private connected: boolean = false; - - constructor(config: NeptuneConfig) { - this.config = { - port: 8182, - timeout: 30000, - ...config, - }; - } - - async connect(): Promise { - if (this.connected && this.client) return; - const { endpoint, port, timeout } = this.config; - this.client = new Gremlin.driver.Client(`wss://${endpoint}:${port}/gremlin`, { - traversalSource: 'g', - connectTimeout: timeout, - messageMaxChunkSize: 65536, - poolSize: 10, - rejectionDecade: 100, - }); - await this.client.open(); - this.connected = true; - } - - async close(): Promise { - if (this.client) { - await this.client.close(); - this.client = null; - this.connected = false; - } - } - - async executeQuery(query: string, bindings: Record = {}): Promise { - if (!this.client) await this.connect(); - const client = this.client!; - const result = await client.submit(query, bindings); - return this.processResult(result); - } - - private processResult(result: unknown): unknown[] { - if (!result) return []; - if (Array.isArray(result)) return result; - if (typeof result === 'object' && result !== null && '_items' in result) { - return (result as { _items: unknown[] })._items; - } - return [result]; - } -} diff --git a/api-service/src/services/neptune-client.ts b/api-service/src/services/neptune-client.ts index 8c6787ae..1c998978 100644 --- a/api-service/src/services/neptune-client.ts +++ b/api-service/src/services/neptune-client.ts @@ -1,4 +1,5 @@ import Gremlin from 'gremlin'; +import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; import type { GraphVertex, GraphEdge } from '../types'; const DEFAULT_TIMEOUT = 30000; @@ -26,6 +27,7 @@ export interface NeptuneConfig { port?: number; timeout?: number; maxConcurrentQueries?: number; + authSecretArn?: string; } export class NeptuneClient { @@ -38,6 +40,7 @@ export class NeptuneClient { port: 8182, timeout: DEFAULT_TIMEOUT, maxConcurrentQueries: 10, + authSecretArn: process.env.NEPTUNE_AUTH_SECRET_ARN || undefined, ...config, }; } @@ -47,7 +50,15 @@ export class NeptuneClient { return; } - const { endpoint, port, timeout } = this.config; + const { endpoint, port, timeout, authSecretArn } = this.config; + + let credentials: { username?: string; password?: string } = {}; + if (authSecretArn) { + const smClient = new SecretsManagerClient({ region: process.env.AWS_REGION || 'us-east-1' }); + const response = await smClient.send(new GetSecretValueCommand({ SecretId: authSecretArn })); + const secret = JSON.parse(response.SecretString || '{}'); + credentials = { username: secret.username, password: secret.password }; + } this.client = new Gremlin.driver.Client(`wss://${endpoint}:${port}/gremlin`, { traversalSource: 'g', @@ -55,6 +66,7 @@ export class NeptuneClient { messageMaxChunkSize: 65536, poolSize: this.config.maxConcurrentQueries, rejectionDecade: 100, + ...credentials, }); await this.client.open(); @@ -306,8 +318,8 @@ export class NeptuneClient { } async getEffectivePermissions(principalArn: string): Promise | null> { - const query = `g.V().hasLabel('EffectivePermission').has('principal_arn', '${principalArn}').valueMap(true)`; - const results = await this.executeQuery(query); + const query = `g.V().hasLabel('EffectivePermission').has('principal_arn', principalArn).valueMap(true)`; + const results = await this.executeQuery(query, { principalArn }); if (results.length === 0) return null; return this.extractVertexFromResult(results[0] as NeptuneRawVertex).properties; } @@ -318,21 +330,29 @@ export class NeptuneClient { escalationType?: string; riskLevel?: string; }): Promise[]> { - let query = `g.V().hasLabel('EscalationPath')`; + const bindings: Record = {}; + const parts: string[] = [`g.V().hasLabel('EscalationPath')`]; + if (filters.escalationType) { - query += `.has('escalation_type', '${filters.escalationType}')`; + bindings.escalationType = filters.escalationType; + parts.push(`.has('escalation_type', escalationType)`); } if (filters.riskLevel) { - query += `.has('risk_level', '${filters.riskLevel}')`; + bindings.riskLevel = filters.riskLevel; + parts.push(`.has('risk_level', riskLevel)`); } if (filters.sourceAccount) { - query += `.has('source_arn', containing('${filters.sourceAccount}'))`; + bindings.sourceAccount = filters.sourceAccount; + parts.push(`.has('source_arn', containing(sourceAccount))`); } if (filters.targetRole) { - query += `.has('target_arn', '${filters.targetRole}')`; + bindings.targetRole = filters.targetRole; + parts.push(`.has('target_arn', targetRole)`); } - query += `.valueMap(true).limit(100)`; - const results = await this.executeQuery(query); + parts.push(`.valueMap(true).limit(100)`); + + const query = parts.join(''); + const results = await this.executeQuery(query, bindings); return results.map((r) => this.extractVertexFromResult(r as NeptuneRawVertex).properties); } diff --git a/cdk/bin/khalifa.ts b/cdk/bin/khalifa.ts index 8e5b8ece..5cec1012 100644 --- a/cdk/bin/khalifa.ts +++ b/cdk/bin/khalifa.ts @@ -2,6 +2,7 @@ import 'source-map-support/register'; import * as cdk from 'aws-cdk-lib'; import { SecurityGraphIngestionStack } from '../lib/khalifa-stack'; +import { SecurityGraphEksStack } from '../lib/eks-infrastructure'; const app = new cdk.App(); @@ -11,7 +12,7 @@ const accountIds = process.env.ACCOUNT_IDS .filter(Boolean) : [process.env.MASTER_ACCOUNT_ID || '123456789012']; -new SecurityGraphIngestionStack(app, 'SecurityGraphIngestionStack', { +const ingestionStack = new SecurityGraphIngestionStack(app, 'SecurityGraphIngestionStack', { env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.AWS_REGION || 'us-east-1', @@ -21,3 +22,20 @@ new SecurityGraphIngestionStack(app, 'SecurityGraphIngestionStack', { masterAccountId: process.env.MASTER_ACCOUNT_ID || '123456789012', accountIds, }); + +if (process.env.DEPLOY_EKS === 'true' && process.env.EKS_VPC_ID) { + new SecurityGraphEksStack(app, 'SecurityGraphEksStack', { + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.AWS_REGION || 'us-east-1', + }, + vpcId: process.env.EKS_VPC_ID, + neptuneEndpoint: process.env.NEPTUNE_ENDPOINT || 'neptune-cluster.us-east-1.amazonaws.com', + issuesTableName: ingestionStack.issuesTable.tableName, + evidenceTableName: ingestionStack.evidenceTable.tableName, + reportsTableName: ingestionStack.reportsTable.tableName, + certificateArn: process.env.EKS_CERTIFICATE_ARN || '', + cognitoUserPoolId: process.env.COGNITO_USER_POOL_ID || '', + cognitoClientId: process.env.COGNITO_CLIENT_ID || '', + }); +} diff --git a/cdk/lib/khalifa-stack.ts b/cdk/lib/khalifa-stack.ts index edcbd11c..b36a5c7d 100644 --- a/cdk/lib/khalifa-stack.ts +++ b/cdk/lib/khalifa-stack.ts @@ -18,6 +18,10 @@ export interface SecurityGraphIngestionStackProps extends cdk.StackProps { } export class SecurityGraphIngestionStack extends cdk.Stack { + public readonly issuesTable!: dynamodb.Table; + public readonly evidenceTable!: dynamodb.Table; + public readonly reportsTable!: dynamodb.Table; + constructor(scope: Construct, id: string, props: SecurityGraphIngestionStackProps) { super(scope, id, props); @@ -376,25 +380,25 @@ export class SecurityGraphIngestionStack extends cdk.Stack { targets: [new targets.LambdaFunction(incrementalProcessorFn)], }); - const issuesTable = new dynamodb.Table(this, 'SecurityIssuesTable', { + this.issuesTable = new dynamodb.Table(this, 'SecurityIssuesTable', { tableName: 'SecurityIssues', partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING }, sortKey: { name: 'ruleId', type: dynamodb.AttributeType.STRING }, billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, removalPolicy: cdk.RemovalPolicy.RETAIN, }); - issuesTable.addGlobalSecondaryIndex({ + this.issuesTable.addGlobalSecondaryIndex({ indexName: 'RuleIdIndex', partitionKey: { name: 'ruleId', type: dynamodb.AttributeType.STRING }, sortKey: { name: 'updatedAt', type: dynamodb.AttributeType.STRING }, }); - issuesTable.addGlobalSecondaryIndex({ + this.issuesTable.addGlobalSecondaryIndex({ indexName: 'StatusIndex', partitionKey: { name: 'status', type: dynamodb.AttributeType.STRING }, sortKey: { name: 'updatedAt', type: dynamodb.AttributeType.STRING }, }); - const evidenceTable = new dynamodb.Table(this, 'ComplianceEvidenceTable', { + this.evidenceTable = new dynamodb.Table(this, 'ComplianceEvidenceTable', { tableName: 'ComplianceEvidence', partitionKey: { name: 'controlId', type: dynamodb.AttributeType.STRING }, sortKey: { name: 'resourceId', type: dynamodb.AttributeType.STRING }, @@ -402,7 +406,7 @@ export class SecurityGraphIngestionStack extends cdk.Stack { removalPolicy: cdk.RemovalPolicy.RETAIN, }); - const reportsTable = new dynamodb.Table(this, 'ComplianceReportsTable', { + this.reportsTable = new dynamodb.Table(this, 'ComplianceReportsTable', { tableName: 'ComplianceReports', partitionKey: { name: 'framework', type: dynamodb.AttributeType.STRING }, sortKey: { name: 'generatedAt', type: dynamodb.AttributeType.STRING }, @@ -419,7 +423,7 @@ export class SecurityGraphIngestionStack extends cdk.Stack { memorySize: 512, environment: { NEPTUNE_ENDPOINT: neptuneEndpoint, - ISSUES_TABLE: issuesTable.tableName, + ISSUES_TABLE: this.issuesTable.tableName, NEPTUNE_AUTH_SECRET_ARN: neptuneSecret.secretArn, }, }); @@ -427,7 +431,7 @@ export class SecurityGraphIngestionStack extends cdk.Stack { new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ['dynamodb:PutItem', 'dynamodb:GetItem', 'dynamodb:UpdateItem', 'dynamodb:Query'], - resources: [issuesTable.tableArn, issuesTable.tableArn + '/index/*'], + resources: [this.issuesTable.tableArn, this.issuesTable.tableArn + '/index/*'], }) ); riskEngineFn.addToRolePolicy( @@ -441,7 +445,7 @@ export class SecurityGraphIngestionStack extends cdk.Stack { 'dynamodb:Scan', 'dynamodb:BatchWriteItem', ], - resources: [evidenceTable.tableArn, reportsTable.tableArn], + resources: [this.evidenceTable.tableArn, this.reportsTable.tableArn], }) ); diff --git a/lambdas/collector/index.ts b/lambdas/collector/index.ts index ff673500..bcbe9ccf 100644 --- a/lambdas/collector/index.ts +++ b/lambdas/collector/index.ts @@ -7,6 +7,7 @@ import { DescribeNetworkAclsCommand, DescribeRouteTablesCommand, DescribeTransitGatewaysCommand, + DescribeFlowLogsCommand, } from '@aws-sdk/client-ec2'; import { ResourceGroupsTaggingAPIClient } from '@aws-sdk/client-resource-groups-tagging-api'; import { ElasticLoadBalancingV2Client } from '@aws-sdk/client-elastic-load-balancing-v2'; @@ -188,6 +189,7 @@ export const handler = async ( RoleArn: roleArn, RoleSessionName: `SecurityGraphIngestion-${Date.now()}`, DurationSeconds: 3600, + ExternalId: process.env.EXTERNAL_ID || 'khalifa-collector', }) ); @@ -419,6 +421,14 @@ async function collectEc2(client: EC2Client, accountId: string, region: string) }); } + if (instance.IamInstanceProfile?.Arn) { + edges.push({ + from: instanceArn, + to: instance.IamInstanceProfile.Arn, + label: 'HAS_IAM_ROLE', + }); + } + for (const ni of instance.NetworkInterfaces || []) { const niArn = `arn:aws:ec2:${region}:${accountId}:network-interface/${ni.NetworkInterfaceId}`; nodes.push({ @@ -441,7 +451,7 @@ async function collectEc2(client: EC2Client, accountId: string, region: string) for (const sg of ni.Groups || []) { edges.push({ - from: `arn:aws:ec2:${sg.GroupId}:${sg.GroupId}`, + from: `arn:aws:ec2:${region}:${accountId}:security-group/${sg.GroupId}`, to: niArn, label: 'PROTECTS', }); @@ -559,7 +569,9 @@ async function collectIam(client: IAMClient, accountId: string) { edges.push({ from: userArn, to: policyArn, label: 'ATTACHED_TO' }); addStatementNodeAndEdges(nodes, edges, policyArn, documentJson, accountId); } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } try { const attached = await client.send( @@ -578,7 +590,9 @@ async function collectIam(client: IAMClient, accountId: string) { ); } } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } try { const groups = await client.send( @@ -590,7 +604,9 @@ async function collectIam(client: IAMClient, accountId: string) { const groupArn = `arn:aws:iam::${accountId}:group/${group.GroupName}`; edges.push({ from: userArn, to: groupArn, label: 'MEMBER_OF' }); } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } } const roles = await client.send(new ListRolesCommand({})); @@ -650,7 +666,9 @@ async function collectIam(client: IAMClient, accountId: string) { edges.push({ from: roleArn, to: policyArn, label: 'ATTACHED_TO' }); addStatementNodeAndEdges(nodes, edges, policyArn, documentJson, accountId); } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } try { const attached = await client.send( @@ -669,7 +687,9 @@ async function collectIam(client: IAMClient, accountId: string) { ); } } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } if (role.PermissionsBoundary?.PermissionsBoundaryArn) { edges.push({ @@ -724,7 +744,9 @@ async function collectIam(client: IAMClient, accountId: string) { edges.push({ from: groupArn, to: policyArn, label: 'ATTACHED_TO' }); addStatementNodeAndEdges(nodes, edges, policyArn, documentJson, accountId); } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } try { const attached = await client.send( @@ -743,7 +765,9 @@ async function collectIam(client: IAMClient, accountId: string) { ); } } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } } const policies = await client.send(new ListPoliciesCommand({ Scope: 'Local' })); @@ -816,7 +840,9 @@ async function collectIam(client: IAMClient, accountId: string) { label: 'HAS_POLICY', }); } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } return { nodes, edges }; } @@ -861,7 +887,9 @@ async function cacheManagedPolicyDocument( addStatementNodeAndEdges(nodes, edges, docNodeArn, documentJson, accountId); cache.set(policyArn, documentJson); - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } } function addStatementNodeAndEdges( @@ -895,6 +923,9 @@ function addStatementNodeAndEdges( ? [stmt.NotResource] : undefined; + const hasWildcardResource = resources.includes('*'); + const hasWildcardAction = actions.some((a: any) => a === '*' || a === '*:*'); + nodes.push({ id: stmtArn, label: 'IamPolicyStatement', @@ -907,6 +938,8 @@ function addStatementNodeAndEdges( conditions_json: conditions ? JSON.stringify(conditions) : undefined, not_actions: notActions, not_resources: notResources, + has_wildcard_resource: hasWildcardResource, + has_wildcard_action: hasWildcardAction, account_id: accountId, }, }); @@ -918,7 +951,9 @@ function addStatementNodeAndEdges( } } }); - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } } function parseTrustPolicy( @@ -962,12 +997,14 @@ function parseTrustPolicy( } } }); - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } } function isCrossAccountPrincipal(principal: string, accountId: string): boolean { if (principal === '*') return true; - const match = principal.match(/^arn:aws:iam::(\d+)/); + const match = principal.match(/^arn:aws(?:-us-gov|-cn)?:iam::(\d+)/); if (match) return match[1] !== accountId; return false; } @@ -995,7 +1032,9 @@ async function collectKms(client: KMSClient, accountId: string) { }); edges.push({ from: `arn:aws:iam::${accountId}:root`, to: kmd.Arn!, label: 'OWNS' }); } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } } return { nodes, edges }; @@ -1018,6 +1057,7 @@ async function collectRds(client: RDSClient, accountId: string, region: string) engine: db.Engine, instance_class: db.DBInstanceClass, publicly_accessible: db.PubliclyAccessible, + is_publicly_accessible: db.PubliclyAccessible, storage_encrypted: db.StorageEncrypted, backup_retention_period: db.BackupRetentionPeriod, deletion_protection: db.DeletionProtection, @@ -1053,7 +1093,9 @@ async function collectEks(client: EKSClient, accountId: string, _region: string) }); edges.push({ from: `arn:aws:iam::${accountId}:root`, to: clusterArn, label: 'OWNS' }); } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } } return { nodes, edges }; @@ -1064,28 +1106,37 @@ async function collectSecurityHub(client: SecurityHubClient, accountId: string, const edges: GraphEdge[] = []; try { - const findings = await client.send( - new GetFindingsCommand({ - Filters: { RecordState: [{ Value: 'ACTIVE', Comparison: 'EQUALS' }] }, - MaxResults: 100, - }) - ); - for (const f of findings.Findings || []) { - const findingArn = `arn:aws:securityhub:${f.Region || 'us-east-1'}:${accountId}:finding/${f.Id}`; - nodes.push({ - id: findingArn, - label: 'Finding', - properties: { - id: f.Id, - arn: findingArn, - account_id: accountId, - title: f.Title, - severity: f.Severity?.Label, - status: f.RecordState, - }, - }); - edges.push({ from: `arn:aws:iam::${accountId}:root`, to: findingArn, label: 'HAS_FINDING' }); - } + let nextToken: string | undefined; + do { + const findings = await client.send( + new GetFindingsCommand({ + Filters: { RecordState: [{ Value: 'ACTIVE', Comparison: 'EQUALS' }] }, + MaxResults: 100, + NextToken: nextToken, + }) + ); + for (const f of findings.Findings || []) { + const findingArn = `arn:aws:securityhub:${f.Region || 'us-east-1'}:${accountId}:finding/${f.Id}`; + nodes.push({ + id: findingArn, + label: 'Finding', + properties: { + id: f.Id, + arn: findingArn, + account_id: accountId, + title: f.Title, + severity: f.Severity?.Label, + status: f.RecordState, + }, + }); + edges.push({ + from: `arn:aws:iam::${accountId}:root`, + to: findingArn, + label: 'HAS_FINDING', + }); + } + nextToken = findings.NextToken; + } while (nextToken); } catch (e) { console.error('SecurityHub collection error:', e); } @@ -1134,7 +1185,9 @@ async function collectCloudTrail(client: CloudTrailClient, accountId: string, re }, }); edges.push({ from: trailArn, to: `${trailArn}/status`, label: 'HAS_STATUS' }); - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } } } catch (e) { console.error('CloudTrail collection error:', e); @@ -1272,7 +1325,9 @@ async function collectAccessAnalyzer( }); edges.push({ from: analyzerArn, to: findingArn, label: 'HAS_FINDING' }); } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } } } catch (e) { console.error('Access Analyzer collection error:', e); @@ -1294,6 +1349,19 @@ async function collectNetwork( const vpcs = await ec2Client.send(new DescribeVpcsCommand({})); for (const vpc of vpcs.Vpcs || []) { const vpcArn = `arn:aws:ec2:${region}:${accountId}:vpc/${vpc.VpcId}`; + + let flowLogsEnabled = false; + try { + const flowLogs = await ec2Client.send( + new DescribeFlowLogsCommand({ + Filter: [{ Name: 'vpc-id', Values: [vpc.VpcId!] }], + }) + ); + flowLogsEnabled = (flowLogs.FlowLogs || []).length > 0; + } catch (e) { + logger.warn('Failed to query flow logs', { vpcId: vpc.VpcId, error: String(e) }); + } + nodes.push({ id: vpcArn, label: 'Vpc', @@ -1304,7 +1372,7 @@ async function collectNetwork( cidr_block: vpc.CidrBlock, state: vpc.State, is_default: vpc.IsDefault, - flow_logs_enabled: false, + flow_logs_enabled: flowLogsEnabled, }, }); edges.push({ from: `arn:aws:iam::${accountId}:root`, to: vpcArn, label: 'OWNS' }); @@ -1332,7 +1400,9 @@ async function collectNetwork( }); edges.push({ from: vpcArn, to: epArn, label: 'HAS_ENDPOINT' }); } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } try { const acls = await ec2Client.send( @@ -1356,7 +1426,9 @@ async function collectNetwork( }); edges.push({ from: vpcArn, to: aclArn, label: 'HAS_NACL' }); } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } try { const rtbs = await ec2Client.send( @@ -1380,7 +1452,9 @@ async function collectNetwork( }); edges.push({ from: vpcArn, to: rtbArn, label: 'HAS_ROUTE_TABLE' }); } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } } const tgws = await ec2Client.send(new DescribeTransitGatewaysCommand({})); @@ -1471,7 +1545,9 @@ async function collectServerless( }); edges.push({ from: apiArn, to: stageArn, label: 'HAS_STAGE' }); } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } } } catch (e) { console.error('API Gateway collection error:', e); @@ -1497,11 +1573,17 @@ async function collectServerless( timeout: fn.Timeout, memory_size: fn.MemorySize, vpc_config: fn.VpcConfig, + is_in_vpc: !!(fn.VpcConfig?.SubnetIds && fn.VpcConfig.SubnetIds.length > 0), + has_internet_access: !(fn.VpcConfig?.SubnetIds && fn.VpcConfig.SubnetIds.length > 0), last_modified: fn.LastModified, }, }); edges.push({ from: `arn:aws:iam::${accountId}:root`, to: fnArn, label: 'OWNS' }); + if (fn.Role) { + edges.push({ from: fnArn, to: fn.Role, label: 'HAS_IAM_ROLE' }); + } + try { const aliases = await lambdaClient.send( new ListAliasesCommand({ FunctionName: fn.FunctionName! }) @@ -1522,7 +1604,9 @@ async function collectServerless( }); edges.push({ from: fnArn, to: aliasArn, label: 'HAS_ALIAS' }); } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } } } catch (e) { console.error('Lambda collection error:', e); @@ -1613,7 +1697,9 @@ async function collectDataStores( }); edges.push({ from: `arn:aws:iam::${accountId}:root`, to: tableArn, label: 'OWNS' }); } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } } } catch (e) { console.error('DynamoDB collection error:', e); @@ -1676,7 +1762,9 @@ async function collectDataStores( }); edges.push({ from: `arn:aws:iam::${accountId}:root`, to: domainArn, label: 'OWNS' }); } - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } } } catch (e) { console.error('OpenSearch collection error:', e); @@ -1748,7 +1836,9 @@ async function collectSecrets( }, }); edges.push({ from: `arn:aws:iam::${accountId}:root`, to: secretArn, label: 'OWNS' }); - } catch (e) {} + } catch (e) { + logger.warn('Operation failed', { error: String(e) }); + } } } catch (e) { console.error('Secrets Manager collection error:', e); diff --git a/lambdas/collector/src/network.ts b/lambdas/collector/src/network.ts index bcf92ea2..deb00f97 100644 --- a/lambdas/collector/src/network.ts +++ b/lambdas/collector/src/network.ts @@ -83,6 +83,8 @@ export async function collectSecurityGroups( account_id: accountId, direction: 'ingress', protocol: perm.IpProtocol, + port_from: perm.FromPort ?? 0, + port_to: perm.ToPort ?? 0, port_range: `${perm.FromPort || 0}-${perm.ToPort || 0}`, cidr_block: range.CidrIp, }, diff --git a/lambdas/graph-writer/index.ts b/lambdas/graph-writer/index.ts index 09710cec..3b96b8db 100644 --- a/lambdas/graph-writer/index.ts +++ b/lambdas/graph-writer/index.ts @@ -65,36 +65,58 @@ export const handler = async ( async function writeNodesBatch(client: Gremlin.driver.Client, nodes: GraphNode[]): Promise { const statements: string[] = []; + const bindings: Record = {}; + let idx = 0; for (const node of nodes) { - const props = Object.entries(node.properties) - .filter(([_, v]) => v !== undefined && v !== null) - .map(([k, v]) => { - const val = typeof v === 'string' ? `'${v.replace(/'/g, "\\'")}'` : JSON.stringify(v); - return `${k}: ${val}`; - }) - .join(', '); + const lblKey = `lbl_${idx}`; + const arnKey = `arn_${idx}`; + bindings[lblKey] = node.label; + bindings[arnKey] = node.id; + + const propParts: string[] = []; + let propIdx = 0; + for (const [k, v] of Object.entries(node.properties)) { + if (v === undefined || v === null) continue; + const pk = `pk_${idx}_${propIdx}`; + const pv = `pv_${idx}_${propIdx}`; + bindings[pk] = k; + bindings[pv] = v; + propParts.push(`property(${pk}, ${pv})`); + propIdx++; + } + const propsClause = propParts.length > 0 ? '.' + propParts.join('.') : ''; statements.push( - `g.V().has('${node.label}', 'arn', '${node.id}').fold().coalesce(` + - `unfold(), addV('${node.label}').property('arn', '${node.id}').property(${props})).next()` + `g.V().has(${lblKey}, 'arn', ${arnKey}).fold().coalesce(unfold(), addV(${lblKey}).property('arn', ${arnKey})${propsClause}).next()` ); + idx++; } const script = statements.join('\n'); - await client.submit(script); + await client.submit(script, bindings); } async function writeEdgesBatch(client: Gremlin.driver.Client, edges: GraphEdge[]): Promise { const statements: string[] = []; + const bindings: Record = {}; + let idx = 0; for (const edge of edges) { + const fromKey = `ef_${idx}`; + const toKey = `et_${idx}`; + const lblKey = `el_${idx}`; + bindings[fromKey] = edge.from; + bindings[toKey] = edge.to; + bindings[lblKey] = edge.label; + statements.push( - `g.V().has('arn', '${edge.from}').as('from').V().has('arn', '${edge.to}').as('to').coalesce(` + - `__.has('from').out('${edge.label}').has('to'), __.addE('${edge.label}').from('from').to('to')).next()` + `g.V().has('arn', ${fromKey}).as('from').V().has('arn', ${toKey}).as('to').coalesce(` + + `__.select('from').out(${lblKey}).where(__.as('to')), __.addE(${lblKey}).from('from').to('to')).next()` ); + idx++; } const script = statements.join('\n'); - await client.submit(script); + await client.submit(script, bindings); } diff --git a/lambdas/incremental-collector/index.ts b/lambdas/incremental-collector/index.ts index a135500d..77fe0cb1 100644 --- a/lambdas/incremental-collector/index.ts +++ b/lambdas/incremental-collector/index.ts @@ -7,8 +7,8 @@ import { Logger } from '../shared/types'; import { getSecret } from '../shared/secrets-client'; const logger = new Logger('incremental-processor'); -const sqsClient = new SQSClient({ region: 'us-east-1' }); -const stsClient = new STSClient({ region: 'us-east-1' }); +const sqsClient = new SQSClient({ region: process.env.AWS_REGION || 'us-east-1' }); +const stsClient = new STSClient({ region: process.env.AWS_REGION || 'us-east-1' }); export const handler = async (): Promise<{ processed: number }> => { const queueUrl = process.env.SQS_QUEUE_URL || ''; @@ -72,7 +72,7 @@ async function processEvent( return { nodes, edges }; } - const region = 'us-east-1'; + const region = detail.awsRegion || process.env.AWS_REGION || 'us-east-1'; const isMaster = accountId === masterAccountId; let credentials: any; @@ -83,6 +83,7 @@ async function processEvent( RoleArn: `arn:aws:iam::${accountId}:role/SecurityGraphCollectorRole`, RoleSessionName: `SecurityGraphInc-${Date.now()}`, DurationSeconds: 900, + ExternalId: process.env.EXTERNAL_ID || 'khalifa-collector', }) ) .then((res) => res.Credentials); @@ -155,19 +156,27 @@ async function writeToNeptune(data: { nodes: GraphNode[]; edges: GraphEdge[] }): try { for (const node of data.nodes) { - const props = Object.entries(node.properties) - .filter(([_, v]) => v !== undefined) - .map(([k, v]) => `${k}: ${typeof v === 'string' ? `'${v}'` : JSON.stringify(v)}`) - .join(', '); - + const bindings: Record = { lbl: node.label, arn: node.id }; + const propParts: string[] = []; + let pi = 0; + for (const [k, v] of Object.entries(node.properties)) { + if (v === undefined) continue; + bindings[`pk_${pi}`] = k; + bindings[`pv_${pi}`] = v; + propParts.push(`property(pk_${pi}, pv_${pi})`); + pi++; + } + const propsClause = propParts.length > 0 ? '.' + propParts.join('.') : ''; await client.submit( - `g.V().has('${node.label}', 'arn', '${node.id}').fold().coalesce(unfold(), addV('${node.label}').property('arn', '${node.id}').property(${props})).next()` + `g.V().has(lbl, 'arn', arn).fold().coalesce(unfold(), addV(lbl).property('arn', arn)${propsClause}).next()`, + bindings ); } for (const edge of data.edges) { await client.submit( - `g.V().has('arn', '${edge.from}').as('from').V().has('arn', '${edge.to}').as('to').coalesce(__.has('from').out('${edge.label}').has('to'), __.addE('${edge.label}').from('from').to('to')).next()` + `g.V().has('arn', ef).as('from').V().has('arn', et).as('to').coalesce(__.select('from').out(el).where(__.as('to')), __.addE(el).from('from').to('to')).next()`, + { ef: edge.from, et: edge.to, el: edge.label } ); } } finally { diff --git a/lambdas/policy-evaluator/index.ts b/lambdas/policy-evaluator/index.ts index 796908ac..df348f9a 100644 --- a/lambdas/policy-evaluator/index.ts +++ b/lambdas/policy-evaluator/index.ts @@ -96,13 +96,13 @@ async function fetchPoliciesForPrincipal( principalArn: string ): Promise<{ policyArn: string; documentJson: string }[]> { const query = ` - g.V().has('arn', '${principalArn}') + g.V().has('arn', principalArn) .out('ATTACHED_TO').hasLabel('IamPolicyDocument') .project('policyArn', 'documentJson') .by('policy_arn') .by('document_json') `; - const results = await gremlinClient.submit(query); + const results = await gremlinClient.submit(query, { principalArn }); return results.toArray().map((item: any) => ({ policyArn: item.get('policyArn') || '', documentJson: item.get('documentJson') || '{}', @@ -159,125 +159,83 @@ async function writeEffectivePermission( gremlinClient: Gremlin.driver.Client, perm: EffectivePermission ): Promise { - const escapedActions = JSON.stringify(perm.allowedActions).replace(/'/g, "\\'"); - const escapedDenied = JSON.stringify(perm.deniedActions).replace(/'/g, "\\'"); - const escapedPolicies = JSON.stringify(perm.policiesEvaluated).replace(/'/g, "\\'"); + const actionsJson = JSON.stringify(perm.allowedActions); + const deniedJson = JSON.stringify(perm.deniedActions); + const policiesJson = JSON.stringify(perm.policiesEvaluated); + + const bindings = { + id: perm.id, + arn: perm.id, + principal_arn: perm.principalArn, + allowed_actions: actionsJson, + denied_actions: deniedJson, + is_admin: perm.isAdmin, + blast_radius: perm.blastRadius, + evaluated_at: perm.evaluatedAt, + policies_evaluated: policiesJson, + }; - const updateQuery = - "g.V().has('EffectivePermission', 'principal_arn', '" + - perm.principalArn + - "').fold()" + + const query = + "g.V().has('EffectivePermission', 'principal_arn', principal_arn).fold()" + '.coalesce(unfold()' + - ".property('allowed_actions', '" + - escapedActions + - "')" + - ".property('denied_actions', '" + - escapedDenied + - "')" + - ".property('is_admin', " + - perm.isAdmin + - ')' + - ".property('blast_radius', " + - perm.blastRadius + - ')' + - ".property('evaluated_at', '" + - perm.evaluatedAt + - "')" + - ".property('policies_evaluated', '" + - escapedPolicies + - "'), " + - "addV('EffectivePermission')" + - ".property('id', '" + - perm.id + - "')" + - ".property('arn', '" + - perm.id + - "')" + - ".property('principal_arn', '" + - perm.principalArn + - "')" + - ".property('allowed_actions', '" + - escapedActions + - "')" + - ".property('denied_actions', '" + - escapedDenied + - "')" + - ".property('is_admin', " + - perm.isAdmin + - ')' + - ".property('blast_radius', " + - perm.blastRadius + - ')' + - ".property('evaluated_at', '" + - perm.evaluatedAt + - "')" + - ".property('policies_evaluated', '" + - escapedPolicies + - "')).next()"; - - await gremlinClient.submit(updateQuery); + ".property('allowed_actions', allowed_actions)" + + ".property('denied_actions', denied_actions)" + + ".property('is_admin', is_admin)" + + ".property('blast_radius', blast_radius)" + + ".property('evaluated_at', evaluated_at)" + + ".property('policies_evaluated', policies_evaluated)" + + ", addV('EffectivePermission')" + + ".property('id', id)" + + ".property('arn', arn)" + + ".property('principal_arn', principal_arn)" + + ".property('allowed_actions', allowed_actions)" + + ".property('denied_actions', denied_actions)" + + ".property('is_admin', is_admin)" + + ".property('blast_radius', blast_radius)" + + ".property('evaluated_at', evaluated_at)" + + ".property('policies_evaluated', policies_evaluated)).next()"; + + await gremlinClient.submit(query, bindings); } async function writeEscalationPath( gremlinClient: Gremlin.driver.Client, path: EscalationPath ): Promise { - const conditionsJson = JSON.stringify(path.conditions).replace(/'/g, "\\'"); + const conditionsJson = JSON.stringify(path.conditions); + + const bindings = { + id: path.id, + arn: path.id, + source_arn: path.sourceArn, + target_arn: path.targetArn, + path_length: path.pathLength, + risk_level: path.riskLevel, + escalation_type: path.escalationType, + conditions_json: conditionsJson, + detected_at: path.detectedAt, + }; const query = - "g.V().has('EscalationPath', 'id', '" + - path.id + - "').fold()" + + "g.V().has('EscalationPath', 'id', id).fold()" + '.coalesce(unfold()' + - ".property('source_arn', '" + - path.sourceArn + - "')" + - ".property('target_arn', '" + - path.targetArn + - "')" + - ".property('path_length', " + - path.pathLength + - ')' + - ".property('risk_level', '" + - path.riskLevel + - "')" + - ".property('escalation_type', '" + - path.escalationType + - "')" + - ".property('conditions_json', '" + - conditionsJson + - "')" + - ".property('detected_at', '" + - path.detectedAt + - "'), " + - "addV('EscalationPath')" + - ".property('id', '" + - path.id + - "')" + - ".property('arn', '" + - path.id + - "')" + - ".property('source_arn', '" + - path.sourceArn + - "')" + - ".property('target_arn', '" + - path.targetArn + - "')" + - ".property('path_length', " + - path.pathLength + - ')' + - ".property('risk_level', '" + - path.riskLevel + - "')" + - ".property('escalation_type', '" + - path.escalationType + - "')" + - ".property('conditions_json', '" + - conditionsJson + - "')" + - ".property('detected_at', '" + - path.detectedAt + - "')).next()"; - - await gremlinClient.submit(query); + ".property('source_arn', source_arn)" + + ".property('target_arn', target_arn)" + + ".property('path_length', path_length)" + + ".property('risk_level', risk_level)" + + ".property('escalation_type', escalation_type)" + + ".property('conditions_json', conditions_json)" + + ".property('detected_at', detected_at)" + + ", addV('EscalationPath')" + + ".property('id', id)" + + ".property('arn', arn)" + + ".property('source_arn', source_arn)" + + ".property('target_arn', target_arn)" + + ".property('path_length', path_length)" + + ".property('risk_level', risk_level)" + + ".property('escalation_type', escalation_type)" + + ".property('conditions_json', conditions_json)" + + ".property('detected_at', detected_at)).next()"; + + await gremlinClient.submit(query, bindings); } diff --git a/lambdas/shared/secrets-client.d.ts b/lambdas/shared/secrets-client.d.ts deleted file mode 100644 index 4ac19b84..00000000 --- a/lambdas/shared/secrets-client.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -export declare function getSecret(secretArn: string): Promise<{ - username: string; - password: string; -}>; -//# sourceMappingURL=secrets-client.d.ts.map \ No newline at end of file diff --git a/lambdas/shared/secrets-client.d.ts.map b/lambdas/shared/secrets-client.d.ts.map deleted file mode 100644 index e115995e..00000000 --- a/lambdas/shared/secrets-client.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"secrets-client.d.ts","sourceRoot":"","sources":["secrets-client.ts"],"names":[],"mappings":"AAIA,wBAAsB,SAAS,CAC7B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAIjD"} \ No newline at end of file diff --git a/lambdas/shared/secrets-client.js.map b/lambdas/shared/secrets-client.js.map deleted file mode 100644 index d69a01db..00000000 --- a/lambdas/shared/secrets-client.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"secrets-client.js","sourceRoot":"","sources":["secrets-client.ts"],"names":[],"mappings":";;AAIA,8BAMC;AAVD,4EAA8F;AAE9F,MAAM,QAAQ,GAAG,IAAI,6CAAoB,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC,CAAC;AAE5D,KAAK,UAAU,SAAS,CAC7B,SAAiB;IAEjB,MAAM,OAAO,GAAG,IAAI,8CAAqB,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC,CAAC;IACnE,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC9C,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,YAAY,IAAI,IAAI,CAAC,CAAC;AACnD,CAAC"} \ No newline at end of file diff --git a/lambdas/shared/secrets-client.ts b/lambdas/shared/secrets-client.ts index 509dd5c4..fdcee5b3 100644 --- a/lambdas/shared/secrets-client.ts +++ b/lambdas/shared/secrets-client.ts @@ -1,6 +1,6 @@ import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; -const smClient = new SecretsManagerClient({ region: 'us-east-1' }); +const smClient = new SecretsManagerClient({ region: process.env.AWS_REGION || 'us-east-1' }); export async function getSecret( secretArn: string diff --git a/lambdas/shared/types.d.ts b/lambdas/shared/types.d.ts deleted file mode 100644 index 538a7bec..00000000 --- a/lambdas/shared/types.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface GraphNode { - id: string; - label: string; - properties: Record; -} -export interface GraphEdge { - from: string; - to: string; - label: string; - properties?: Record; -} -export declare class Logger { - private context; - constructor(context: string); - info(message: string, meta?: Record): void; - error(message: string, meta?: Record): void; - warn(message: string, meta?: Record): void; -} -export interface SecretsClient { - username: string; - password: string; -} -export declare function getSecret(secretArn: string): Promise; -//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/lambdas/shared/types.d.ts.map b/lambdas/shared/types.d.ts.map deleted file mode 100644 index 409d5c4d..00000000 --- a/lambdas/shared/types.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAClC;AAED,qBAAa,MAAM;IACL,OAAO,CAAC,OAAO;gBAAP,OAAO,EAAE,MAAM;IAEnC,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;IAYhD,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;IAYjD,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;CAWjD;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,wBAAsB,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAEzE"} \ No newline at end of file diff --git a/lambdas/shared/types.js.map b/lambdas/shared/types.js.map deleted file mode 100644 index aa4d98ff..00000000 --- a/lambdas/shared/types.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":";;;AA0DA,8BAEC;AA/CD,MAAa,MAAM;IACjB,YAAoB,OAAe;QAAf,YAAO,GAAP,OAAO,CAAQ;IAAG,CAAC;IAEvC,IAAI,CAAC,OAAe,EAAE,IAA0B;QAC9C,OAAO,CAAC,GAAG,CACT,IAAI,CAAC,SAAS,CAAC;YACb,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;YACnC,KAAK,EAAE,MAAM;YACb,OAAO,EAAE,IAAI,CAAC,OAAO;YACrB,OAAO;YACP,GAAG,IAAI;SACR,CAAC,CACH,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,OAAe,EAAE,IAA0B;QAC/C,OAAO,CAAC,KAAK,CACX,IAAI,CAAC,SAAS,CAAC;YACb,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;YACnC,KAAK,EAAE,OAAO;YACd,OAAO,EAAE,IAAI,CAAC,OAAO;YACrB,OAAO;YACP,GAAG,IAAI;SACR,CAAC,CACH,CAAC;IACJ,CAAC;IAED,IAAI,CAAC,OAAe,EAAE,IAA0B;QAC9C,OAAO,CAAC,IAAI,CACV,IAAI,CAAC,SAAS,CAAC;YACb,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;YACnC,KAAK,EAAE,MAAM;YACb,OAAO,EAAE,IAAI,CAAC,OAAO;YACrB,OAAO;YACP,GAAG,IAAI;SACR,CAAC,CACH,CAAC;IACJ,CAAC;CACF;AAtCD,wBAsCC;AAOM,KAAK,UAAU,SAAS,CAAC,SAAiB;IAC/C,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;AAChD,CAAC"} \ No newline at end of file diff --git a/lambdas/shared/types.ts b/lambdas/shared/types.ts index 93e3c110..bed3eda3 100644 --- a/lambdas/shared/types.ts +++ b/lambdas/shared/types.ts @@ -55,7 +55,3 @@ export interface SecretsClient { username: string; password: string; } - -export async function getSecret(secretArn: string): Promise { - return { username: 'user', password: 'pass' }; -} diff --git a/package-lock.json b/package-lock.json index 4e25640d..d28ea17a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "version": "1.0.0", "dependencies": { "@aws-sdk/client-dynamodb": "^3.450.0", + "@aws-sdk/client-secrets-manager": "^3.1073.0", "@aws-sdk/lib-dynamodb": "^3.450.0", "@aws-sdk/util-dynamodb": "^3.450.0", "@khalifa/risk-engine": "*", @@ -1371,25 +1372,6 @@ "node": ">=20.0.0" } }, - "lambdas/collector/node_modules/@aws-sdk/client-secrets-manager": { - "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-securityhub": { "version": "3.1072.0", "license": "Apache-2.0", @@ -1625,25 +1607,6 @@ "@aws-sdk/client-secrets-manager": "^3.400.0" } }, - "lambdas/shared/node_modules/@aws-sdk/client-secrets-manager": { - "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" - } - }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -2011,6 +1974,26 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-secrets-manager": { + "version": "3.1073.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1073.0.tgz", + "integrity": "sha512-Qfey4X2/DtP6k6GrOa6YVByWNt26ZUxEm/GSg91LDYHWmbprF2GKflLuDxqw5why94MF5aYE40laWGgWYl9zrg==", + "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" + } + }, "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", diff --git a/packages/risk-engine/src/compliance-engine.ts b/packages/risk-engine/src/compliance-engine.ts index b636325c..4e1287dd 100644 --- a/packages/risk-engine/src/compliance-engine.ts +++ b/packages/risk-engine/src/compliance-engine.ts @@ -215,7 +215,6 @@ export class DynamoDBEvidenceStore implements EvidenceStore { async saveEvidence(evidence: ComplianceEvidence[]): Promise { if (evidence.length === 0) return; - const client = new DynamoDBClient({ region: process.env.AWS_REGION || 'us-east-1' }); const items = evidence.map((e) => ({ PutRequest: { Item: marshall({ @@ -226,7 +225,7 @@ export class DynamoDBEvidenceStore implements EvidenceStore { })); for (let i = 0; i < items.length; i += 25) { - await client.send( + await this.docClient.send( new BatchWriteItemCommand({ RequestItems: { [this.tableName]: items.slice(i, i + 25), @@ -237,8 +236,7 @@ export class DynamoDBEvidenceStore implements EvidenceStore { } async getEvidence(controlId: string): Promise { - const client = new DynamoDBClient({ region: process.env.AWS_REGION || 'us-east-1' }); - const result = await client.send( + const result = await this.docClient.send( new QueryCommand({ TableName: this.tableName, KeyConditionExpression: 'controlId = :controlId', @@ -252,8 +250,7 @@ export class DynamoDBEvidenceStore implements EvidenceStore { } async getAllEvidence(): Promise { - const client = new DynamoDBClient({ region: process.env.AWS_REGION || 'us-east-1' }); - const result = await client.send(new ScanCommand({ TableName: this.tableName })); + const result = await this.docClient.send(new ScanCommand({ TableName: this.tableName })); if (!result.Items) return []; return result.Items.map((item) => unmarshall(item) as ComplianceEvidence); } @@ -285,8 +282,7 @@ export class DynamoDBReportStore implements ReportStore { } async getLatestReport(framework: ComplianceFramework): Promise { - const client = new DynamoDBClient({ region: process.env.AWS_REGION || 'us-east-1' }); - const result = await client.send( + const result = await this.docClient.send( new QueryCommand({ TableName: this.tableName, KeyConditionExpression: 'framework = :framework', @@ -303,8 +299,7 @@ export class DynamoDBReportStore implements ReportStore { } async listReports(framework: ComplianceFramework, limit?: number): Promise { - const client = new DynamoDBClient({ region: process.env.AWS_REGION || 'us-east-1' }); - const result = await client.send( + const result = await this.docClient.send( new QueryCommand({ TableName: this.tableName, KeyConditionExpression: 'framework = :framework', diff --git a/packages/risk-engine/src/compliance-types.ts b/packages/risk-engine/src/compliance-types.ts index 2c9a9c24..8cca26ea 100644 --- a/packages/risk-engine/src/compliance-types.ts +++ b/packages/risk-engine/src/compliance-types.ts @@ -162,18 +162,6 @@ export const CIS_AWS_FOUNDATIONS_V3_CONTROLS: ComplianceControl[] = [ evidenceRequirements: ['IAM credential report showing hardware MFA for console users'], remediationGuidance: 'Enforce hardware MFA for all IAM users with console access.', }, - { - id: '1.9', - framework: 'CIS_AWS_FOUNDATIONS', - section: '1. Identity and Access Management', - title: 'Ensure root account hardware MFA is enabled', - description: 'Ensure root account uses hardware MFA.', - severity: 'critical', - automated: true, - relatedRules: ['RULE-004'], - evidenceRequirements: ['Root account MFA type is hardware'], - remediationGuidance: 'Replace virtual MFA with hardware MFA for root account.', - }, { id: '1.10', framework: 'CIS_AWS_FOUNDATIONS', @@ -247,42 +235,6 @@ export const CIS_AWS_FOUNDATIONS_V3_CONTROLS: ComplianceControl[] = [ evidenceRequirements: ['IAM password policy MaxPasswordAge <= 90'], remediationGuidance: 'Set password expiration to 90 days or less.', }, - { - id: '1.16', - framework: 'CIS_AWS_FOUNDATIONS', - section: '1. Identity and Access Management', - title: 'Ensure no root account access key exists', - description: 'Ensure no access keys exist for the root user.', - severity: 'critical', - automated: true, - relatedRules: ['RULE-004'], - evidenceRequirements: ['No root access keys in credential report'], - remediationGuidance: 'Delete any root access keys immediately.', - }, - { - id: '1.17', - framework: 'CIS_AWS_FOUNDATIONS', - section: '1. Identity and Access Management', - title: 'Ensure MFA is enabled for the root account', - description: 'Ensure multi-factor authentication (MFA) is enabled for the root user.', - severity: 'critical', - automated: true, - relatedRules: ['RULE-004'], - evidenceRequirements: ['Root MFA enabled in credential report'], - remediationGuidance: 'Enable MFA for root account.', - }, - { - id: '1.18', - framework: 'CIS_AWS_FOUNDATIONS', - section: '1. Identity and Access Management', - title: 'Ensure hardware MFA is enabled for the root account', - description: 'Ensure hardware MFA is enabled for the root user.', - severity: 'high', - automated: true, - relatedRules: ['RULE-004'], - evidenceRequirements: ['Root MFA type is hardware'], - remediationGuidance: 'Use hardware MFA device for root account.', - }, { id: '1.19', framework: 'CIS_AWS_FOUNDATIONS', diff --git a/packages/risk-engine/src/rules.test.ts b/packages/risk-engine/src/rules.test.ts index 4c9e7dc7..668e57a8 100644 --- a/packages/risk-engine/src/rules.test.ts +++ b/packages/risk-engine/src/rules.test.ts @@ -44,9 +44,14 @@ describe('getEnabledRules', () => { } }); - test('should return all rules when all enabled', () => { + test('RULE-003 should be disabled (vulnerability scanning not yet built)', () => { + const rule = getRuleById('RULE-003'); + expect(rule?.enabled).toBe(false); + }); + + test('should return 9 enabled rules (RULE-003 disabled)', () => { const enabledRules = getEnabledRules(); - expect(enabledRules.length).toBe(riskRules.length); + expect(enabledRules.length).toBe(9); }); }); @@ -65,7 +70,7 @@ describe('getRuleById', () => { test('should find internet-exposed EC2 rule', () => { const rule = getRuleById('RULE-001'); expect(rule?.name).toContain('Internet-Exposed EC2'); - expect(rule?.gremlinQueryTemplate).toContain('EC2Instance'); + expect(rule?.gremlinQueryTemplate).toContain('Ec2Instance'); expect(rule?.gremlinQueryTemplate).toContain('data_classification'); }); @@ -85,7 +90,7 @@ describe('getRuleById', () => { test('should find over-privileged IAM rule', () => { const rule = getRuleById('RULE-004'); expect(rule?.name).toContain('IAM'); - expect(rule?.gremlinQueryTemplate).toContain('ALLOWS_ACCESS_TO'); + expect(rule?.gremlinQueryTemplate).toContain('HAS_IAM_ROLE'); }); test('should find crown jewel attack path rule', () => { @@ -103,19 +108,19 @@ describe('getRuleById', () => { test('should find public S3 bucket rule', () => { const rule = getRuleById('RULE-007'); expect(rule?.name).toContain('S3'); - expect(rule?.gremlinQueryTemplate).toContain('isPubliclyAccessible'); + expect(rule?.gremlinQueryTemplate).toContain('is_publicly_accessible'); }); test('should find RDS public access rule', () => { const rule = getRuleById('RULE-008'); expect(rule?.name).toContain('RDS'); - expect(rule?.gremlinQueryTemplate).toContain('RDSInstance'); + expect(rule?.gremlinQueryTemplate).toContain('RdsInstance'); }); test('should find Lambda VPC rule', () => { const rule = getRuleById('RULE-009'); expect(rule?.name).toContain('Lambda'); - expect(rule?.gremlinQueryTemplate).toContain('Lambda'); + expect(rule?.gremlinQueryTemplate).toContain('LambdaFunction'); }); test('should find Secrets Manager rule', () => { @@ -140,11 +145,30 @@ describe('Rule gremlin queries', () => { test('RULE-003 should query for CRITICAL CVEs', () => { const rule = getRuleById('RULE-003'); expect(rule?.gremlinQueryTemplate).toContain('CRITICAL'); - expect(rule?.gremlinQueryTemplate).toContain('9.0'); + expect(rule?.gremlinQueryTemplate).toContain('cvss_base_score'); }); test('RULE-004 should have threshold for permission count', () => { const rule = getRuleById('RULE-004'); - expect(rule?.gremlinQueryTemplate).toContain('gt(50)'); + expect(rule?.gremlinQueryTemplate).toContain('gt(5)'); + }); + + test('rules should use snake_case property names', () => { + for (const rule of riskRules) { + expect(rule.gremlinQueryTemplate).not.toContain('isInternetExposed'); + expect(rule.gremlinQueryTemplate).not.toContain('isPubliclyAccessible'); + expect(rule.gremlinQueryTemplate).not.toContain('cidrBlock'); + expect(rule.gremlinQueryTemplate).not.toContain('portRange'); + expect(rule.gremlinQueryTemplate).not.toContain('isInVpc'); + expect(rule.gremlinQueryTemplate).not.toContain('hasInternetAccess'); + } + }); + + test('rules should not reference non-existent edges', () => { + for (const rule of riskRules) { + expect(rule.gremlinQueryTemplate).not.toContain('ALLOWS_ACCESS_TO'); + expect(rule.gremlinQueryTemplate).not.toContain('ALLOWS_INGRESS'); + expect(rule.gremlinQueryTemplate).not.toContain('STORES'); + } }); }); diff --git a/packages/risk-engine/src/rules.ts b/packages/risk-engine/src/rules.ts index 3bdf16a6..7152a541 100644 --- a/packages/risk-engine/src/rules.ts +++ b/packages/risk-engine/src/rules.ts @@ -17,13 +17,14 @@ export const riskRules: RiskRule[] = [ severityHint: 'critical', riskFactors: [...baseRiskFactors], gremlinQueryTemplate: ` - g.V().has('label', 'EC2Instance') - .has('isInternetExposed', true) + g.V().has('label', 'Ec2Instance') + .has('is_internet_exposed', true) .as('ec2') .out('HAS_IAM_ROLE') .as('iamRole') - .out('ALLOWS_ACCESS_TO') - .has('label', 'S3Bucket') + .out('ATTACHED_TO').has('label', 'IamPolicyDocument') + .out('CONTAINS').has('label', 'IamPolicyStatement') + .out('GRANTS').has('label', 'S3Bucket') .has('data_classification', 'restricted') .as('s3Bucket') .path() @@ -41,19 +42,18 @@ export const riskRules: RiskRule[] = [ id: 'RULE-002', name: 'Security Groups with 0.0.0.0/0 on SSH/RDP', description: - 'Detects security groups that allow unrestricted SSH (port 22) or RDP (port 3389) access', + 'Detects security group rules that allow unrestricted SSH (port 22) or RDP (port 3389) access to EC2 instances', severityHint: 'high', riskFactors: [...baseRiskFactors], gremlinQueryTemplate: ` - g.V().has('label', 'SecurityGroup') - .as('sg') - .out('ALLOWS_INGRESS') - .has('protocol', within('tcp', 'all')) - .has('portRange', within(22, 3389)) - .has('cidrBlock', '0.0.0.0/0') - .in_('ATTACHED_TO') - .has('label', 'EC2Instance') - .as('ec2') + g.V().has('label', 'SecurityGroupRule') + .has('cidr_block', '0.0.0.0/0') + .has('protocol', within('tcp', '-1')) + .or(has('port_from', 22), has('port_from', 3389), has('port_from', 0)) + .as('rule') + .out('PART_OF').has('label', 'SecurityGroup').as('sg') + .out('PROTECTS') + .out('ATTACHED_TO').has('label', 'Ec2Instance').as('ec2') .path() .by(valueMap(true)) `, @@ -69,7 +69,7 @@ export const riskRules: RiskRule[] = [ id: 'RULE-003', name: 'Container Images with Critical CVEs on Internet-Exposed Workloads', description: - 'Detects container images with critical severity CVEs running on pods or instances exposed to the internet', + 'Detects container images with critical severity CVEs running on pods or instances exposed to the internet. Disabled until vulnerability scanning is implemented.', severityHint: 'critical', riskFactors: [...baseRiskFactors], gremlinQueryTemplate: ` @@ -77,17 +77,17 @@ export const riskRules: RiskRule[] = [ .as('image') .out('HAS_CVE') .has('severity', 'CRITICAL') - .has('cvssBaseScore', gte(9.0)) + .has('cvss_base_score', gte(9.0)) .as('cve') .in_('RUNS_ON') - .has('label', within('EC2Instance', 'KubernetesPod')) - .has('isInternetExposed', true) + .has('label', within('Ec2Instance', 'KubernetesPod')) + .has('is_internet_exposed', true) .as('workload') .path() .by(valueMap(true)) `, ownerTeam: 'container-security', - enabled: true, + enabled: false, autoTicketConfig: { enabled: true, projectKey: 'SEC', @@ -98,20 +98,16 @@ export const riskRules: RiskRule[] = [ id: 'RULE-004', name: 'Over-Privileged IAM Roles with Internet-Reachable Workloads', description: - 'Detects IAM roles with excessive permissions (many ALLOWS_ACCESS_TO edges) attached to internet-reachable EC2 or Lambda', + 'Detects IAM roles with excessive attached policies connected to internet-reachable EC2 or Lambda', severityHint: 'high', riskFactors: [...baseRiskFactors], gremlinQueryTemplate: ` - g.V().has('label', 'IAMRole') - .as('role') - .out('ALLOWS_ACCESS_TO') - .count() - .as('permissionCount') - .filter(gt(50)) - .in_('HAS_IAM_ROLE') - .has('label', within('EC2Instance', 'Lambda')) - .has('isInternetExposed', true) + g.V().has('label', within('Ec2Instance', 'LambdaFunction')) + .has('is_internet_exposed', true) .as('workload') + .out('HAS_IAM_ROLE') + .as('role') + .where(out('ATTACHED_TO').count().is(gt(5))) .path() .by(valueMap(true)) `, @@ -152,24 +148,20 @@ export const riskRules: RiskRule[] = [ }, { id: 'RULE-006', - name: 'Cross-Account IAM Trust with Admin Privileges', + name: 'Cross-Account IAM Trust with Wildcard Resource Access', description: - 'Detects IAM roles with cross-account trust relationships that grant administrative privileges', + 'Detects IAM roles with cross-account trust relationships that have policy statements granting access to all resources (Resource: *)', severityHint: 'critical', riskFactors: [...baseRiskFactors], gremlinQueryTemplate: ` - g.V().has('label', 'IAMRole') + g.V().has('label', 'IamRole') .as('role') - .out('TRUSTS') - .has('label', 'AWSAccount') - .where(neq(__.in('BELONGS_TO'))) - .as('trustedAccount') - .in_('HAS_IAM_ROLE') - .out('ALLOWS_ACCESS_TO') - .out('CONTAINS') - .has('label', 'IAMPolicy') - .has('isAdminPolicy', true) - .as('policy') + .where(out('TRUSTS')) + .out('ATTACHED_TO').has('label', 'IamPolicyDocument') + .out('CONTAINS').has('label', 'IamPolicyStatement') + .has('effect', 'Allow') + .has('has_wildcard_resource', true) + .as('statement') .path() .by(valueMap(true)) `, @@ -185,16 +177,14 @@ export const riskRules: RiskRule[] = [ id: 'RULE-007', name: 'Public S3 Buckets with Sensitive Data', description: - 'Detects S3 buckets with public access that contain data_classification=restricted or secret', + 'Detects S3 buckets with public access that are tagged data_classification=restricted or secret', severityHint: 'critical', riskFactors: [...baseRiskFactors], gremlinQueryTemplate: ` g.V().has('label', 'S3Bucket') - .has('isPubliclyAccessible', true) - .as('bucket') - .out('STORES') + .has('is_publicly_accessible', true) .has('data_classification', within('restricted', 'secret')) - .as('data') + .as('bucket') .path() .by(valueMap(true)) `, @@ -210,17 +200,14 @@ export const riskRules: RiskRule[] = [ id: 'RULE-008', name: 'RDS with Public Access and Sensitive Data', description: - 'Detects RDS instances exposed to the internet containing databases tagged with sensitive data', + 'Detects RDS instances with public access that are tagged with sensitive data classification', severityHint: 'critical', riskFactors: [...baseRiskFactors], gremlinQueryTemplate: ` - g.V().has('label', 'RDSInstance') - .has('isPubliclyAccessible', true) - .as('rds') - .out('CONTAINS') - .has('label', 'Database') + g.V().has('label', 'RdsInstance') + .has('is_publicly_accessible', true) .has('data_classification', within('restricted', 'secret')) - .as('db') + .as('rds') .path() .by(valueMap(true)) `, @@ -234,18 +221,20 @@ export const riskRules: RiskRule[] = [ }, { id: 'RULE-009', - name: 'Lambda with VPC and Internet Gateway to Sensitive Resources', + name: 'Lambda with VPC and Internet Access to Sensitive Resources', description: - 'Detects Lambda functions in VPCs with internet access that can reach sensitive S3 or DynamoDB', + 'Detects Lambda functions in VPCs with internet access whose IAM role grants access to sensitive S3 or DynamoDB', severityHint: 'medium', riskFactors: [...baseRiskFactors], gremlinQueryTemplate: ` - g.V().has('label', 'Lambda') - .has('isInVpc', true) - .has('hasInternetAccess', true) + g.V().has('label', 'LambdaFunction') + .has('is_in_vpc', true) + .has('has_internet_access', true) .as('lambda') - .out('ALLOWS_ACCESS_TO') - .has('label', within('S3Bucket', 'DynamoDBTable')) + .out('HAS_IAM_ROLE').as('role') + .out('ATTACHED_TO').has('label', 'IamPolicyDocument') + .out('CONTAINS').has('label', 'IamPolicyStatement') + .out('GRANTS').has('label', within('S3Bucket', 'DynamoDBTable')) .has('data_classification', within('internal', 'restricted', 'secret')) .as('resource') .path() @@ -260,21 +249,20 @@ export const riskRules: RiskRule[] = [ { id: 'RULE-010', name: 'Secrets Manager Secrets with Overly Permissive IAM', - description: 'Detects AWS Secrets Manager secrets accessible by broad IAM policies', + description: + 'Detects AWS Secrets Manager secrets accessible by IAM policy statements that grant access to all resources (Resource: *)', severityHint: 'high', riskFactors: [...baseRiskFactors], gremlinQueryTemplate: ` g.V().has('label', 'Secret') - .has('secretType', 'database') + .has('secret_type', 'database') .as('secret') - .in_('ALLOWS_ACCESS_TO') - .has('label', 'IAMPolicy') - .as('policy') - .out('CONTAINS') - .has('label', 'IAMStatement') + .in_('GRANTS').has('label', 'IamPolicyStatement') .has('effect', 'Allow') - .has('resource', contains('*')) + .has('has_wildcard_resource', true) .as('statement') + .in_('CONTAINS').has('label', 'IamPolicyDocument').as('policy') + .in_('ATTACHED_TO').has('label', 'IamRole').as('role') .path() .by(valueMap(true)) `, diff --git a/packages/risk-engine/src/runner.ts b/packages/risk-engine/src/runner.ts index aa931bfd..8eb756c6 100644 --- a/packages/risk-engine/src/runner.ts +++ b/packages/risk-engine/src/runner.ts @@ -332,7 +332,7 @@ export class RiskRuleRunner { const attackPathLength = path.length; for (const v of path) { - if (v.properties?.isInternetExposed === true) { + if (v.properties?.is_internet_exposed === true) { exposureLevel = 'internet'; } if ( diff --git a/ui/app/compliance/[framework]/report/page.tsx b/ui/app/compliance/[framework]/report/page.tsx index 9914bdc3..8deaeb2c 100644 --- a/ui/app/compliance/[framework]/report/page.tsx +++ b/ui/app/compliance/[framework]/report/page.tsx @@ -55,11 +55,15 @@ export default function ComplianceReportPage() { async function downloadReport() { try { - const response = await fetch(`/api/compliance/${framework}/report?format=csv`, { - headers: { - Authorization: `Bearer ${localStorage.getItem('id_token')}`, - }, - }); + const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; + const response = await fetch( + `${baseUrl}/compliance/frameworks/${framework}/report?format=csv`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem('id_token')}`, + }, + } + ); if (response.ok) { const blob = await response.blob(); const url = window.URL.createObjectURL(blob);