Skip to content

fix(delta): rank SRG blocks by requirement text, not CCI overlap#7971

Open
wdower wants to merge 7 commits into
mainfrom
fix/delta-matching-semantic-discriminator
Open

fix(delta): rank SRG blocks by requirement text, not CCI overlap#7971
wdower wants to merge 7 commits into
mainfrom
fix/delta-matching-semantic-discriminator

Conversation

@wdower

@wdower wdower commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

fix(delta): rank SRG blocks by requirement text, not CCI overlap

The previous matcher treated SRG-ID + CCI tags as a stable identifier of a specific technical requirement. They are not — they are categorization tags DISA reuses across unrelated requirements between vendor STIGs (RHEL9 vs AL2023). In cross-vendor deltas this produced confident-but-wrong pairings, concentrated in dense control families (audit, PAM), and the existing potentialMismatch flag — gated on low CCI Jaccard — missed ~88% of the real errors because the dominant failure mode produced high CCI Jaccard on wrong matches.

Changes to the matcher (src/utils/delta_matching.ts)

  • Tier 2 ranks by SEMANTIC_WEIGHT * semanticScore(title+check Jaccard) + CCI_WEIGHT * cciJaccard (0.7 / 0.3, inverted from the prior weights).
  • Each SRG block is now resolved by globally-optimal greedy bipartite assignment, so winning pairs don't permute under input-order reshuffles of the new profile.
  • Tier 1 (single-candidate block) flags potentialMismatch when the lone old candidate's requirement text is semantically unrelated to the new control — catches the cross-vendor blind-trust case (e.g. AL2023 SV-274020 'rsyslog package installed' vs RHEL9 SV-258168 'auditd flush' sharing SRG-OS-000051-GPOS-00024 by accident of DISA categorization).
  • potentialMismatch is now driven by semantic distance for Tier 1/2 (threshold 0.3) and by Fuse confidence for Tier 3, replacing the CCI-Jaccard heuristic that was anti-correlated with real errors.
  • delta.json links[] now surface titleSimilarity, checkSimilarity, cciJaccardScore, semanticScore, blockNewCount, blockOldCount for reviewer triage.

Changes to the command (src/commands/generate/delta.ts)

  • Emit a Block cardinality mismatch for SRG X: N new vs M old warning per SRG block where the matcher had to drop or pile up controls.
  • Per-link log line surfaces the component scores (title/check/CCI).

How the mapper processes controls

Before — per-control, in input order; SRG candidate count picks the tier; ranking signal is CCI-dominant:

flowchart TD
  A[for each new control<br/>in input order] --> B{# of old<br/>candidates<br/>sharing its SRG}
  B -->|1| C[Tier 1<br/>accept lone candidate<br/>confidence = 1.0<br/>**never flagged**]
  B -->|N ≥ 2| D[Tier 2<br/>rank candidates by<br/>0.7 · CCI Jaccard +<br/>0.3 · title Jaccard]
  D --> E[claim best UNCLAIMED old;<br/>if all claimed → 'related']
  E --> F{CCI Jaccard<br/>< 0.5?}
  F -->|yes| G[flag potentialMismatch]
  F -->|no| H[no flag]
  B -->|0| I[Tier 3<br/>Fuse fuzzy match<br/>on normalized title]
Loading

After — controls grouped by SRG, each block resolved as a whole via bipartite assignment; ranking signal is requirement text (title + check); flag fires on weak semantic evidence regardless of tier:

flowchart TD
  A[all new controls] --> B[group new controls by SRG]
  B --> C[Phase 1<br/>for each SRG block:]
  C --> D[score every new × old pair:<br/>0.7 · semanticScore +<br/>0.3 · CCI Jaccard<br/>where semantic = title + check Jaccard]
  D --> E[sort pairs descending,<br/>claim globally-best free pairs as primary;<br/>leftover new controls → 'related'<br/>to their best-scoring old]
  E --> F{block has<br/>1 old?}
  F -->|yes| G[matchMethod:<br/>srg-deterministic]
  F -->|no| H[matchMethod:<br/>srg-semantic-tiebreak]
  G --> I{semantic score<br/>< 0.3?}
  H --> I
  I -->|yes| J[flag potentialMismatch]
  I -->|no| K[no flag]
  C -.->|block has new ≠ old count| W[warn: block cardinality mismatch]
  B --> L[Phase 2<br/>for new controls without a<br/>Phase-1 link:]
  L --> M[Tier 3 Fuse fuzzy match<br/>on normalized title<br/>can reach across SRG boundaries]
Loading

Key shifts: SRG goes from "identifier" to "blocking hint"; ranking signal moves from CCI Jaccard to title + check Jaccard; assignment is globally-optimal within a block rather than input-order-greedy; the flag is now driven by semantic distance, which is the actual evidence of a wrong body.

Schema note

delta.json links[].matchMethod value srg-cci-tiebreak is renamed to srg-semantic-tiebreak. Downstream consumers pattern-matching on the literal need to update.

Validation

Validated against the original AL2023-from-RHEL9 derivation that surfaced the bug: of the 17 controls human review re-mapped, 8 are now correctly flagged for review (vs 2 before) and 5 are now mapped to the correct RHEL9 ancestor by the bipartite pass. One residual silent miss (SV-274116) is caused by cross-vendor SRG re-categorization that within-block scoring cannot reach across; the block-cardinality warning fires for its SRG, providing indirect signal.

Breakdown of the 17 originally-misrouted AL2023 controls

"Exhibit A" = dense Tier-2 SRG block where CCI Jaccard saturated to 1.0 across candidates and the input-order-greedy pass permuted bodies onto neighbors. "Exhibit B" = Tier-1 single-candidate SRG block by accident of DISA categorization (the lone old candidate's requirement was unrelated). "Cross-SRG twin" = the AL2023 control's true RHEL9 partner exists but DISA bucketed it under a different SRG, so SRG-blocking can't reach it.

AL2023 requirement Originally mapped to Why wrong Now maps to
SV-274018 — produce audit records (event type) SV-257796 — GRUB audit=1 Exhibit A SV-258181 — chacl audit · flagged
SV-274020 — rsyslog package installed SV-258168 — auditd freq flush Exhibit B; true partner SV-258140 is in a different SRG SV-258168 · flagged
SV-274035 — packages for encrypting off-loaded audit logs SV-258237 — federal-law crypto mechanisms Exhibit B SV-258237 · flagged
SV-274037 — openssl-pkcs11 package installed SV-258123 — certificate status checking Tier-2; no exact RHEL9 ancestor exists SV-258126 — opensc package · related (block primary flagged)
SV-274069 — label off-loaded audit logs (name_format) SV-258146 — authenticate remote logging server Exhibit A SV-258146 · flagged
SV-274077 — authenticate remote logging server SV-258140 — rsyslog package installed Exhibit A (swapped with SV-274020) SV-258140 · flagged
SV-274080 — off-load audit records (journal-upload.conf) SV-258147 — encrypt off-loaded audit records Exhibit A SV-258147 · semantic 0.5, not flagged
SV-274116 — audit logs group-owned by root/logging group SV-257915 — /var/log group-owned by root Cross-SRG twin: real partner SV-258165 carries a different SRG SV-257915 · related; block-cardinality warning fires for the SRG
SV-274117 — audit log directory owned by root SV-257914 — /var/log owned by root Cross-SRG twin SV-257914 · related; block-cardinality warning fires
SV-274157 — maintain account lock until admin releases SV-258056 — lock account on 3 failed logons in 15 min Exhibit A SV-258057 — matching title (semantic 1.0)
SV-274158 — restrict PPSM CAL functions/ports/protocols SV-257947 — disable network management of chronyd Exhibit A SV-257940 — matching title (semantic 0.81)
SV-274167 — GRUB audit=1 (audit before audit daemon) SV-258181 — chacl audit Exhibit A (swapped with SV-274018) SV-257796 — matching title (semantic 1.0)
SV-274177 — prevent loading a new kernel SV-257825 — subscription-manager package Exhibit A; no kexec_load_disabled ancestor in RHEL9 SV-257825 · flagged
SV-274178 — nosuid on /boot/efi SV-257850 — no device files on user home FS Exhibit A SV-257862 — matching title (semantic 1.0)
SV-274182 — disable automount unless required SV-258038 — block unauthorized peripherals Exhibit A; no RHEL9 automount ancestor SV-258038 · flagged
SV-274183 — limit DoS effects via rate-limiting SV-257939 — same title Title-correct pick; AL2023 reviewer rewrote the body so it landed in the audit list SV-257939 — same pick (semantic 0.83)
SV-283441 — enable FIPS mode SV-258232 — IP tunnels must use FIPS algorithms Tier-2; no RHEL9 "enable FIPS mode" ancestor SV-258232 · flagged

Test plan

  • npx vitest run — 514 / 514 pass
  • npx tsc --noEmit — clean
  • npx eslint <changed files> — clean
  • Re-ran saf generate delta against the AL2023-v1r3 XCCDF + RHEL9 profile from the original bug report; flag/remap behavior matches the breakdown above

Signed-off-by: Will Dower <will@dower.dev>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors the cross-vendor STIG delta matcher so SRG-ID + CCI tags are treated as blocking/secondary hints rather than as a stable requirement identity. Ranking is now driven by title + check-text semantic similarity, block resolution uses globally-optimal greedy bipartite assignment, and potentialMismatch fires on weak semantic evidence (catching the Tier-1 cross-vendor blind-trust case).

Changes:

  • Replace CCI-dominant Tier-2 composite (0.7·CCI + 0.3·title) with semantic-dominant composite (0.7·semantic(title+check) + 0.3·CCI) and resolve each SRG block via bipartite assignment.
  • Drive potentialMismatch off semantic distance (Tier 1/2, threshold 0.3) and Fuse confidence (Tier 3); rename srg-cci-tiebreaksrg-semantic-tiebreak; expose per-link triage fields (titleSimilarity, checkSimilarity, cciJaccardScore, semanticScore, blockNewCount, blockOldCount).
  • Emit per-block cardinality warning and per-link triage log line in generate delta; update tests accordingly.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
src/utils/delta_matching.ts Core refactor: semantic scoring, bipartite block resolution, renamed match method, new triage fields, threshold changes.
src/commands/generate/delta.ts New cardinality warning, triage-aware log lines, updated pipeline header comments.
test/utils/tests/delta_matching.test.ts New Tier-2 block/order-stability/triage/check-tiebreak tests; updated mismatch-flag and weight-constant tests to semantic model.
test/utils/tests/cross_vendor_integration.test.ts Rename srg-cci-tiebreaksrg-semantic-tiebreak in expectations.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

wdower and others added 6 commits June 1, 2026 23:24
…eSrgBlock

Signed-off-by: Will Dower <will@dower.dev>
The requirement-first matcher read STIG check text from `tags.check`, but
both processInSpecProfile and processXCCDF store it in `descs.check`
(`tags.check` is moved/emptied during parsing). The title+check semantic
discriminator was therefore title-only in every real delta. Read check text
from `descs.check` (with `tags.check` as a fallback for hand-built inputs).
On the RHEL9 -> SLES15 validation this activates check similarity on 206/214
links and corrects 29 mappings, including body transpositions in dense SRG
blocks that title-only matching still got wrong.

Also surface weak-evidence `related` grafts that were silently shipping
wrong bodies:
- potentialMismatch now fires for `related` links, not just `primary` (a
  related link grafts the old body forward just like a primary, so a weak
  match ships a wrong body either way). Only no-match stays exempt.
- Tier-3 fuse-fallback now computes the title+check semanticScore against
  the matched old control (was null) and gates the flag on weak semantic OR
  low Fuse title-confidence.
- tickMatchCounter counts any flagged link (primary or related) as a
  possible mismatch so the summary reflects reality; the
  match + mismatch + related = total invariant still holds.

Residual: a `related` graft whose title+check stays highly similar to the
wrong old control (e.g. "owned by root" vs "group-owned by root") is not
caught by any aggregate-similarity threshold; same class as the documented
cross-SRG re-categorization miss.

Signed-off-by: Will <will@dower.dev>
…view

Adversarial review of the matcher surfaced documentation and test gaps (no
correctness defect in the assignment itself). Address them:

- Docstrings called the per-block assignment "globally-optimal"; it is greedy
  best-first, a 1/2-approximation, not a maximum-weight matching. Reword across
  the module/resolveSrgBlock/claimPrimaryPairings/applyRequirementFirstPipeline
  docstrings and state the deliberate bias (commit the single most-trustworthy
  link first, which suits copying a body forward).
- Make order-independence actually hold rather than merely documented: add an
  id-based tiebreak to the pair sort so exact composite ties no longer resolve
  by new-profile input order.
- Fix the stale `tags.check` field-doc (check text is read from `descs.check`)
  and soften the potentialMismatch docstring: 0.3 is an obvious-junk floor, not
  a correctness separator. The real matched-link semantic distribution is
  smooth/unimodal with no separating valley, so an unflagged mid-band link is
  "not obviously junk", not "verified correct".
- Stats: flagged related grafts now count as posMisMatch, so relabel dupMatch
  as "Trusted Related Matches" and surface the flagged-related count in both the
  live log and the markdown report so the triage signal isn't hidden.

Tests:
- Positive Tier-3 flag test for the load-bearing `fuseConfidence < 0.9` gate
  (previously every fuse-fallback test asserted potentialMismatch=false).
- Order-independence under exact composite ties.
- Reciprocal transposition resolved by `descs.check` alone, in both input orders.
- Add `descs.check` to the integration mini-fixtures' CRYPTO block and assert
  checkSimilarity > 0, so the integration test exercises the real check-text
  path instead of silently running title-only (the configuration that hid the
  original bug).

Signed-off-by: Will <will@dower.dev>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants