Skip to content

generate delta -M: 1:N control splits silently drop bodies (mapped file keyed by old ID, last-write-wins) #8488

Description

@wdower

When saf generate delta -M maps multiple new controls to a single old control (a 1:N split — emitted as one primary link plus one or more related links), only one of those new controls keeps the old control's describe body. The others are written out as empty stubs. The surviving body lands on whichever sibling happens to be last in iteration order, which is effectively arbitrary — it is frequently not the best-matching (primary) control, and in at least one real case it landed on the matcher's worst pick.

Affected code

src/commands/generate/delta.ts, the mapped-controls write loop (~lines 375–423). The output file is named by the old id:

for (const [key, value] of Object.entries(controls)) {   // key = NEW id, value = OLD id
  const sourceControlFile = path.join(controlsDir, `${value}.rb`);
  const mappedControlFile = path.join(mappedDir,  `${value}.rb`);   // <-- keyed by OLD id
  ...
  // read old body, rewrite inner `control '<old>' do` -> `control '<new>' do`
  fs.writeFileSync(mappedControlFile, lines.join('\n'));            // <-- collides on shared old id
}

When two controlMappings entries share the same value (old id), both iterations write to mapped_controls/${oldId}.rb; the second overwrites the first. After inspec json regenerates the profile summary from mapped_controls/, only the last-written new control exists there with a body. Every other new control in that split has no entry, so the downstream delta writes it out without a code block (empty describe).

Root cause

The mapped file is keyed by old id, but a 1:N split has N distinct new ids sharing one old id → filename collision. The primary/related distinction the matcher computes is not consulted at the write stage, so even the intent "give the body to the best match" is lost — it goes to whoever Object.entries visits last.

Empirical evidence (real RHEL9 → SLES15 run, this branch's build)

Surfaced when using SAF CLI to prepolutate a SLES15 STIG profile using RHEL9 STIG profile as a basis for delta.

The validated SLES15 delta has 11 old controls referenced by 2–3 new controls = 13 siblings at risk. Inspecting the actual output controls/*.rb for the presence of a describe block:

Old control New controls (relationship / has body in output)
SV-258012 SV-234806 (primary / ❌ no body), SV-234808 (related / ❌), SV-234809 (related / ✅ got body)
SV-257918 SV-234842 (primary / ❌), SV-234843 (related / ❌), SV-234845 (related / ✅ got body — and SV-234845 is the known wrong graft)
SV-257779 SV-234805 (primary / ❌), SV-234807 (related / ✅)
SV-257826 SV-234804 (related / ❌), SV-234818 (primary / ✅)
SV-257882 SV-234840 (primary / ❌), SV-234841 (related / ✅)
SV-257929 SV-234828 (primary / ❌), SV-255921 (related / ✅)
SV-258071 SV-234982 (primary / ❌), SV-234983 (related / ✅)
SV-258133 SV-234857 (related / ❌), SV-234858 (primary / ✅)
SV-258135 SV-234851 (primary / ❌), SV-234864 (related / ✅)
SV-258137 SV-234961 (primary / ❌), SV-234962 (related / ✅)
SV-258149 SV-234865 (related / ❌), SV-234978 (primary / ✅)

Observations:

  • In 9 of 11 splits the primary (best-match) control did NOT get the body — a related sibling did.
  • For SV-257918, the body went to SV-234845, which independent review flagged as the wrong match, while the correct primary SV-234842 got nothing.
  • 13 controls in the SLES output are missing the body they should have inherited.

Impact

  • Generated cross-vendor profiles (e.g. the SLES15 STIG profile this is intended to seed) ship a meaningful number of controls as empty stubs that should have carried a forwarded implementation.
  • The damage is silent: nothing in the logs or delta.json indicates the body was dropped (delta.json links[] still records all the primary/related links correctly — the loss happens only at the file-emit stage).
  • Single-vendor / same-id deltas are largely unaffected (few or no 1:N splits); the bug concentrates in exactly the cross-vendor use case the matcher now targets.

Proposed fix

Key the mapped output file by the new id (key) instead of the old id (value), so each sibling in a split writes its own file:

const mappedControlFile = path.join(mappedDir, `${key}.rb`);   // new id — one file per new control

The source is still read from ${value}.rb (old id), and the inner control '<old>' do is still rewritten to control '<new>' do. Each new control then gets its own mapped_controls/<newId>.rb carrying the old body under the new id, and inspec json sees all N controls with bodies.

Considerations to verify when implementing:

  • Confirm the value === key "already current" branch and the logging still read correctly once the target filename changes.
  • Confirm no downstream step assumes mapped filenames equal old ids.
  • This is the intended semantics for related links per mapControls ("copy the old Ruby body to every related new control") — the fix simply makes the file-write honor it.

Suggested tests

  • A command-level test driving a 1:N SRG block end to end, asserting each new control yields its own output file with a describe body, and that delta.json links[] contains the primary + related records.
  • A regression assertion that no two mapped output filenames collide for distinct new ids.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions