2301fb2122
The Obligation Registry filled proposed_obligation_id (7/7) + cut the logging family (obligations 47->66). Adopted obligation_id onto our 7 accepted CRA->OWASP mappings; the join now prefers the EXACT obligation_id over the coarse citation_unit (which stays as fallback for not-yet-adopted rows). Effect: semantic coverage 2->4 (user_authentication_required, credential_confidentiality_protection, auth_key_management, event_logging_security_events). Befund 1 resolved: V11.2.1 crypto now sits under credential_confidentiality_protection, not user_authentication_required. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
173 lines
6.1 KiB
Go
173 lines
6.1 KiB
Go
package ucca
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// ObligationKey is one entry of the Obligation Registry's cross-session contract
|
|
// (obligations/obligation_join_keys.json). obligation_id is the STABLE join key — assigned
|
|
// only by the Registry, never minted here. citation_units are the interim bridge until our
|
|
// ControlMapping adopts obligation_id directly.
|
|
type ObligationKey struct {
|
|
ObligationID string `json:"obligation_id"`
|
|
Regulation string `json:"regulation"`
|
|
Family string `json:"family"`
|
|
Tier string `json:"tier"`
|
|
CitationUnits []string `json:"citation_units"`
|
|
SourceRole string `json:"source_role"`
|
|
}
|
|
|
|
// ObligationJoinKeys is the loaded contract + a citation-unit index for the interim join.
|
|
type ObligationJoinKeys struct {
|
|
SchemaVersion string `json:"schema_version"`
|
|
Count int `json:"count"`
|
|
ObligationIDs []ObligationKey `json:"obligation_ids"`
|
|
byCitationKey map[string][]string
|
|
}
|
|
|
|
var citationRefRe = regexp.MustCompile(`\(([0-9a-zA-Z]+)\)`)
|
|
|
|
// citationUnitKey normalizes a CRA Annex I reference for the INTERIM citation_unit join, so
|
|
// our "CRA Annex I Part I (2)(c)" and the Registry's "Annex I (2)(c)" collapse to the same
|
|
// key ("i:2.c"). Interim only — superseded by the stable obligation_id once adopted.
|
|
func citationUnitKey(cu string) string {
|
|
low := strings.ToLower(cu)
|
|
part := ""
|
|
switch {
|
|
case strings.Contains(low, "part ii"):
|
|
part = "ii"
|
|
case strings.Contains(low, "part i"), strings.Contains(low, "(2)"):
|
|
part = "i" // CRA Annex I Part I = the (2)(x) essential requirements
|
|
}
|
|
var refs []string
|
|
for _, m := range citationRefRe.FindAllStringSubmatch(cu, -1) {
|
|
refs = append(refs, strings.ToLower(m[1]))
|
|
}
|
|
return part + ":" + strings.Join(refs, ".")
|
|
}
|
|
|
|
// LoadObligationJoinKeys reads the Registry contract and indexes it by citation-unit key.
|
|
func LoadObligationJoinKeys(path string) (*ObligationJoinKeys, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var o ObligationJoinKeys
|
|
if err := json.Unmarshal(raw, &o); err != nil {
|
|
return nil, err
|
|
}
|
|
o.byCitationKey = map[string][]string{}
|
|
for _, ob := range o.ObligationIDs {
|
|
for _, cu := range ob.CitationUnits {
|
|
k := citationUnitKey(cu)
|
|
o.byCitationKey[k] = append(o.byCitationKey[k], ob.ObligationID)
|
|
}
|
|
}
|
|
return &o, nil
|
|
}
|
|
|
|
// ObligationsForCitation returns the obligation_ids that join (interim) to a citation
|
|
// reference such as a control_mapping.source_norm.
|
|
func (o *ObligationJoinKeys) ObligationsForCitation(citationRef string) []string {
|
|
return o.byCitationKey[citationUnitKey(citationRef)]
|
|
}
|
|
|
|
// FindObligation returns the registry entry for an obligation_id (nil if unknown).
|
|
func (o *ObligationJoinKeys) FindObligation(obligationID string) *ObligationKey {
|
|
for i := range o.ObligationIDs {
|
|
if o.ObligationIDs[i].ObligationID == obligationID {
|
|
return &o.ObligationIDs[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mappingReaches reports whether a control mapping reaches an obligation — EXACT via the
|
|
// adopted obligation_id (semantic, preferred), else via the interim citation_unit join (for
|
|
// not-yet-adopted rows). Once obligation_id is set, the coarse citation_unit match is ignored:
|
|
// that is how the semantic join replaces the structural one (e.g. V11.2.1 crypto no longer
|
|
// rides (2)(d) into user_authentication_required — it goes to credential_confidentiality_protection).
|
|
func mappingReaches(m ControlMapping, ob ObligationKey, citationKeys map[string]bool) bool {
|
|
if m.ObligationID != "" {
|
|
return m.ObligationID == ob.ObligationID
|
|
}
|
|
return citationKeys[citationUnitKey(m.SourceNorm)]
|
|
}
|
|
|
|
// AcceptedControlsForObligation returns our accepted control mappings that reach an obligation
|
|
// (deduped by target control), obligation_id-exact where adopted, citation_unit otherwise.
|
|
func AcceptedControlsForObligation(ob ObligationKey, mappings *ControlMappingSet) []ControlMapping {
|
|
keys := make(map[string]bool, len(ob.CitationUnits))
|
|
for _, cu := range ob.CitationUnits {
|
|
keys[citationUnitKey(cu)] = true
|
|
}
|
|
out := []ControlMapping{}
|
|
seen := map[string]bool{}
|
|
for _, m := range mappings.All {
|
|
if !m.IsAccepted() || !mappingReaches(m, ob, keys) {
|
|
continue
|
|
}
|
|
ck := m.TargetFramework + ":" + m.TargetControl
|
|
if seen[ck] {
|
|
continue
|
|
}
|
|
seen[ck] = true
|
|
out = append(out, m)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ObligationCoverage is one row of the cross-session coverage report.
|
|
type ObligationCoverage struct {
|
|
ObligationID string `json:"obligation_id"`
|
|
Family string `json:"family"`
|
|
Status string `json:"status"` // covered | mapped_rejected | uncovered
|
|
AcceptedControls []string `json:"accepted_controls"`
|
|
EvidenceCount int `json:"evidence_count"`
|
|
}
|
|
|
|
// ComputeObligationCoverage joins the Registry obligations to our control mappings — exact via
|
|
// obligation_id where adopted, else via the interim citation_unit join — and reports per
|
|
// obligation: covered (>=1 accepted control reaches it), mapped_rejected (only rejected
|
|
// mappings reach it), or uncovered. The signal back to the Obligation session.
|
|
func ComputeObligationCoverage(joins *ObligationJoinKeys, mappings *ControlMappingSet, evidence *EvidenceRequirementSet) []ObligationCoverage {
|
|
out := make([]ObligationCoverage, 0, len(joins.ObligationIDs))
|
|
for _, ob := range joins.ObligationIDs {
|
|
keys := make(map[string]bool, len(ob.CitationUnits))
|
|
for _, cu := range ob.CitationUnits {
|
|
keys[citationUnitKey(cu)] = true
|
|
}
|
|
cov := ObligationCoverage{ObligationID: ob.ObligationID, Family: ob.Family}
|
|
seen := map[string]bool{}
|
|
rejected := false
|
|
for _, m := range mappings.All {
|
|
if !mappingReaches(m, ob, keys) {
|
|
continue
|
|
}
|
|
if m.IsAccepted() {
|
|
ck := m.TargetFramework + ":" + m.TargetControl
|
|
if !seen[ck] {
|
|
seen[ck] = true
|
|
cov.AcceptedControls = append(cov.AcceptedControls, ck)
|
|
cov.EvidenceCount += len(evidence.RequiredFor(m.TargetFramework, m.TargetControl))
|
|
}
|
|
} else if m.MappingStatus == "rejected" {
|
|
rejected = true
|
|
}
|
|
}
|
|
switch {
|
|
case len(cov.AcceptedControls) > 0:
|
|
cov.Status = "covered"
|
|
case rejected:
|
|
cov.Status = "mapped_rejected"
|
|
default:
|
|
cov.Status = "uncovered"
|
|
}
|
|
out = append(out, cov)
|
|
}
|
|
return out
|
|
}
|