Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -305,3 +305,56 @@ func (suite *SSPProfilesIntegrationSuite) TestImplementedRequirements_MultiProfi
suite.True(controlSet["ac-2"], "union should include ac-2 (shared)")
suite.True(controlSet["ac-3"], "union should include ac-3 from profile B")
}

// TestImplementedRequirements_PreserveCanonicalCasing guards the casing fix:
// when a profile's controls use canonical (mixed-case) IDs, the implemented
// requirements auto-created on profile attach must store that exact casing —
// not a lowercased copy — so downstream catalog/profile resolution by exact
// string keeps matching. It also covers the direct-create hardening: a control
// ID supplied with the "wrong" casing is canonicalized against the bound
// profile's controls before being persisted.
func (suite *SSPProfilesIntegrationSuite) TestImplementedRequirements_PreserveCanonicalCasing() {
sspID := suite.createSSP()
// Profile controls use the catalog-canonical casing.
p1 := suite.createProfile("Canonical Profile", []string{"GD.Sec.C08", "GD.Conf.C01"})

rec, req := suite.req(http.MethodPost,
fmt.Sprintf("/api/oscal/system-security-plans/%s/profiles", sspID),
addProfileRequest{ProfileID: p1.String()})
suite.server.E().ServeHTTP(rec, req)
suite.Require().Equal(http.StatusOK, rec.Code, "add profile: %s", rec.Body.String())

// The auto-created implemented requirements must keep the canonical casing.
rec, req = suite.req(http.MethodGet,
fmt.Sprintf("/api/oscal/system-security-plans/%s/control-implementation/implemented-requirements", sspID), nil)
suite.server.E().ServeHTTP(rec, req)
suite.Require().Equal(http.StatusOK, rec.Code, "get implemented-requirements: %s", rec.Body.String())

var resp handler.GenericDataListResponse[oscalTypes_1_1_3.ImplementedRequirement]
suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp))

controlSet := map[string]bool{}
for _, ir := range resp.Data {
controlSet[ir.ControlId] = true
}
suite.True(controlSet["GD.Sec.C08"], "auto-created IR should preserve canonical casing GD.Sec.C08, got: %v", controlSet)
suite.True(controlSet["GD.Conf.C01"], "auto-created IR should preserve canonical casing GD.Conf.C01, got: %v", controlSet)
suite.False(controlSet["gd.sec.c08"], "auto-created IR must not be lowercased")

// Direct create with mismatched casing is canonicalized against the bound
// profile's controls: "gd.conf.c01" must be stored as "GD.Conf.C01".
newReq := oscalTypes_1_1_3.ImplementedRequirement{
UUID: uuid.New().String(),
ControlId: "gd.conf.c01",
}
rec, req = suite.req(http.MethodPost,
fmt.Sprintf("/api/oscal/system-security-plans/%s/control-implementation/implemented-requirements", sspID),
newReq)
suite.server.E().ServeHTTP(rec, req)
suite.Require().Equal(http.StatusCreated, rec.Code, "create IR: %s", rec.Body.String())

var createResp handler.GenericDataResponse[oscalTypes_1_1_3.ImplementedRequirement]
suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &createResp))
suite.Equal("GD.Conf.C01", createResp.Data.ControlId,
"directly-created IR should be canonicalized to the catalog casing")
}
100 changes: 82 additions & 18 deletions internal/api/handler/oscal/system_security_plans.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,19 @@ type addProfileRequest struct {
ProfileID string `json:"profileId"`
}

// normalizeControlID lowercases a control ID. It is used only to derive a
// case-insensitive comparison key (dedup, map lookups, SQL IN lists). The
// lowercased value must NOT be persisted as an ImplementedRequirement.ControlId
// or returned for display — storage and display use the catalog-canonical
// casing (e.g. "GD.Sec.C08"). See dedupeControlIDs for the value-preserving
// counterpart.
func normalizeControlID(controlID string) string {
return strings.ToLower(controlID)
}

// normalizeControlIDs lowercases and case-insensitively dedupes a slice of
// control IDs. Use it to build comparison keys / SQL IN lists, never to produce
// values that get stored or shown to users.
func normalizeControlIDs(controlIDs []string) []string {
seen := make(map[string]struct{}, len(controlIDs))
normalized := make([]string, 0, len(controlIDs))
Expand All @@ -62,6 +71,25 @@ func normalizeControlIDs(controlIDs []string) []string {
return normalized
}

// dedupeControlIDs removes duplicates case-insensitively while PRESERVING the
// original (catalog-canonical) casing of the first occurrence. This is the
// resolver-side counterpart to normalizeControlIDs: control IDs flow through
// here so that the canonical casing reaches storage and display, while matching
// elsewhere stays case-insensitive via normalizeControlID keys.
func dedupeControlIDs(controlIDs []string) []string {
seen := make(map[string]struct{}, len(controlIDs))
deduped := make([]string, 0, len(controlIDs))
for _, controlID := range controlIDs {
key := normalizeControlID(controlID)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
deduped = append(deduped, controlID)
}
return deduped
}

func buildProfileSummaries(profiles []relational.Profile) ([]profileSummary, error) {
summaries := make([]profileSummary, 0, len(profiles))
for _, p := range profiles {
Expand Down Expand Up @@ -123,9 +151,9 @@ func (h *SystemSecurityPlanHandler) getControlIDsForProfile(profileID uuid.UUID)
// 1. Check in-memory cache first
if val, ok := h.profileCache.Load(profileID); ok {
if cachedControlIDs, ok := val.([]string); ok {
normalizedControlIDs := normalizeControlIDs(cachedControlIDs)
h.profileCache.Store(profileID, normalizedControlIDs)
return normalizedControlIDs, nil
dedupedControlIDs := dedupeControlIDs(cachedControlIDs)
h.profileCache.Store(profileID, dedupedControlIDs)
return dedupedControlIDs, nil
}
h.sugar.Warnw("profileCache contains value of unexpected type", "profileId", profileID, "actualType", fmt.Sprintf("%T", val))
h.profileCache.Delete(profileID)
Expand Down Expand Up @@ -155,7 +183,7 @@ func (h *SystemSecurityPlanHandler) getControlIDsForProfile(profileID uuid.UUID)
}
}

controlIDs = normalizeControlIDs(controlIDs)
controlIDs = dedupeControlIDs(controlIDs)
h.profileCache.Store(profileID, controlIDs)
return controlIDs, nil
}
Expand Down Expand Up @@ -184,12 +212,13 @@ func (h *SystemSecurityPlanHandler) getControlIDsForAllProfiles(profiles []relat
allControlIDs := make([]string, 0)
appendControlIDs := func(controlIDs []string) {
for _, cid := range controlIDs {
normalizedID := normalizeControlID(cid)
if _, exists := seenControls[normalizedID]; exists {
// Dedupe case-insensitively but keep the canonical casing of the value.
key := normalizeControlID(cid)
if _, exists := seenControls[key]; exists {
continue
}
seenControls[normalizedID] = struct{}{}
allControlIDs = append(allControlIDs, normalizedID)
seenControls[key] = struct{}{}
allControlIDs = append(allControlIDs, cid)
}
}

Expand Down Expand Up @@ -230,6 +259,34 @@ func (h *SystemSecurityPlanHandler) getControlIDsForAllProfiles(profiles []relat
return allControlIDs, nil
}

// canonicalizeControlID resolves controlID to its catalog-canonical casing
// (e.g. "gd.sec.c08" -> "GD.Sec.C08") by matching it case-insensitively against
// the controls of the profiles bound to the SSP via the profile_controls pivot.
// If no bound profile contains the control (e.g. an ad-hoc requirement created
// directly, or before profiles are resolved), the input is returned unchanged
// so direct creation still works. This keeps control IDs stored on
// ImplementedRequirements consistent with the catalog regardless of the casing
// the client supplied.
func (h *SystemSecurityPlanHandler) canonicalizeControlID(sspID uuid.UUID, controlID string) string {
if controlID == "" {
return controlID
}
var matches []string
if err := h.db.Table("profile_controls").
Joins("JOIN ssp_profiles ON ssp_profiles.profile_id = profile_controls.profile_id").
Where("ssp_profiles.system_security_plan_id = ? AND UPPER(profile_controls.control_id) = UPPER(?)", sspID, controlID).
Limit(1).
Pluck("profile_controls.control_id", &matches).Error; err != nil {
h.sugar.Warnw("Failed to canonicalize control ID; storing as provided",
"sspId", sspID, "controlId", controlID, "error", err)
return controlID
}
if len(matches) == 0 || matches[0] == "" {
return controlID
}
return matches[0]
}

// validateSSPInput validates SSP input following OSCAL requirements
func (h *SystemSecurityPlanHandler) validateSSPInput(ssp *oscalTypes_1_1_3.SystemSecurityPlan) error {
if ssp.UUID == "" {
Expand Down Expand Up @@ -1610,7 +1667,8 @@ func (h *SystemSecurityPlanHandler) GetControlImplementation(ctx echo.Context) e
query := h.db.Model(&ssp.ControlImplementation).
Preload("ImplementedRequirements", func(db *gorm.DB) *gorm.DB {
if len(controlIDs) > 0 {
return db.Where("LOWER(control_id) IN ?", controlIDs)
// controlIDs carry canonical casing; lower them for the IN match.
return db.Where("LOWER(control_id) IN ?", normalizeControlIDs(controlIDs))
}
return db
}).
Expand Down Expand Up @@ -1683,7 +1741,8 @@ func (h *SystemSecurityPlanHandler) Full(ctx echo.Context) error {
Preload("ControlImplementation").
Preload("ControlImplementation.ImplementedRequirements", func(db *gorm.DB) *gorm.DB {
if len(controlIDs) > 0 {
return db.Where("LOWER(control_id) IN ?", controlIDs)
// controlIDs carry canonical casing; lower them for the IN match.
return db.Where("LOWER(control_id) IN ?", normalizeControlIDs(controlIDs))
}
return db
}).
Expand Down Expand Up @@ -2089,13 +2148,13 @@ func (h *SystemSecurityPlanHandler) AttachProfile(ctx echo.Context) error {

var newReqs []relational.ImplementedRequirement
for _, controlID := range controlIDs {
normalizedControlID := normalizeControlID(controlID)
if !existingMap[normalizedControlID] {
// Dedupe case-insensitively, but persist the canonical casing.
if !existingMap[normalizeControlID(controlID)] {
newUUID := uuid.New()
newReqs = append(newReqs, relational.ImplementedRequirement{
UUIDModel: relational.UUIDModel{ID: &newUUID},
ControlImplementationId: *ssp.ControlImplementation.ID,
ControlId: normalizedControlID,
ControlId: controlID,
})
}
}
Expand Down Expand Up @@ -2283,13 +2342,13 @@ func (h *SystemSecurityPlanHandler) AddProfile(ctx echo.Context) error {

var newReqs []relational.ImplementedRequirement
for _, controlID := range controlIDs {
normalizedControlID := normalizeControlID(controlID)
if !existingMap[normalizedControlID] {
// Dedupe case-insensitively, but persist the canonical casing.
if !existingMap[normalizeControlID(controlID)] {
newUUID := uuid.New()
newReqs = append(newReqs, relational.ImplementedRequirement{
UUIDModel: relational.UUIDModel{ID: &newUUID},
ControlImplementationId: *ssp.ControlImplementation.ID,
ControlId: normalizedControlID,
ControlId: controlID,
})
}
}
Expand Down Expand Up @@ -3448,7 +3507,8 @@ func (h *SystemSecurityPlanHandler) GetImplementedRequirements(ctx echo.Context)
var implementedRequirements []relational.ImplementedRequirement
query := h.db.Where("control_implementation_id = ?", ssp.ControlImplementation.ID)
if len(controlIDs) > 0 {
query = query.Where("LOWER(control_id) IN ?", controlIDs)
// controlIDs carry canonical casing; lower them for the IN match.
query = query.Where("LOWER(control_id) IN ?", normalizeControlIDs(controlIDs))
}

if err := query.Find(&implementedRequirements).Error; err != nil {
Expand Down Expand Up @@ -3515,6 +3575,8 @@ func (h *SystemSecurityPlanHandler) CreateImplementedRequirement(ctx echo.Contex
relReq := &relational.ImplementedRequirement{}
relReq.UnmarshalOscal(oscalReq)
relReq.ControlImplementationId = *ssp.ControlImplementation.ID
// Store the catalog-canonical casing regardless of what the client supplied.
relReq.ControlId = h.canonicalizeControlID(id, relReq.ControlId)

if err := h.db.Create(relReq).Error; err != nil {
h.sugar.Errorf("Failed to create implemented requirement: %v", err)
Expand Down Expand Up @@ -3582,6 +3644,8 @@ func (h *SystemSecurityPlanHandler) UpdateImplementedRequirement(ctx echo.Contex
relReq.UnmarshalOscal(oscalReq)
relReq.ControlImplementationId = *ssp.ControlImplementation.ID
relReq.ID = &reqID
// Store the catalog-canonical casing regardless of what the client supplied.
relReq.ControlId = h.canonicalizeControlID(sspID, relReq.ControlId)

if err := h.db.Save(relReq).Error; err != nil {
h.sugar.Errorf("Failed to update implemented requirement: %v", err)
Expand Down Expand Up @@ -4522,7 +4586,7 @@ func (h *SystemSecurityPlanHandler) extractControlIDsFromProfile(profile *relati
for id := range idsMap {
controlIDs = append(controlIDs, id)
}
controlIDs = normalizeControlIDs(controlIDs)
controlIDs = dedupeControlIDs(controlIDs)

if profile.ID != nil {
h.profileCache.Store(*profile.ID, controlIDs)
Expand Down
Loading