Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add transformation from OSCAL Assessment Plan to OSCAL Assessment Results #48

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
3 changes: 3 additions & 0 deletions extensions/props.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ const (
// TestParameterClass represents the property class for all test parameters
// in OSCAL Activity types in Assessment Plans.
TestParameterClass = "test-parameter"
// AssessmentRuleIdProp represent the property name for a rule associated to an OSCAL
// Observation.
AssessmentRuleIdProp = "assessment-rule-id"
)

type findOptions struct {
Expand Down
File renamed without changes.
26 changes: 15 additions & 11 deletions models/plans/plan.go → internal/plans/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,19 +107,15 @@ func GenerateAssessmentPlan(ctx context.Context, comps []components.Component, i
associatedActivities := AssessmentActivities(assessmentSubject, componentActivities)
*ruleBasedTask.AssociatedActivities = append(*ruleBasedTask.AssociatedActivities, associatedActivities...)

// Here we assume the Components are from a corresponding
// SSP making them locally defined.
if options.importSSP == models.SampleRequiredString {
// In this use case, there is no linked SSP, making specified Components
// locally defined.
localComponents = append(localComponents, comp)
}
}

assessmentAssets := AssessmentAssets(comps)
taskAssessmentSubject := oscalTypes.AssessmentSubject{
IncludeSubjects: &subjectSelectors,
Type: defaultSubjectType,
}
*ruleBasedTask.Subjects = append(*ruleBasedTask.Subjects, taskAssessmentSubject)
*ruleBasedTask.Subjects = append(*ruleBasedTask.Subjects, oscalTypes.AssessmentSubject{IncludeSubjects: &subjectSelectors})

metadata := models.NewSampleMetadata()
metadata.Title = options.title
Expand Down Expand Up @@ -151,7 +147,7 @@ func newTask() oscalTypes.Task {
UUID: uuid.NewUUID(),
Title: "Automated Assessment",
Type: defaultTaskType,
Description: "Evaluation of defined rules for components.",
Description: "Evaluation of defined rules for applicable comps.",
Subjects: &[]oscalTypes.AssessmentSubject{},
AssociatedActivities: &[]oscalTypes.AssociatedActivity{},
}
Expand Down Expand Up @@ -296,12 +292,20 @@ func AssessmentAssets(comps []components.Component) oscalTypes.AssessmentAssets

}
}

// AssessmentPlatforms is a required field under AssessmentAssets
assessmentPlatform := oscalTypes.AssessmentPlatform{
UUID: uuid.NewUUID(),
Title: models.SampleRequiredString,
UsesComponents: &usedComponents,
UUID: uuid.NewUUID(),
Title: models.SampleRequiredString,
}

if len(usedComponents) == 0 {
return oscalTypes.AssessmentAssets{
AssessmentPlatforms: []oscalTypes.AssessmentPlatform{assessmentPlatform},
}
}

assessmentPlatform.UsesComponents = &usedComponents
assessmentAssets := oscalTypes.AssessmentAssets{
Components: &systemComponents,
AssessmentPlatforms: []oscalTypes.AssessmentPlatform{assessmentPlatform},
Expand Down
File renamed without changes.
7 changes: 7 additions & 0 deletions internal/results/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*
Copyright 2025 The OSCAL Compass Authors
SPDX-License-Identifier: Apache-2.0
*/

// Package results defines logic for working with OSCAL Assessment Results.
package results
83 changes: 83 additions & 0 deletions internal/results/observations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
Copyright 2025 The OSCAL Compass Authors
SPDX-License-Identifier: Apache-2.0
*/

package results

import (
"github.com/defenseunicorns/go-oscal/src/pkg/uuid"
oscalTypes "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2"

"github.com/oscal-compass/oscal-sdk-go/extensions"
)

// observationsManager indexes and manages OSCAL Observations
// to support Assessment Result generation.
type observationsManager struct {
observationsByCheck map[string]oscalTypes.Observation
actorsByCheck map[string]string
}

// newObservationManager create an observationManager struct loaded with
// actor information from the Assessment Plan Assessment Assets.
func newObservationManager(plan oscalTypes.AssessmentPlan) *observationsManager {
// Index validation components to set the Actor information
m := &observationsManager{
observationsByCheck: make(map[string]oscalTypes.Observation),
actorsByCheck: make(map[string]string),
}
if plan.AssessmentAssets != nil && plan.AssessmentAssets.Components != nil {
for _, comp := range *plan.AssessmentAssets.Components {
if comp.Props == nil {
continue
}
checkProps := extensions.FindAllProps(*comp.Props, extensions.WithName(extensions.CheckIdProp))
for _, check := range checkProps {
m.actorsByCheck[check.Value] = comp.UUID
}
}
}
return m
}

// load indexing and updates a set of given observations.
func (o *observationsManager) load(observations []oscalTypes.Observation) {
for _, observation := range observations {
o.updateObservation(&observation)
}
}

// createOrGet return an existing observation or a newly created one.
func (o *observationsManager) createOrGet(checkId string) oscalTypes.Observation {
observation, ok := o.observationsByCheck[checkId]
if ok {
return observation
}

emptyObservation := oscalTypes.Observation{
UUID: uuid.NewUUID(),
Title: checkId,
}
o.updateObservation(&emptyObservation)
return emptyObservation
}

// updateObservation with Origin Actor information
func (o *observationsManager) updateObservation(observation *oscalTypes.Observation) {
actor, found := o.actorsByCheck[observation.Title]
if found {
origins := []oscalTypes.Origin{
{
Actors: []oscalTypes.OriginActor{
{
Type: defaultActor,
ActorUuid: actor,
},
},
},
}
observation.Origins = &origins
}
o.observationsByCheck[observation.Title] = *observation
}
167 changes: 167 additions & 0 deletions internal/results/results.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
Copyright 2025 The OSCAL Compass Authors
SPDX-License-Identifier: Apache-2.0
*/

package results

import (
"fmt"
"time"

"github.com/defenseunicorns/go-oscal/src/pkg/uuid"
oscalTypes "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2"

"github.com/oscal-compass/oscal-sdk-go/extensions"
"github.com/oscal-compass/oscal-sdk-go/models"
)

const defaultActor = "tool"

type generateOpts struct {
title string
importAP string
observations []oscalTypes.Observation
}

func (g *generateOpts) defaults() {
g.title = models.SampleRequiredString
g.importAP = models.SampleRequiredString
}

// GenerateOption defines an option to tune the behavior of the
// GenerateAssessmentPlan function.
type GenerateOption func(opts *generateOpts)

// WithTitle is a GenerateOption that sets the AssessmentPlan title
// in the metadata.
func WithTitle(title string) GenerateOption {
return func(opts *generateOpts) {
opts.title = title
}
}

// WithImport is a GenerateOption that sets the AssessmentPlan
// ImportAP Href value.
func WithImport(importAP string) GenerateOption {
return func(opts *generateOpts) {
opts.importAP = importAP
}
}

// WithObservations is a GenerateOption that adds pre-processed OSCAL Observations
// to Assessment Results for associated to Assessment Plan Activities.
func WithObservations(observations []oscalTypes.Observation) GenerateOption {
return func(opts *generateOpts) {
opts.observations = observations
}
}

// GenerateAssessmentResults generates an AssessmentPlan for a set of Components and ImplementationSettings. The chosen inputs allow an Assessment Plan to be generated from
// a set of OSCAL ComponentDefinitions or a SystemSecurityPlan.
//
// If `WithImport` is not set, all input components are set as Components in the Local Definitions.
// If `WithObservations is not set, default behavior is to create a new, empty Observation for each activity step with the step.Title as the
// Observation title.
func GenerateAssessmentResults(plan oscalTypes.AssessmentPlan, opts ...GenerateOption) (*oscalTypes.AssessmentResults, error) {
options := generateOpts{}
options.defaults()
for _, opt := range opts {
opt(&options)
}

metadata := models.NewSampleMetadata()
metadata.Title = options.title

assessmentResults := &oscalTypes.AssessmentResults{
UUID: uuid.NewUUID(),
ImportAp: oscalTypes.ImportAp{
Href: options.importAP,
},
Metadata: metadata,
Results: make([]oscalTypes.Result, 0), // Required field
}

if plan.Tasks == nil {
return assessmentResults, fmt.Errorf("assessment plan tasks cannot be empty")
}
tasks := *plan.Tasks

observationManager := newObservationManager(plan)
if options.observations != nil {
observationManager.load(options.observations)
}

activitiesByUUID := make(map[string]oscalTypes.Activity)
if plan.LocalDefinitions != nil || plan.LocalDefinitions.Activities != nil {
for _, activity := range *plan.LocalDefinitions.Activities {
activitiesByUUID[activity.UUID] = activity
}
}

// Process each task in the assessment plan
for _, task := range tasks {
result := oscalTypes.Result{
Title: fmt.Sprintf("Result For Task %q", task.Title),
Description: fmt.Sprintf("OSCAL Assessment Result For Task %q", task.Title),
Start: time.Now(),
UUID: uuid.NewUUID(),
}

// Some initial checks before proceeding with the rest
if task.AssociatedActivities == nil {
assessmentResults.Results = append(assessmentResults.Results, result)
continue
}

// Observations associated to the tasks found through
// checks.
var reviewedControls oscalTypes.ReviewedControls
var associatedObservations []oscalTypes.Observation
for _, assocActivity := range *task.AssociatedActivities {
activity := activitiesByUUID[assocActivity.ActivityUuid]

if activity.RelatedControls != nil {
reviewedControls.ControlSelections = append(reviewedControls.ControlSelections, activity.RelatedControls.ControlSelections...)
}

if activity.Steps != nil {
relatedTask := oscalTypes.RelatedTask{
TaskUuid: task.UUID,
Subjects: &assocActivity.Subjects,
}

// Activity Title == Rule
// One Observation per Activity Step
// Observation Title == Check
for _, step := range *activity.Steps {
observation := observationManager.createOrGet(step.Title)

if activity.Props != nil {
methods := extensions.FindAllProps(*activity.Props, extensions.WithName("method"), extensions.WithNamespace(""))
for _, method := range methods {
observation.Methods = append(observation.Methods, method.Value)
}
}

if observation.Origins != nil && len(*observation.Origins) == 1 {
origin := *observation.Origins
if origin[0].RelatedTasks == nil {
origin[0].RelatedTasks = &[]oscalTypes.RelatedTask{}
}
*origin[0].RelatedTasks = append(*origin[0].RelatedTasks, relatedTask)
}
associatedObservations = append(associatedObservations, observation)
}
}
}

result.ReviewedControls = reviewedControls
if len(associatedObservations) > 0 {
result.Observations = &associatedObservations
}
assessmentResults.Results = append(assessmentResults.Results, result)
}

return assessmentResults, nil
}
Loading