From 4d3cfdcd8caba081046f44a06ed0e297810a0a8f Mon Sep 17 00:00:00 2001 From: Will Dower Date: Mon, 1 Jun 2026 22:27:51 -0400 Subject: [PATCH 1/4] fix(delta): rank SRG blocks by requirement text, not CCI overlap Signed-off-by: Will Dower --- src/commands/generate/delta.ts | 79 ++- src/utils/delta_matching.ts | 561 +++++++++++------- .../cross_vendor_integration.test.ts | 8 +- test/utils/__tests__/delta_matching.test.ts | 192 +++++- 4 files changed, 567 insertions(+), 273 deletions(-) diff --git a/src/commands/generate/delta.ts b/src/commands/generate/delta.ts index e0e3d4ea1d..37fbe644eb 100644 --- a/src/commands/generate/delta.ts +++ b/src/commands/generate/delta.ts @@ -629,22 +629,20 @@ export default class GenerateDelta extends BaseCommand { // @param newProfile - The profile containing the new controls. mapControls(oldProfile: Profile, newProfile: Profile): object { // Requirement-first pipeline (see src/utils/delta_matching.ts): - // Tier 1 Exact SRG-OS block with single old candidate -> deterministic accept - // Tier 2 Multiple old candidates in the SRG block -> CCI Jaccard tiebreak - // Tier 3 No SRG overlap -> Fuse fallback with - // auto-detected vendor- - // prefix stripping and - // keys=['title','gtitle'] + // Tier 1 Single old candidate in the SRG block -> deterministic + // Tier 2 Multiple old candidates in the SRG block -> bipartite + // assignment by + // semantic + CCI + // composite score + // Tier 3 No SRG overlap -> Fuse fallback + // on vendor-prefix- + // stripped titles // - // 1:N splits (multiple new controls resolving to the same old) are - // preserved as primary + related links. Both land in the returned - // controlMappings so downstream file-writing copies the old Ruby - // body to every new control that shares the requirement. + // 1:N splits (multiple new controls resolving to one old) are preserved + // as primary + related links; both land in controlMappings so downstream + // file-writing copies the old Ruby body to every related new control. // - // Existing static counters on GenerateDelta are reused to keep the - // summary output format stable; `dupMatch` is repurposed to count - // `related` links (it used to count rejected duplicates in the - // former 1:1 model). + // `dupMatch` counts `related` links (kept for output-format stability). const oldControls: Control[] = oldProfile.controls; const newControls: Control[] = newProfile.controls; GenerateDelta.oldControlsLength = oldControls.length; @@ -653,11 +651,30 @@ export default class GenerateDelta extends BaseCommand { const controlMappings: Record = {}; this.logger.info('Mapping Process ==========================================================================='); - this.logger.info('Using requirement-first pipeline: SRG-ID blocking + CCI Jaccard tiebreak + vendor-prefix-normalized Fuse fallback\n'); + this.logger.info('Using requirement-first pipeline: SRG-ID blocking + semantic (title+check) ranking with CCI secondary signal + vendor-prefix-normalized Fuse fallback\n'); const links = applyRequirementFirstPipeline(oldProfile, newProfile); GenerateDelta.links = links; + // Block-cardinality warning: emit one warning per SRG block where new + // and old counts disagree — at least one control in such a block has + // no true partner and the assignment is guessing. + const blocksWarned = new Set(); + for (const link of links) { + if ( + link.srg + && link.blockNewCount !== undefined + && link.blockOldCount !== undefined + && link.blockNewCount !== link.blockOldCount + && !blocksWarned.has(link.srg) + ) { + blocksWarned.add(link.srg); + this.logger.warn( + `Block cardinality mismatch for SRG ${link.srg}: ${link.blockNewCount} new vs ${link.blockOldCount} old — at least one control in this block has no true partner.`, + ); + } + } + // Cheap lookup tables for per-link logging const oldById = new Map(oldControls.map(c => [c.id, c])); const newByBasename = new Map( @@ -732,16 +749,17 @@ export default class GenerateDelta extends BaseCommand { */ private static logMatchMethod(log: Logger, link: LinkRecord): void { const confidencePct = (link.confidence * 100).toFixed(0) + '%'; + const triage = GenerateDelta.formatTriage(link); switch (link.matchMethod) { case 'srg-deterministic': { log.info( - ` Match method: SRG deterministic (${link.srg}) [${link.relationship}]`, + ` Match method: SRG deterministic (${link.srg}) [${link.relationship}]${triage}`, ); break; } - case 'srg-cci-tiebreak': { + case 'srg-semantic-tiebreak': { log.info( - ` Match method: SRG block + CCI tiebreak (Jaccard=${confidencePct}) [${link.relationship}]`, + ` Match method: SRG block + semantic tiebreak (semantic=${confidencePct}) [${link.relationship}]${triage}`, ); break; } @@ -762,6 +780,24 @@ export default class GenerateDelta extends BaseCommand { } } + /** + * Format the per-link triage components (title/check/CCI) for the log + * line. Returns "" when none are populated (e.g. Tier 3 fallback). + */ + private static formatTriage(link: LinkRecord): string { + const parts: string[] = []; + if (link.titleSimilarity !== undefined) { + parts.push(`title=${(link.titleSimilarity * 100).toFixed(0)}%`); + } + if (link.checkSimilarity !== undefined) { + parts.push(`check=${(link.checkSimilarity * 100).toFixed(0)}%`); + } + if (link.cciJaccardScore !== undefined) { + parts.push(`cci=${(link.cciJaccardScore * 100).toFixed(0)}%`); + } + return parts.length > 0 ? ` (${parts.join(', ')})` : ''; + } + /** * Advance the GenerateDelta static counters for a single link so the * end-of-run stats match reality. @@ -776,10 +812,9 @@ export default class GenerateDelta extends BaseCommand { return; } // `potentialMismatch` is the single source of truth for "accepted - // primary but below the tier's strong-confidence threshold" — see - // computePotentialMismatch + TIER{2,3}_MISMATCH_THRESHOLD in - // delta_matching.ts. Reading the flag here keeps stats bookkeeping - // aligned with the tier definitions automatically. + // primary but below the tier's confidence threshold". Reading the + // flag here keeps the stats aligned with the tier definitions in + // delta_matching.ts. if (link.potentialMismatch) { GenerateDelta.posMisMatch++; } else { diff --git a/src/utils/delta_matching.ts b/src/utils/delta_matching.ts index ae6f99fd6c..49e66a1ab0 100644 --- a/src/utils/delta_matching.ts +++ b/src/utils/delta_matching.ts @@ -3,15 +3,18 @@ import Fuse from 'fuse.js'; /** * Helpers for requirement-first delta matching. * - * Cross-vendor STIG deltas (RHEL9 -> AL2023, Ubuntu -> Oracle Linux, etc.) - * suffer under a pure fuzzy matcher on control titles alone: the vendor - * prefix drift ("RHEL 9" vs "Amazon Linux 2023") dominates the score and - * pushes identical requirements out of the accept band. + * SRG-IDs and CCI tags categorize STIG requirements; they do not identify + * them. A single SRG by design buckets multiple specific rules with shared + * CCIs (CCI Jaccard saturates inside dense blocks), and independently + * authored STIGs bucket distinct requirements under the same SRG/CCI. The + * stable signal is the requirement text itself (title + check). * - * These helpers treat the upstream DISA SRG ID as the canonical requirement - * identity, CCIs as the block-internal tiebreaker when one SRG is split - * into N vendor-specific rules, and auto-detected / normalized titles as a - * last-resort tiebreaker for the long tail. + * Pipeline: SRG-ID is a blocking key (narrows the candidate pool); inside + * each block we run globally-optimal greedy bipartite assignment scored on + * `SEMANTIC_WEIGHT * semanticScore(title+check) + CCI_WEIGHT * cciJaccard`, + * so winning pairs don't permute under reordering of the new profile. + * Controls with no SRG overlap fall through to a Fuse fuzzy fallback on + * vendor-prefix-stripped titles. */ /** @@ -48,6 +51,8 @@ function tokenizeSet(s: string): Set { /** * Minimal structural shape the matcher needs from an InSpec control. * Matches the subset of `@mitre/inspec-objects`' Control that we read. + * `tags.check` is the STIG check text — the most discriminating semantic + * content inside a dense SRG block where titles are near-synonymous. */ export type ControlLike = { id: string; @@ -55,14 +60,14 @@ export type ControlLike = { tags?: { gtitle?: string | null; cci?: string[] | null; + check?: string | null; }; }; /** * Return the upstream DISA SRG ID for a control (from `tags.gtitle`), - * or null when the field is missing. SRG IDs are identical across vendor - * flavors of the same requirement, which makes them the canonical - * blocking key for cross-vendor STIG delta matching. + * or null when the field is missing. Used as a blocking key only — the + * actual requirement identity is in the title + check text. */ export function extractSrgId(control: ControlLike): string | null { return control.tags?.gtitle ?? null; @@ -72,10 +77,14 @@ function safeTitle(title: string | null | undefined): string { return title ?? ''; } +function safeCheck(control: ControlLike): string { + return control.tags?.check ?? ''; +} + /** * Return the control's CCI set (from `tags.cci`), deduped. Empty Set when - * missing. Used as block-internal tiebreaker when multiple new-profile - * controls share an SRG with the same old-profile control (1:N splits). + * missing. Used as a secondary tiebreaker only (see module docstring on + * why CCIs do not identify a requirement). */ export function extractCcis(control: ControlLike): Set { return new Set(control.tags?.cci); @@ -84,10 +93,6 @@ export function extractCcis(control: ControlLike): Set { /** * Token-level Jaccard similarity between two strings. Lowercased, * whitespace-split, empty tokens dropped. 0.0 when either side is empty. - * - * Used as a block-internal tiebreaker in Tier 2 when multiple old - * candidates share the new control's SRG *and* its CCI set — distinct - * control titles (modulo normalized vendor prefix) still discriminate. */ export function tokenJaccard(a: string, b: string): number { const ta = tokenizeSet(a); @@ -150,26 +155,70 @@ export function buildSrgIndex( return index; } +/** + * Combined title + check-text Jaccard similarity, with vendor-prefix stripping + * on titles. This is the requirement-identity signal — titles can be + * near-synonymous inside dense SRG blocks (audit, PAM); the check text carries + * the distinguishing technical content (commands, file paths, expected values). + * + * `combined` weights title and check equally when both sides have check text, + * and falls back to `titleSim` alone when either side lacks it (so a missing + * check field doesn't penalize an otherwise-strong title match). + */ +export const SEMANTIC_TITLE_WEIGHT = 0.5; +export const SEMANTIC_CHECK_WEIGHT = 0.5; + +export function semanticScore( + newControl: ControlLike, + oldControl: ControlLike, + newPrefix: string, + oldPrefix: string, +): { titleSim: number; checkSim: number; combined: number } { + const newTitle = normalizeTitle(safeTitle(newControl.title), newPrefix); + const oldTitle = normalizeTitle(safeTitle(oldControl.title), oldPrefix); + const titleSim = tokenJaccard(newTitle, oldTitle); + + const newCheck = safeCheck(newControl); + const oldCheck = safeCheck(oldControl); + const hasCheck = newCheck.length > 0 && oldCheck.length > 0; + const checkSim = hasCheck ? tokenJaccard(newCheck, oldCheck) : 0; + const combined = hasCheck + ? SEMANTIC_TITLE_WEIGHT * titleSim + SEMANTIC_CHECK_WEIGHT * checkSim + : titleSim; + + return { titleSim, checkSim, combined }; +} + /** * Structured link record produced by applyRequirementFirstPipeline for * every control in the new profile. * * `matchMethod` tracks which tier accepted the link: - * - `srg-deterministic` Tier 1: single SRG candidate, accepted. - * - `srg-cci-tiebreak` Tier 2: multiple SRG candidates, best CCI Jaccard won. - * - `fuse-fallback` Tier 3: no SRG match, Fuse title similarity carried it. - * - `none` No link found. + * - `srg-deterministic` Tier 1: single SRG candidate, accepted. + * - `srg-semantic-tiebreak` Tier 2: multi-candidate block, scored by + * requirement-text similarity with + * CCI as a secondary signal. + * - `fuse-fallback` Tier 3: no SRG match, Fuse title similarity. + * - `none` No link found. * * `relationship`: * - `primary` This new control is the best (or only) link to its old control. - * - `related` Another new control has a better CCI Jaccard for the same old - * control; kept here so downstream can copy the RHEL body once + * - `related` Another new control has a better composite score for the same + * old control; kept here so downstream can copy the body once * but know all the related new controls. * - `no-match` Paired with matchMethod=`none`. + * + * Triage fields (optional, populated when relevant): + * - `titleSimilarity` Vendor-prefix-stripped title Jaccard. + * - `checkSimilarity` tags.check token Jaccard (0 when either side lacks check text). + * - `cciJaccardScore` CCI overlap, retained for visibility / downstream sorting. + * - `semanticScore` Combined title + check (the requirement-identity signal). + * - `blockNewCount` # of new controls sharing this SRG (Tier 1/2 only). + * - `blockOldCount` # of old controls sharing this SRG (Tier 1/2 only). */ export type MatchMethod = | 'srg-deterministic' - | 'srg-cci-tiebreak' + | 'srg-semantic-tiebreak' | 'fuse-fallback' | 'none'; @@ -181,6 +230,12 @@ export type LinkRecord = { relationship: 'primary' | 'related' | 'no-match'; srg?: string | null; potentialMismatch: boolean; + titleSimilarity?: number; + checkSimilarity?: number; + cciJaccardScore?: number; + semanticScore?: number; + blockNewCount?: number; + blockOldCount?: number; }; export type ProfileLike = { @@ -188,70 +243,71 @@ export type ProfileLike = { }; /** - * Tier-3 acceptance threshold. Fuse.js Levenshtein-style score where 0.0 - * is a perfect match and 1.0 is no match at all. The original saf - * implementation accepted scores < 0.3, which rejected ~65% of genuinely - * equivalent cross-vendor rules due to vendor-prefix drift. With the - * prefix stripped by normalizeTitle beforehand, a threshold of 0.45 - * generously admits token-level typos / re-ordering without accepting - * unrelated content. + * Tier-3 Fuse acceptance threshold (Levenshtein-style score: 0 perfect, + * 1 no match). With vendor prefix stripped via normalizeTitle, a threshold + * of 0.45 admits token-level typos / re-ordering without unrelated content. */ const FUSE_ACCEPT_THRESHOLD = 0.45; /** - * Primary Tier-2 links with a CCI Jaccard below this threshold are flagged - * as `potentialMismatch`. The algorithm still accepts them (they are the - * best candidate in their SRG block), but reviewers should confirm the - * carried-forward control body still fits the new requirement. + * Tier-1 / Tier-2 primary links with a semantic score below this threshold + * are flagged as `potentialMismatch`. The pipeline still accepts them (they + * are the best candidate inside their SRG block), but the requirement-text + * evidence is weak enough that a human reviewer should confirm before + * trusting the carried-forward body. 0.3 is intentionally permissive — + * cross-vendor titles diverge enough that a stricter threshold would + * generate too many soft warnings. */ -export const TIER2_MISMATCH_THRESHOLD = 0.5; +export const TIER1_MISMATCH_THRESHOLD = 0.3; +export const TIER2_MISMATCH_THRESHOLD = 0.3; /** - * Primary Tier-3 (Fuse-fallback) links with a confidence below this - * threshold are flagged as `potentialMismatch`. Fuse already gates - * acceptance at `1 - FUSE_ACCEPT_THRESHOLD` (= 0.55 confidence), so the - * flag fires across the [0.55, 0.9) band: accepted but soft. + * Tier-3 (Fuse-fallback) primary links with confidence below this threshold + * are flagged. Fuse already gates acceptance at `1 - FUSE_ACCEPT_THRESHOLD` + * (= 0.55 confidence), so the flag fires across [0.55, 0.9): accepted but soft. */ export const TIER3_MISMATCH_THRESHOLD = 0.9; /** - * Tier-2 ranker composite weights: `composite = CCI_WEIGHT * cciJaccard - * + TITLE_WEIGHT * tokenJaccard(normalizedTitle)`. CCI dominates because - * it's the block-internal discriminator; title is a tiebreak for the - * N:N-in-one-SRG cross-vendor case where every candidate has identical - * CCIs. The two MUST sum to 1.0 — asserted in tests. + * Tier-2 composite weights: `composite = SEMANTIC_WEIGHT * semanticScore + * + CCI_WEIGHT * cciJaccard`. Semantic similarity (title + check text) is + * the requirement-identity signal; CCI is a secondary tag that + * disambiguates only when semantic scores are tied. The two MUST sum + * to 1.0 — asserted in tests. */ -export const TIER2_COMPOSITE_CCI_WEIGHT = 0.7; -export const TIER2_COMPOSITE_TITLE_WEIGHT = 0.3; +export const TIER2_COMPOSITE_SEMANTIC_WEIGHT = 0.7; +export const TIER2_COMPOSITE_CCI_WEIGHT = 0.3; /** - * Compute the `potentialMismatch` flag for a link from its (matchMethod, - * relationship, confidence) tuple. Related and no-match links never flag - * (the flag is about soft primary matches). Tier 1 is always trusted. + * Compute the `potentialMismatch` flag from a link's semantic score. + * Related and no-match links never flag (the flag is about soft primary + * matches). Tier 1 and Tier 2 share a semantic-score threshold; Tier 3 + * uses its own confidence threshold against the (also-semantic) Fuse score. */ function computePotentialMismatch( matchMethod: MatchMethod, relationship: 'primary' | 'related' | 'no-match', - confidence: number, + semantic: number, + fuseConfidence: number, ): boolean { if (relationship !== 'primary') { return false; } - if (matchMethod === 'srg-cci-tiebreak') { - return confidence < TIER2_MISMATCH_THRESHOLD; + if (matchMethod === 'srg-deterministic') { + return semantic < TIER1_MISMATCH_THRESHOLD; + } + if (matchMethod === 'srg-semantic-tiebreak') { + return semantic < TIER2_MISMATCH_THRESHOLD; } if (matchMethod === 'fuse-fallback') { - return confidence < TIER3_MISMATCH_THRESHOLD; + return fuseConfidence < TIER3_MISMATCH_THRESHOLD; } return false; } /** * Shape of the text-diff portion of `delta.json`, produced by - * `@mitre/inspec-objects::updateProfileUsingXCCDF`. Carried as an opaque - * key bag because inspec-objects doesn't export a named type, but we - * lock in the keys downstream consumers rely on so a breaking change - * there is noisy here. + * `@mitre/inspec-objects::updateProfileUsingXCCDF`. */ export type DeltaDiff = { ignoreFormattingDiff?: Record; @@ -260,34 +316,13 @@ export type DeltaDiff = { } & Record; /** - * The complete `delta.json` payload. Consumers (adaptation queue - * tooling, the future profile-derivation skill, etc.) should treat this - * as the authoritative schema reference. - * - * Top-level keys: - * - `ignoreFormattingDiff` (inherited) — whitespace-insensitive diff - * of the rewritten controls/*.rb vs their originals. - * - `rawDiff` (inherited) — byte-level diff, same scope. - * - `markdown` (inherited, optional) — human-facing diff report. - * - `links` (added by this command) — one LinkRecord per new-profile - * control, describing which old control's body was carried forward: - * * `oldId` old control id, or `null` for no-match - * * `newId` new control id (always present) - * * `matchMethod` 'srg-deterministic' | 'srg-cci-tiebreak' | - * 'fuse-fallback' | 'none' - * * `confidence` 0-1 tier-specific confidence - * * `relationship` 'primary' | 'related' | 'no-match' - * * `srg` SRG-OS id from the new control, or null - * * `potentialMismatch` soft-match flag for reviewer triage - * See LinkRecord for per-field semantics. + * The complete `delta.json` payload. See LinkRecord for per-field semantics. */ export type DeltaJsonPayload = DeltaDiff & { links: LinkRecord[] }; /** * Assemble the object written to `delta.json`. `links` is applied last - * so it wins over any stale key in the diff object (defensive — - * `updatedResult.diff` should not carry a `links` key, but this keeps - * the contract explicit). + * so it wins over any stale key in the diff object. */ export function buildDeltaJsonPayload({ diff, @@ -302,40 +337,21 @@ export function buildDeltaJsonPayload({ type SearchRecord = { originalId: string; title: string; gtitle: string }; // Fuse.js's default export is typed as both a class and a namespace, -// which makes `Fuse` ambiguous in type position. Structurally -// describing the one method we call sidesteps the namespace collision -// and documents exactly what tier 3 consumes. +// which makes `Fuse` ambiguous in type position. type FuseSearcher = { search(query: string): { item: SearchRecord; score?: number }[]; }; -type TierContext = { +type PipelineContext = { oldPrefix: string; newPrefix: string; fuse: FuseSearcher | null; claimedOldIds: Set; }; -type ScoredCandidate = { idx: number; composite: number; cci: number }; - -/** - * Claim `oldId` as primary if not already claimed; otherwise mark as related. - * Mutates `claimedOldIds` in place. - */ -function claimOrRelate( - oldId: string, - claimedOldIds: Set, -): 'primary' | 'related' { - if (claimedOldIds.has(oldId)) { - return 'related'; - } - claimedOldIds.add(oldId); - return 'primary'; -} - /** - * Construct a successful LinkRecord (any tier). Derives - * `potentialMismatch` consistently via `computePotentialMismatch`. + * Build a LinkRecord with triage fields populated and `potentialMismatch` + * derived from semantic score (Tier 1/2) or Fuse confidence (Tier 3). */ function makeLink(args: { newControl: ControlLike; @@ -344,7 +360,15 @@ function makeLink(args: { confidence: number; srg: string | null; relationship: 'primary' | 'related'; + titleSimilarity?: number; + checkSimilarity?: number; + cciJaccardScore?: number; + semanticScore?: number; + blockNewCount?: number; + blockOldCount?: number; }): LinkRecord { + const semantic = args.semanticScore ?? args.confidence; + const fuseConfidence = args.matchMethod === 'fuse-fallback' ? args.confidence : 0; return { oldId: args.oldId, newId: args.newControl.id, @@ -355,8 +379,15 @@ function makeLink(args: { potentialMismatch: computePotentialMismatch( args.matchMethod, args.relationship, - args.confidence, + semantic, + fuseConfidence, ), + titleSimilarity: args.titleSimilarity, + checkSimilarity: args.checkSimilarity, + cciJaccardScore: args.cciJaccardScore, + semanticScore: args.semanticScore, + blockNewCount: args.blockNewCount, + blockOldCount: args.blockOldCount, }; } @@ -373,105 +404,165 @@ function makeNoMatch(newControl: ControlLike, srg: string | null): LinkRecord { }; } +type PairScore = { + newIdx: number; + oldIdx: number; + composite: number; + semantic: number; + titleSim: number; + checkSim: number; + cci: number; +}; + /** - * Tier 1: exactly one candidate in the SRG block. Deterministic link, - * confidence always 1.0. + * Score every (new, old) pair inside an SRG block on the composite + * `SEMANTIC_WEIGHT * semantic + CCI_WEIGHT * cciJaccard`. Sorted descending + * by composite for the assignment pass. */ -function tier1DeterministicMatch( - newControl: ControlLike, - candidate: ControlLike, - srg: string, - claimedOldIds: Set, -): LinkRecord { - return makeLink({ - newControl, - oldId: candidate.id, - matchMethod: 'srg-deterministic', - confidence: 1, - srg, - relationship: claimOrRelate(candidate.id, claimedOldIds), - }); +function scoreBlockPairs( + newControls: ControlLike[], + oldCandidates: ControlLike[], + ctx: PipelineContext, +): PairScore[] { + const pairs: PairScore[] = []; + const newCcis = newControls.map(c => extractCcis(c)); + const oldCcis = oldCandidates.map(c => extractCcis(c)); + for (const [i, newControl] of newControls.entries()) { + for (const [j, oldControl] of oldCandidates.entries()) { + const sem = semanticScore(newControl, oldControl, ctx.newPrefix, ctx.oldPrefix); + const cci = cciJaccard(newCcis[i], oldCcis[j]); + const composite + = TIER2_COMPOSITE_SEMANTIC_WEIGHT * sem.combined + + TIER2_COMPOSITE_CCI_WEIGHT * cci; + pairs.push({ + newIdx: i, + oldIdx: j, + composite, + semantic: sem.combined, + titleSim: sem.titleSim, + checkSim: sem.checkSim, + cci, + }); + } + } + pairs.sort((a, b) => b.composite - a.composite); + return pairs; } /** - * Tier 2: multiple candidates in this SRG block. Rank by a composite - * score of CCI Jaccard (primary signal) + normalized-title Jaccard - * (tiebreak — catches the N:N-in-one-SRG cross-vendor case where every - * candidate has identical CCIs). Prefer unclaimed candidates so distinct - * new controls don't all pile onto the same old; only fall back to a - * claimed candidate (emits `related`) when every old in the block is - * already taken. + * Resolve an SRG block to per-new-control link records using globally- + * optimal greedy bipartite assignment on the composite score: + * + * 1. Score every (new, old) pair in the block. + * 2. Sort pairs by composite score, descending. + * 3. Walk the sorted list, claiming pairs whose new and old are both + * free (primary links). + * 4. Any new control still unassigned (block has more new than old) + * becomes `related` to its single best-scoring (already-claimed) old. + * + * Order-independent: the assignment depends only on the set of pairs and + * their scores, not on the iteration order of the new profile. * - * The reported `confidence` is the winner's CCI Jaccard alone (not the - * composite), so downstream thresholds on `confidence` stay semantically - * "how well do the block-internal CCI sets overlap" — see beads memory - * `tier-2-composite-scoring`. + * Single-candidate (Tier-1) blocks share the same scoring path so the + * potentialMismatch flag derives consistently; the only difference is the + * `matchMethod` label. */ -function tier2CciTiebreak( - newControl: ControlLike, - candidates: ControlLike[], +function resolveSrgBlock( + newControls: ControlLike[], + oldCandidates: ControlLike[], srg: string, - ctx: TierContext, -): LinkRecord { - const newCcis = extractCcis(newControl); - const newNormTitle = normalizeTitle( - safeTitle(newControl.title), - ctx.newPrefix, + ctx: PipelineContext, +): LinkRecord[] { + const pairs = scoreBlockPairs(newControls, oldCandidates, ctx); + const blockNewCount = newControls.length; + const blockOldCount = oldCandidates.length; + // `srg-deterministic` when there is only one old candidate to pick from + // (no ranking choice on the old side). Independent of new-side cardinality: + // multiple news can all deterministically resolve to a single old, with + // primary/related disambiguating the split. + const matchMethod: MatchMethod + = blockOldCount === 1 ? 'srg-deterministic' : 'srg-semantic-tiebreak'; + // Deterministic links report confidence 1.0 (no ranking was needed); + // semantic-tiebreak links report the winning pair's semantic score. + const confidenceFor = (semantic: number): number => + matchMethod === 'srg-deterministic' ? 1 : semantic; + const linkFromPair = ( + p: PairScore, + relationship: 'primary' | 'related', + ): LinkRecord => makeLink({ + newControl: newControls[p.newIdx], + oldId: oldCandidates[p.oldIdx].id, + matchMethod, + confidence: confidenceFor(p.semantic), + srg, + relationship, + titleSimilarity: p.titleSim, + checkSimilarity: p.checkSim, + cciJaccardScore: p.cci, + semanticScore: p.semantic, + blockNewCount, + blockOldCount, + }); + + const links: (LinkRecord | null)[] = Array.from( + { length: newControls.length }, + () => null, ); + const claimedNewIdx = new Set(); + const claimedOldIdx = new Set(); - let bestUnclaimed: ScoredCandidate | null = null; - let bestClaimed: ScoredCandidate | null = null; - - for (const [i, candidate] of candidates.entries()) { - const cci = cciJaccard(newCcis, extractCcis(candidate)); - const oldNormTitle = normalizeTitle( - safeTitle(candidate.title), - ctx.oldPrefix, - ); - const title = tokenJaccard(newNormTitle, oldNormTitle); - const composite - = TIER2_COMPOSITE_CCI_WEIGHT * cci - + TIER2_COMPOSITE_TITLE_WEIGHT * title; - const slot: ScoredCandidate = { idx: i, composite, cci }; - if (ctx.claimedOldIds.has(candidate.id)) { - if (!bestClaimed || composite > bestClaimed.composite) { - bestClaimed = slot; - } - } else if (!bestUnclaimed || composite > bestUnclaimed.composite) { - bestUnclaimed = slot; + // Pass 1: globally-best unique pairings. + for (const p of pairs) { + if (claimedNewIdx.has(p.newIdx) || claimedOldIdx.has(p.oldIdx)) { + continue; + } + const oldControl = oldCandidates[p.oldIdx]; + // Guard against double-claiming an old across SRG blocks. Currently + // unreachable (buildSrgIndex partitions olds by single gtitle), but + // cheap and prevents silent corruption if that invariant changes. + if (ctx.claimedOldIds.has(oldControl.id)) { + claimedOldIdx.add(p.oldIdx); + continue; } + claimedNewIdx.add(p.newIdx); + claimedOldIdx.add(p.oldIdx); + ctx.claimedOldIds.add(oldControl.id); + links[p.newIdx] = linkFromPair(p, 'primary'); } - // Invariant: tier 2 is only entered when candidates.length >= 2, so at - // least one of bestUnclaimed / bestClaimed is populated. Explicit guard - // so the type narrows without a non-null assertion. - const winner = bestUnclaimed ?? bestClaimed; - if (winner === null) { - throw new Error( - 'tier2CciTiebreak invariant violated: no candidate selected from a non-empty candidate list', - ); + // Pass 2: leftover new controls become `related` to their highest-scoring + // old candidate (already claimed by another new in pass 1). + for (const [i, newControl] of newControls.entries()) { + if (links[i] !== null) { + continue; + } + let best: PairScore | null = null; + for (const p of pairs) { + if (p.newIdx !== i) { + continue; + } + if (best === null || p.composite > best.composite) { + best = p; + } + } + // `best` is non-null whenever oldCandidates is non-empty; no-match + // is a defensive fallback only. + links[i] = best === null ? makeNoMatch(newControl, srg) : linkFromPair(best, 'related'); } - const winningCandidate = candidates[winner.idx]; - return makeLink({ - newControl, - oldId: winningCandidate.id, - matchMethod: 'srg-cci-tiebreak', - confidence: Math.max(winner.cci, 0), - srg, - relationship: claimOrRelate(winningCandidate.id, ctx.claimedOldIds), - }); + + return links.filter((l): l is LinkRecord => l !== null); } /** * Tier 3: no SRG candidates. Normalize the new control's title with its - * corpus prefix, search Fuse over old titles. Returns null when Fuse is - * unavailable (empty old profile), no query is extractable, or the best - * hit doesn't clear FUSE_ACCEPT_THRESHOLD — callers emit `makeNoMatch`. + * corpus prefix and search Fuse over old titles. Returns null when Fuse is + * unavailable, no query is extractable, or the best hit doesn't clear + * FUSE_ACCEPT_THRESHOLD — callers emit `makeNoMatch`. */ function tier3FuseFallback( newControl: ControlLike, srg: string | null, - ctx: TierContext, + ctx: PipelineContext, ): LinkRecord | null { if (!ctx.fuse) { return null; @@ -488,25 +579,38 @@ function tier3FuseFallback( if (best?.score === undefined || best.score >= FUSE_ACCEPT_THRESHOLD) { return null; } - // Invert Fuse score (0=perfect, 1=no match) into a 0-1 confidence - // where 1.0 is perfect. const confidence = 1 - best.score; + const oldId = best.item.originalId; + const relationship: 'primary' | 'related' + = ctx.claimedOldIds.has(oldId) ? 'related' : 'primary'; + if (relationship === 'primary') { + ctx.claimedOldIds.add(oldId); + } return makeLink({ newControl, - oldId: best.item.originalId, + oldId, matchMethod: 'fuse-fallback', confidence, srg, - relationship: claimOrRelate(best.item.originalId, ctx.claimedOldIds), + relationship, }); } /** * Requirement-first cross-vendor matcher. For every control in the new - * profile, try (in order): Tier 1 deterministic SRG match, Tier 2 - * composite CCI+title tiebreak, Tier 3 Fuse title fallback. Returns a - * LinkRecord per new control, including explicit no-match records so - * downstream consumers can iterate uniformly. + * profile, resolve to a LinkRecord (including explicit no-match records). + * + * Strategy: + * - Group new controls by their SRG-OS id. + * - For each SRG block whose old side also has candidates, run + * globally-optimal bipartite assignment scored on the composite + * `SEMANTIC_WEIGHT * (title+check Jaccard) + CCI_WEIGHT * CCI Jaccard`. + * Single-candidate blocks resolve to `srg-deterministic`; multi-candidate + * to `srg-semantic-tiebreak`. + * - New controls without an SRG match (no `gtitle` or empty old block) + * fall through to Tier-3 Fuse on normalized titles. The fallback can + * reach across SRG boundaries to pick up re-categorized requirements. + * - The returned array preserves the original new-profile order. */ export function applyRequirementFirstPipeline( oldProfile: ProfileLike, @@ -514,9 +618,6 @@ export function applyRequirementFirstPipeline( ): LinkRecord[] { const srgIndex = buildSrgIndex(oldProfile.controls); - // Detect each corpus's dominant leading prefix so cross-vendor drift - // (e.g. "RHEL 9" vs "Amazon Linux 2023") doesn't bleed into the fuzzy - // scores in tier 3. const oldPrefix = autoDetectPrefix( oldProfile.controls.map(c => safeTitle(c.title)), ); @@ -524,9 +625,6 @@ export function applyRequirementFirstPipeline( newProfile.controls.map(c => safeTitle(c.title)), ); - // Pre-compute a Fuse index over normalized old-control titles + gtitles. - // Only built when tier 3 will actually fire (there's at least one old - // control to search against). const searchCorpus: SearchRecord[] = oldProfile.controls.map(c => ({ originalId: c.id, title: normalizeTitle(safeTitle(c.title), oldPrefix), @@ -544,36 +642,51 @@ export function applyRequirementFirstPipeline( }) : null; - // Track which old control has already been claimed as `primary`. If a - // second new control best-matches the same old, it becomes `related` - // (1:N split — multiple new controls inherit one old body, but only the - // highest-scoring is primary). const claimedOldIds = new Set(); - const ctx: TierContext = { oldPrefix, newPrefix, fuse, claimedOldIds }; + const ctx: PipelineContext = { oldPrefix, newPrefix, fuse, claimedOldIds }; - const links: LinkRecord[] = []; - for (const newControl of newProfile.controls) { - const srg = extractSrgId(newControl); - // `candidates` is non-empty only when `srg` is non-null (srgIndex - // only stores non-null SRG keys). The explicit `srg !== null` guard - // in the tier 1/2 branches narrows the type so neither tier needs - // a non-null assertion. - const candidates = srg === null ? [] : (srgIndex.get(srg) ?? []); - - if (srg !== null && candidates.length === 1) { - links.push( - tier1DeterministicMatch(newControl, candidates[0], srg, claimedOldIds), - ); - } else if (srg !== null && candidates.length > 1) { - links.push(tier2CciTiebreak(newControl, candidates, srg, ctx)); + // Phase 1: resolve SRG blocks. Group new controls by SRG so the + // bipartite assignment sees the full block at once. + const newBySrg = new Map(); + for (const c of newProfile.controls) { + const srg = extractSrgId(c); + if (srg === null) { + continue; + } + const bucket = newBySrg.get(srg); + if (bucket) { + bucket.push(c); } else { - links.push( - tier3FuseFallback(newControl, srg, ctx) - ?? makeNoMatch(newControl, srg), - ); + newBySrg.set(srg, [c]); + } + } + + const linkById = new Map(); + for (const [srg, group] of newBySrg.entries()) { + const candidates = srgIndex.get(srg) ?? []; + if (candidates.length === 0) { + // No old SRG match — leave these for Phase 2 (Tier 3). + continue; + } + for (const link of resolveSrgBlock(group, candidates, srg, ctx)) { + linkById.set(link.newId, link); } } - return links; + + // Phase 2: every new control without a Phase-1 link gets a Tier-3 + // attempt or no-match. Iterating newProfile.controls here also + // preserves the original input order in the returned array. + const out: LinkRecord[] = []; + for (const newControl of newProfile.controls) { + const existing = linkById.get(newControl.id); + if (existing) { + out.push(existing); + continue; + } + const srg = extractSrgId(newControl); + out.push(tier3FuseFallback(newControl, srg, ctx) ?? makeNoMatch(newControl, srg)); + } + return out; } /** @@ -595,12 +708,6 @@ export function normalizeTitle(title: string, prefix: string): string { /** * Discover the dominant leading-token prefix of a corpus of rule titles. - * - * Tries long prefixes first; at each length, returns the prefix if it - * dominates more than `threshold` (default 0.5, strict majority) of the - * corpus. Falls back to progressively shorter prefixes when no long prefix - * dominates. Returns '' when no prefix at any length reaches the threshold - * (feature-focused corpora like Google Chrome STIG). */ export function autoDetectPrefix(titles: string[], threshold = 0.5): string { if (titles.length === 0) { diff --git a/test/utils/__tests__/cross_vendor_integration.test.ts b/test/utils/__tests__/cross_vendor_integration.test.ts index ddaa4fc1ab..db7033bd49 100644 --- a/test/utils/__tests__/cross_vendor_integration.test.ts +++ b/test/utils/__tests__/cross_vendor_integration.test.ts @@ -56,7 +56,7 @@ describe('Cross-vendor integration: RHEL 9 -> Amazon Linux 2023 mini', () => { byMethod[l.matchMethod] = (byMethod[l.matchMethod] ?? 0) + 1; } expect(byMethod['srg-deterministic']).toBe(6); - expect(byMethod['srg-cci-tiebreak']).toBe(3); + expect(byMethod['srg-semantic-tiebreak']).toBe(3); expect(byMethod['fuse-fallback']).toBe(1); expect(byMethod.none).toBe(1); }); @@ -103,7 +103,7 @@ describe('Cross-vendor integration: RHEL 9 -> Amazon Linux 2023 mini', () => { // 0.5) and 0 with SV-257901/SV-257902 -> wins SV-257900. expect(byNew['SV-273900']).toMatchObject({ oldId: 'SV-257900', - matchMethod: 'srg-cci-tiebreak', + matchMethod: 'srg-semantic-tiebreak', relationship: 'primary', potentialMismatch: false, }); @@ -111,7 +111,7 @@ describe('Cross-vendor integration: RHEL 9 -> Amazon Linux 2023 mini', () => { // (Jaccard 1.0) and 0 with the other two -> wins SV-257901. expect(byNew['SV-273901']).toMatchObject({ oldId: 'SV-257901', - matchMethod: 'srg-cci-tiebreak', + matchMethod: 'srg-semantic-tiebreak', relationship: 'primary', potentialMismatch: false, }); @@ -125,7 +125,7 @@ describe('Cross-vendor integration: RHEL 9 -> Amazon Linux 2023 mini', () => { // because the CCI Jaccard is below 0.5. expect(byNew['SV-273902']).toMatchObject({ oldId: 'SV-257902', - matchMethod: 'srg-cci-tiebreak', + matchMethod: 'srg-semantic-tiebreak', relationship: 'primary', potentialMismatch: true, }); diff --git a/test/utils/__tests__/delta_matching.test.ts b/test/utils/__tests__/delta_matching.test.ts index 2034310624..05a675a251 100644 --- a/test/utils/__tests__/delta_matching.test.ts +++ b/test/utils/__tests__/delta_matching.test.ts @@ -9,7 +9,7 @@ import { extractSrgId, normalizeTitle, TIER2_COMPOSITE_CCI_WEIGHT, - TIER2_COMPOSITE_TITLE_WEIGHT, + TIER2_COMPOSITE_SEMANTIC_WEIGHT, tokenJaccard, type LinkRecord, } from '../../../src/utils/delta_matching'; @@ -21,12 +21,14 @@ const mkControl = ( gtitle?: string, ccis: string[] = [], title?: string, + check?: string, ) => ({ id, title, tags: { ...(gtitle === undefined ? {} : { gtitle }), ...(ccis.length > 0 ? { cci: ccis } : {}), + ...(check === undefined ? {} : { check }), }, }); @@ -36,6 +38,9 @@ const mkControl = ( // collapses the "arrange block" duplication that Sonar's CPD flags. const profile = (...controls: ReturnType[]) => ({ controls }); +const linkAssignments = (ls: LinkRecord[]): Record => + Object.fromEntries(ls.map(l => [l.newId, l.oldId])); + describe('autoDetectPrefix', () => { // Data-driven cases — one arrange-then-assert shape; each row exercises // a distinct prefix-shape scenario (uniform majority, dominant-with- @@ -288,7 +293,7 @@ describe('applyRequirementFirstPipeline — Tier 1 (deterministic SRG)', () => { expect(links[0]).toMatchObject({ oldId: 'SV-OLD-B', newId: 'SV-NEW-1', - matchMethod: 'srg-cci-tiebreak', + matchMethod: 'srg-semantic-tiebreak', relationship: 'primary', srg: 'SRG-OS-000366-GPOS-00153', }); @@ -313,12 +318,12 @@ describe('applyRequirementFirstPipeline — Tier 1 (deterministic SRG)', () => { const byNewId = Object.fromEntries(links.map(l => [l.newId, l])); expect(byNewId['SV-NEW-alpha']).toMatchObject({ oldId: 'SV-OLD-alpha', - matchMethod: 'srg-cci-tiebreak', + matchMethod: 'srg-semantic-tiebreak', relationship: 'primary', }); expect(byNewId['SV-NEW-beta']).toMatchObject({ oldId: 'SV-OLD-beta', - matchMethod: 'srg-cci-tiebreak', + matchMethod: 'srg-semantic-tiebreak', relationship: 'primary', }); }); @@ -367,6 +372,120 @@ describe('applyRequirementFirstPipeline — Tier 1 (deterministic SRG)', () => { }); }); +describe('applyRequirementFirstPipeline — Tier 2 block resolution', () => { + it('resolves a multi-candidate SRG block correctly even when CCI Jaccard saturates to 1.0 across every candidate', () => { + // Every old candidate in this block carries identical CCIs, so CCI + // Jaccard is 1.0 across the block and provides no discriminating + // signal. Title + check similarity must carry the pairings. + const oldProfile = profile( + mkControl('SV-OLD-AUTH', 'SRG-OS-OFFLOAD', ['CCI-001851'], 'RHEL 9 must authenticate the remote audit logging server.'), + mkControl('SV-OLD-ENCRYPT', 'SRG-OS-OFFLOAD', ['CCI-001851'], 'RHEL 9 must encrypt the transfer of off-loaded audit records.'), + mkControl('SV-OLD-NAME', 'SRG-OS-OFFLOAD', ['CCI-001851'], 'RHEL 9 must label off-loaded audit logs via the name_format directive.'), + ); + const newProfile = profile( + mkControl('SV-NEW-NAME', 'SRG-OS-OFFLOAD', ['CCI-001851'], 'Amazon Linux 2023 must label off-loaded audit logs via the name_format directive.'), + mkControl('SV-NEW-AUTH', 'SRG-OS-OFFLOAD', ['CCI-001851'], 'Amazon Linux 2023 must authenticate the remote audit logging server.'), + mkControl('SV-NEW-ENCRYPT', 'SRG-OS-OFFLOAD', ['CCI-001851'], 'Amazon Linux 2023 must encrypt the transfer of off-loaded audit records.'), + ); + const byNew = Object.fromEntries( + applyRequirementFirstPipeline(oldProfile, newProfile).map(l => [l.newId, l]), + ); + expect(byNew['SV-NEW-NAME']?.oldId).toBe('SV-OLD-NAME'); + expect(byNew['SV-NEW-AUTH']?.oldId).toBe('SV-OLD-AUTH'); + expect(byNew['SV-NEW-ENCRYPT']?.oldId).toBe('SV-OLD-ENCRYPT'); + for (const id of ['SV-NEW-NAME', 'SV-NEW-AUTH', 'SV-NEW-ENCRYPT']) { + expect(byNew[id]?.matchMethod).toBe('srg-semantic-tiebreak'); + expect(byNew[id]?.relationship).toBe('primary'); + expect(byNew[id]?.potentialMismatch).toBe(false); + } + }); + + it('produces the same block assignment regardless of new-profile input order', () => { + const oldA = mkControl('SV-OLD-A', 'SRG-OS-X', ['CCI-1'], 'RHEL 9 must configure alpha service.'); + const oldB = mkControl('SV-OLD-B', 'SRG-OS-X', ['CCI-1'], 'RHEL 9 must configure beta service.'); + const newA = mkControl('SV-NEW-A', 'SRG-OS-X', ['CCI-1'], 'Amazon Linux 2023 must configure alpha service.'); + const newB = mkControl('SV-NEW-B', 'SRG-OS-X', ['CCI-1'], 'Amazon Linux 2023 must configure beta service.'); + const linksAB = applyRequirementFirstPipeline( + { controls: [oldA, oldB] }, + { controls: [newA, newB] }, + ); + const linksBA = applyRequirementFirstPipeline( + { controls: [oldA, oldB] }, + { controls: [newB, newA] }, + ); + expect(linkAssignments(linksAB)).toEqual(linkAssignments(linksBA)); + }); + + it('surfaces title, check, CCI, and semantic component scores on the link for triage', () => { + const oldProfile = profile( + mkControl( + 'SV-OLD-A', + 'SRG-OS-Q', + ['CCI-1'], + 'RHEL 9 must enforce setting X.', + 'Verify by running: cat /etc/foo.conf and confirming setting X is enabled.', + ), + mkControl( + 'SV-OLD-B', + 'SRG-OS-Q', + ['CCI-2'], + 'RHEL 9 must enforce setting Y.', + 'Verify by running: cat /etc/bar.conf and confirming setting Y is enabled.', + ), + ); + const newProfile = profile( + mkControl( + 'SV-NEW', + 'SRG-OS-Q', + ['CCI-1'], + 'Amazon Linux 2023 must enforce setting X.', + 'Verify by running: cat /etc/foo.conf and confirming setting X is enabled.', + ), + ); + const [link] = applyRequirementFirstPipeline(oldProfile, newProfile); + expect(link.titleSimilarity).toBeGreaterThan(0.9); + expect(link.checkSimilarity).toBeGreaterThan(0.9); + expect(link.cciJaccardScore).toBe(1); + expect(link.semanticScore).toBeGreaterThan(0.9); + expect(link.blockNewCount).toBe(1); + expect(link.blockOldCount).toBe(2); + }); + + it('uses tags.check to break a tie when titles alone are near-synonymous (the dense-family scenario)', () => { + // Both old candidates have title "must configure auditd". The check + // text carries the distinguishing technical content. The new control + // matches old B's check verbatim; semantic must pick B. + const oldProfile = profile( + mkControl( + 'SV-OLD-A', + 'SRG-OS-R', + ['CCI-1'], + 'RHEL 9 must configure auditd.', + 'Verify auditd is configured to flush records to disk by inspecting freq= setting in auditd.conf.', + ), + mkControl( + 'SV-OLD-B', + 'SRG-OS-R', + ['CCI-1'], + 'RHEL 9 must configure auditd.', + 'Verify auditd is configured to label off-loaded logs by inspecting the name_format directive in audisp-remote.conf.', + ), + ); + const newProfile = profile( + mkControl( + 'SV-NEW', + 'SRG-OS-R', + ['CCI-1'], + 'Amazon Linux 2023 must configure auditd.', + 'Verify auditd is configured to label off-loaded logs by inspecting the name_format directive in audisp-remote.conf.', + ), + ); + const [link] = applyRequirementFirstPipeline(oldProfile, newProfile); + expect(link.oldId).toBe('SV-OLD-B'); + expect(link.matchMethod).toBe('srg-semantic-tiebreak'); + }); +}); + describe('applyRequirementFirstPipeline — Tier 3 (Fuse fallback)', () => { it('falls back to fuzzy title match (with vendor-prefix normalization) when SRG indexes do not overlap', () => { // Classic cross-vendor scenario: both sides have the same core @@ -425,34 +544,67 @@ describe('applyRequirementFirstPipeline — potentialMismatch flag', () => { expect(link.potentialMismatch).toBe(false); }); - it('is true for Tier 2 primary when CCI Jaccard is below 0.5 (weak block-internal evidence)', () => { - // Two old candidates in the SRG block force Tier 2. Winner has Jaccard - // 1/3 = 0.333 (below the 0.5 Tier-2 threshold) -> flagged. + it('is true for Tier 1 deterministic when the lone old candidate is semantically unrelated', () => { + // Two unrelated requirements can share an SRG + CCI by accident of + // DISA categorization. A 1:1 SRG match alone is not evidence the + // bodies should be linked; the semantic check must flag this. + const oldProfile = profile( + mkControl( + 'SV-258168', + 'SRG-OS-000051-GPOS-00024', + ['CCI-001851'], + 'RHEL 9 must periodically flush audit records to disk to avoid in-memory record loss.', + ), + ); + const newProfile = profile( + mkControl( + 'SV-274020', + 'SRG-OS-000051-GPOS-00024', + ['CCI-001851'], + 'Amazon Linux 2023 must have the rsyslog package installed.', + ), + ); + const [link] = applyRequirementFirstPipeline(oldProfile, newProfile); + expect(link.matchMethod).toBe('srg-deterministic'); + expect(link.relationship).toBe('primary'); + // Body is still carried forward (reviewers can keep or rewrite); + // the flag fires so they know to look. + expect(link.oldId).toBe('SV-258168'); + expect(link.potentialMismatch).toBe(true); + }); + + it('is true for Tier 2 primary when winner semantic score is below the threshold', () => { + // Multi-candidate block with identical CCIs across candidates (so + // CCI Jaccard is informationless) and a new title that shares almost + // no tokens with any candidate. const oldProfile = profile( - mkControl('SV-OLD-A', 'SRG-OS-B', ['CCI-1', 'CCI-2', 'CCI-3'], 'RHEL 9 must alpha.'), - mkControl('SV-OLD-B', 'SRG-OS-B', ['CCI-9'], 'RHEL 9 must beta.'), + mkControl('SV-OLD-A', 'SRG-OS-B', ['CCI-1'], 'RHEL 9 must alpha alpha alpha.'), + mkControl('SV-OLD-B', 'SRG-OS-B', ['CCI-1'], 'RHEL 9 must beta beta beta.'), ); const newProfile = profile( - mkControl('SV-NEW', 'SRG-OS-B', ['CCI-1'], 'Amazon Linux 2023 must alpha.'), + mkControl('SV-NEW', 'SRG-OS-B', ['CCI-1'], 'Amazon Linux 2023 must implement quantum key distribution.'), ); const [link] = applyRequirementFirstPipeline(oldProfile, newProfile); - expect(link.matchMethod).toBe('srg-cci-tiebreak'); + expect(link.matchMethod).toBe('srg-semantic-tiebreak'); expect(link.relationship).toBe('primary'); - expect(link.confidence).toBeLessThan(0.5); + expect(link.confidence).toBeLessThan(0.3); expect(link.potentialMismatch).toBe(true); }); - it('is false for Tier 2 primary when CCI Jaccard is at least 0.5 (strong block-internal evidence)', () => { + it('is false for Tier 2 primary when semantic score is strong even with weak CCI overlap', () => { + // CCI Jaccard is only 1/3 but titles align perfectly after + // prefix stripping; the flag must not fire on the CCI signal alone. const oldProfile = profile( - mkControl('SV-OLD-A', 'SRG-OS-C', ['CCI-1', 'CCI-2'], 'RHEL 9 must alpha.'), + mkControl('SV-OLD-A', 'SRG-OS-C', ['CCI-1', 'CCI-2', 'CCI-3'], 'RHEL 9 must alpha.'), mkControl('SV-OLD-B', 'SRG-OS-C', ['CCI-9'], 'RHEL 9 must beta.'), ); const newProfile = profile( - mkControl('SV-NEW', 'SRG-OS-C', ['CCI-1', 'CCI-2'], 'Amazon Linux 2023 must alpha.'), + mkControl('SV-NEW', 'SRG-OS-C', ['CCI-1'], 'Amazon Linux 2023 must alpha.'), ); const [link] = applyRequirementFirstPipeline(oldProfile, newProfile); - expect(link.matchMethod).toBe('srg-cci-tiebreak'); + expect(link.matchMethod).toBe('srg-semantic-tiebreak'); expect(link.relationship).toBe('primary'); + expect(link.oldId).toBe('SV-OLD-A'); expect(link.confidence).toBeGreaterThanOrEqual(0.5); expect(link.potentialMismatch).toBe(false); }); @@ -507,13 +659,13 @@ describe('applyRequirementFirstPipeline — potentialMismatch flag', () => { describe('Tier-2 composite weight constants', () => { it('sum to 1.0 (fairness invariant)', () => { expect( - TIER2_COMPOSITE_CCI_WEIGHT + TIER2_COMPOSITE_TITLE_WEIGHT, + TIER2_COMPOSITE_SEMANTIC_WEIGHT + TIER2_COMPOSITE_CCI_WEIGHT, ).toBeCloseTo(1, 10); }); - it('CCI weight dominates title weight', () => { - expect(TIER2_COMPOSITE_CCI_WEIGHT).toBeGreaterThan( - TIER2_COMPOSITE_TITLE_WEIGHT, + it('semantic weight dominates CCI weight (requirement text identifies the requirement; CCI categorizes it)', () => { + expect(TIER2_COMPOSITE_SEMANTIC_WEIGHT).toBeGreaterThan( + TIER2_COMPOSITE_CCI_WEIGHT, ); }); }); From fd4a4e8c475880721dfcf83cd8d2dbc0aff3f38d Mon Sep 17 00:00:00 2001 From: Will Dower Date: Mon, 1 Jun 2026 23:24:04 -0400 Subject: [PATCH 2/4] refactor(delta): lower cognitive complexity of mapControls and resolveSrgBlock Signed-off-by: Will Dower --- src/commands/generate/delta.ts | 111 ++++++++++++++++++--------------- src/utils/delta_matching.ts | 55 +++++++++++----- 2 files changed, 102 insertions(+), 64 deletions(-) diff --git a/src/commands/generate/delta.ts b/src/commands/generate/delta.ts index 37fbe644eb..4f5d888e39 100644 --- a/src/commands/generate/delta.ts +++ b/src/commands/generate/delta.ts @@ -656,24 +656,7 @@ export default class GenerateDelta extends BaseCommand { const links = applyRequirementFirstPipeline(oldProfile, newProfile); GenerateDelta.links = links; - // Block-cardinality warning: emit one warning per SRG block where new - // and old counts disagree — at least one control in such a block has - // no true partner and the assignment is guessing. - const blocksWarned = new Set(); - for (const link of links) { - if ( - link.srg - && link.blockNewCount !== undefined - && link.blockOldCount !== undefined - && link.blockNewCount !== link.blockOldCount - && !blocksWarned.has(link.srg) - ) { - blocksWarned.add(link.srg); - this.logger.warn( - `Block cardinality mismatch for SRG ${link.srg}: ${link.blockNewCount} new vs ${link.blockOldCount} old — at least one control in this block has no true partner.`, - ); - } - } + GenerateDelta.emitBlockCardinalityWarnings(this.logger, links); // Cheap lookup tables for per-link logging const oldById = new Map(oldControls.map(c => [c.id, c])); @@ -682,37 +665,7 @@ export default class GenerateDelta extends BaseCommand { ); for (const link of links) { - const newId = basename(link.newId); - const oldCtl = link.oldId ? oldById.get(link.oldId) : undefined; - const newCtl = newByBasename.get(newId); - - // `none` links and (defensively) any link missing oldId are no-op - // for body-copying purposes. - if (link.matchMethod === 'none' || link.oldId === null) { - this.logger.info(` New XCCDF Control: ${newId}`); - this.logger.error( - ` No Match Found for: ${newId}${link.srg ? ` (SRG=${link.srg})` : ''}\n`, - ); - GenerateDelta.noMatch++; - continue; - } - - // Every non-none link resolves to an old control and goes into the - // returned map. Primary and related both need the old Ruby body. - controlMappings[newId] = link.oldId; - - this.logger.info(`Processing New Control: ${newId}`); - if (newCtl?.title) { - this.logger.info(` New Control Title: ${this.updateTitle(newCtl.title)}`); - } - if (oldCtl?.title) { - this.logger.info(` Old Control Title: ${this.updateTitle(oldCtl.title)}`); - } - - GenerateDelta.logMatchMethod(this.logger, link); - GenerateDelta.tickMatchCounter(link); - - this.logger.info(` Best Match Candidate: ${link.oldId} --> ${newId}\n`); + this.processLink(link, controlMappings, oldById, newByBasename); } this.logger.info('Mapping Results ==========================================================================='); @@ -742,6 +695,66 @@ export default class GenerateDelta extends BaseCommand { return controlMappings; } + /** + * Emit one warning per SRG block whose new/old counts disagree. At + * least one control in such a block has no true partner and the + * assignment is guessing. + */ + private static emitBlockCardinalityWarnings(log: Logger, links: LinkRecord[]): void { + const warned = new Set(); + for (const link of links) { + const { srg, blockNewCount, blockOldCount } = link; + if ( + srg + && blockNewCount !== undefined + && blockOldCount !== undefined + && blockNewCount !== blockOldCount + && !warned.has(srg) + ) { + warned.add(srg); + log.warn( + `Block cardinality mismatch for SRG ${srg}: ${blockNewCount} new vs ${blockOldCount} old — at least one control in this block has no true partner.`, + ); + } + } + } + + /** + * Process a single LinkRecord: update controlMappings for any non-`none` + * link, log the per-link diagnostics, and advance the run counters. + */ + private processLink( + link: LinkRecord, + controlMappings: Record, + oldById: Map, + newByBasename: Map, + ): void { + const newId = basename(link.newId); + if (link.matchMethod === 'none' || link.oldId === null) { + this.logger.info(` New XCCDF Control: ${newId}`); + this.logger.error( + ` No Match Found for: ${newId}${link.srg ? ` (SRG=${link.srg})` : ''}\n`, + ); + GenerateDelta.noMatch++; + return; + } + + controlMappings[newId] = link.oldId; + const oldCtl = oldById.get(link.oldId); + const newCtl = newByBasename.get(newId); + + this.logger.info(`Processing New Control: ${newId}`); + if (newCtl?.title) { + this.logger.info(` New Control Title: ${this.updateTitle(newCtl.title)}`); + } + if (oldCtl?.title) { + this.logger.info(` Old Control Title: ${this.updateTitle(oldCtl.title)}`); + } + GenerateDelta.logMatchMethod(this.logger, link); + GenerateDelta.tickMatchCounter(link); + this.logger.info(` Best Match Candidate: ${link.oldId} --> ${newId}\n`); + } + /** * Emit the per-link match-method log line. Kept separate from * tickMatchCounter so the output format can evolve independently of diff --git a/src/utils/delta_matching.ts b/src/utils/delta_matching.ts index 49e66a1ab0..b04b3dde66 100644 --- a/src/utils/delta_matching.ts +++ b/src/utils/delta_matching.ts @@ -508,10 +508,24 @@ function resolveSrgBlock( { length: newControls.length }, () => null, ); + claimPrimaryPairings(pairs, oldCandidates, ctx, links, linkFromPair); + fillRelatedForLeftovers(newControls, pairs, srg, links, linkFromPair); + return links.filter((l): l is LinkRecord => l !== null); +} + +/** + * Pass 1 of block resolution: walk pairs in descending composite order, + * claim each globally-best pair whose new and old are both unclaimed. + */ +function claimPrimaryPairings( + pairs: PairScore[], + oldCandidates: ControlLike[], + ctx: PipelineContext, + links: (LinkRecord | null)[], + linkFromPair: (p: PairScore, r: 'primary' | 'related') => LinkRecord, +): void { const claimedNewIdx = new Set(); const claimedOldIdx = new Set(); - - // Pass 1: globally-best unique pairings. for (const p of pairs) { if (claimedNewIdx.has(p.newIdx) || claimedOldIdx.has(p.oldIdx)) { continue; @@ -529,28 +543,39 @@ function resolveSrgBlock( ctx.claimedOldIds.add(oldControl.id); links[p.newIdx] = linkFromPair(p, 'primary'); } +} - // Pass 2: leftover new controls become `related` to their highest-scoring - // old candidate (already claimed by another new in pass 1). +/** Highest-composite pair whose newIdx matches `i`, or null if none exists. */ +function bestPairForNew(pairs: PairScore[], i: number): PairScore | null { + let best: PairScore | null = null; + for (const p of pairs) { + if (p.newIdx === i && (best === null || p.composite > best.composite)) { + best = p; + } + } + return best; +} + +/** + * Pass 2 of block resolution: every new control still unassigned after + * Pass 1 becomes `related` to its highest-scoring (already-claimed) old. + */ +function fillRelatedForLeftovers( + newControls: ControlLike[], + pairs: PairScore[], + srg: string, + links: (LinkRecord | null)[], + linkFromPair: (p: PairScore, r: 'primary' | 'related') => LinkRecord, +): void { for (const [i, newControl] of newControls.entries()) { if (links[i] !== null) { continue; } - let best: PairScore | null = null; - for (const p of pairs) { - if (p.newIdx !== i) { - continue; - } - if (best === null || p.composite > best.composite) { - best = p; - } - } + const best = bestPairForNew(pairs, i); // `best` is non-null whenever oldCandidates is non-empty; no-match // is a defensive fallback only. links[i] = best === null ? makeNoMatch(newControl, srg) : linkFromPair(best, 'related'); } - - return links.filter((l): l is LinkRecord => l !== null); } /** From 9b0e6f0cac3cee9fbef48b23396adce34a284bfe Mon Sep 17 00:00:00 2001 From: Will Date: Wed, 24 Jun 2026 19:29:36 -0400 Subject: [PATCH 3/4] fix(delta): activate check-text discriminator and flag related grafts 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 --- src/commands/generate/delta.ts | 28 +++--- src/utils/delta_matching.ts | 76 ++++++++++++--- test/utils/__tests__/delta_matching.test.ts | 103 ++++++++++++++++++-- 3 files changed, 177 insertions(+), 30 deletions(-) diff --git a/src/commands/generate/delta.ts b/src/commands/generate/delta.ts index e135075aca..7d5428e4f2 100644 --- a/src/commands/generate/delta.ts +++ b/src/commands/generate/delta.ts @@ -832,24 +832,28 @@ export default class GenerateDelta extends BaseCommand { * Advance the GenerateDelta static counters for a single link so the * end-of-run stats match reality. * - * match -> primary link, high confidence - * posMisMatch -> primary link, lower confidence (still accepted) - * dupMatch -> related link (shares old body with an earlier primary) + * match -> primary link above the tier's semantic bar + * posMisMatch -> any flagged link (primary OR related) — accepted but + * below the semantic bar; the body grafted forward is + * weakly supported and wants review + * dupMatch -> unflagged related link (shares old body with a primary) */ private static tickMatchCounter(link: LinkRecord): void { + // `potentialMismatch` is the single source of truth for "accepted but + // below the tier's semantic bar". It now fires for `related` grafts too + // (they carry a body forward just like primaries), so it is checked + // first: a flagged related graft counts as a possible mismatch, not a + // trusted duplicate. Each mapped link still ticks exactly one counter, + // so the match + mismatch + related = total invariant holds. + if (link.potentialMismatch) { + GenerateDelta.posMisMatch++; + return; + } if (link.relationship === 'related') { GenerateDelta.dupMatch++; return; } - // `potentialMismatch` is the single source of truth for "accepted - // primary but below the tier's confidence threshold". Reading the - // flag here keeps the stats aligned with the tier definitions in - // delta_matching.ts. - if (link.potentialMismatch) { - GenerateDelta.posMisMatch++; - } else { - GenerateDelta.match++; - } + GenerateDelta.match++; } getMappedStatisticsValidation(totalMappedControls: number, statValidation: string): string { diff --git a/src/utils/delta_matching.ts b/src/utils/delta_matching.ts index b04b3dde66..bcc9a6d7ab 100644 --- a/src/utils/delta_matching.ts +++ b/src/utils/delta_matching.ts @@ -51,12 +51,18 @@ function tokenizeSet(s: string): Set { /** * Minimal structural shape the matcher needs from an InSpec control. * Matches the subset of `@mitre/inspec-objects`' Control that we read. - * `tags.check` is the STIG check text — the most discriminating semantic - * content inside a dense SRG block where titles are near-synonymous. + * + * The STIG check text — the most discriminating semantic content inside a + * dense SRG block where titles are near-synonymous — lives in `descs.check`, + * NOT `tags.check`, on real processed controls: `processInSpecProfile` moves + * `tags.check` into `descs.check` (parsers/json), and `processXCCDF` writes + * `descs.check` directly (parsers/xccdf). `tags.check` is kept here only as a + * fallback for hand-built / pre-normalized inputs. See `safeCheck`. */ export type ControlLike = { id: string; title?: string | null; + descs?: Record | null; tags?: { gtitle?: string | null; cci?: string[] | null; @@ -78,7 +84,10 @@ function safeTitle(title: string | null | undefined): string { } function safeCheck(control: ControlLike): string { - return control.tags?.check ?? ''; + // Real processed controls carry check text in `descs.check` (see ControlLike + // docstring); `tags.check` is effectively always undefined after parsing. + // Prefer descs.check; fall back to tags.check for hand-built inputs. + return control.descs?.check ?? control.tags?.check ?? ''; } /** @@ -262,12 +271,22 @@ export const TIER1_MISMATCH_THRESHOLD = 0.3; export const TIER2_MISMATCH_THRESHOLD = 0.3; /** - * Tier-3 (Fuse-fallback) primary links with confidence below this threshold + * Tier-3 (Fuse-fallback) links with Fuse title-confidence below this threshold * are flagged. Fuse already gates acceptance at `1 - FUSE_ACCEPT_THRESHOLD` * (= 0.55 confidence), so the flag fires across [0.55, 0.9): accepted but soft. */ export const TIER3_MISMATCH_THRESHOLD = 0.9; +/** + * Tier-3 (Fuse-fallback) links whose title+check semantic score is below this + * threshold are flagged, independent of the Fuse title-confidence gate above. + * Title-template collisions (one swapped noun: "sudo package installed" vs + * "auditing package installed") score high on title-only Fuse confidence but + * low here once the check text is taken into account. Shares the 0.3 value + * with Tier 1/2 for a single, explainable semantic bar across all tiers. + */ +export const TIER3_SEMANTIC_THRESHOLD = 0.3; + /** * Tier-2 composite weights: `composite = SEMANTIC_WEIGHT * semanticScore * + CCI_WEIGHT * cciJaccard`. Semantic similarity (title + check text) is @@ -280,9 +299,20 @@ export const TIER2_COMPOSITE_CCI_WEIGHT = 0.3; /** * Compute the `potentialMismatch` flag from a link's semantic score. - * Related and no-match links never flag (the flag is about soft primary - * matches). Tier 1 and Tier 2 share a semantic-score threshold; Tier 3 - * uses its own confidence threshold against the (also-semantic) Fuse score. + * + * The flag fires for BOTH `primary` and `related` links: a `related` link + * still grafts the old control's body onto the new control downstream, so a + * weak-evidence `related` association ships a wrong body just as silently as + * a weak `primary` would (a legitimate 1:N split keeps a high semantic score + * and stays unflagged; a control force-assigned `related` to a poor match in + * a cardinality-mismatched block scores low and flags). Only explicit + * no-match links are exempt — there is no candidate body to be suspicious of. + * + * Tier 1 and Tier 2 gate on the title+check semantic score. Tier 3 (Fuse + * fallback) gates on EITHER a weak title+check semantic score OR a low Fuse + * title-confidence — the semantic term catches title-template collisions + * (near-identical titles whose check text diverges) that title-only Fuse + * confidence rates highly. */ function computePotentialMismatch( matchMethod: MatchMethod, @@ -290,7 +320,7 @@ function computePotentialMismatch( semantic: number, fuseConfidence: number, ): boolean { - if (relationship !== 'primary') { + if (relationship === 'no-match') { return false; } if (matchMethod === 'srg-deterministic') { @@ -300,7 +330,8 @@ function computePotentialMismatch( return semantic < TIER2_MISMATCH_THRESHOLD; } if (matchMethod === 'fuse-fallback') { - return fuseConfidence < TIER3_MISMATCH_THRESHOLD; + return semantic < TIER3_SEMANTIC_THRESHOLD + || fuseConfidence < TIER3_MISMATCH_THRESHOLD; } return false; } @@ -347,11 +378,16 @@ type PipelineContext = { newPrefix: string; fuse: FuseSearcher | null; claimedOldIds: Set; + // Old controls keyed by id, so Tier 3 can compute the title+check semantic + // score against the Fuse-matched old control (the Fuse corpus only carries + // titles, not check text). + oldById: Map; }; /** * Build a LinkRecord with triage fields populated and `potentialMismatch` - * derived from semantic score (Tier 1/2) or Fuse confidence (Tier 3). + * derived from the title+check semantic score (Tier 1/2, and Tier 3 once the + * matched old control is resolved) and/or Fuse title-confidence (Tier 3). */ function makeLink(args: { newControl: ControlLike; @@ -611,6 +647,17 @@ function tier3FuseFallback( if (relationship === 'primary') { ctx.claimedOldIds.add(oldId); } + // Compute the same title+check semantic signal Tier 1/2 use, against the + // matched old control, so the flag can catch title-template collisions the + // title-only Fuse score rates highly. Falls back to confidence-only gating + // when the old control can't be resolved (defensive; should not happen). + const oldControl = ctx.oldById.get(oldId); + const sem = oldControl + ? semanticScore(newControl, oldControl, ctx.newPrefix, ctx.oldPrefix) + : undefined; + const cci = oldControl + ? cciJaccard(extractCcis(newControl), extractCcis(oldControl)) + : undefined; return makeLink({ newControl, oldId, @@ -618,6 +665,10 @@ function tier3FuseFallback( confidence, srg, relationship, + titleSimilarity: sem?.titleSim, + checkSimilarity: sem?.checkSim, + cciJaccardScore: cci, + semanticScore: sem?.combined, }); } @@ -668,7 +719,10 @@ export function applyRequirementFirstPipeline( : null; const claimedOldIds = new Set(); - const ctx: PipelineContext = { oldPrefix, newPrefix, fuse, claimedOldIds }; + const oldById = new Map(oldProfile.controls.map(c => [c.id, c])); + const ctx: PipelineContext = { + oldPrefix, newPrefix, fuse, claimedOldIds, oldById, + }; // Phase 1: resolve SRG blocks. Group new controls by SRG so the // bipartite assignment sees the full block at once. diff --git a/test/utils/__tests__/delta_matching.test.ts b/test/utils/__tests__/delta_matching.test.ts index 05a675a251..cc67e32044 100644 --- a/test/utils/__tests__/delta_matching.test.ts +++ b/test/utils/__tests__/delta_matching.test.ts @@ -38,6 +38,17 @@ const mkControl = ( // collapses the "arrange block" duplication that Sonar's CPD flags. const profile = (...controls: ReturnType[]) => ({ controls }); +// Builds a control whose check text lives in `descs.check` — the field real +// processed controls use (processInSpecProfile/processXCCDF), as opposed to +// mkControl's `tags.check`. Used to exercise the production read path. +const mkControlWithDescsCheck = ( + id: string, + gtitle: string, + cci: string, + title: string, + check: string, +) => ({ id, title, descs: { check }, tags: { gtitle, cci: [cci] } }); + const linkAssignments = (ls: LinkRecord[]): Record => Object.fromEntries(ls.map(l => [l.newId, l.oldId])); @@ -451,6 +462,31 @@ describe('applyRequirementFirstPipeline — Tier 2 block resolution', () => { expect(link.blockOldCount).toBe(2); }); + it('reads check text from descs.check (the field real processed controls use) to break a tie', () => { + // Real controls from processInSpecProfile / processXCCDF carry check text + // in descs.check, not tags.check. Titles are identical; only the + // descs.check content distinguishes the candidates. semantic must pick B. + const oldProfile = profile( + mkControlWithDescsCheck( + 'SV-OLD-A', 'SRG-OS-DESCS', 'CCI-1', 'RHEL 9 must configure auditd.', + 'Verify auditd flushes records to disk by inspecting the freq= setting in auditd.conf.', + ), + mkControlWithDescsCheck( + 'SV-OLD-B', 'SRG-OS-DESCS', 'CCI-1', 'RHEL 9 must configure auditd.', + 'Verify auditd labels off-loaded logs via the name_format directive in audisp-remote.conf.', + ), + ); + const newProfile = profile( + mkControlWithDescsCheck( + 'SV-NEW', 'SRG-OS-DESCS', 'CCI-1', 'Amazon Linux 2023 must configure auditd.', + 'Verify auditd labels off-loaded logs via the name_format directive in audisp-remote.conf.', + ), + ); + const [link] = applyRequirementFirstPipeline(oldProfile, newProfile); + expect(link.oldId).toBe('SV-OLD-B'); + expect(link.checkSimilarity).toBeGreaterThan(0.9); + }); + it('uses tags.check to break a tie when titles alone are near-synonymous (the dense-family scenario)', () => { // Both old candidates have title "must configure auditd". The check // text carries the distinguishing technical content. The new control @@ -529,6 +565,38 @@ describe('applyRequirementFirstPipeline — Tier 3 (Fuse fallback)', () => { expect(links[0].matchMethod).toBe('none'); expect(links[0].oldId).toBeNull(); }); + + it('populates the title+check semantic triage fields on a fuse-fallback link (no longer null)', () => { + // Tier 3 used to rank on title-only Fuse confidence and left + // semanticScore/checkSimilarity unset. They are now computed against the + // matched old control so reviewers and the flag can see the check-text + // evidence even on cross-SRG fuzzy matches. + const oldProfile = profile( + mkControl( + 'SV-OLD', + 'SRG-OS-111-GPOS-999', + ['CCI-X'], + 'RHEL 9 must be a vendor-supported release.', + 'Verify the operating system is a vendor-supported release by checking the release notes.', + ), + ); + const newProfile = profile( + mkControl( + 'SV-NEW', + 'SRG-OS-222-GPOS-888', + ['CCI-X'], + 'Amazon Linux 2023 must be a vendor-supported release.', + 'Verify the operating system is a vendor-supported release by checking the release notes.', + ), + ); + const [link] = applyRequirementFirstPipeline(oldProfile, newProfile); + expect(link.matchMethod).toBe('fuse-fallback'); + expect(link.semanticScore).toBeGreaterThan(0.9); + expect(link.titleSimilarity).toBeGreaterThan(0.9); + expect(link.checkSimilarity).toBeGreaterThan(0.9); + expect(link.cciJaccardScore).toBe(1); + expect(link.potentialMismatch).toBe(false); + }); }); describe('applyRequirementFirstPipeline — potentialMismatch flag', () => { @@ -609,23 +677,44 @@ describe('applyRequirementFirstPipeline — potentialMismatch flag', () => { expect(link.potentialMismatch).toBe(false); }); - it('is false for Tier 2 related links regardless of confidence (related never flags)', () => { - // N:1 split — two new controls compete for the single old SV-OLD with - // equal Jaccard. Earlier wins primary, later becomes related. The - // related link's confidence mirrors the primary's; flag must stay false. + it('is false for a related link whose body genuinely fits (legitimate 1:N split, strong semantic)', () => { + // N:1 split — two new controls compete for the single old SV-OLD. Both + // are semantically the same requirement as the old (a real split), so the + // related link's grafted body is appropriate and must NOT flag. const oldProfile = profile( - mkControl('SV-OLD', 'SRG-OS-D', ['CCI-1'], 'RHEL 9 must do Y.'), + mkControl('SV-OLD', 'SRG-OS-D', ['CCI-1'], 'RHEL 9 must configure the system audit policy'), ); const newProfile = profile( - mkControl('SV-NEW-1', 'SRG-OS-D', ['CCI-1'], 'Amazon Linux 2023 must do Y (one).'), - mkControl('SV-NEW-2', 'SRG-OS-D', ['CCI-1'], 'Amazon Linux 2023 must do Y (two).'), + mkControl('SV-NEW-1', 'SRG-OS-D', ['CCI-1'], 'Amazon Linux 2023 must configure the system audit policy for local events'), + mkControl('SV-NEW-2', 'SRG-OS-D', ['CCI-1'], 'Amazon Linux 2023 must configure the system audit policy for remote events'), ); const links = applyRequirementFirstPipeline(oldProfile, newProfile); const related = links.find(l => l.relationship === 'related'); expect(related).toBeDefined(); + expect(related?.semanticScore).toBeGreaterThanOrEqual(0.3); expect(related?.potentialMismatch).toBe(false); }); + it('is true for a related link force-assigned to a poor match (cardinality-mismatched block)', () => { + // Single old candidate, two new controls. SV-NEW-MATCH claims it as + // primary; SV-NEW-ALIEN is forced `related` onto the same old because the + // block has more new than old. Its requirement is unrelated, so its + // grafted body is wrong — the flag must fire even though it is `related`. + const oldProfile = profile( + mkControl('SV-OLD', 'SRG-OS-D2', ['CCI-1'], 'RHEL 9 must enable the auditd service'), + ); + const newProfile = profile( + mkControl('SV-NEW-MATCH', 'SRG-OS-D2', ['CCI-1'], 'Amazon Linux 2023 must enable the auditd service'), + mkControl('SV-NEW-ALIEN', 'SRG-OS-D2', ['CCI-1'], 'Amazon Linux 2023 must restrict the kernel message buffer to privileged users'), + ); + const links = applyRequirementFirstPipeline(oldProfile, newProfile); + const alien = links.find(l => l.newId === 'SV-NEW-ALIEN'); + expect(alien?.relationship).toBe('related'); + expect(alien?.oldId).toBe('SV-OLD'); + expect(alien?.semanticScore).toBeLessThan(0.3); + expect(alien?.potentialMismatch).toBe(true); + }); + it('is false for no-match links (no candidate to be suspicious of)', () => { const oldProfile = profile( mkControl('SV-OLD', 'SRG-OS-E', ['CCI-1'], 'RHEL 9 must do Z.'), From f6fa28cdc8028189f8828526a0a3a750991c59ac Mon Sep 17 00:00:00 2001 From: Will Date: Fri, 26 Jun 2026 13:32:27 -0400 Subject: [PATCH 4/4] docs(delta): correct algorithm claims and harden matcher tests per review 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 --- src/commands/generate/delta.ts | 25 ++++-- src/utils/delta_matching.ts | 84 ++++++++++++++----- .../al2023-target-mini-profile.json | 9 ++ .../rhel9-base-mini-profile.json | 9 ++ .../cross_vendor_integration.test.ts | 10 +++ test/utils/__tests__/delta_matching.test.ts | 75 +++++++++++++++++ 6 files changed, 182 insertions(+), 30 deletions(-) diff --git a/src/commands/generate/delta.ts b/src/commands/generate/delta.ts index 7d5428e4f2..6669e1f510 100644 --- a/src/commands/generate/delta.ts +++ b/src/commands/generate/delta.ts @@ -588,8 +588,8 @@ export default class GenerateDelta extends BaseCommand { + `Total Controls Available for Delta: ${GenerateDelta.oldControlsLength}\n` + ` Total Controls Found on XCCDF: ${GenerateDelta.newControlsLength}\n` + ` Match Controls: ${GenerateDelta.match}\n` - + ` Possible Mismatch Controls: ${GenerateDelta.posMisMatch}\n` - + ` Related Match Controls: ${GenerateDelta.dupMatch}\n` + + ` Possible Mismatch Controls: ${GenerateDelta.posMisMatch} (incl. ${GenerateDelta.links.filter(l => l.relationship === 'related' && l.potentialMismatch).length} flagged related grafts)\n` + + ` Trusted Related Matches: ${GenerateDelta.dupMatch}\n` + ` No Match Controls: ${GenerateDelta.noMatch}\n` + ` New XCCDF Controls: ${GenerateDelta.newXccdfControl}\n\n` + 'Statistics Validation ------------------------------------------\n' @@ -698,10 +698,17 @@ export default class GenerateDelta extends BaseCommand { this.logger.info(`Total Controls Available for Delta: ${GenerateDelta.oldControlsLength}`); this.logger.info(` Total Controls Found on XCCDF: ${GenerateDelta.newControlsLength}\n`); + // Flagged `related` grafts now count toward posMisMatch (they ship a body + // forward just like a flagged primary). Surface how many of the possible + // mismatches are related grafts so the triage signal isn't hidden, and + // relabel dupMatch as the *trusted* (unflagged) related count it now is. + const flaggedRelated = GenerateDelta.links.filter( + l => l.relationship === 'related' && l.potentialMismatch, + ).length; this.logger.info('Match Statistics ========================='); this.logger.info(` Match Controls: ${GenerateDelta.match}`); - this.logger.info(` Possible Mismatch Controls: ${GenerateDelta.posMisMatch}`); - this.logger.info(` Related Match Controls: ${GenerateDelta.dupMatch}`); + this.logger.info(` Possible Mismatch Controls: ${GenerateDelta.posMisMatch} (incl. ${flaggedRelated} flagged related graft${flaggedRelated === 1 ? '' : 's'})`); + this.logger.info(` Trusted Related Matches: ${GenerateDelta.dupMatch}`); this.logger.info(` No Match Controls: ${GenerateDelta.noMatch}`); this.logger.info(` New XCCDF Controls: ${GenerateDelta.newXccdfControl}\n`); @@ -857,10 +864,12 @@ export default class GenerateDelta extends BaseCommand { } getMappedStatisticsValidation(totalMappedControls: number, statValidation: string): string { - // In the requirement-first pipeline `dupMatch` counts `related` links, - // which ARE included in controlMappings (they share a body with a - // primary). `newXccdfControl` is kept at 0 because the new pipeline - // doesn't have a distinct "no Fuse candidate" bucket — those fall + // In the requirement-first pipeline `dupMatch` counts UNFLAGGED `related` + // links (flagged related grafts move to `posMisMatch`); all related links + // are included in controlMappings (they share a body with a primary), so + // the match+mismatch+related total is unaffected by where flagged related + // links are counted. `newXccdfControl` is kept at 0 because the new + // pipeline doesn't have a distinct "no Fuse candidate" bucket — those fall // into `noMatch`. const match = GenerateDelta.match; const misMatch = GenerateDelta.posMisMatch; diff --git a/src/utils/delta_matching.ts b/src/utils/delta_matching.ts index bcc9a6d7ab..a85e61b54c 100644 --- a/src/utils/delta_matching.ts +++ b/src/utils/delta_matching.ts @@ -10,11 +10,14 @@ import Fuse from 'fuse.js'; * stable signal is the requirement text itself (title + check). * * Pipeline: SRG-ID is a blocking key (narrows the candidate pool); inside - * each block we run globally-optimal greedy bipartite assignment scored on - * `SEMANTIC_WEIGHT * semanticScore(title+check) + CCI_WEIGHT * cciJaccard`, - * so winning pairs don't permute under reordering of the new profile. - * Controls with no SRG overlap fall through to a Fuse fuzzy fallback on - * vendor-prefix-stripped titles. + * each block we run greedy best-first bipartite assignment scored on + * `SEMANTIC_WEIGHT * semanticScore(title+check) + CCI_WEIGHT * cciJaccard` — + * the highest-composite pair with both endpoints free is claimed first. This + * is a 1/2-approximation, NOT a maximum-weight matching; the bias is + * deliberate (commit the single most-trustworthy link first, which suits + * copying a body forward). Winning pairs don't permute under reordering of + * the new profile (ties broken by control id). Controls with no SRG overlap + * fall through to a Fuse fuzzy fallback on vendor-prefix-stripped titles. */ /** @@ -219,7 +222,8 @@ export function semanticScore( * * Triage fields (optional, populated when relevant): * - `titleSimilarity` Vendor-prefix-stripped title Jaccard. - * - `checkSimilarity` tags.check token Jaccard (0 when either side lacks check text). + * - `checkSimilarity` check-text token Jaccard from `descs.check` (falling back to + * `tags.check`); 0 when either side lacks check text. See `safeCheck`. * - `cciJaccardScore` CCI overlap, retained for visibility / downstream sorting. * - `semanticScore` Combined title + check (the requirement-identity signal). * - `blockNewCount` # of new controls sharing this SRG (Tier 1/2 only). @@ -303,16 +307,28 @@ export const TIER2_COMPOSITE_CCI_WEIGHT = 0.3; * The flag fires for BOTH `primary` and `related` links: a `related` link * still grafts the old control's body onto the new control downstream, so a * weak-evidence `related` association ships a wrong body just as silently as - * a weak `primary` would (a legitimate 1:N split keeps a high semantic score - * and stays unflagged; a control force-assigned `related` to a poor match in - * a cardinality-mismatched block scores low and flags). Only explicit - * no-match links are exempt — there is no candidate body to be suspicious of. + * a weak `primary` would. Only explicit no-match links are exempt — there is + * no candidate body to be suspicious of. + * + * IMPORTANT — this is NOT a clean correctness classifier. On real cross-vendor + * data the matched-link semantic scores form a smooth, unimodal distribution + * with no separating valley: correct and wrong grafts interleave across the + * ~0.3–0.6 mid-band. The 0.3 threshold is an "obvious junk" floor (catches + * grafts with almost no shared requirement text), not a line that separates + * right from wrong. A wrong graft can sit above 0.3 unflagged (e.g. an + * "owned by root" vs "group-owned by root" dimension swap at ~0.44), and + * correct 1:N splits can fall just under and flag. Treat an UNFLAGGED + * mid-band link as "not obviously junk", not as "verified correct"; raising + * the threshold to catch the mid-band wrong grafts would flag a large share + * of correct ones (no single threshold separates the populations — that needs + * an orthogonal signal such as antonym/dimension-swap detection). * * Tier 1 and Tier 2 gate on the title+check semantic score. Tier 3 (Fuse * fallback) gates on EITHER a weak title+check semantic score OR a low Fuse - * title-confidence — the semantic term catches title-template collisions - * (near-identical titles whose check text diverges) that title-only Fuse - * confidence rates highly. + * title-confidence. On real data the `fuseConfidence < 0.9` term dominates + * (it fires on nearly every fuse link, and alone catches wrong fuse primaries + * the semantic term misses); the semantic term is defensive insurance that + * rarely flips a flag on its own. */ function computePotentialMismatch( matchMethod: MatchMethod, @@ -481,23 +497,47 @@ function scoreBlockPairs( }); } } - pairs.sort((a, b) => b.composite - a.composite); + pairs.sort((a, b) => { + if (b.composite !== a.composite) { + return b.composite - a.composite; + } + // Deterministic tiebreak by control identity so the assignment is + // invariant to new-profile input order even when composites tie exactly + // (Jaccard rationals can collide). Without this, a stable sort would + // resolve ties by input position, making the output order-dependent. + const an = newControls[a.newIdx].id; + const bn = newControls[b.newIdx].id; + if (an !== bn) { + return an < bn ? -1 : 1; + } + const ao = oldCandidates[a.oldIdx].id; + const bo = oldCandidates[b.oldIdx].id; + return ao < bo ? -1 : (ao > bo ? 1 : 0); + }); return pairs; } /** - * Resolve an SRG block to per-new-control link records using globally- - * optimal greedy bipartite assignment on the composite score: + * Resolve an SRG block to per-new-control link records using greedy + * best-first bipartite assignment on the composite score: * * 1. Score every (new, old) pair in the block. - * 2. Sort pairs by composite score, descending. + * 2. Sort pairs by composite score, descending (ties broken by control id). * 3. Walk the sorted list, claiming pairs whose new and old are both * free (primary links). * 4. Any new control still unassigned (block has more new than old) * becomes `related` to its single best-scoring (already-claimed) old. * + * This is a 1/2-approximation, not a maximum-weight matching: claiming the + * single highest pair first can leave a globally-higher-total pairing on the + * table. The tradeoff is intentional — committing the most-trustworthy + * individual link first is the right bias when the consequence is copying a + * body forward. No suboptimal divergence was observed in the validated + * RHEL9->SLES15 run. + * * Order-independent: the assignment depends only on the set of pairs and - * their scores, not on the iteration order of the new profile. + * their scores (with id-based tiebreaks), not on the iteration order of the + * new profile. * * Single-candidate (Tier-1) blocks share the same scoring path so the * potentialMismatch flag derives consistently; the only difference is the @@ -551,7 +591,7 @@ function resolveSrgBlock( /** * Pass 1 of block resolution: walk pairs in descending composite order, - * claim each globally-best pair whose new and old are both unclaimed. + * claim each highest-composite pair whose new and old are both unclaimed. */ function claimPrimaryPairings( pairs: PairScore[], @@ -678,9 +718,9 @@ function tier3FuseFallback( * * Strategy: * - Group new controls by their SRG-OS id. - * - For each SRG block whose old side also has candidates, run - * globally-optimal bipartite assignment scored on the composite - * `SEMANTIC_WEIGHT * (title+check Jaccard) + CCI_WEIGHT * CCI Jaccard`. + * - For each SRG block whose old side also has candidates, run greedy + * best-first bipartite assignment (a 1/2-approximation) scored on the + * composite `SEMANTIC_WEIGHT * (title+check Jaccard) + CCI_WEIGHT * CCI Jaccard`. * Single-candidate blocks resolve to `srg-deterministic`; multi-candidate * to `srg-semantic-tiebreak`. * - New controls without an SRG match (no `gtitle` or empty old block) diff --git a/test/sample_data/delta-matching/al2023-target-mini-profile.json b/test/sample_data/delta-matching/al2023-target-mini-profile.json index d5749da0cf..5a4a237522 100644 --- a/test/sample_data/delta-matching/al2023-target-mini-profile.json +++ b/test/sample_data/delta-matching/al2023-target-mini-profile.json @@ -59,6 +59,9 @@ { "id": "SV-273900", "title": "Amazon Linux 2023 must use FIPS 140-3 approved cryptography for system services.", + "descs": { + "check": "Run fips-mode-setup --check and verify FIPS 140-3 approved cryptography is enabled for system services." + }, "tags": { "gtitle": "SRG-OS-CRYPTO", "cci": ["CCI-000803"] @@ -67,6 +70,9 @@ { "id": "SV-273901", "title": "Amazon Linux 2023 must disable SSH MAC algorithms not approved by FIPS 140-3.", + "descs": { + "check": "Inspect /etc/ssh/sshd_config and verify SSH MAC algorithms not approved by FIPS 140-3 are disabled." + }, "tags": { "gtitle": "SRG-OS-CRYPTO", "cci": ["CCI-002450"] @@ -75,6 +81,9 @@ { "id": "SV-273902", "title": "Amazon Linux 2023 must use post-quantum key encapsulation for TLS sessions.", + "descs": { + "check": "Verify post-quantum key encapsulation ML-KEM is enabled for all TLS sessions." + }, "tags": { "gtitle": "SRG-OS-CRYPTO", "cci": ["CCI-999999"] diff --git a/test/sample_data/delta-matching/rhel9-base-mini-profile.json b/test/sample_data/delta-matching/rhel9-base-mini-profile.json index c013e5e8c8..076c90553d 100644 --- a/test/sample_data/delta-matching/rhel9-base-mini-profile.json +++ b/test/sample_data/delta-matching/rhel9-base-mini-profile.json @@ -51,6 +51,9 @@ { "id": "SV-257900", "title": "RHEL 9 must use FIPS 140-3 approved cryptography for system services.", + "descs": { + "check": "Run fips-mode-setup --check and verify FIPS 140-3 approved cryptography is enabled for system services." + }, "tags": { "gtitle": "SRG-OS-CRYPTO", "cci": ["CCI-000803", "CCI-001199"] @@ -59,6 +62,9 @@ { "id": "SV-257901", "title": "RHEL 9 must disable SSH MAC algorithms not approved by FIPS 140-3.", + "descs": { + "check": "Inspect /etc/ssh/sshd_config and verify SSH MAC algorithms not approved by FIPS 140-3 are disabled." + }, "tags": { "gtitle": "SRG-OS-CRYPTO", "cci": ["CCI-002450"] @@ -67,6 +73,9 @@ { "id": "SV-257902", "title": "RHEL 9 must disable SSH key exchange algorithms not approved by FIPS 140-3.", + "descs": { + "check": "Inspect /etc/ssh/sshd_config and verify SSH key exchange algorithms not approved by FIPS 140-3 are disabled." + }, "tags": { "gtitle": "SRG-OS-CRYPTO", "cci": ["CCI-002460"] diff --git a/test/utils/__tests__/cross_vendor_integration.test.ts b/test/utils/__tests__/cross_vendor_integration.test.ts index db7033bd49..b3c76ba4d3 100644 --- a/test/utils/__tests__/cross_vendor_integration.test.ts +++ b/test/utils/__tests__/cross_vendor_integration.test.ts @@ -117,6 +117,16 @@ describe('Cross-vendor integration: RHEL 9 -> Amazon Linux 2023 mini', () => { }); }); + it('exercises the real descs.check signal in the SRG-OS-CRYPTO block (guards against the check field silently going dark again)', () => { + // The CRYPTO controls carry check text under descs.check (where real + // processInSpecProfile/processXCCDF output puts it). The matched links + // must therefore show nonzero checkSimilarity — if a future change reads + // check from the wrong field, this drops to 0 and fails, instead of the + // matcher silently degrading to title-only as it did before the fix. + expect(byNew['SV-273900'].checkSimilarity).toBeGreaterThan(0); + expect(byNew['SV-273901'].checkSimilarity).toBeGreaterThan(0); + }); + it('flags a primary Tier-2 match with CCI Jaccard=0 as potentialMismatch', () => { // SV-273902 has CCI-999999, which has zero overlap with any candidate // in the SRG-OS-CRYPTO block. After SV-273900 and SV-273901 claim diff --git a/test/utils/__tests__/delta_matching.test.ts b/test/utils/__tests__/delta_matching.test.ts index cc67e32044..4e2f0cceee 100644 --- a/test/utils/__tests__/delta_matching.test.ts +++ b/test/utils/__tests__/delta_matching.test.ts @@ -427,6 +427,62 @@ describe('applyRequirementFirstPipeline — Tier 2 block resolution', () => { expect(linkAssignments(linksAB)).toEqual(linkAssignments(linksBA)); }); + it('is order-independent even when composite scores tie exactly (id tiebreak)', () => { + // Two new x two old, identical normalized titles, identical CCIs, no check + // text -> every (new,old) composite is exactly equal. A stable sort alone + // would resolve by input position, making the assignment order-dependent. + // The id tiebreak must produce the same assignment under any permutation. + const oldA = mkControl('SV-OLD-A', 'SRG-OS-TIE', ['CCI-1'], 'RHEL 9 must configure the service.'); + const oldB = mkControl('SV-OLD-B', 'SRG-OS-TIE', ['CCI-1'], 'RHEL 9 must configure the service.'); + const newA = mkControl('SV-NEW-A', 'SRG-OS-TIE', ['CCI-1'], 'Amazon Linux 2023 must configure the service.'); + const newB = mkControl('SV-NEW-B', 'SRG-OS-TIE', ['CCI-1'], 'Amazon Linux 2023 must configure the service.'); + const ab = linkAssignments(applyRequirementFirstPipeline( + { controls: [oldA, oldB] }, { controls: [newA, newB] }, + )); + const ba = linkAssignments(applyRequirementFirstPipeline( + { controls: [oldA, oldB] }, { controls: [newB, newA] }, + )); + const oldSwap = linkAssignments(applyRequirementFirstPipeline( + { controls: [oldB, oldA] }, { controls: [newA, newB] }, + )); + expect(ba).toEqual(ab); + expect(oldSwap).toEqual(ab); + // Both news are assigned to distinct olds (a 2x2 perfect matching). + expect(new Set(Object.values(ab)).size).toBe(2); + }); + + it('resolves a reciprocal transposition using descs.check alone, in both input orders', () => { + // The real-data failure class: two controls in one SRG block with + // near-identical titles, distinguishable ONLY by check text. Title-only + // scoring permutes the bodies onto the wrong partners; the descs.check + // signal must pin each new control to its true old in EITHER input order. + const oldFreq = mkControlWithDescsCheck( + 'SV-OLD-FREQ', 'SRG-OS-XPOSE', 'CCI-1', 'RHEL 9 must configure auditd.', + 'Verify auditd flushes records to disk by inspecting the freq setting in auditd.conf.', + ); + const oldName = mkControlWithDescsCheck( + 'SV-OLD-NAME', 'SRG-OS-XPOSE', 'CCI-1', 'RHEL 9 must configure auditd.', + 'Verify auditd labels off-loaded logs via the name_format directive in audisp-remote.conf.', + ); + const newFreq = mkControlWithDescsCheck( + 'SV-NEW-FREQ', 'SRG-OS-XPOSE', 'CCI-1', 'Amazon Linux 2023 must configure auditd.', + 'Verify auditd flushes records to disk by inspecting the freq setting in auditd.conf.', + ); + const newName = mkControlWithDescsCheck( + 'SV-NEW-NAME', 'SRG-OS-XPOSE', 'CCI-1', 'Amazon Linux 2023 must configure auditd.', + 'Verify auditd labels off-loaded logs via the name_format directive in audisp-remote.conf.', + ); + for (const order of [[newFreq, newName], [newName, newFreq]]) { + const byNew = Object.fromEntries( + applyRequirementFirstPipeline( + { controls: [oldFreq, oldName] }, { controls: order }, + ).map(l => [l.newId, l.oldId]), + ); + expect(byNew['SV-NEW-FREQ']).toBe('SV-OLD-FREQ'); + expect(byNew['SV-NEW-NAME']).toBe('SV-OLD-NAME'); + } + }); + it('surfaces title, check, CCI, and semantic component scores on the link for triage', () => { const oldProfile = profile( mkControl( @@ -743,6 +799,25 @@ describe('applyRequirementFirstPipeline — potentialMismatch flag', () => { expect(link.confidence).toBeGreaterThanOrEqual(0.9); expect(link.potentialMismatch).toBe(false); }); + + it('is true for a Tier 3 fuse-fallback link accepted at confidence in [0.55, 0.9) (the load-bearing conf gate)', () => { + // Tier 3 accepts at Fuse score < 0.45 (confidence > 0.55) but flags below + // 0.9 confidence. This is the branch that, on real data, catches wrong + // fuse primaries the semantic term misses — yet every other fuse test + // asserts `false`. Cross-SRG titles that share the leading requirement + // tokens but diverge in the tail match with mid-band confidence. + const oldProfile = profile( + mkControl('SV-OLD', 'SRG-OS-G-111', ['CCI-X'], 'RHEL 9 audit records must be generated for all account creation events.'), + ); + const newProfile = profile( + mkControl('SV-NEW', 'SRG-OS-G-222', ['CCI-X'], 'Amazon Linux 2023 audit records must be generated for all privilege escalation events.'), + ); + const [link] = applyRequirementFirstPipeline(oldProfile, newProfile); + expect(link.matchMethod).toBe('fuse-fallback'); + expect(link.confidence).toBeGreaterThanOrEqual(0.55); + expect(link.confidence).toBeLessThan(0.9); + expect(link.potentialMismatch).toBe(true); + }); }); describe('Tier-2 composite weight constants', () => {