From aaac520e90d4c79c4bc6f3b2191df085d71f64f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Le=C3=B3n=20Acosta?= Date: Mon, 27 Jul 2026 10:48:24 +0700 Subject: [PATCH] fix(core): unbound HNDL findings default to the minimum-concern horizon An unbound finding (matching no declared hndl.yml asset) was scored with retention = secrecy lifetime = 0, so its protection horizon X = 0 and the Mosca factor M = (0 + Y - Z)/(0 + Y) clamped to 0 under any horizon where Z >= Y (including the defaults Y=5, Z=15). Every unbound finding scored 0 regardless of classification, so the documented defaults.classification fallback never produced a rankable exposure. Unbound findings now assume the minimum-concern horizon X = Z (secrecy lifetime = quantumThreatYears): the shortest data lifetime for which HNDL is a concern at all. This yields M = Y / (Y + Z), a small but non-zero, rankable exposure that never exceeds a declared long-lived asset's and self-adjusts to any per-org horizon override, with no magic constant. Retention stays 0 (genuinely unknown); the assumed secrecy lifetime is surfaced in the finding rationale so it is visible and contestable. The Mosca math and the public API surface are unchanged; the fix is purely additive scoring. Detection F1 = 1.000 benchmark and scan exit codes unaffected. Docs: HNDL.md 4.1 + CHANGELOG. Action bundle rebuilt. --- CHANGELOG.md | 22 ++++++++++++++++++++ docs/HNDL.md | 37 ++++++++++++++++++++++++++++++++- packages/action/dist/index.js | 12 ++++++++++- packages/core/src/hndl.ts | 18 ++++++++++++++-- packages/core/test/hndl.test.ts | 13 ++++++++++-- 5 files changed, 96 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 807bb57..dc07b76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) from 1.0.0. ## [Unreleased] +### Fixed - HNDL exposure of unbound findings was always 0 + +The `defaults.classification` fallback documented for findings that bind to no +declared `hndl.yml` asset never produced a non-zero, rankable score. An unbound +finding was scored with retention = secrecy lifetime = 0, so its protection +horizon `X = 0`; under any horizon where `Z >= Y` (including the defaults +`Y = 5`, `Z = 15`) the Mosca factor `M = (0 + Y - Z)/(0 + Y)` clamps to 0, driving +the exposure to 0 for **every** unbound finding regardless of classification. The +fallback was effectively dead. + +Unbound findings now assume the **minimum-concern horizon**: an unknown data +lifetime is taken to be at least the quantum-threat horizon (`X = Z`), the shortest +lifetime for which HNDL is a concern at all. This yields `M = Y / (Y + Z)` - a +small but non-zero, rankable exposure that never exceeds a declared long-lived +asset's and self-adjusts to any per-org horizon override (no magic constant). +Retention stays 0 (genuinely unknown); the assumed secrecy lifetime carries the +horizon and is surfaced in the finding `rationale` (`secrecyLifetimeYears`, +`moscaMarginYears`, `moscaBreach`) so it is visible and contestable. The Mosca math +itself was already correct and is unchanged. Bound findings, the public API surface, +the detection F1 = 1.000 benchmark, and scan exit codes are unaffected; this is a +purely additive scoring fix. See docs/HNDL.md §4.1. SemVer: **patch**. + ### Added - detection quick-wins (PHP composer, JS recall edges, npm lockfile depth) Four post-1.0-roadmap accuracy/perf items on the benchmark-guarded detection diff --git a/docs/HNDL.md b/docs/HNDL.md index 1f15671..ca7d451 100644 --- a/docs/HNDL.md +++ b/docs/HNDL.md @@ -65,7 +65,9 @@ The classification declared for the bound asset, mapped to a weight: The vocabulary matches the readiness model's data-protection (DPE) practice. A finding that binds to **no** declared asset is scored with the map's `defaults.classification` (default `internal`) and flagged `bound: false`, so a -fallback score is never mistaken for a declared one. +fallback score is never mistaken for a declared one. Its protection horizon (X) is +also assumed - the *minimum-concern* horizon - so the fallback is genuinely +rankable and not a silent zero; see §4.1. ## 4. M - the Mosca-factor @@ -118,6 +120,39 @@ The same finding over a `public`, 1-year-retention marketing asset scores **0**: `X + Y = 6 < Z = 10`, so `M = 0`. The Mosca-factor, not the finding, is doing the ranking. +### 4.1 Unbound findings: the minimum-concern horizon + +A finding that matches no declared asset has an unknown data lifetime, so there is +no `X` to read off. Assuming `X = 0` (as if the data were never kept) would drive +`M`, and therefore the score, to **0** for *every* unbound finding under any sane +horizon (`0 + Y − Z ≤ 0` whenever `Z ≥ Y`, which is the default). That would make +the documented `defaults.classification` fallback unrankable - a dead value - which +defeats its purpose. + +Instead an unbound finding assumes the **minimum-concern horizon**: data captured +today must stay confidential *at least* until the quantum threat arrives, i.e. + +``` +X = Z (retention_years = 0, secrecy_lifetime_years = Z assumed) +``` + +This is the shortest lifetime for which HNDL is a concern at all - captured data is +exposed only if it is still secret when the CRQC lands at year `Z`. It yields + +``` +M = (Z + Y − Z) / (Z + Y) = Y / (Y + Z) +``` + +a small but non-zero, **rankable** exposure that (a) is driven purely by the +migration window `Y` as a fraction of the assumed horizon, (b) never exceeds a +declared long-lived asset's score, and (c) self-adjusts to any per-org horizon +override rather than depending on a fixed magic number. Under the defaults +(`Y = 5`, `Z = 15`) an unbound finding gets `M = 5/20 = 0.25`; a `bound: false`, +`internal`, `high`/`high` HNDL finding then scores +`round(100 · 0.80 · 0.40 · 0.25) = 8`. The `rationale` reports +`retentionYears: 0`, `secrecyLifetimeYears: Z`, `moscaMarginYears: Y`, and +`moscaBreach: true`, so the assumption is fully visible and contestable. + ## 5. Binding findings to assets A finding binds to a declared asset when: diff --git a/packages/action/dist/index.js b/packages/action/dist/index.js index 1e99e57..789e7ef 100644 --- a/packages/action/dist/index.js +++ b/packages/action/dist/index.js @@ -10312,8 +10312,18 @@ function computeHndl(findings, map) { best = scoreFinding(f, { vulnerability, classification: map.defaults.classification, + // Unbound: the data's lifetime is unknown, so we cannot read X off a + // declared asset. Assuming X = 0 would drive M (hence the score) to a + // dead 0 for EVERY unbound finding under any sane horizon, making the + // documented `defaults.classification` fallback unrankable. Instead we + // assume the MINIMUM-CONCERN horizon: data captured today must stay + // confidential at least until the quantum threat arrives (X = Z). That + // yields M = Y / (Y + Z) - a small but non-zero, rankable exposure that + // never exceeds a declared long-lived asset's and self-adjusts to any + // per-org horizon override. Retention stays 0 (genuinely unknown); the + // secrecy lifetime carries the assumption. See docs/HNDL.md §4. retentionYears: 0, - secrecyLifetimeYears: 0, + secrecyLifetimeYears: map.horizon.quantumThreatYears, bound: false }, map.horizon); } else { diff --git a/packages/core/src/hndl.ts b/packages/core/src/hndl.ts index 17a82c3..df1be29 100644 --- a/packages/core/src/hndl.ts +++ b/packages/core/src/hndl.ts @@ -440,7 +440,11 @@ function matchingAssets(f: Finding, map: HndlMap, scope: HndlScope): HndlDataAss * exposure is chosen and its asset recorded, since risk ranking should not be * diluted by an incidental low-sensitivity overlap. A finding that binds to no * asset is still scored, using the map's `defaults.classification` and the - * global horizons, and flagged `bound: false` so it is visibly a fallback. + * global horizons, and flagged `bound: false` so it is visibly a fallback. Its + * secrecy lifetime is assumed to be the quantum-threat horizon Z (the + * minimum-concern horizon; see {@link scoreFinding} call below and docs/HNDL.md + * §4), so the fallback classification produces a real, rankable exposure rather + * than a dead 0. * * Purely additive: it never mutates findings and never affects a scan's exit * code. @@ -463,8 +467,18 @@ export function computeHndl(findings: readonly Finding[], map: HndlMap): HndlRep { vulnerability, classification: map.defaults.classification, + // Unbound: the data's lifetime is unknown, so we cannot read X off a + // declared asset. Assuming X = 0 would drive M (hence the score) to a + // dead 0 for EVERY unbound finding under any sane horizon, making the + // documented `defaults.classification` fallback unrankable. Instead we + // assume the MINIMUM-CONCERN horizon: data captured today must stay + // confidential at least until the quantum threat arrives (X = Z). That + // yields M = Y / (Y + Z) - a small but non-zero, rankable exposure that + // never exceeds a declared long-lived asset's and self-adjusts to any + // per-org horizon override. Retention stays 0 (genuinely unknown); the + // secrecy lifetime carries the assumption. See docs/HNDL.md §4. retentionYears: 0, - secrecyLifetimeYears: 0, + secrecyLifetimeYears: map.horizon.quantumThreatYears, bound: false, }, map.horizon, diff --git a/packages/core/test/hndl.test.ts b/packages/core/test/hndl.test.ts index a63c0d4..de6e342 100644 --- a/packages/core/test/hndl.test.ts +++ b/packages/core/test/hndl.test.ts @@ -300,8 +300,17 @@ test("computeHndl: unbound finding uses defaults and is flagged bound:false", () assert.equal(exp.dataAsset, null); assert.equal(exp.rationale.bound, false); assert.equal(exp.rationale.classification, "internal"); - // retention/secrecy default to 0 → X=0, margin = 0+5-10 = -5 → M=0 → score 0. - assert.equal(exp.exposureScore, 0); + // Unbound findings assume the minimum-concern horizon X = Z (secrecy lifetime = + // quantumThreatYears), so `defaults.classification` yields a real, rankable + // exposure instead of a dead 0. Here Z=10, Y=5: X=10, retention=0, secrecy=10, + // margin = 10+5-10 = 5 (Mosca breached), M = 5/15 = 0.333. V=0.8, S=0.4 (internal) + // → round(100 · 0.8 · 0.4 · 0.333) = 11. + assert.equal(exp.rationale.secrecyLifetimeYears, 10); + assert.equal(exp.rationale.retentionYears, 0); + assert.equal(exp.rationale.secrecyHorizonYears, 10); + assert.equal(exp.rationale.moscaMarginYears, 5); + assert.equal(exp.rationale.moscaBreach, true); + assert.equal(exp.exposureScore, 11); }); test("computeHndl: overlapping assets pick the worst-case (highest) exposure", () => {