diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1820bec8..22ca54f5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -169,13 +169,27 @@ A scheduled Lambda (`lambdas/least-privilege-sweep`) runs daily (03:00 UTC, afte ## Path likelihood and fix simulation Beyond severity, every matched path carries an explainable **likelihood**: the -product of four visible factors — exposure, privilege (identity blast radius), -exploitability (Amazon Inspector CVSS/exploit data) and reachability (halved -when a vertex says its exposure or state could not be read). Unknown detail is -a mid-value, not zero, and the factors are persisted with the value (issue +product of five visible factors — exposure, privilege (identity blast radius), +exploitability (Amazon Inspector CVSS/exploit data), reachability (halved +when a vertex says its exposure or state could not be read) and **coverage**, +the freshness of the graph the path was read from. Unknown detail is a +mid-value, not zero, and the factors are persisted with the value (issue metadata, attack-path search results) so a ranking can be argued with; it is a v1 heuristic, calibrated against outcomes only in a later phase. +Freshness tempering is deterministic and shared: the collector cadence grades +coverage (`fresh` ≤ 8h, `aging` ≤ 72h, `stale` beyond — the levels and +thresholds live in `packages/risk-engine/src/coverage.ts`, one source of truth +for the API statements, the runner and the plan report) and the level scales +the likelihood — `aging` × 0.8, `stale` × 0.5. `unknown` (no collection +recorded, or a failed read) is deliberately **not** discounted: the surfaces +label it in words instead, so a fresh install is not punished, while a graph +that is provably old can never look confident. The rule runner resolves the +level once per run (newest `CollectionCoverage` SUCCEEDED completion, bounded +and failure-tolerant) and stamps `metadata.coverageLevel` on every issue it +creates or refreshes; the explorer, the fix panel and the scenario runner each +attach the statement — and show its note — beside their results. + `GET /attack-paths/simulate?ruleId=…` re-runs one rule against the live graph (bounded at 200 paths / 10 edges) and counts, per edge on the matched paths, how many paths traverse it — the "what removes these paths" panel on the issue @@ -211,7 +225,7 @@ corroboration**: once per run it resolves the resources Security Hub / GuardDuty findings point at (`DETECTS_ON`, one bounded read, truncation-aware, failure-tolerant) and records `metadata.independentFinding` — true when one of the issue's resources was flagged too, false when the complete set missed it, -absent when the set was unreadable, truncated, or the rule *is* those detectors +absent when the set was unreadable, truncated, or the rule _is_ those detectors (RULE-018/029). `/rules/quality` publishes `corroborationRate = corroborated ÷ labeled` once at least five issues are labelled, and the verdict deliberately stays dismissal-based until that data and a calibration decision @@ -408,7 +422,7 @@ the gate. | `ConfigRule` | Config | `arn` | | `GuardDutyDetector` | GuardDuty | `arn`, `status` | | `AccessAnalyzer` | IAM Access Analyzer | `arn` | -| `LambdaFunction` | Lambda | `arn`, `runtime`, `role`, `is_in_vpc`, `has_internet_access`, `function_url_auth_type`, `function_url_public`, `public_invoke_policy` | +| `LambdaFunction` | Lambda | `arn`, `runtime`, `role`, `is_in_vpc`, `has_internet_access`, `function_url_auth_type`, `function_url_public`, `public_invoke_policy` | | `LambdaAlias` | Lambda | `arn` | | `ApiGateway` | API Gateway | `arn`, `endpoint_type` | | `ApiGatewayStage` | API Gateway | `arn` | @@ -424,68 +438,68 @@ the gate. | `BackupPlan` | AWS Backup | `arn` | | `HostedZone` | Route53 | `arn` | | `DataClassificationFinding` | DSPM Scanner | `arn`, `pii_types`, `secret_count`, `skipped_kms`, `data_classification`, `data_class_source`, `confidence`, `classifier` | -| `Vulnerability` | Amazon Inspector | `cve_id`, `severity`, `cvss_base_score`, `exploit_available`, `fixed_versions`, `inspector_score`, `first_observed_at`, `last_observed_at` | +| `Vulnerability` | Amazon Inspector | `cve_id`, `severity`, `cvss_base_score`, `exploit_available`, `fixed_versions`, `inspector_score`, `first_observed_at`, `last_observed_at` | | `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 -| Edge | Meaning | -| ------------------------------ | --------------------------------------------------------------- | -| `EXPOSES` | Internet → IGW/LB/ExternalAccount | -| `CONTAINS` | VPC → Subnet/Instance; Account → Resource; Policy → Statement | -| `ATTACHED_TO` | ENI → Instance; IGW → VPC; IAMPrincipal → Policy | -| `PROTECTS` | SecurityGroup → NetworkInterface | -| `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/AccessAnalyzer finding | -| `HAS_ACCESS_KEY` | IamUser → AccessKey | -| `HAS_RESOURCE_POLICY` | Secret → IamPolicyDocument | -| `DETECTS_ON` | SecurityHubFinding → collected resource | -| `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 | +| Edge | Meaning | +| ------------------------------ | ------------------------------------------------------------------- | +| `EXPOSES` | Internet → IGW/LB/ExternalAccount | +| `CONTAINS` | VPC → Subnet/Instance; Account → Resource; Policy → Statement | +| `ATTACHED_TO` | ENI → Instance; IGW → VPC; IAMPrincipal → Policy | +| `PROTECTS` | SecurityGroup → NetworkInterface | +| `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/AccessAnalyzer finding | +| `HAS_ACCESS_KEY` | IamUser → AccessKey | +| `HAS_RESOURCE_POLICY` | Secret → IamPolicyDocument | +| `DETECTS_ON` | SecurityHubFinding → collected resource | +| `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 | | `CAN_ESCALATE_TO` | IAMPrincipal → IAMRole (assume hops, or a `pass_role` compute link) | -| `SHARES_WITH` | IamPolicyStatement → AwsAccount (cross-account share) | -| `BELONGS_TO` | ContainerImage → EcrRepository | -| `RUNS_ON` | ContainerImage → workload (not yet created) | -| `HAS_CVE` | Instance/Lambda/ContainerImage → Vulnerability (Inspector) | -| `HAS_ALIAS` / `HAS_STAGE` | Function → Alias/Stage | -| `CLASSIFIES` | DataClassificationFinding → S3Bucket/RdsInstance/LambdaFunction | +| `SHARES_WITH` | IamPolicyStatement → AwsAccount (cross-account share) | +| `BELONGS_TO` | ContainerImage → EcrRepository | +| `RUNS_ON` | ContainerImage → workload (not yet created) | +| `HAS_CVE` | Instance/Lambda/ContainerImage → Vulnerability (Inspector) | +| `HAS_ALIAS` / `HAS_STAGE` | Function → Alias/Stage | +| `CLASSIFIES` | DataClassificationFinding → S3Bucket/RdsInstance/LambdaFunction | ### Risk Rule Properties (queried by Gremlin rules) -| Property | Type | Source | -| ------------------------------------------------ | ---------------- | ----------------------------------- | -| `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 | -| `has_cross_account_trust` | boolean | IAM trust analysis | -| `has_wildcard_resource` | boolean | IAM policy statement analysis | -| `has_wildcard_action` | boolean | IAM policy statement analysis | -| `grants_secret_read` / `secret_grant_scope` | boolean / string | IAM policy statement analysis | -| `mfa_enabled` / `password_enabled` | boolean | IAM MFA devices and login profiles | -| `key_age_days` / `last_used_days_ago` | number | IAM access key collection | -| `endpoint_public_access` / `endpoint_world_open` | boolean | EKS `resourcesVpcConfig` | -| `function_url_public` / `public_invoke_policy` | boolean | Lambda function URLs and invoke policies | -| `cvss_base_score` / `exploit_available` | number / boolean | Amazon Inspector finding (RULE-003, risk scoring) | -| `lambda_exposure_restricted` | boolean | Lambda reads the collector role cannot make | -| `principal_is_anyone` / `principal_account_count` | boolean / number | IAM policy statement principal analysis | -| `principal_org_restricted` | boolean | `aws:PrincipalOrgID` condition on a statement | -| `is_prod` | boolean | `PROD_ACCOUNT_IDS` account marking | -| `traffic_observed` / `traffic_observed_at` | boolean / string | Flow-log analyzer observation, read by the collector | -| `anomaly_type` | string | CloudTrail analyzer anomaly queries | -| `port_from` / `port_to` | number | Security group rule | -| `flow_logs_enabled` | boolean | EC2 DescribeFlowLogs | +| Property | Type | Source | +| ------------------------------------------------- | ---------------- | ---------------------------------------------------- | +| `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 | +| `has_cross_account_trust` | boolean | IAM trust analysis | +| `has_wildcard_resource` | boolean | IAM policy statement analysis | +| `has_wildcard_action` | boolean | IAM policy statement analysis | +| `grants_secret_read` / `secret_grant_scope` | boolean / string | IAM policy statement analysis | +| `mfa_enabled` / `password_enabled` | boolean | IAM MFA devices and login profiles | +| `key_age_days` / `last_used_days_ago` | number | IAM access key collection | +| `endpoint_public_access` / `endpoint_world_open` | boolean | EKS `resourcesVpcConfig` | +| `function_url_public` / `public_invoke_policy` | boolean | Lambda function URLs and invoke policies | +| `cvss_base_score` / `exploit_available` | number / boolean | Amazon Inspector finding (RULE-003, risk scoring) | +| `lambda_exposure_restricted` | boolean | Lambda reads the collector role cannot make | +| `principal_is_anyone` / `principal_account_count` | boolean / number | IAM policy statement principal analysis | +| `principal_org_restricted` | boolean | `aws:PrincipalOrgID` condition on a statement | +| `is_prod` | boolean | `PROD_ACCOUNT_IDS` account marking | +| `traffic_observed` / `traffic_observed_at` | boolean / string | Flow-log analyzer observation, read by the collector | +| `anomaly_type` | string | CloudTrail analyzer anomaly queries | +| `port_from` / `port_to` | number | Security group rule | +| `flow_logs_enabled` | boolean | EC2 DescribeFlowLogs | ## Authentication diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a0616bb..51dbf8f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to Khalifa. +## [Unreleased] - Freshness tempering across risk + +### Changed + +- **Likelihood is now five factors**: exposure, privilege, exploitability, reachability and **coverage** — the freshness of the graph the path was read from. The level and thresholds move to `packages/risk-engine/src/coverage.ts` as the single source of truth (the API's coverage statements grade with the same function), and the level scales the product deterministically: `aging` × 0.8, `stale` × 0.5. `unknown` (nothing collected yet, or a failed read) is deliberately not discounted — surfaces label it in words — so a fresh install is not punished while a provably old graph can never look confident. +- **The rule runner resolves coverage once per run** (newest SUCCEEDED `resource-inventory` `CollectionCoverage` completion, bounded at 5000 and failure-tolerant) and passes it to every path likelihood; each issue created or refreshed carries `metadata.coverageLevel`. The attack-path explorer and fix simulation temper their likelihoods the same way, and the explorer response now includes the coverage statement beside `truncated`. +- **The UI states coverage where results appear**: the explorer shows the statement note under its results, the fix panel under the edge ranking, the scenario card after a run, and the issue page labels the graph coverage the issue was scored against ("— the path likelihood is tempered down" for aging/stale). + +### Notes + +- Still open from the re-review (`~/Documents/Testing/plans/2026-10-27-predictive-completion.md`): observed-reachability v2 (remote endpoints, per-hop citation), shift-left v2 (IAM diff/Helm, CI wiring), and the long tail (UEBA breadth, EPSS/KEV, trust-center projections, per-issue risk window/SLO). + ## [Unreleased] - Event-triggered recompute ### Added @@ -83,7 +95,7 @@ All notable changes to Khalifa. ### Changed -- **AccessDenied spikes are deviations from each principal's own baseline, not a fixed count**: the analyzer now pulls per-principal-per-IP-per-*day* counts for the evaluation window plus a 30-day baseline and decides in code (testable against historical windows): a day flags when it reaches mean + 3 standard deviations of that pair's daily counts, with 10% of the mean as a floor on the spread (a perfectly steady principal is not flagged by +1) and an absolute floor of 10 events. A steady 25-denieds-per-day principal is no longer flagged at 26 (the old fixed threshold's false-positive class), and a quiet principal jumping to 12 — which the old threshold of 20 missed — is flagged. When fewer than 7 baseline days exist the previous fixed threshold applies (cold start). Each spike records `baseline_mean`, `baseline_stddev`, `baseline_days`, `basis` and `deviation`, and the principal's blast radius ranks it in scoring as before. +- **AccessDenied spikes are deviations from each principal's own baseline, not a fixed count**: the analyzer now pulls per-principal-per-IP-per-_day_ counts for the evaluation window plus a 30-day baseline and decides in code (testable against historical windows): a day flags when it reaches mean + 3 standard deviations of that pair's daily counts, with 10% of the mean as a floor on the spread (a perfectly steady principal is not flagged by +1) and an absolute floor of 10 events. A steady 25-denieds-per-day principal is no longer flagged at 26 (the old fixed threshold's false-positive class), and a quiet principal jumping to 12 — which the old threshold of 20 missed — is flagged. When fewer than 7 baseline days exist the previous fixed threshold applies (cold start). Each spike records `baseline_mean`, `baseline_stddev`, `baseline_days`, `basis` and `deviation`, and the principal's blast radius ranks it in scoring as before. - **Secret-read "new source IP" is a true IP baseline**: the query joins the evaluation window's secret reads against the DISTINCT source IPs the principal used in the 30 days before it, left-joined, instead of the principal-first-seen proxy — an IP dormant for the whole baseline window and used again now counts as new, and the principal-age requirement is gone. ### Notes @@ -413,7 +425,6 @@ All notable changes to Khalifa. - **Deletion protection for the Terraform DynamoDB tables (closes the gap left by D2 part 1)**: all six tables now set `deletion_protection_enabled`. The module previously had no deletion protection, and the plan's note that "Terraform already does this" was not accurate, so the earlier D2 change only hardened the CDK tables, which production does not use. The module test now asserts deletion protection and point-in-time recovery on every table. - Note: the module already declares `point_in_time_recovery { enabled = true }`, but the deployed `khalifa-*` tables report PITR disabled, so an apply is still required to reconcile that drift. - - **Data protection and DSPM read scoping (D2 of the remediation plan, part 1)**: - Every CDK DynamoDB table now has point-in-time recovery and deletion protection, matching the Terraform module. Previously a stack operation or a bad delete could destroy posture history - The DSPM scanner's `s3:GetObject` is scoped to an approved bucket allowlist (`DSPM_BUCKET_ALLOWLIST` / `dspm_bucket_allowlist`) instead of `arn:aws:s3:::*/*`. The scanner's own `dspm-scan` tag gate remains as defence in depth, but it is the allowlist that bounds what object reads can reach. Bucket tags cannot do this: S3 objects are not matched by `aws:ResourceTag`, and the opt-in tag lives on the bucket. `s3:ListAllMyBuckets` is split out because it cannot be resource-scoped. An empty allowlist grants no object read at all (bucket-level listing remains), so content scanning requires configuring the allowlist; it never falls back to every bucket diff --git a/README.md b/README.md index 1fe1890e..724aba24 100644 --- a/README.md +++ b/README.md @@ -22,20 +22,20 @@ One member role. Evaluation stays in-account. ## Features -| Surface | What you get | -| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Collection** | 30+ AWS services via [`SecurityGraphCollectorRole`](templates/SecurityGraphCollectorRole.yaml). A hub account collects; each member account gets the spoke role. | -| **Graph** | Typed vertices and edges in Neptune. Same input, same Gremlin traversal. | -| **CIEM** | IAM decomposition, effective permissions, escalation paths (assume chains and `iam:PassRole` to compute, RULE-034), CloudTrail-grounded rightsizing. | -| **Least privilege** | Sweep of roles and users. Deployable policy recommendations. | -| **Secrets** | Secrets Manager and SSM parameter inventory with who-can-read analysis from identity and resource policies, rotation and access age, sharing detection (RULE-010, RULE-032, RULE-033), and admin users holding active access keys (RULE-031). | -| **Risk** | Scheduled rules and attack-path traversals on the live graph (34 rules), with an exploitability factor that raises scores for paths carrying Amazon Inspector findings, an explainable likelihood on every path, escalation paths recomputed within minutes of an IAM change (event-triggered and rate-bounded), a per-fix simulation that counts what each edge removal would clear, matching against customer-supplied threat-intel lists (additive score bonus, TTL-fresh), curated MITRE ATT&CK techniques on every finding, graph-only adversary scenarios that re-run technique chains over the live graph, and a per-rule quality report (suppression vs resolution) that calibrates which rules to trust. | -| **Detection** | GuardDuty detector coverage per region (including `MISSING`), Security Hub findings joined to internet-exposed resources (RULE-017, RULE-018), CloudTrail anomaly signals: root activity, secret reads from IPs outside each principal's own 30-day baseline, AccessDenied deviations from each principal's own daily baseline (RULE-020 to RULE-022), canary credential tripwires (RULE-023), secret scanning of Lambda environment variables plus IMDSv1-with-role detection (RULE-024), and Amazon Inspector CVEs on internet-exposed workloads (RULE-003). | -| **Exposure** | Public EKS control planes (RULE-019) plus public S3/RDS and world-open security groups, derived from collected endpoint and network config, with optional observed traffic from VPC flow logs confirming where traffic actually goes. | -| **Compliance** | CIS AWS Foundations v3.0 and the Khalifa baseline, per-control evidence, mapped SOC 2 / ISO 27001 / PCI DSS / NIST 800-53 requirements, and CIS reconciliation against Security Hub before anything is publishable. | -| **Trust center** | Nightly static trust-center page built from the publishable posture (statuses only, in-repo site). | -| **Runtime** | EKS Helm + `khalifa-eks` / `khalifa-data` / `khalifa-registry`. CDK Lambda remains for the in-repo pipeline. | -| **Shift-left** | `npm run risk:plan -- ` predicts which rules a Terraform plan would feed (world-open security groups, account-root trusts, public buckets/RDS/EKS endpoints, secret policies, function URLs, IMDSv1) and fails the gate, naming the rule and the predicted path before anything is deployed. | +| Surface | What you get | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Collection** | 30+ AWS services via [`SecurityGraphCollectorRole`](templates/SecurityGraphCollectorRole.yaml). A hub account collects; each member account gets the spoke role. | +| **Graph** | Typed vertices and edges in Neptune. Same input, same Gremlin traversal. | +| **CIEM** | IAM decomposition, effective permissions, escalation paths (assume chains and `iam:PassRole` to compute, RULE-034), CloudTrail-grounded rightsizing. | +| **Least privilege** | Sweep of roles and users. Deployable policy recommendations. | +| **Secrets** | Secrets Manager and SSM parameter inventory with who-can-read analysis from identity and resource policies, rotation and access age, sharing detection (RULE-010, RULE-032, RULE-033), and admin users holding active access keys (RULE-031). | +| **Risk** | Scheduled rules and attack-path traversals on the live graph (34 rules), with an exploitability factor that raises scores for paths carrying Amazon Inspector findings, an explainable likelihood on every path (tempered down when the graph it read is aging or stale), escalation paths recomputed within minutes of an IAM change (event-triggered and rate-bounded), a per-fix simulation that counts what each edge removal would clear, matching against customer-supplied threat-intel lists (additive score bonus, TTL-fresh), curated MITRE ATT&CK techniques on every finding, graph-only adversary scenarios that re-run technique chains over the live graph, and a per-rule quality report (suppression vs resolution) that calibrates which rules to trust. | +| **Detection** | GuardDuty detector coverage per region (including `MISSING`), Security Hub findings joined to internet-exposed resources (RULE-017, RULE-018), CloudTrail anomaly signals: root activity, secret reads from IPs outside each principal's own 30-day baseline, AccessDenied deviations from each principal's own daily baseline (RULE-020 to RULE-022), canary credential tripwires (RULE-023), secret scanning of Lambda environment variables plus IMDSv1-with-role detection (RULE-024), and Amazon Inspector CVEs on internet-exposed workloads (RULE-003). | +| **Exposure** | Public EKS control planes (RULE-019) plus public S3/RDS and world-open security groups, derived from collected endpoint and network config, with optional observed traffic from VPC flow logs confirming where traffic actually goes. | +| **Compliance** | CIS AWS Foundations v3.0 and the Khalifa baseline, per-control evidence, mapped SOC 2 / ISO 27001 / PCI DSS / NIST 800-53 requirements, and CIS reconciliation against Security Hub before anything is publishable. | +| **Trust center** | Nightly static trust-center page built from the publishable posture (statuses only, in-repo site). | +| **Runtime** | EKS Helm + `khalifa-eks` / `khalifa-data` / `khalifa-registry`. CDK Lambda remains for the in-repo pipeline. | +| **Shift-left** | `npm run risk:plan -- ` predicts which rules a Terraform plan would feed (world-open security groups, account-root trusts, public buckets/RDS/EKS endpoints, secret policies, function URLs, IMDSv1) and fails the gate, naming the rule and the predicted path before anything is deployed. | --- @@ -152,40 +152,40 @@ cdk deploy SecurityGraphIngestionStack EKS uses [`deploy/helm/khalifa/values.yaml`](deploy/helm/khalifa/values.yaml). The UI proxies `/api` to the in-cluster API Service. Lambda uses: -| Variable | Description | Default | -| --------------------------------- | --------------------------------------------------------------------------------- | ------------------------------- | -| `NEPTUNE_ENDPOINT` | Neptune Gremlin endpoint | — | -| `NEPTUNE_ENDPOINT` (auth) | Graph access uses SigV4 from the execution role; no Neptune auth secret is read | — | -| `ISSUES_TABLE` | DynamoDB issues table | `SecurityIssues` | -| `ACCESS_ANALYZER_TABLE` | CloudTrail usage cache | `AccessAnalyzerCache` | -| `TRAFFIC_OBSERVATIONS_TABLE` | Observed-traffic cache for the EC2 collector (empty disables the read) | `TrafficObservations` | -| `THREAT_INDICATORS_TABLE` | Threat-intel indicators read by the rule runner | `ThreatIndicators` | -| `THREAT_INTEL_IP_LIST_S3_URI` | s3://bucket/key of the newline-separated IP indicator list (empty disables) | — | -| `THREAT_INTEL_ACCOUNT_LIST_S3_URI`| s3://bucket/key of the newline-separated account-id list (empty disables) | — | -| `THREAT_INTEL_TTL_DAYS` | Days an imported indicator stays valid (DynamoDB TTL) | `90` | -| `RECOMPUTE_QUEUE_URL` | FIFO queue the incremental collector signals and the recompute worker drains | — | -| `RECOMPUTE_MIN_INTERVAL_SECONDS` | Minimum seconds between event-triggered policy evaluations (recompute worker) | `900` | -| `ATHENA_DATABASE` | Glue database for CloudTrail | `khalifa_cloudtrail_db` | -| `ATHENA_WORKGROUP` | Athena workgroup | `khalifa-cloudtrail-analysis` | -| `CLOUDTRAIL_S3_LOCATION` | CloudTrail S3 prefix | `s3://cloudtrail-logs/AWSLogs/` | -| `ANALYSIS_DAYS` | CloudTrail lookback | `90` | -| `AWS_REGION` | AWS region | `us-east-1` | -| `HOME_ACCOUNT_ID` | Account that uses ambient credentials | — | -| `COLLECTOR_EXTERNAL_ID` | Member-role ExternalId | — | -| `GRAPH_WRITER_TOKEN` | Bearer token for `/mutations` | — | -| `COLLECTION_ACCOUNT_IDS` | Approved account IDs; names come from `ListAccounts` when the hub may call it | — | -| `COLLECTION_PARENT_IDS` | Approved org root or OU IDs (still requires Organizations APIs) | — | -| `COLLECTION_REGIONS` | Approved Regions | — | -| `GRAPH_WRITER_FUNCTION_NAME` | Graph-writer Lambda for analyzer anomalies | — | -| `CANARY_PRINCIPAL_ARNS` | Comma-separated canary IAM user ARNs; any CloudTrail activity becomes RULE-023 | — | -| `PROD_ACCOUNT_IDS` | Collected accounts that hold production; RULE-032 flags prod secrets shared with the rest | — | -| `SLACK_WEBHOOK_SECRET_ARN` | Secrets Manager secret with the Slack webhook URL (bare URL or `{"SLACK_WEBHOOK_URL": …}`) | — | -| `RATE_LIMIT_PER_MINUTE` | Per-identity API request limit per minute | 600 | -| `RATE_LIMIT_EXPENSIVE_PER_MINUTE` | Per-identity limit on expensive endpoints (attack paths, resource graph, reports) | 60 | -| `DSPM_CLASSIFY_BUDGET_MS` | Hard CPU budget (ms) for classifying untrusted S3 content per scan | 30000 | -| `NEPTUNE_MUTATION_CHUNK` | Mutations per Gremlin write traversal (1-200) | 25 | -| `COLLECTION_STAGE_CONCURRENCY` | Independent collection stages in flight (1-20) | 4 | -| `COLLECTION_RESOURCE_CONCURRENCY` | Per-resource fan-out within a stage, e.g. S3 buckets and tag chunks (1-20) | 5 | +| Variable | Description | Default | +| ---------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------- | +| `NEPTUNE_ENDPOINT` | Neptune Gremlin endpoint | — | +| `NEPTUNE_ENDPOINT` (auth) | Graph access uses SigV4 from the execution role; no Neptune auth secret is read | — | +| `ISSUES_TABLE` | DynamoDB issues table | `SecurityIssues` | +| `ACCESS_ANALYZER_TABLE` | CloudTrail usage cache | `AccessAnalyzerCache` | +| `TRAFFIC_OBSERVATIONS_TABLE` | Observed-traffic cache for the EC2 collector (empty disables the read) | `TrafficObservations` | +| `THREAT_INDICATORS_TABLE` | Threat-intel indicators read by the rule runner | `ThreatIndicators` | +| `THREAT_INTEL_IP_LIST_S3_URI` | s3://bucket/key of the newline-separated IP indicator list (empty disables) | — | +| `THREAT_INTEL_ACCOUNT_LIST_S3_URI` | s3://bucket/key of the newline-separated account-id list (empty disables) | — | +| `THREAT_INTEL_TTL_DAYS` | Days an imported indicator stays valid (DynamoDB TTL) | `90` | +| `RECOMPUTE_QUEUE_URL` | FIFO queue the incremental collector signals and the recompute worker drains | — | +| `RECOMPUTE_MIN_INTERVAL_SECONDS` | Minimum seconds between event-triggered policy evaluations (recompute worker) | `900` | +| `ATHENA_DATABASE` | Glue database for CloudTrail | `khalifa_cloudtrail_db` | +| `ATHENA_WORKGROUP` | Athena workgroup | `khalifa-cloudtrail-analysis` | +| `CLOUDTRAIL_S3_LOCATION` | CloudTrail S3 prefix | `s3://cloudtrail-logs/AWSLogs/` | +| `ANALYSIS_DAYS` | CloudTrail lookback | `90` | +| `AWS_REGION` | AWS region | `us-east-1` | +| `HOME_ACCOUNT_ID` | Account that uses ambient credentials | — | +| `COLLECTOR_EXTERNAL_ID` | Member-role ExternalId | — | +| `GRAPH_WRITER_TOKEN` | Bearer token for `/mutations` | — | +| `COLLECTION_ACCOUNT_IDS` | Approved account IDs; names come from `ListAccounts` when the hub may call it | — | +| `COLLECTION_PARENT_IDS` | Approved org root or OU IDs (still requires Organizations APIs) | — | +| `COLLECTION_REGIONS` | Approved Regions | — | +| `GRAPH_WRITER_FUNCTION_NAME` | Graph-writer Lambda for analyzer anomalies | — | +| `CANARY_PRINCIPAL_ARNS` | Comma-separated canary IAM user ARNs; any CloudTrail activity becomes RULE-023 | — | +| `PROD_ACCOUNT_IDS` | Collected accounts that hold production; RULE-032 flags prod secrets shared with the rest | — | +| `SLACK_WEBHOOK_SECRET_ARN` | Secrets Manager secret with the Slack webhook URL (bare URL or `{"SLACK_WEBHOOK_URL": …}`) | — | +| `RATE_LIMIT_PER_MINUTE` | Per-identity API request limit per minute | 600 | +| `RATE_LIMIT_EXPENSIVE_PER_MINUTE` | Per-identity limit on expensive endpoints (attack paths, resource graph, reports) | 60 | +| `DSPM_CLASSIFY_BUDGET_MS` | Hard CPU budget (ms) for classifying untrusted S3 content per scan | 30000 | +| `NEPTUNE_MUTATION_CHUNK` | Mutations per Gremlin write traversal (1-200) | 25 | +| `COLLECTION_STAGE_CONCURRENCY` | Independent collection stages in flight (1-20) | 4 | +| `COLLECTION_RESOURCE_CONCURRENCY` | Per-resource fan-out within a stage, e.g. S3 buckets and tag chunks (1-20) | 5 | Canary credentials are deployed with `ENABLE_CANARIES=true` in the CDK environment (`cdk/bin/khalifa.ts`): two permission-less IAM users (`khalifa-canary-1/2`) whose access keys are stored in Secrets Manager (`khalifa-canary/credentials-1/2`). The keys have no legitimate use, so any CloudTrail activity from them is treated as a security incident. Off by default. @@ -208,33 +208,33 @@ Event-driven scanning is opt-in via `ENABLE_DSPM_EVENT_TRIGGER=true`: an SQS que Unauthenticated: `/health`, `/livez`, `/readyz`, `/metrics`. Everything else needs a Cognito JWT, or a verified Cloudflare Access identity when the Access provider is configured. RBAC groups: `khalifa-admin`, `khalifa-analyst`, `khalifa-viewer`. -| Endpoint | Description | -| ------------------------------------------------ | --------------------------------------------------------------------- | -| Endpoint | Description | -| --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `GET /session` | Signed-in identity and effective role (Viewer+) | -| `GET /rules` · `/rules/:id` | Rule catalog with names, owners, severity hints, and ATT&CK techniques (Viewer+) | -| `GET /rules/quality` | Per-rule open/suppressed/resolved counts, the dismissal-rate verdict, and independent-detector corroboration (Viewer+) | -| `GET /issues` | Filters: `severity`, `team`, `status`, `ruleId`, `limit`, `nextToken`; `order` = `desc` (default) or `asc` (Viewer+) | -| `GET /issues/:id` | Issue plus attack path (Viewer+) | -| `GET /issues/counts` · `/issues/stats` · `/issues/summary` · `/issues/schedule` | Counts, statistics, summary, and collection schedule (Viewer+) | -| `POST /issues/:id/suppress` · `/issues/:id/reopen` | Suppress or reopen an issue (Analyst+) | -| `GET /attack-paths/search` | Presets or a resource to start/end at, `account`, `maxPathLength`; each path carries a likelihood (Viewer+) | -| `GET /attack-paths/simulate` | Per-fix path counts for a rule, with a coverage statement for the graph they read (Viewer+) | +| Endpoint | Description | +| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Endpoint | Description | +| --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `GET /session` | Signed-in identity and effective role (Viewer+) | +| `GET /rules` · `/rules/:id` | Rule catalog with names, owners, severity hints, and ATT&CK techniques (Viewer+) | +| `GET /rules/quality` | Per-rule open/suppressed/resolved counts, the dismissal-rate verdict, and independent-detector corroboration (Viewer+) | +| `GET /issues` | Filters: `severity`, `team`, `status`, `ruleId`, `limit`, `nextToken`; `order` = `desc` (default) or `asc` (Viewer+) | +| `GET /issues/:id` | Issue plus attack path (Viewer+) | +| `GET /issues/counts` · `/issues/stats` · `/issues/summary` · `/issues/schedule` | Counts, statistics, summary, and collection schedule (Viewer+) | +| `POST /issues/:id/suppress` · `/issues/:id/reopen` | Suppress or reopen an issue (Analyst+) | +| `GET /attack-paths/search` | Presets or a resource to start/end at, `account`, `maxPathLength`; each path carries a likelihood (Viewer+) | +| `GET /attack-paths/simulate` | Per-fix path counts for a rule, with a coverage statement for the graph they read (Viewer+) | | `GET /attack-scenarios` · `/attack-scenarios/:id` | Curated ATT&CK technique chains; running one re-evaluates each step's rule over the live graph, with a coverage statement (Viewer+) | -| `GET /accounts` | Account IDs and names (Viewer+) | -| `GET /data-sources` | Collection freshness per data source (Viewer+) | -| `GET /secrets` · `/secrets/reader-counts` · `/secrets/:arn` | Secret inventory, reader counts, and one secret's facts and readers (Viewer+) | -| `GET /resources/:arn` · `/resources/graph` | Resource with neighbors and issues; graph slice (Viewer+) | -| `GET /resources/search` · `/resources/labels` · `/resources/properties` · `/resources/values` | Resource search and label/property explorer (Viewer+) | -| `GET /compliance/frameworks` | List frameworks; `/:framework`, `/controls`, `/controls/:controlId`, `/report`, `/drift` (Viewer+) | -| `GET /compliance/reconciliation/publishable` | Sanitized publishable CIS statuses; Cloudflare Access service token only | -| `GET /identity/effective-permissions/:principal` | Computed effective permissions (Viewer+) | -| `GET /identity/escalation-paths` · `/identity/unused-permissions` · `/identity/trust-graph` | CIEM views (Viewer+) | -| `GET /identity/rightsizing/:principal` | Least-privilege recommendation (Viewer+) | -| `GET /identity/least-privilege` | Sweep findings, `/summary`, and per-principal detail (Viewer+) | -| `GET /trends` · `/trends/:metric` | Posture trend metrics and series, each with a 7/30-day projection and confidence (Viewer+) | -| `GET /integrations/status` | Issue sink status (Viewer+) | +| `GET /accounts` | Account IDs and names (Viewer+) | +| `GET /data-sources` | Collection freshness per data source (Viewer+) | +| `GET /secrets` · `/secrets/reader-counts` · `/secrets/:arn` | Secret inventory, reader counts, and one secret's facts and readers (Viewer+) | +| `GET /resources/:arn` · `/resources/graph` | Resource with neighbors and issues; graph slice (Viewer+) | +| `GET /resources/search` · `/resources/labels` · `/resources/properties` · `/resources/values` | Resource search and label/property explorer (Viewer+) | +| `GET /compliance/frameworks` | List frameworks; `/:framework`, `/controls`, `/controls/:controlId`, `/report`, `/drift` (Viewer+) | +| `GET /compliance/reconciliation/publishable` | Sanitized publishable CIS statuses; Cloudflare Access service token only | +| `GET /identity/effective-permissions/:principal` | Computed effective permissions (Viewer+) | +| `GET /identity/escalation-paths` · `/identity/unused-permissions` · `/identity/trust-graph` | CIEM views (Viewer+) | +| `GET /identity/rightsizing/:principal` | Least-privilege recommendation (Viewer+) | +| `GET /identity/least-privilege` | Sweep findings, `/summary`, and per-principal detail (Viewer+) | +| `GET /trends` · `/trends/:metric` | Posture trend metrics and series, each with a 7/30-day projection and confidence (Viewer+) | +| `GET /integrations/status` | Issue sink status (Viewer+) | ```bash curl "https://api.example.com/api/issues?severity=critical&status=open&limit=100" \ @@ -247,10 +247,10 @@ curl "https://api.example.com/api/issues?severity=critical&status=open&limit=100 Gremlin evaluators write pass / fail / manual evidence to DynamoDB. 89 controls: 55 evaluated by automated evaluators, 10 judged from their mapped rules, and 2 manual. -| Framework | Controls | Focus | -| ------------------------ | -------: | ------------------------------------------------------------------------------ | -| CIS AWS Foundations v3.0 | 47 | IAM, storage, logging, monitoring, networking, data protection | -| Khalifa baseline | 42 | Detection, Kubernetes, secrets and vulnerability management, attack paths | +| Framework | Controls | Focus | +| ------------------------ | -------: | ------------------------------------------------------------------------- | +| CIS AWS Foundations v3.0 | 47 | IAM, storage, logging, monitoring, networking, data protection | +| Khalifa baseline | 42 | Detection, Kubernetes, secrets and vulnerability management, attack paths | The compliance job also runs the CIS reconciliation: it reads Security Hub's CIS v3.0.0 control findings through the audit account's read-only reader role and @@ -277,19 +277,19 @@ reported as certified. ## Repository -| Path | Contents | -| ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Path | Contents | +| ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | [`lambdas/`](lambdas) | Collector, incremental, graph-writer, policy-evaluator, CloudTrail, flow-log, threat-intel import, recompute, least-privilege, DSPM, runtime | -| [`packages/risk-engine`](packages/risk-engine) | Rules, scoring, compliance | -| [`api-service/`](api-service) · [`ui/`](ui) | REST API and Next.js UI | -| [`deploy/`](deploy) | Helm chart, Terraform modules, fixtures | -| [`AGENTS.md`](AGENTS.md) | Secure AWS install workflow and repo conventions for AI agents | -| [`SECURITY.md`](SECURITY.md) | Vulnerability disclosure policy and safe harbor | -| [`templates/`](templates) | Member collection role (`SecurityGraphCollectorRole`) | -| [`scripts/`](scripts) | Helm/Terraform validation, Helm kind smoke, container smoke, Lambda package/smoke, trust-center sync, pre-deploy plan risk, npm audit | -| [`trust-center/`](trust-center) | In-repo static trust-center site generated from the publishable posture | -| [`cdk/`](cdk) | Lambda + EventBridge stack | -| [`ARCHITECTURE.md`](ARCHITECTURE.md) · [`OPERATIONAL.md`](OPERATIONAL.md) · [`CHANGELOG.md`](CHANGELOG.md) | Design, runbooks, history | +| [`packages/risk-engine`](packages/risk-engine) | Rules, scoring, compliance | +| [`api-service/`](api-service) · [`ui/`](ui) | REST API and Next.js UI | +| [`deploy/`](deploy) | Helm chart, Terraform modules, fixtures | +| [`AGENTS.md`](AGENTS.md) | Secure AWS install workflow and repo conventions for AI agents | +| [`SECURITY.md`](SECURITY.md) | Vulnerability disclosure policy and safe harbor | +| [`templates/`](templates) | Member collection role (`SecurityGraphCollectorRole`) | +| [`scripts/`](scripts) | Helm/Terraform validation, Helm kind smoke, container smoke, Lambda package/smoke, trust-center sync, pre-deploy plan risk, npm audit | +| [`trust-center/`](trust-center) | In-repo static trust-center site generated from the publishable posture | +| [`cdk/`](cdk) | Lambda + EventBridge stack | +| [`ARCHITECTURE.md`](ARCHITECTURE.md) · [`OPERATIONAL.md`](OPERATIONAL.md) · [`CHANGELOG.md`](CHANGELOG.md) | Design, runbooks, history | --- diff --git a/api-service/src/__tests__/attack-path-search.test.ts b/api-service/src/__tests__/attack-path-search.test.ts index 5d01e50c..df5c89a3 100644 --- a/api-service/src/__tests__/attack-path-search.test.ts +++ b/api-service/src/__tests__/attack-path-search.test.ts @@ -3,9 +3,10 @@ import type { GraphVertex } from '../types'; const searchAttackPaths = jest.fn(); const getTrustedPrincipals = jest.fn(); +const listCollectionCoverage = jest.fn(); jest.mock('../services/api-dependencies', () => ({ - getApiNeptuneClient: () => ({ searchAttackPaths, getTrustedPrincipals }), + getApiNeptuneClient: () => ({ searchAttackPaths, getTrustedPrincipals, listCollectionCoverage }), })); let route: typeof import('../routes/attack-paths'); @@ -31,6 +32,7 @@ describe('GET /attack-paths/search', () => { beforeEach(() => { jest.clearAllMocks(); getTrustedPrincipals.mockResolvedValue([]); + listCollectionCoverage.mockResolvedValue([]); }); it('accepts only the preset starts and targets and a 12-digit account', async () => { @@ -225,6 +227,28 @@ describe('GET /attack-paths/search', () => { privilege: 0.5, exploitability: 0.5, reachability: 1, + coverage: 1, }); + // No coverage recorded is labelled, not discounted. + expect(res.body.coverage).toMatchObject({ level: 'unknown' }); + }); + + it('tempers every path when the newest collection is stale, and says so', async () => { + const ec2 = v('i', 'Ec2Instance', { is_internet_exposed: true }); + searchAttackPaths.mockResolvedValue([{ nodes: [ec2], edges: [] }]); + listCollectionCoverage.mockResolvedValue([ + v('coverage', 'CollectionCoverage', { + service: 'resource-inventory', + status: 'SUCCEEDED', + completed_at: new Date(Date.now() - 100 * 3_600_000).toISOString(), + }), + ]); + + const res = await call({ from: 'internet', to: 'crown' }); + + // 0.25 over a fresh graph, halved by a stale one. + expect(res.body.paths[0].likelihood).toBe(0.125); + expect(res.body.paths[0].likelihoodFactors.coverage).toBe(0.5); + expect(res.body.coverage.level).toBe('stale'); }); }); diff --git a/api-service/src/routes/attack-paths.ts b/api-service/src/routes/attack-paths.ts index 835e4693..b121a0a4 100644 --- a/api-service/src/routes/attack-paths.ts +++ b/api-service/src/routes/attack-paths.ts @@ -116,6 +116,9 @@ export async function searchAttackPaths(req: Request, res: Response): Promise 0 || results.some((result) => result.length >= limit); + // A stale graph tempers the likelihood of every path it yielded, and the + // statement travels with the results (same as simulate and scenarios). + const coverage = await graphCoverage(neptuneClient); // Same scoring as the rule runner, so the explorer and Issues agree on // severity and on the path likelihood. const paths = found @@ -123,7 +126,7 @@ export async function searchAttackPaths(req: Request, res: Response): Promise { const riskInput = riskInputFromPath(path.nodes); const { score, severity } = computeRiskScore(riskInput); - const likelihood = pathLikelihood(riskInput); + const likelihood = pathLikelihood(riskInput, coverage.level); return { ...path, score, @@ -164,7 +167,7 @@ export async function searchAttackPaths(req: Request, res: Response): Promise MAX_SIMULATION_PATHS; const bounded = paths.slice(0, MAX_SIMULATION_PATHS); + const coverage = await graphCoverage(neptuneClient); const byEdge = new Map< string, @@ -222,7 +226,7 @@ export async function simulateAttackPaths(req: Request, res: Response): Promise< } >(); for (const path of bounded) { - const likelihood = pathLikelihood(riskInputFromPath(path.vertices)).value; + const likelihood = pathLikelihood(riskInputFromPath(path.vertices), coverage.level).value; const edgeCount = Math.min(path.edges.length, path.vertices.length - 1); for (let index = 0; index < edgeCount; index++) { const edge = path.edges[index]; @@ -248,7 +252,7 @@ export async function simulateAttackPaths(req: Request, res: Response): Promise< ruleName: rule.name, totalPaths: bounded.length, truncated, - coverage: await graphCoverage(neptuneClient), + coverage, edges: [...byEdge.values()] .sort((a, b) => b.pathsUsing - a.pathsUsing || b.worstLikelihood - a.worstLikelihood) .slice(0, MAX_SIMULATION_EDGES) diff --git a/api-service/src/services/coverage.ts b/api-service/src/services/coverage.ts index 79c65492..cc0d43e0 100644 --- a/api-service/src/services/coverage.ts +++ b/api-service/src/services/coverage.ts @@ -1,11 +1,17 @@ +import { + COVERAGE_AGING_HOURS, + COVERAGE_FRESH_HOURS, + coverageLevelForAge, + type CoverageLevel, +} from '@khalifa/risk-engine'; import type { GraphVertex } from '../types'; // Every predictive surface states how fresh the graph it read is, so a // count computed from a days-old collection cannot look confident. The -// thresholds follow the collector cadence: fresh is one collection window -// (two runs' worth, matching the compliance engine's COLLECTION_MAX_AGE_MS), -// aging is three days, stale is beyond that. -export type CoverageLevel = 'fresh' | 'aging' | 'stale' | 'unknown'; +// thresholds and levels are shared with the likelihood they temper +// (@khalifa/risk-engine coverage.ts) so grading never drifts. +export type { CoverageLevel }; +export { COVERAGE_AGING_HOURS, COVERAGE_FRESH_HOURS }; export interface CoverageStatement { level: CoverageLevel; @@ -14,9 +20,6 @@ export interface CoverageStatement { note: string; } -export const COVERAGE_FRESH_HOURS = 8; -export const COVERAGE_AGING_HOURS = 72; - export function coverageFromVertices( coverage: GraphVertex[], now: Date = new Date() @@ -40,12 +43,7 @@ export function coverageFromVertices( const latest = new Date(Math.max(...completions)); const ageHours = Math.round(((now.getTime() - latest.getTime()) / 3_600_000) * 10) / 10; - const level: CoverageLevel = - ageHours <= COVERAGE_FRESH_HOURS - ? 'fresh' - : ageHours <= COVERAGE_AGING_HOURS - ? 'aging' - : 'stale'; + const level = coverageLevelForAge(Math.max(0, ageHours)); const note = level === 'fresh' ? `Newest complete collection ${ageHours}h ago.` diff --git a/api-service/src/types/index.ts b/api-service/src/types/index.ts index 20b739b8..77ad1aa9 100644 --- a/api-service/src/types/index.ts +++ b/api-service/src/types/index.ts @@ -234,7 +234,9 @@ export interface AttackScenarioRunResponse { coverage: CoverageStatement; } -export type CoverageLevel = 'fresh' | 'aging' | 'stale' | 'unknown'; +// Single source of truth lives with the likelihood it tempers. +import type { CoverageLevel } from '@khalifa/risk-engine'; +export type { CoverageLevel }; export interface CoverageStatement { level: CoverageLevel; diff --git a/packages/risk-engine/src/coverage.ts b/packages/risk-engine/src/coverage.ts new file mode 100644 index 00000000..5c832e00 --- /dev/null +++ b/packages/risk-engine/src/coverage.ts @@ -0,0 +1,35 @@ +// Graph coverage vocabulary, shared by every surface that predicts: the API's +// coverage statements, the rule runner's likelihood tempering and the +// risk-plan report all grade freshness the same way. The thresholds follow +// the collector cadence: fresh is one collection window (two runs' worth, +// matching the compliance engine's COLLECTION_MAX_AGE_MS), aging is three +// days, stale is beyond that. +export type CoverageLevel = 'fresh' | 'aging' | 'stale' | 'unknown'; + +export const COVERAGE_FRESH_HOURS = 8; +export const COVERAGE_AGING_HOURS = 72; + +export function coverageLevelForAge(ageHours: number): CoverageLevel { + return ageHours <= COVERAGE_FRESH_HOURS + ? 'fresh' + : ageHours <= COVERAGE_AGING_HOURS + ? 'aging' + : 'stale'; +} + +// How much a coverage level tempers a likelihood. Fresh evidence is not +// punished, aging evidence is discounted, and stale evidence halves the +// plausibility. `unknown` is deliberately NOT discounted: no coverage +// statement (a fresh install, a read failure) is a *labelling* concern — the +// surfaces say so in words — while a stale graph is a *fact* that the score +// should carry. Callers with no level at all get 1, unchanged behavior. +export const COVERAGE_FACTORS: Record = { + fresh: 1, + aging: 0.8, + stale: 0.5, + unknown: 1, +}; + +export function coverageFactor(level?: CoverageLevel): number { + return level ? COVERAGE_FACTORS[level] : 1; +} diff --git a/packages/risk-engine/src/index.ts b/packages/risk-engine/src/index.ts index 74d411ae..b937533f 100644 --- a/packages/risk-engine/src/index.ts +++ b/packages/risk-engine/src/index.ts @@ -51,6 +51,14 @@ export { export { globMatch } from './glob'; export { extractRulePath, type RulePath } from './rule-path'; export { pathLikelihood, type PathLikelihood, type PathLikelihoodFactors } from './likelihood'; +export { + COVERAGE_AGING_HOURS, + COVERAGE_FACTORS, + COVERAGE_FRESH_HOURS, + coverageFactor, + coverageLevelForAge, + type CoverageLevel, +} from './coverage'; export { forecastSeries, type Forecast, type ForecastProjection } from './forecast'; export { indicatorKey, diff --git a/packages/risk-engine/src/likelihood.test.ts b/packages/risk-engine/src/likelihood.test.ts index 2aca561a..644454c2 100644 --- a/packages/risk-engine/src/likelihood.test.ts +++ b/packages/risk-engine/src/likelihood.test.ts @@ -1,3 +1,4 @@ +import { coverageLevelForAge } from './coverage'; import { pathLikelihood } from './likelihood'; import type { RiskScoreInput } from './types'; @@ -9,7 +10,7 @@ const base: RiskScoreInput = { }; describe('pathLikelihood', () => { - it('multiplies the four factors', () => { + it('multiplies the five factors', () => { const result = pathLikelihood({ ...base, exposureLevel: 'internet', @@ -18,6 +19,7 @@ describe('pathLikelihood', () => { }); expect(result.factors).toEqual({ exposure: 1, + coverage: 1, privilege: 1, exploitability: 1, reachability: 1, @@ -91,3 +93,56 @@ describe('pathLikelihood observed reachability', () => { expect(observed.value).toBeGreaterThan(restricted.value); }); }); + +describe('coverage tempering', () => { + const input: RiskScoreInput = { + ...base, + exposureLevel: 'internet', + identityBlastRadius: { type: 'admin', scope: 1 }, + }; + + it('discounts aging and stale graphs deterministically', () => { + const fresh = pathLikelihood(input, 'fresh'); + const aging = pathLikelihood(input, 'aging'); + const stale = pathLikelihood(input, 'stale'); + + expect(fresh.factors.coverage).toBe(1); + expect(aging.factors.coverage).toBe(0.8); + expect(stale.factors.coverage).toBe(0.5); + expect(aging.value).toBeLessThan(fresh.value); + expect(stale.value).toBeLessThan(aging.value); + }); + + it('labels unknown coverage without discounting it, and defaults to no tempering', () => { + const unknown = pathLikelihood(input, 'unknown'); + const omitted = pathLikelihood(input); + + expect(unknown.factors.coverage).toBe(1); + expect(omitted.factors.coverage).toBe(1); + expect(unknown.value).toBe(omitted.value); + }); + + it('multiplies the tempered product with the other factors', () => { + const result = pathLikelihood( + { ...input, exploitability: { cvssBaseScore: 10, exploitAvailable: true } }, + 'stale' + ); + expect(result.value).toBe(0.5); + expect(result.factors).toMatchObject({ + exposure: 1, + privilege: 1, + exploitability: 1, + coverage: 0.5, + }); + }); +}); + +describe('coverageLevelForAge', () => { + it('grades at the collector cadence boundaries', () => { + expect(coverageLevelForAge(0)).toBe('fresh'); + expect(coverageLevelForAge(8)).toBe('fresh'); + expect(coverageLevelForAge(8.1)).toBe('aging'); + expect(coverageLevelForAge(72)).toBe('aging'); + expect(coverageLevelForAge(72.1)).toBe('stale'); + }); +}); diff --git a/packages/risk-engine/src/likelihood.ts b/packages/risk-engine/src/likelihood.ts index 5e5040b0..2d6e6536 100644 --- a/packages/risk-engine/src/likelihood.ts +++ b/packages/risk-engine/src/likelihood.ts @@ -1,3 +1,4 @@ +import { coverageFactor, type CoverageLevel } from './coverage'; import type { RiskScoreInput } from './types'; // Explainable v1 path likelihood: the product of four independent 0..1 @@ -18,6 +19,13 @@ export interface PathLikelihoodFactors { * vertex says exposure or state could not be read. */ reachability: number; + /** + * 1 when the graph is fresh (or no level was provided), 0.8 when aging, + * 0.5 when stale: a prediction over evidence older than the collector + * cadence should not read as confident. `unknown` coverage is labelled on + * the surface rather than discounted — see coverage.ts. + */ + coverage: number; } export interface PathLikelihood { @@ -44,7 +52,10 @@ const PRIVILEGE: Record['type // with a known exploit and more likely than a scoped one. const UNKNOWN = 0.5; -export function pathLikelihood(input: RiskScoreInput): PathLikelihood { +export function pathLikelihood( + input: RiskScoreInput, + coverageLevel?: CoverageLevel +): PathLikelihood { const exposure = EXPOSURE[input.exposureLevel]; const privilege = input.identityBlastRadius ? PRIVILEGE[input.identityBlastRadius.type] : UNKNOWN; @@ -61,8 +72,13 @@ export function pathLikelihood(input: RiskScoreInput): PathLikelihood { // confidence becomes observed AND configured, and observation wins. const reachability = input.reachabilityObserved ? 1 : input.reachabilityRestricted ? UNKNOWN : 1; + // Freshness tempering: the same path over a stale graph is less plausible + // evidence, so the level scales the product and rides along in the factors. + const coverage = coverageFactor(coverageLevel); + return { - value: Math.round(exposure * privilege * exploitability * reachability * 10000) / 10000, - factors: { exposure, privilege, exploitability, reachability }, + value: + Math.round(exposure * privilege * exploitability * reachability * coverage * 10000) / 10000, + factors: { exposure, privilege, exploitability, reachability, coverage }, }; } diff --git a/packages/risk-engine/src/runner.test.ts b/packages/risk-engine/src/runner.test.ts index 3323db69..da6705e9 100644 --- a/packages/risk-engine/src/runner.test.ts +++ b/packages/risk-engine/src/runner.test.ts @@ -882,3 +882,73 @@ describe('RiskRuleRunner one issue per problem', () => { }); }); }); + +describe('graph coverage tempering', () => { + function element(id: string, label: string) { + const { t } = Gremlin.process; + return new Map([ + [t.id, id], + [t.label, label], + ]); + } + + it('grades the newest completed collection into a level', async () => { + const runner = new RiskRuleRunner('localhost:8182'); + const now = new Date('2026-10-27T12:00:00Z'); + const neptune = (runner as any).neptuneClient; + + neptune.executeQuery = jest + .fn() + .mockResolvedValue(['2026-10-27T11:00:00Z', '2026-10-20T00:00:00Z']); + await expect((runner as any).loadCoverageLevel(now)).resolves.toBe('fresh'); + + neptune.executeQuery = jest.fn().mockResolvedValue(['2026-10-20T00:00:00Z']); + await expect((runner as any).loadCoverageLevel(now)).resolves.toBe('stale'); + + neptune.executeQuery = jest.fn().mockResolvedValue([]); + await expect((runner as any).loadCoverageLevel(now)).resolves.toBe('unknown'); + }); + + it('stays tolerant when the coverage read fails', async () => { + const runner = new RiskRuleRunner('localhost:8182'); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + (runner as any).neptuneClient.executeQuery = jest.fn().mockRejectedValue(new Error('down')); + + await expect((runner as any).loadCoverageLevel()).resolves.toBe('unknown'); + warn.mockRestore(); + }); + + it("tempers a stale run's likelihood and stamps the level on issue metadata", async () => { + const { Path } = Gremlin.structure; + const instanceArn = 'arn:aws:ec2:us-west-2:111122223333:instance/i-stale'; + const runner = new RiskRuleRunner('localhost:8182'); + (runner as any).coverageLevel = 'stale'; + const store = (runner as any).issueStore; + const neptune = (runner as any).neptuneClient; + neptune.executeQuery = jest + .fn() + .mockResolvedValue([new Path(['i'], [element(instanceArn, 'Ec2Instance')])]); + store.getIssueByRuleAndResources = jest.fn().mockResolvedValue(null); + store.getActiveIssuesByRule = jest.fn().mockResolvedValue([]); + let created: Issue | undefined; + store.createIssue = jest.fn().mockImplementation(async (issue: Issue) => { + created = issue; + return true; + }); + + await runner.runRule({ + id: 'RULE-001', + name: 'Coverage host rule', + description: 'test', + severityHint: 'critical', + riskFactors: [], + gremlinQueryTemplate: 'g.V()', + ownerTeam: 'cloud-security', + enabled: true, + autoTicketConfig: { enabled: false }, + }); + + expect(created?.metadata.likelihoodFactors).toMatchObject({ coverage: 0.5 }); + expect(created?.metadata.coverageLevel).toBe('stale'); + }); +}); diff --git a/packages/risk-engine/src/runner.ts b/packages/risk-engine/src/runner.ts index febfc446..e684b861 100644 --- a/packages/risk-engine/src/runner.ts +++ b/packages/risk-engine/src/runner.ts @@ -15,6 +15,7 @@ import type { Severity, } from './types'; import { extractRulePath, type RulePath } from './rule-path'; +import { coverageLevelForAge, type CoverageLevel } from './coverage'; import { pathLikelihood } from './likelihood'; import { pathIndicators, ThreatIndicatorStore, type ThreatIndicator } from './threat-intel'; import { techniquesForRule } from './attack-techniques'; @@ -30,6 +31,7 @@ export const RULE_RETRY_DELAYS_MS = [5_000, 15_000, 30_000, 60_000, 60_000]; // Security Hub / GuardDuty findings point at. Beyond the cap the set is // incomplete and issues get no label (absent, not false). const INDEPENDENT_FINDING_CAP = 5000; +const COVERAGE_RECORD_CAP = 5000; // Resources listed on one issue; matchCount keeps the full number. const MAX_ISSUE_RESOURCES = 200; @@ -57,6 +59,11 @@ export class RiskRuleRunner { // driven directly (tests) or the label read failed. private independentFindingLabels?: { resources: Set; complete: boolean }; + // Set by runAllRules for the duration of one run; absent when runRule is + // driven directly (tests). A stale or aging graph tempers every issue's + // path likelihood and the level is stamped on the issue metadata. + private coverageLevel?: CoverageLevel; + async initialize(): Promise { await this.neptuneClient.connect(); } @@ -187,6 +194,7 @@ export class RiskRuleRunner { const results: RuleExecutionResult[] = []; // Resolved once per run so every issue labelled below read the same set. this.independentFindingLabels = await this.loadIndependentFindingLabels(); + this.coverageLevel = await this.loadCoverageLevel(); for (const rule of rules) { const result = await this.runRule(rule); @@ -194,6 +202,7 @@ export class RiskRuleRunner { } this.independentFindingLabels = undefined; + this.coverageLevel = undefined; await this.snapshotTrends(results); return results; @@ -221,6 +230,29 @@ export class RiskRuleRunner { } } + // Freshness of the graph the run just read, from the collector's newest + // SUCCEEDED resource-inventory completion. Failure-tolerant: an unreadable + // or empty coverage set is 'unknown' (the surfaces label it) — never a + // failed run, and never silently confident. + private async loadCoverageLevel(now: Date = new Date()): Promise { + try { + const rows = await this.neptuneClient.executeQuery( + `g.V().hasLabel('CollectionCoverage').has('service', 'resource-inventory').has('status', 'SUCCEEDED').limit(${COVERAGE_RECORD_CAP}).values('completed_at')` + ); + const completions = rows + .map((row) => Date.parse(String(row))) + .filter((time) => Number.isFinite(time)); + if (completions.length === 0) return 'unknown'; + const ageHours = (now.getTime() - Math.max(...completions)) / 3_600_000; + return coverageLevelForAge(Math.max(0, ageHours)); + } catch (error) { + console.warn( + `Collection coverage unavailable (${error instanceof Error ? error.message : String(error)})` + ); + return 'unknown'; + } + } + // Whether an independent detector corroborated at least one resource of the // finding. Rules that are themselves detectors stay unlabelled: corroborating // RULE-018/RULE-029 with their own source is circular. @@ -419,7 +451,7 @@ export class RiskRuleRunner { const now = new Date().toISOString(); const riskInput = this.riskInputWithThreatIntel(path, threatIntel); const scoreResult = computeRiskScore(riskInput); - const likelihood = pathLikelihood(riskInput); + const likelihood = pathLikelihood(riskInput, this.coverageLevel); const independentFinding = this.independentFindingFor(rule, resources); return { @@ -444,6 +476,7 @@ export class RiskRuleRunner { matchCount, ...(threatIntel.length > 0 ? { threatIntelMatches: threatIntel } : {}), ...(independentFinding !== undefined ? { independentFinding } : {}), + ...(this.coverageLevel ? { coverageLevel: this.coverageLevel } : {}), attackTechniques: techniquesForRule(rule.id), }, }; @@ -475,7 +508,7 @@ export class RiskRuleRunner { ): Promise { const riskInput = this.riskInputWithThreatIntel(path, threatIntel); const scoreResult = computeRiskScore(riskInput); - const likelihood = pathLikelihood(riskInput); + const likelihood = pathLikelihood(riskInput, this.coverageLevel); const updatedAt = new Date().toISOString(); const refreshed: Issue = { @@ -497,6 +530,7 @@ export class RiskRuleRunner { environment: riskInput.environment, matchCount, lastRefreshedAt: updatedAt, + ...(this.coverageLevel ? { coverageLevel: this.coverageLevel } : {}), attackTechniques: techniquesForRule(rule.id), }, threatIntel, diff --git a/ui/app/attack-paths/page.test.tsx b/ui/app/attack-paths/page.test.tsx index efb69998..2c7e999c 100644 --- a/ui/app/attack-paths/page.test.tsx +++ b/ui/app/attack-paths/page.test.tsx @@ -25,6 +25,7 @@ function result(overrides: Partial = {}): AttackPathSe paths: [], entries: {}, truncated: false, + coverage: { level: 'fresh', note: 'Newest complete collection 1.2h ago.' }, ...overrides, }; } @@ -255,4 +256,22 @@ describe('AttackPathsPage', () => { expect(await screen.findByRole('alert')).toBeInTheDocument(); expect(screen.queryByText(/more exist/)).not.toBeInTheDocument(); }); + + describe('explorer coverage note', () => { + it('states how fresh the graph behind the results is', async () => { + window.history.replaceState(null, '', '/attack-paths?from=any&to=restricted'); + mockedSearch.mockResolvedValue( + result({ + coverage: { + level: 'stale', + note: 'Newest complete collection 96h ago; treat the counts as stale evidence.', + }, + }) + ); + + render(); + + expect(await screen.findByTestId('coverage-note')).toHaveTextContent('stale evidence'); + }); + }); }); diff --git a/ui/app/attack-paths/page.tsx b/ui/app/attack-paths/page.tsx index 59232358..6c81a960 100644 --- a/ui/app/attack-paths/page.tsx +++ b/ui/app/attack-paths/page.tsx @@ -291,6 +291,11 @@ export default function AttackPathsPage() { account or a narrower start or target to see the rest. )} + {ready && results && ( +

+ {results.coverage.note} +

+ )} {ready && results && ( { name: 'Internet exposure to admin', matchedSteps: 1, totalSteps: 2, + coverage: { level: 'fresh', note: 'Newest complete collection 1.2h ago.' }, steps: [ { ruleId: 'RULE-002', @@ -76,5 +77,6 @@ describe('AttackScenariosPage', () => { expect(within(card).getByText(/3 matched paths/)).toBeInTheDocument(); expect(within(card).getByText('no matches')).toBeInTheDocument(); expect(within(card).getByText(/Graph-only/)).toBeInTheDocument(); + expect(within(card).getByText(/Newest complete collection 1.2h ago/)).toBeInTheDocument(); }); }); diff --git a/ui/app/attack-scenarios/page.tsx b/ui/app/attack-scenarios/page.tsx index 78835d97..4d4c8708 100644 --- a/ui/app/attack-scenarios/page.tsx +++ b/ui/app/attack-scenarios/page.tsx @@ -65,6 +65,7 @@ function ScenarioCard({ scenario }: { scenario: AttackScenarioDetail }) { ); })} + {run &&

{run.coverage.note}

}
diff --git a/ui/types/index.ts b/ui/types/index.ts index 6a192ab6..60f7ea40 100644 --- a/ui/types/index.ts +++ b/ui/types/index.ts @@ -181,6 +181,17 @@ export interface PathLikelihoodFactors { privilege: number; exploitability: number; reachability: number; + /** Freshness tempering (1 fresh/unknown, 0.8 aging, 0.5 stale); absent on old issues. */ + coverage?: number; +} + +export type CoverageLevel = 'fresh' | 'aging' | 'stale' | 'unknown'; + +export interface CoverageStatement { + level: CoverageLevel; + latestSuccessAt?: string; + ageHours?: number; + note: string; } export interface ScoredPath { @@ -212,12 +223,14 @@ export interface AttackPathSimulationResponse { totalPaths: number; truncated: boolean; edges: AttackPathSimulationEdge[]; + coverage: CoverageStatement; } export interface AttackPathSearchResponse { from: AttackPathStart; to: AttackPathTarget; maxPathLength: number; + coverage: CoverageStatement; paths: ScoredPath[]; entries: Record; truncated: boolean; @@ -367,6 +380,7 @@ export interface AttackScenarioRunResponse { matchedSteps: number; totalSteps: number; steps: ScenarioStepRunResult[]; + coverage: CoverageStatement; } export interface RuleQualityItem {