diff --git a/README.md b/README.md index 02829c5..5ce9a1b 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ The current implementation was exercised with: |---|---| | Root `terraform validate` and format check | Passed | | Terraform native mock tests | Root 2/2, KMS 6/6, ALB 5/5 passed without credentials | -| OPA 1.18.2 | 42/42 tests passed | +| OPA 1.18.2 | 57/57 tests passed | | Conftest | Generated hybrid plan passed; generated classical-only plan was denied with 2 failures; malformed plan was blocked | | Checkov 3.3.8 / Trivy 0.69.2 | Zero blocking findings; scanner images pinned by digest | | Live AWS KMS | `ML_DSA_65` created, KMS sign/verify passed, OpenSSL 3.5.7 verification passed, key entered `PendingDeletion` | diff --git a/ROADMAP.md b/ROADMAP.md index 6f58ff8..007388c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,8 @@ QuantumForge expands measurement before remediation surface. New cloud deployment modules should not outrun the inventory, policy, test, and evidence systems needed to evaluate them. +This roadmap distills future work into concrete engineering items. Have an idea, or want to claim an item below? Open an issue or see [CONTRIBUTING.md](CONTRIBUTING.md) before starting a PR. Weighting rationale for the risk-scoring items lives in [docs/SCORING_METHODOLOGY.md](docs/SCORING_METHODOLOGY.md), not here. + ## Shipped foundations - [x] AWS Provider 6.x compatibility and committed lockfiles @@ -12,31 +14,53 @@ QuantumForge expands measurement before remediation surface. New cloud deploymen - [x] Real AWS ML-DSA KMS sign/verify lifecycle harness - [x] Real ALB hybrid PQ-TLS plus classical-fallback runtime harness - [x] Separate inherent risk, migration urgency, remediation effort, and evidence confidence +- [x] Impact promoted to a first-class, co-primary scoring dimension alongside HNDL exposure and data classification (`impact_weight` in `risk_score.rego`) +- [x] Deadline pressure as an independent 0-100 scale blended into migration urgency (80/20), replacing the additive-and-clamped model that collapsed differentiation inside the critical tier +- [x] Calendar-anchored deadlines derived from CNSA 2.0 equipment categories, recomputed from the current time so a stale hand-entered "months remaining" field can't quietly rot +- [x] Overdue assets (`migration_deadline_months < 0`) as valid, maximally-urgent input rather than rejected input +- [x] Classification/impact divergence flagged for review rather than silently double-counted +- [x] Dependency-fan-in advisory hint for possibly-underrated `impact` values (`dependent_asset_count`) +- [x] `migration_work_queue`: a single ranked list across all valid assets by risk, then urgency, with remediation effort used only as a tiebreaker — the previously-unscheduled "PQC Migration Priority Matrix generator" (see Priority 2 below) - [x] Owned, approved, expiring exception records - [x] Attested evidence bundles and an S3 Object Lock publishing interface - [x] Versioned vendor-neutral crypto-inventory schema ## Priority 1: discovery coverage -Expand source-specific collectors and converters into the canonical inventory schema: - -1. cloud API collectors for keys, certificates, CAs, TLS policies, VPN/IPsec, and ownership tags -2. Cryptographic Bill of Materials (CBOM) and application dependency ingestion -3. active and passive protocol observations -4. certificate expiry, signature, key-size, and chain metadata -5. data-flow, environment, business impact, and secrecy-lifetime enrichment -6. SaaS and manual attestations with explicit confidence -7. Kubernetes, container-image, and service-mesh inventory - -Unknown or incomplete evidence must remain visible. A failed collector cannot become an empty or compliant inventory. +Expand source-specific collectors and converters into the canonical inventory schema. Unknown or incomplete evidence must remain visible — a failed collector cannot become an empty or compliant inventory. + +1. **Cloud API collectors for keys, certificates, CAs, TLS policies, VPN/IPsec, and ownership tags** — extends beyond the current Terraform-plan-only collection to assets never provisioned through Terraform. + - [ ] AWS: ACM, KMS custom key stores, and Secrets Manager rotation policies via the AWS SDK, populating `source: "cloud_api"` (already in the schema's `source` enum, currently only emitted in fixtures/design, not by a real collector) + - [ ] Azure: Key Vault and Managed HSM inventory via the Azure SDK + - [ ] GCP: Cloud KMS and Certificate Manager via the Cloud SDK + - [ ] Carry ownership/environment tags into `metadata` rather than dropping them, matching the Terraform-plan collector's existing behavior + - [ ] Add `policies/inventory/validate_test.rego` cases for the new collector outputs +2. **Cryptographic Bill of Materials (CBOM) and application dependency ingestion** + - [ ] Define a CycloneDX-CBOM-to-inventory converter emitting `crypto_function: "library"` / `"protocol"` records with `source: "cbom"` + - [ ] Handle unknown/unparseable CBOM entries as `classification: "unknown"`, never dropped +3. **Active and passive protocol observations** + - [ ] Normalize TLS handshake captures (cipher suite, key exchange group) into the schema's `protocol` and `algorithm` fields +4. **Certificate expiry, signature, key-size, and chain metadata** + - [ ] Populate `certificate_not_after` and `key_size_bits` from CA/certificate stores already discoverable via existing collectors, rather than requiring a new source +5. **Data-flow, environment, business impact, and secrecy-lifetime enrichment** + - [ ] Build an enrichment step that fills `data_flow`, `secrecy_lifetime_years`, `data_classification`, and `impact` from ownership/CMDB records so these aren't purely manual entry + - [ ] Feed `dependent_asset_count` (Priority-2-adjacent, see gap notes in `docs/SCORING_METHODOLOGY.md`) from the same enrichment pass once a real dependency graph exists +6. **SaaS and manual attestations with explicit confidence** + - [ ] Structured attestation template mapping directly to `source: "manual_attestation"` plus a required `evidence_confidence` +7. **Kubernetes, container-image, and service-mesh inventory** + - [ ] cert-manager and service-mesh mTLS certificate inventory, feeding the same schema ahead of any Kubernetes remediation module (see Priority 3) ## Priority 2: governance automation -- generate migration work queues from risk and urgency while keeping effort separate -- require independent approval and immutable history for exceptions -- add policy/version metadata to every decision -- add dashboards for inventory coverage, stale observations, unknown algorithms, and expiring exceptions -- add automated NIST Cryptographic Module Validation Program (CMVP) watchlist comparison after a stable machine-readable source and baseline snapshot exist +- [x] Generate migration work queues from risk and urgency while keeping effort separate — implemented as `migration_work_queue` in `policies/scoring/risk_score.rego`, sorted by `inherent_risk_score` desc, then `migration_urgency_score` desc, with `remediation_effort` used only as a tiebreaker. This is the previously-unscheduled "PQC Migration Priority Matrix generator" (see the restored section below) — folded in here rather than left as a separate, disconnected idea. +- [ ] Require independent approval and immutable history for exceptions + - [ ] `policies/governance/exceptions.rego` already requires owner/approver/rationale/compensating controls; add a check that `owner != approver` for a real independent-approval guarantee + - [ ] Exception renewals must append a new record rather than mutate `expires_at` in place — add a test asserting the same `id` cannot appear twice with different `expires_at` values (distinct from the existing `duplicate_exception_ids` check, which only catches exact-ID collisions) +- [ ] Add policy/version metadata to every decision + - [ ] Stamp `assessment.policy_version` (git SHA or semver) into `risk_score.rego`'s and `exceptions.rego`'s output objects so an old evidence bundle can be tied back to the exact policy version that produced it +- [ ] Add dashboards for inventory coverage, stale observations, unknown algorithms, and expiring exceptions + - [ ] Start from `assessment.invalid_inventory`, `priority_matrix`, and `migration_work_queue` as the data source; no new Rego needed to start, just a rendering layer +- [ ] Add automated NIST Cryptographic Module Validation Program (CMVP) watchlist comparison after a stable machine-readable source and baseline snapshot exist (see restored section below for the specific source) ## Priority 3: platform remediation modules @@ -53,6 +77,22 @@ Only add deployment modules when the target API is generally available and can b Azure, GCP, on-prem, SaaS, and application assets can already be represented in `schemas/crypto-inventory.schema.json`; representation does not imply deployable PQC support. +Every new Terraform module should follow the pattern already established by `hybrid-pqc-kms`/`hybrid-pqc-alb`: + +- [ ] Variable `validation` blocks constrain inputs to NIST-approved algorithms/constructions — never rely on documentation alone +- [ ] `terraform validate` and `terraform fmt -check -recursive` clean before merge +- [ ] A discovery policy update so `policies/discovery/classify.rego` recognizes the new resource type +- [ ] A README module table entry and a Validation Status row + +## Other Build Guide items not yet scheduled + +Called out in the [PQC Readiness Program Build Guide](docs/PQC_Readiness_Program_Build_Guide.md) but not currently sequenced. Two of the four items previously listed here have moved: automated CMVP watchlist comparison is now tracked under Priority 2 above, and the Migration Priority Matrix generator is implemented (see Shipped foundations and Priority 2). The remaining two: + +- **CBOM generation in CI** — wire CycloneDX-CBOM generation into `census.yml` rather than leaving it a manual step ahead of the CBOM ingestion work in Priority 1. +- **Executive crypto-census briefing generator** — auto-produce the board-ready "% of estate quantum-vulnerable" summary directly from `assessment` (inventory + scoring output), rather than a manually assembled deck. + +CMVP watchlist comparison specifically means scripting the cross-reference against the [NIST CMVP Modules in Process list](https://csrc.nist.gov/projects/cryptographic-module-validation-program/modules-in-process/modules-in-process-list) instead of doing it manually — tracked as the last bullet under Priority 2. + ## Contribution rule Every new collector or module must include: @@ -64,3 +104,12 @@ Every new collector or module must include: - live verification when claiming platform behavior - unconditional cleanup and a cost estimate for paid tests - updated inventory, policy, and evidence documentation + +## Status legend + +| Symbol | Meaning | +|---|---| +| [x] | Shipped | +| [ ] | Planned, not started | + +This roadmap reflects current thinking and will shift as platform PQC support (especially Azure and GCP) matures and as discovery coverage expands. Open an issue if you want to propose reordering or adding an item. diff --git a/docs/GOVERNANCE.md b/docs/GOVERNANCE.md index 39f4581..0bd4304 100644 --- a/docs/GOVERNANCE.md +++ b/docs/GOVERNANCE.md @@ -2,16 +2,24 @@ ## Risk model -`policies/scoring/risk_score.rego` keeps four decision dimensions separate: +`policies/scoring/risk_score.rego` keeps decision dimensions separate: -- `inherent_risk_score` (0–100): exposure to data stolen now and decrypted after future quantum advances, data classification, and business impact -- `migration_urgency_score` (0–100): inherent risk plus a deadline-pressure weight -- `remediation_effort`: `low`, `medium`, or `high`; it never reduces risk -- `evidence_confidence`: `1.0`, `0.7`, or `0.4`; it is reported independently rather than multiplying risk downward +- `inherent_risk_score` (0–100): HNDL exposure window, data classification, and business impact, summed +- `migration_urgency_score` (0–100): a weighted blend of `inherent_risk_score` (80%) and a separate 0–100 `deadline_pressure_score` (20%) — not an additive bonus clamped at 100, so two assets can share the same inherent risk yet still rank differently by how close their deadline is +- `remediation_effort`: `low`, `medium`, or `high`; it never reduces risk — the only place it affects ordering is as a tiebreaker in `migration_work_queue` between assets that already tie on risk and urgency +- `evidence_confidence`: `1.0`, `0.7`, or `0.4`; reported independently rather than multiplying risk downward +- `needs_divergence_review`: flags assets where `data_classification` and `impact` tiers diverge by 2+ levels, since the two are summed as if independent but correlate in practice — advisory only, never changes the score +- `impact_may_be_underrated`: flags assets whose optional `dependent_asset_count` suggests a higher blast radius than the manually assigned `impact` tier — advisory only, never overrides the manual value -Scoring consumes the same canonical inventory contract as discovery. Before scoring, enrich each asset with the schema-defined optional fields `secrecy_lifetime_years`, `data_classification`, `impact`, `migration_deadline_months`, and `remediation_effort`. Results are keyed by `asset_id`. Missing, malformed, negative, or unsupported enrichment appears in `invalid_inventory` and is not silently scored with optimistic defaults. +The full weighting rationale, the deadline-pressure bucket boundaries, and worked examples are in [SCORING_METHODOLOGY.md](SCORING_METHODOLOGY.md). -See [`examples/inventory/scoring-ready-inventory.json`](../examples/inventory/scoring-ready-inventory.json) for a schema-valid end-to-end input. +An asset's deadline can come from either input: an explicit `migration_deadline_months` (which may be negative — an asset that has already missed its deadline is valid input, not an error), or an optional `regulatory_category` mapped to the CNSA 2.0 equipment-category calendar and recomputed from the current time on every evaluation so it never goes stale. An explicit `migration_deadline_months` always takes precedence when both are present. + +`migration_work_queue` produces a single ranked list across all valid assets — sorted by `inherent_risk_score` descending, then `migration_urgency_score` descending, with `remediation_effort` used only to break ties — implementing the roadmap's migration-priority-matrix generator. `priority_matrix` still groups assets by tier for a coarser view. + +Scoring consumes the same canonical inventory contract as discovery. Before scoring, enrich each asset with the schema-defined optional fields `secrecy_lifetime_years`, `data_classification`, `impact`, `migration_deadline_months` or `regulatory_category`, `remediation_effort`, and optionally `dependent_asset_count`. Results are keyed by `asset_id`. Missing, malformed, or unsupported enrichment appears in `invalid_inventory` and is not silently scored with optimistic defaults. + +See [`examples/inventory/scoring-ready-inventory.json`](../examples/inventory/scoring-ready-inventory.json) and [`examples/inventory/calendar-anchored-inventory.json`](../examples/inventory/calendar-anchored-inventory.json) for schema-valid end-to-end inputs. ## Exceptions diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 9d4e97f..45e14d0 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -46,7 +46,7 @@ check-jsonschema \ The collector emits the standard `{schema_version, assets}` document, normalizes KMS `SIGN_VERIFY` to `signing`, carries Terraform tags into metadata, and records one observation timestamp for the full collection. Missing owner or environment tags remain explicit as `unassigned` or `unknown`; downstream enrichment should resolve them rather than silently dropping the assets. -Risk enrichment remains inside the same schema through optional `secrecy_lifetime_years`, `data_classification`, `impact`, `migration_deadline_months`, and `remediation_effort` fields. `examples/inventory/scoring-ready-inventory.json` demonstrates an asset that can be validated and scored without discarding information during conversion. +Risk enrichment remains inside the same schema through optional `secrecy_lifetime_years`, `data_classification`, `impact`, `migration_deadline_months` (or the calendar-anchored `regulatory_category` alternative), `remediation_effort`, and `dependent_asset_count` fields. `examples/inventory/scoring-ready-inventory.json` and `examples/inventory/calendar-anchored-inventory.json` demonstrate assets that can be validated and scored without discarding information during conversion. See [docs/GOVERNANCE.md](GOVERNANCE.md) and [docs/SCORING_METHODOLOGY.md](SCORING_METHODOLOGY.md) for what each field feeds into. ## Next inventory data sources diff --git a/docs/PQC_Readiness_Program_Build_Guide.md b/docs/PQC_Readiness_Program_Build_Guide.md index e509f69..37dc465 100644 --- a/docs/PQC_Readiness_Program_Build_Guide.md +++ b/docs/PQC_Readiness_Program_Build_Guide.md @@ -305,6 +305,9 @@ Collected records are normalized into the versioned inventory envelope and class tier(asset) := "medium" if migration_urgency_score(asset) >= 35; migration_urgency_score(asset) < 60 tier(asset) := "low" if migration_urgency_score(asset) < 35 ``` + + > **Note:** this snippet is the Phase 2 origin design and is kept as-written for history. The shipped `policies/scoring/risk_score.rego` has since evolved past it — `deadline_weight`'s additive-and-clamped formula collapsed differentiation once `inherent_risk_score` hit 80, so `migration_urgency_score` is now an explicit 80/20 blend against a separate `deadline_pressure_score` scale, deadlines can be calendar-anchored to a `regulatory_category` instead of only a hand-entered `migration_deadline_months`, and `tier` is now derived from `inherent_risk_score` rather than the blended urgency score. See [docs/SCORING_METHODOLOGY.md](SCORING_METHODOLOGY.md) for the current model and rationale. + 2. Write unit tests covering boundary conditions on every tier threshold before deployment: ```bash opa test -v policies/scoring/ diff --git a/docs/SCORING_METHODOLOGY.md b/docs/SCORING_METHODOLOGY.md new file mode 100644 index 0000000..605705b --- /dev/null +++ b/docs/SCORING_METHODOLOGY.md @@ -0,0 +1,208 @@ +# Scoring methodology + +This document exists so a reviewer or auditor can trace every weight and +bucket boundary in `policies/scoring/risk_score.rego` back to a rationale, +rather than treating them as arbitrary constants. It is the companion to +[GOVERNANCE.md](GOVERNANCE.md), which describes what the scoring dimensions +mean; this document explains why they're weighted the way they are. + +## Framework anchor: NIST SP 800-30 + +The scoring model is a likelihood x impact risk model in the sense used by +[NIST SP 800-30, "Guide for Conducting Risk Assessments"](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-30r1.pdf): + +- **Likelihood-side inputs** — how likely is it that this asset's + cryptography is broken and exploited, and over what time horizon — + are represented by the harvest-now-decrypt-later (HNDL) exposure window + (`hndl_weight`, driven by `secrecy_lifetime_years`) and by `data_classification` + (an NSS or regulated asset draws more attacker attention and sits under a + harder regulatory deadline than an internal or public one). +- **Impact-side inputs** — how consequential is it if that break happens — + are represented by `impact` (`impact_weight`), the blast radius/criticality + of the asset. + +`inherent_risk_score` sums these three weights rather than multiplying them, +which is a deliberate simplification versus a strict likelihood x impact +product: the underlying weights are ordinal tiers (four buckets each), not +calibrated probabilities or dollar-loss estimates, so a product would imply +more numeric precision than the inputs actually carry. The sum preserves +monotonicity (more exposure, more sensitive classification, or higher +impact each only ever raise the score) while staying auditable as +"add up the tier weights," which is easy to hand-verify during a review. + +## Why HNDL exposure caps at 40 for `secrecy_lifetime_years >= 10` + +Harvest-now-decrypt-later is the primary reason PQC migration is urgent +*before* a cryptographically relevant quantum computer exists: data +encrypted today with a vulnerable algorithm can be captured now and +decrypted retroactively once one exists. The bucket boundaries (`>=10`, +`5-10`, `<5` years) follow the horizon most PQC transition guidance +converges on: NIST's own transition timeline +([NIST IR 8547](https://nvlpubs.nist.gov/nistpubs/ir/2024/NIST.IR.8547.ipd.pdf)) +treats the mid-2030s as the point where today's <=112-bit classical +algorithms (RSA-2048, ECC P-256) become disallowed, which is roughly a +decade out from when this model was written — so `secrecy_lifetime_years +>= 10` is treated as maximal HNDL exposure rather than continuing to scale +weight upward indefinitely for even longer secrecy requirements. + +## Why classification and impact are summed, not multiplied — and the divergence flag + +`classification_weight` (0/10/20/30 for public/internal/regulated/nss) and +`impact_weight` (5/10/20/30 for informational/operational/business_critical/ +mission_critical) are both additive inputs into `inherent_risk_score`. In +practice these two correlate — an NSS asset is very often also +mission_critical — which risks double-counting the same underlying risk +signal for the common, aligned case. + +This model does **not** attempt to correct for that correlation inside the +score itself. Discounting or interaction-term approaches require knowing the +actual correlation structure across a real inventory, and QuantumForge has +none to validate against yet — an unproven correction is worse than an +acknowledged simplification. Instead, `classification_impact_divergence` and +`needs_divergence_review` (in `risk_score.rego`) surface the cases where the +two tiers disagree by two or more levels (e.g. `public` classification but +`mission_critical` impact) for human review. This is exactly the case an +additive model handles worst either way, so flagging it for a person is more +honest than silently reweighting a score built on an unvalidated assumption. +If a future inventory shows a strong, stable correlation, revisit the sum +itself rather than just the flag. + +## Migration urgency: an 80/20 blend, not additive-and-clamped + +Earlier versions of this model computed +`migration_urgency_score = min(100, inherent_risk_score + deadline_weight)`. +That collapsed differentiation exactly where it mattered most: any asset +with `inherent_risk_score >= 80` (the "critical" tier) combined with any +deadline inside the old 0-6-month bucket (`deadline_weight` = 20) already +hit the 100 ceiling. Two critical-tier assets — one overdue, one three years +from its deadline — reported an identical urgency score, so the ranking +gave no signal for sequencing work inside the tier that most needs +sequencing. + +The current model instead computes: + +``` +migration_urgency_score = round(0.8 * inherent_risk_score + 0.2 * deadline_pressure_score) +``` + +`deadline_pressure_score` is its own independent 0-100 scale (see bucket +table below), and the 80/20 split is a deliberate design choice, not a +fitted parameter: inherent risk (what's exposed, how sensitive, how +consequential) should dominate the ranking, while deadline pressure adds +meaningful separation without letting a merely soon-due but otherwise minor +asset outrank a maximally risky one. Because both terms are bounded to +[0, 100], the blended score is also always bounded to [0, 100], and an asset +that is both maximally risky *and* maximally deadline-pressured still +resolves to exactly 100 — the old model's "true worst case" behavior is +preserved, just no longer masking every other case in the same tier. + +### `deadline_pressure_score` buckets + +| Months remaining | Pressure | +|---|---| +| <= 3 (including any negative/overdue value) | 100 | +| 3 < months <= 6 | 85 | +| 6 < months <= 12 | 65 | +| 12 < months <= 24 | 45 | +| 24 < months <= 36 | 25 | +| 36 < months <= 60 | 10 | +| > 60 | 0 | + +The buckets get coarser as the deadline gets farther out because precision +close to a deadline is operationally useful (quarterly sequencing) while +precision three-plus years out is not (nothing changes about this week's +work queue based on a five-year versus six-year deadline). A negative value +— the deadline has already passed — maps to the same maximum pressure as +"due within 3 months" rather than being rejected as invalid input: an +overdue asset is the most urgent case this model can represent, not an +erroneous one. + +### Where the deadline number comes from + +`effective_deadline_months` resolves an asset's deadline from either of two +inputs, in this precedence order: + +1. An explicit `migration_deadline_months` on the asset, if present. This + lets a team commit to an internal deadline tighter than the regulatory + floor, and always wins when both inputs are supplied. +2. Otherwise, an optional `regulatory_category` mapped to a CNSA 2.0 + "exclusively use CNSA 2.0 algorithms" date + ([NSA CSA, updated May 30, 2025](https://media.defense.gov/2025/May/30/2003728741/-1/-1/0/CSA_CNSA_2.0_ALGORITHMS.PDF)), + converted to a months-remaining figure computed from the current time + (`time.now_ns()`) on every policy evaluation. This is deliberately + calendar-anchored rather than a hand-entered relative number, so the + countdown can't silently go stale the way a manually maintained "months + remaining" field would. + +| `regulatory_category` | CNSA 2.0 exclusive-use date | +|---|---| +| `software_firmware_signing` | 2030-01-01 | +| `traditional_networking` | 2030-01-01 | +| `web_cloud_services` | 2033-01-01 | +| `operating_systems` | 2033-01-01 | +| `niche_equipment` | 2033-01-01 | +| `custom_legacy` | 2033-01-01 | + +An asset with neither input is invalid input, not silently scored with an +optimistic default. + +## Migration work queue: effort as a tiebreaker only + +`migration_work_queue` ranks all valid assets by `inherent_risk_score` +descending, then `migration_urgency_score` descending, and only uses +`remediation_effort` to break ties between assets that are already equal on +both risk and urgency. This mirrors how `remediation_effort` behaves +everywhere else in the model — it is reported, and can help a team pick +which of two equally urgent items to start first, but it can never make a +lower-risk asset outrank a higher-risk one just because it happens to be +cheaper to fix. Optimizing a work queue purely for "easy wins first" is a +common anti-pattern in vulnerability management: it produces good-looking +throughput numbers while the highest-risk assets sit unaddressed. + +## Impact-from-dependencies: an advisory hint, not a derivation + +`impact` is currently a manual judgment call, which means it can be wrong — +typically too low, when a reviewer doesn't realize how many other assets +depend on the one in front of them (a signing key backing forty +certificates, marked "operational" because nobody added up the fan-in). +`impact_may_be_underrated`, driven by the optional `dependent_asset_count` +field, flags exactly that case using fixed count thresholds +(`>=25` suggests `mission_critical`, `>=5` suggests `business_critical`, +`>=1` suggests `operational`) without ever overriding the manually set +`impact` value. This is intentionally a minimal, advisory increment rather +than the full fan-in-based impact derivation a real dependency graph would +enable — that requires a dependency-graph field in the inventory schema, +which doesn't exist yet and is tracked under the discovery-coverage +priority in [ROADMAP.md](../ROADMAP.md). + +## Worked example + +An NSS asset with a 10-year secrecy requirement, `mission_critical` impact, +and a deadline 6 months out: + +``` +inherent_risk_score = hndl_weight(10) + classification_weight("nss") + impact_weight("mission_critical") + = 40 + 30 + 30 + = 100 (tier: critical) + +deadline_pressure_score(6) = 85 (3 < 6 <= 6 bucket) + +migration_urgency_score = round(0.8 * 100 + 0.2 * 85) + = round(80 + 17) + = 97 +``` + +Compare against the same asset with a 3-year-out deadline instead: + +``` +deadline_pressure_score(36) = 25 + +migration_urgency_score = round(0.8 * 100 + 0.2 * 25) + = round(80 + 5) + = 85 +``` + +Both assets tier as `critical` (inherent risk alone decides tier), but the +6-month deadline asset scores meaningfully higher urgency (97 vs. 85) — +exactly the differentiation the old additive-and-clamped model lost once +both assets crossed the 100-point ceiling. diff --git a/examples/inventory/calendar-anchored-inventory.json b/examples/inventory/calendar-anchored-inventory.json new file mode 100644 index 0000000..2c9079d --- /dev/null +++ b/examples/inventory/calendar-anchored-inventory.json @@ -0,0 +1,42 @@ +{ + "schema_version": "1.0.0", + "assets": [ + { + "asset_id": "aws_kms_key.ca-anchored", + "provider": "aws", + "resource_type": "aws_kms_key", + "crypto_function": "signing", + "algorithm": "RSA_2048", + "classification": "classical_only", + "owner": "security-engineering", + "environment": "production", + "source": "terraform_plan", + "observed_at": "2026-07-20T00:00:00Z", + "evidence_confidence": "high", + "secrecy_lifetime_years": 10, + "data_classification": "nss", + "impact": "mission_critical", + "regulatory_category": "software_firmware_signing", + "dependent_asset_count": 40, + "remediation_effort": "high" + }, + { + "asset_id": "aws_acm_certificate.overdue", + "provider": "aws", + "resource_type": "aws_acm_certificate", + "crypto_function": "certificate", + "algorithm": "RSA_2048", + "classification": "classical_only", + "owner": "security-engineering", + "environment": "production", + "source": "terraform_plan", + "observed_at": "2026-07-20T00:00:00Z", + "evidence_confidence": "medium", + "secrecy_lifetime_years": 3, + "data_classification": "regulated", + "impact": "business_critical", + "migration_deadline_months": -2, + "remediation_effort": "medium" + } + ] +} diff --git a/policies/scoring/risk_score.rego b/policies/scoring/risk_score.rego index 4226703..3de8860 100644 --- a/policies/scoring/risk_score.rego +++ b/policies/scoring/risk_score.rego @@ -2,6 +2,10 @@ # # Risk, urgency, implementation effort, and evidence confidence are separate # outputs. Delivery convenience must never lower the inherent risk of an asset. +# +# Weighting rationale, the deadline-pressure model, and worked examples are +# documented in docs/SCORING_METHODOLOGY.md — read that before changing any +# weight or bucket boundary below. package quantumforge.scoring @@ -12,11 +16,25 @@ impact_values := {"mission_critical", "business_critical", "operational", "infor effort_values := {"low", "medium", "high"} confidence_values := {"high", "medium", "low"} +# CNSA 2.0 equipment categories (NSA CSA, updated May 2025) used to derive a +# calendar-anchored migration_deadline_months when an asset doesn't carry an +# explicit override. See regulatory_deadline_rfc3339 below for sources. +regulatory_category_values := { + "software_firmware_signing", + "web_cloud_services", + "traditional_networking", + "operating_systems", + "niche_equipment", + "custom_legacy", +} + hndl_weight(years) := 40 if years >= 10 + hndl_weight(years) := 25 if { years >= 5 years < 10 } + hndl_weight(years) := 10 if years < 5 classification_weight("nss") := 30 @@ -29,12 +47,47 @@ impact_weight("business_critical") := 20 impact_weight("operational") := 10 impact_weight("informational") := 5 -deadline_weight(months) := 20 if months <= 6 -deadline_weight(months) := 10 if { +# --- Deadline pressure (0-100), separate from inherent risk ---------------- +# +# deadline_pressure_score is intentionally its own 0-100 scale rather than a +# small additive bonus. It blends into migration_urgency_score at a fixed +# 20% weight (see migration_urgency_score) instead of being added onto +# inherent_risk_score and clamped, so assets already at maximum inherent risk +# still separate from each other by deadline instead of all collapsing to +# the same capped value. +# +# A negative value means the deadline has already passed. It maps to the +# same maximum pressure as "due within 3 months" rather than being treated +# as invalid input — an overdue asset is the most urgent case, not an +# erroneous one (see valid_number/gap #3 in SCORING_METHODOLOGY.md). +deadline_pressure_score(months) := 100 if months <= 3 + +deadline_pressure_score(months) := 85 if { + months > 3 + months <= 6 +} + +deadline_pressure_score(months) := 65 if { months > 6 - months <= 18 + months <= 12 +} + +deadline_pressure_score(months) := 45 if { + months > 12 + months <= 24 +} + +deadline_pressure_score(months) := 25 if { + months > 24 + months <= 36 } -deadline_weight(months) := 0 if months > 18 + +deadline_pressure_score(months) := 10 if { + months > 36 + months <= 60 +} + +deadline_pressure_score(months) := 0 if months > 60 confidence_value("high") := 1.0 confidence_value("medium") := 0.7 @@ -50,12 +103,59 @@ valid_nonnegative_number(value) if { value >= 0 } else := false +# Unlike valid_nonnegative_number, valid_number allows negative values. Used +# for migration_deadline_months, where a negative value is a meaningful, +# valid "overdue by N months" state rather than bad input. +valid_number(value) if { + is_number(value) +} else := false + +# --- Calendar-anchored deadlines (gap #2) ----------------------------------- +# +# CNSA 2.0 "exclusively use CNSA 2.0 algorithms" dates by equipment category. +# Source: NSA CSA, "CNSA 2.0 Algorithms" (updated May 30, 2025): +# https://media.defense.gov/2025/May/30/2003728741/-1/-1/0/CSA_CNSA_2.0_ALGORITHMS.PDF +regulatory_deadline_rfc3339("software_firmware_signing") := "2030-01-01T00:00:00Z" +regulatory_deadline_rfc3339("traditional_networking") := "2030-01-01T00:00:00Z" +regulatory_deadline_rfc3339("web_cloud_services") := "2033-01-01T00:00:00Z" +regulatory_deadline_rfc3339("operating_systems") := "2033-01-01T00:00:00Z" +regulatory_deadline_rfc3339("niche_equipment") := "2033-01-01T00:00:00Z" +regulatory_deadline_rfc3339("custom_legacy") := "2033-01-01T00:00:00Z" + +ns_per_day := ((86400 * 1000) * 1000) * 1000 + +# Approximate at 30 days/month. Documented rather than hidden, since it's +# only meant to place a date into one of deadline_pressure_score's buckets, +# not to report a precise day count. +months_between_ns(later_ns, earlier_ns) := ((later_ns - earlier_ns) / ns_per_day) / 30 + +calendar_deadline_months(category) := months if { + category in regulatory_category_values + deadline_ns := time.parse_rfc3339_ns(regulatory_deadline_rfc3339(category)) + months := months_between_ns(deadline_ns, time.now_ns()) +} + +# An explicit migration_deadline_months always wins over the regulatory +# calendar — it lets a team commit to an internal deadline tighter than the +# regulatory floor. Falling back to regulatory_category keeps the countdown +# from silently going stale: it's recomputed from time.now_ns() on every +# evaluation instead of being a hand-edited number that rots. +effective_deadline_months(asset) := asset.migration_deadline_months if { + valid_number(object.get(asset, "migration_deadline_months", null)) +} else := calendar_deadline_months(object.get(asset, "regulatory_category", null)) + +has_deadline_input(asset) if { + valid_number(object.get(asset, "migration_deadline_months", null)) +} else if { + object.get(asset, "regulatory_category", null) in regulatory_category_values +} else := false + metadata_checks(asset) := { "asset_id must be a non-empty string": valid_nonempty_string(object.get(asset, "asset_id", null)), "secrecy_lifetime_years must be a non-negative number": valid_nonnegative_number(object.get(asset, "secrecy_lifetime_years", null)), "data_classification is missing or unsupported": object.get(asset, "data_classification", null) in classification_values, "impact is missing or unsupported": object.get(asset, "impact", null) in impact_values, - "migration_deadline_months must be a non-negative number": valid_nonnegative_number(object.get(asset, "migration_deadline_months", null)), + "migration_deadline_months must be a number, or regulatory_category must be set": has_deadline_input(asset), "remediation_effort is missing or unsupported": object.get(asset, "remediation_effort", null) in effort_values, "evidence_confidence is missing or unsupported": object.get(asset, "evidence_confidence", null) in confidence_values, } @@ -69,29 +169,113 @@ is_valid(asset) if count(metadata_errors(asset)) == 0 inherent_risk_score(asset) := total if { is_valid(asset) - total := hndl_weight(asset.secrecy_lifetime_years) + - classification_weight(asset.data_classification) + - impact_weight(asset.impact) + total := (hndl_weight(asset.secrecy_lifetime_years) + classification_weight(asset.data_classification)) + impact_weight(asset.impact) } # Compatibility alias for existing policy consumers. score(asset) := inherent_risk_score(asset) -migration_urgency_score(asset) := min([100, raw]) if { - raw := inherent_risk_score(asset) + deadline_weight(asset.migration_deadline_months) +# migration_urgency_score blends inherent risk (80%) with deadline pressure +# (20%) instead of adding deadline_weight onto inherent_risk_score and +# clamping at 100. The old additive-and-clamp model made deadline pressure +# invisible for any asset already scoring >= 80: 80 + up to 20 always hit the +# 100 ceiling, so two critical-tier assets with wildly different deadlines +# (one overdue, one three years out) reported identically, losing all +# sequencing power exactly where it matters most. The weighted blend keeps +# both signals visible across the whole scale, including inside the critical +# tier, while an asset that is maximally risky *and* maximally +# deadline-pressured still resolves to exactly 100. +migration_urgency_score(asset) := round(raw) if { + is_valid(asset) + raw := (inherent_risk_score(asset) * 0.8) + (deadline_pressure_score(effective_deadline_months(asset)) * 0.2) } tier(asset) := "critical" if inherent_risk_score(asset) >= 80 + tier(asset) := "high" if { inherent_risk_score(asset) >= 60 inherent_risk_score(asset) < 80 } + tier(asset) := "medium" if { inherent_risk_score(asset) >= 35 inherent_risk_score(asset) < 60 } + tier(asset) := "low" if inherent_risk_score(asset) < 35 +# --- Classification/impact divergence (gap #4, reported, never scored) ----- +# +# data_classification and impact are summed as independent inputs, but in +# practice they correlate — an NSS asset is almost always mission_critical. +# Summing both risks double-counting the common, aligned case. Rather than +# guess at a statistical correction with no real inventory data to validate +# it against, this flags only the *divergent* cases for human review instead +# of silently reweighting the score. A large gap between the two tiers +# (e.g. "public" classification but "mission_critical" impact, or the +# reverse) is exactly the case an additive model handles worst either way. +classification_tier_rank("nss") := 3 +classification_tier_rank("regulated") := 2 +classification_tier_rank("internal") := 1 +classification_tier_rank("public") := 0 + +impact_tier_rank("mission_critical") := 3 +impact_tier_rank("business_critical") := 2 +impact_tier_rank("operational") := 1 +impact_tier_rank("informational") := 0 + +classification_impact_divergence(asset) := gap if { + is_valid(asset) + gap := abs(classification_tier_rank(asset.data_classification) - impact_tier_rank(asset.impact)) +} + +# Also a total function for the same reason as impact_may_be_underrated +# below — it's used directly as a value inside scored_inventory's entry. +needs_divergence_review(asset) if { + classification_impact_divergence(asset) >= 2 +} else := false + +# --- Dependency-informed impact hint (gap #7, advisory, never overrides) --- +# +# impact is currently a human judgment call. A durable fix needs a real +# dependency graph in the inventory schema (tracked under ROADMAP.md's +# discovery-coverage priority). As a first, low-cost increment: if an asset +# reports how many other inventoried assets depend on it +# (dependent_asset_count), flag when the manually assigned impact tier looks +# too low for that fan-in, so a reviewer can catch under-rated trust anchors +# (e.g. a signing key backing 40 certificates marked "operational") without +# the tool silently overriding anyone's judgment. +suggested_impact_floor(count) := "mission_critical" if count >= 25 + +suggested_impact_floor(count) := "business_critical" if { + count >= 5 + count < 25 +} + +suggested_impact_floor(count) := "operational" if { + count >= 1 + count < 5 +} + +suggested_impact_floor(count) := "informational" if count < 1 + +# A total function (defaults to false) rather than a partial rule — used +# directly as a value inside scored_inventory's entry object below, where an +# undefined value would silently drop the whole entry instead of reporting +# it with impact_may_be_underrated: false. +impact_may_be_underrated(asset) if { + is_valid(asset) + count := object.get(asset, "dependent_asset_count", null) + is_number(count) + count >= 0 + impact_tier_rank(suggested_impact_floor(count)) > impact_tier_rank(asset.impact) +} else := false + +# Rounded to one decimal for display only — deadline_pressure_score and +# migration_urgency_score always consume the unrounded value from +# effective_deadline_months, so this rounding never affects scoring. +display_deadline_months(asset) := round(effective_deadline_months(asset) * 10) / 10 + scored_inventory contains entry if { some asset in input.assets is_valid(asset) @@ -99,9 +283,12 @@ scored_inventory contains entry if { "asset_id": asset.asset_id, "inherent_risk_score": inherent_risk_score(asset), "migration_urgency_score": migration_urgency_score(asset), + "effective_deadline_months": display_deadline_months(asset), "tier": tier(asset), "remediation_effort": asset.remediation_effort, "evidence_confidence": confidence_value(asset.evidence_confidence), + "needs_divergence_review": needs_divergence_review(asset), + "impact_may_be_underrated": impact_may_be_underrated(asset), } } @@ -122,10 +309,60 @@ priority_matrix := { "low": sort([e.asset_id | some e in scored_inventory; e.tier == "low"]), } +# --- Migration work queue (gap #6 / roadmap: "PQC Migration Priority Matrix +# generator") ---------------------------------------------------------------- +# +# priority_matrix buckets assets into tiers but doesn't rank within a tier. +# migration_work_queue produces a single, fully ordered work queue across all +# valid assets, sorted by: +# 1. inherent_risk_score, descending +# 2. migration_urgency_score, descending +# 3. remediation_effort, ascending (lower effort first) — used ONLY to +# break ties between equally risky/urgent items, never to change the +# underlying risk or urgency number itself +# 4. asset_id, ascending — final deterministic tiebreaker +# +# Rego's sort() is ascending-only, so the key encodes "higher is better" as +# "100 - value" for the two score dimensions, letting a single ascending +# sort produce the descending-by-risk/urgency, ascending-by-effort order. +effort_rank("low") := 0 +effort_rank("medium") := 1 +effort_rank("high") := 2 + +work_queue_sort_key(asset) := [ + 100 - inherent_risk_score(asset), + 100 - migration_urgency_score(asset), + effort_rank(asset.remediation_effort), + asset.asset_id, +] + +work_queue_keys := [work_queue_sort_key(asset) | + some asset in input.assets + is_valid(asset) +] + +work_queue_entry(asset_id) := entry if { + some asset in input.assets + asset.asset_id == asset_id + is_valid(asset) + entry := { + "asset_id": asset.asset_id, + "inherent_risk_score": inherent_risk_score(asset), + "migration_urgency_score": migration_urgency_score(asset), + "tier": tier(asset), + "remediation_effort": asset.remediation_effort, + } +} + +migration_work_queue := [work_queue_entry(key[3]) | + some key in sort(work_queue_keys) +] + assessment := { "scored_inventory": scored_inventory, "invalid_inventory": invalid_inventory, "priority_matrix": priority_matrix, + "migration_work_queue": migration_work_queue, "valid_asset_count": count(scored_inventory), "invalid_asset_count": count(invalid_inventory), } diff --git a/policies/scoring/risk_score_test.rego b/policies/scoring/risk_score_test.rego index 856c0bb..e3c1c5b 100644 --- a/policies/scoring/risk_score_test.rego +++ b/policies/scoring/risk_score_test.rego @@ -16,7 +16,7 @@ base_asset := { "secrecy_lifetime_years": 10, "data_classification": "nss", "impact": "mission_critical", - "migration_deadline_months": 6, + "migration_deadline_months": 3, "remediation_effort": "high", "evidence_confidence": "high", } @@ -29,9 +29,23 @@ test_weight_boundaries if { classification_weight("public") == 0 impact_weight("mission_critical") == 30 impact_weight("informational") == 5 - deadline_weight(6) == 20 - deadline_weight(7) == 10 - deadline_weight(19) == 0 +} + +# --- Gap #1 + #3: deadline_pressure_score buckets, including overdue ------- + +test_deadline_pressure_score_buckets if { + deadline_pressure_score(3) == 100 + deadline_pressure_score(6) == 85 + deadline_pressure_score(12) == 65 + deadline_pressure_score(24) == 45 + deadline_pressure_score(36) == 25 + deadline_pressure_score(60) == 10 + deadline_pressure_score(61) == 0 +} + +test_overdue_deadline_gets_maximum_pressure if { + deadline_pressure_score(-1) == 100 + deadline_pressure_score(-600) == 100 } test_max_inherent_risk_and_urgency_cap if { @@ -40,6 +54,29 @@ test_max_inherent_risk_and_urgency_cap if { tier(base_asset) == "critical" } +# Gap #1: the old model (min(100, inherent_risk_score + deadline_weight)) +# collapsed every critical-tier asset (inherent_risk_score >= 80) to the same +# urgency score once its deadline fell inside the 0-6 month bucket, because +# 80 + 20 already saturates at 100. Two assets that are both maximally +# risky but have very different deadlines must now report different +# migration_urgency_score values. +test_urgency_differentiates_within_critical_tier if { + near_term := base_asset # migration_deadline_months: 3 -> pressure 100 + far_out := object.union(base_asset, { + "asset_id": "aws_kms_key.far-out-critical", + "migration_deadline_months": 36, # pressure 25 + }) + + inherent_risk_score(near_term) == 100 + inherent_risk_score(far_out) == 100 + tier(near_term) == "critical" + tier(far_out) == "critical" + + migration_urgency_score(near_term) == 100 + migration_urgency_score(far_out) == 85 + migration_urgency_score(near_term) != migration_urgency_score(far_out) +} + test_low_risk_case if { asset := object.union(base_asset, { "secrecy_lifetime_years": 2, @@ -48,7 +85,7 @@ test_low_risk_case if { "migration_deadline_months": 24, }) inherent_risk_score(asset) == 15 - migration_urgency_score(asset) == 15 + migration_urgency_score(asset) == 21 tier(asset) == "low" } @@ -72,12 +109,137 @@ test_missing_metadata_is_invalid if { not is_valid(incomplete) } -test_negative_values_are_invalid if { - invalid := object.union(base_asset, { - "secrecy_lifetime_years": -1, - "migration_deadline_months": -1, +# Gap #3: an overdue migration_deadline_months is a valid, meaningful state +# (not an input error). Only secrecy_lifetime_years still rejects negative +# values. +test_negative_secrecy_lifetime_is_invalid if { + invalid := object.union(base_asset, {"secrecy_lifetime_years": -1}) + count(metadata_errors(invalid)) == 1 +} + +test_negative_migration_deadline_is_valid_and_overdue if { + overdue := object.union(base_asset, {"migration_deadline_months": -6}) + count(metadata_errors(overdue)) == 0 + is_valid(overdue) + effective_deadline_months(overdue) == -6 + deadline_pressure_score(effective_deadline_months(overdue)) == 100 + migration_urgency_score(overdue) == 100 +} + +# --- Gap #2: calendar-anchored deadlines ------------------------------------ + +mock_now_before_deadline_ns := time.parse_rfc3339_ns("2029-07-05T00:00:00Z") + +mock_now_after_deadline_ns := time.parse_rfc3339_ns("2031-01-01T00:00:00Z") + +test_calendar_deadline_derives_months_remaining if { + # 2030-01-01 (software_firmware_signing exclusive-use date) minus + # 2029-07-05 is exactly 180 days == 6 months at the 30-day approximation. + calendar_deadline_months("software_firmware_signing") == 6 + with time.now_ns as mock_now_before_deadline_ns +} + +test_calendar_deadline_goes_negative_after_passing if { + months := calendar_deadline_months("software_firmware_signing") with time.now_ns as mock_now_after_deadline_ns + months < 0 + deadline_pressure_score(months) == 100 +} + +test_calendar_deadline_flows_into_effective_deadline_when_no_override if { + asset := object.union(object.remove(base_asset, {"migration_deadline_months"}), { + "regulatory_category": "software_firmware_signing", }) - count(metadata_errors(invalid)) == 2 + is_valid(asset) with time.now_ns as mock_now_before_deadline_ns + effective_deadline_months(asset) == 6 with time.now_ns as mock_now_before_deadline_ns +} + +test_explicit_migration_deadline_overrides_regulatory_category if { + # An org-specific deadline of 1 month must win over the much later + # 2033 web_cloud_services regulatory floor. + asset := object.union(base_asset, { + "migration_deadline_months": 1, + "regulatory_category": "web_cloud_services", + }) + effective_deadline_months(asset) == 1 +} + +test_missing_both_deadline_inputs_is_invalid if { + asset := object.remove(base_asset, {"migration_deadline_months"}) + errors := metadata_errors(asset) + "migration_deadline_months must be a number, or regulatory_category must be set" in errors + not is_valid(asset) +} + +# --- Gap #4: classification/impact divergence (reported only) -------------- + +test_aligned_classification_and_impact_is_not_flagged if { + classification_impact_divergence(base_asset) == 0 + not needs_divergence_review(base_asset) +} + +test_divergent_classification_and_impact_is_flagged if { + divergent := object.union(base_asset, { + "data_classification": "public", + "impact": "mission_critical", + }) + classification_impact_divergence(divergent) == 3 + needs_divergence_review(divergent) + + # The flag is advisory only — it must never change inherent_risk_score, + # which stays a plain sum of the three weights. + inherent_risk_score(divergent) == (classification_weight("public") + impact_weight("mission_critical")) + hndl_weight(divergent.secrecy_lifetime_years) +} + +# --- Gap #6: migration work queue (effort as tiebreaker only) -------------- + +queue_input := {"assets": [ + object.union(base_asset, {"asset_id": "queue.high-effort", "remediation_effort": "high"}), + object.union(base_asset, {"asset_id": "queue.medium-effort", "remediation_effort": "medium"}), + object.union(base_asset, {"asset_id": "queue.low-effort", "remediation_effort": "low"}), + object.union(base_asset, { + "asset_id": "queue.lower-risk-low-effort", + "secrecy_lifetime_years": 2, + "data_classification": "internal", + "impact": "operational", + "remediation_effort": "low", + }), +]} + +test_work_queue_uses_effort_only_as_tiebreaker if { + queue := migration_work_queue with input as queue_input + + # All three "queue.*-effort" assets share identical inherent_risk_score + # and migration_urgency_score, so effort alone decides their order: + # lowest effort first. + queue[0].asset_id == "queue.low-effort" + queue[1].asset_id == "queue.medium-effort" + queue[2].asset_id == "queue.high-effort" + + # The much lower-risk asset never jumps ahead of higher-risk assets just + # because its remediation effort is also low. + queue[3].asset_id == "queue.lower-risk-low-effort" + queue[3].inherent_risk_score < queue[2].inherent_risk_score +} + +# --- Gap #7: dependency-informed impact hint (advisory only) --------------- + +test_high_fan_in_flags_impact_as_possibly_underrated if { + asset := object.union(base_asset, { + "impact": "operational", + "dependent_asset_count": 30, + }) + impact_may_be_underrated(asset) + + # Advisory only — the asset's own impact field is untouched. + asset.impact == "operational" +} + +test_low_fan_in_does_not_flag_impact if { + asset := object.union(base_asset, { + "impact": "business_critical", + "dependent_asset_count": 2, + }) + not impact_may_be_underrated(asset) } sample_input := {"assets": [ @@ -106,3 +268,10 @@ test_batch_assessment_separates_invalid_assets if { some invalid in result.invalid_inventory invalid.asset_id == "unknown.asset" } + +test_batch_assessment_includes_migration_work_queue if { + result := assessment with input as sample_input + count(result.migration_work_queue) == 2 + result.migration_work_queue[0].asset_id == "aws_kms_key.example" + result.migration_work_queue[1].asset_id == "aws_kms_key.business" +} diff --git a/schemas/crypto-inventory.schema.json b/schemas/crypto-inventory.schema.json index 2fd5bc5..99675a7 100644 --- a/schemas/crypto-inventory.schema.json +++ b/schemas/crypto-inventory.schema.json @@ -106,7 +106,16 @@ }, "migration_deadline_months": { "type": "number", - "minimum": 0 + "description": "Months remaining until the migration deadline. Negative values represent an already-overdue asset and are valid input, not an error. Takes precedence over regulatory_category when both are present." + }, + "regulatory_category": { + "description": "CNSA 2.0 equipment category used to derive a calendar-anchored migration deadline when migration_deadline_months is not set explicitly.", + "enum": ["software_firmware_signing", "web_cloud_services", "traditional_networking", "operating_systems", "niche_equipment", "custom_legacy"] + }, + "dependent_asset_count": { + "type": "integer", + "minimum": 0, + "description": "Number of other inventoried assets that depend on this one. Used only to advise reviewers that impact may be underrated for high fan-in assets; never overrides the manually assigned impact tier." }, "remediation_effort": { "enum": ["low", "medium", "high"]