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.
When
saf generate delta -Mmaps multiple new controls to a single old control (a 1:N split — emitted as oneprimarylink plus one or morerelatedlinks), only one of those new controls keeps the old control'sdescribebody. 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:When two
controlMappingsentries share the samevalue(old id), both iterations write tomapped_controls/${oldId}.rb; the second overwrites the first. Afterinspec jsonregenerates the profile summary frommapped_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 (emptydescribe).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/relateddistinction 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 whoeverObject.entriesvisits 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/*.rbfor the presence of adescribeblock:Observations:
primary(best-match) control did NOT get the body — arelatedsibling did.SV-257918, the body went toSV-234845, which independent review flagged as the wrong match, while the correct primarySV-234842got nothing.Impact
delta.jsonindicates the body was dropped (delta.json links[]still records all the primary/related links correctly — the loss happens only at the file-emit stage).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:The source is still read from
${value}.rb(old id), and the innercontrol '<old>' dois still rewritten tocontrol '<new>' do. Each new control then gets its ownmapped_controls/<newId>.rbcarrying the old body under the new id, andinspec jsonsees all N controls with bodies.Considerations to verify when implementing:
value === key"already current" branch and the logging still read correctly once the target filename changes.relatedlinks permapControls("copy the old Ruby body to every related new control") — the fix simply makes the file-write honor it.Suggested tests
describebody, and thatdelta.json links[]contains the primary + related records.