Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 36 additions & 1 deletion docs/HNDL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
12 changes: 11 additions & 1 deletion packages/action/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 16 additions & 2 deletions packages/core/src/hndl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
13 changes: 11 additions & 2 deletions packages/core/test/hndl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading