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
2,042 changes: 1,976 additions & 66 deletions docs/docs.go

Large diffs are not rendered by default.

2,042 changes: 1,976 additions & 66 deletions docs/swagger.json

Large diffs are not rendered by default.

1,885 changes: 1,693 additions & 192 deletions docs/swagger.yaml

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions internal/api/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ func AccessForbidden() Error {
return e
}

// InternalServerError is the client-facing body for a 5xx. It is deliberately generic: NewError
// serialises err.Error() verbatim, so handing it a driver error leaks constraint names, column
// names and SQL state to the caller. Log the real error server-side and return this instead.
func InternalServerError() Error {
e := Error{}
e.Errors = make(map[string]any)
e.Errors["body"] = "internal server error"
return e
}

func NotFound() Error {
e := Error{}
e.Errors = make(map[string]any)
Expand Down
392 changes: 392 additions & 0 deletions internal/api/handler/filter.go

Large diffs are not rendered by default.

537 changes: 537 additions & 0 deletions internal/api/handler/oscal/filter_responsibilities_test.go

Large diffs are not rendered by default.

51 changes: 50 additions & 1 deletion internal/api/handler/oscal/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,33 @@ func (h *ImportHandler) ImportOSCAL(ctx echo.Context) error {
return ctx.JSON(statusCode, handler.GenericDataResponse[ImportResponse]{Data: response})
}

// validateSSPByComponentImplementationStatuses runs the same implementation-status validation
// every by-component write path performs over an SSP's whole control-implementation tree, at
// both anchoring levels. Import bypassed it entirely: POST /api/oscal/import unmarshals the
// document and hands it straight to FirstOrCreate, so an invalid state (anything outside
// implemented/partial/planned/alternative/not-applicable) sailed in and poisoned the tree.
func validateSSPByComponentImplementationStatuses(ssp *relational.SystemSecurityPlan) error {
for i := range ssp.ControlImplementation.ImplementedRequirements {
requirement := &ssp.ControlImplementation.ImplementedRequirements[i]

for j := range requirement.ByComponents {
if err := validateByComponentImplementationStatus(&requirement.ByComponents[j]); err != nil {
return fmt.Errorf("control %q: %w", requirement.ControlId, err)
}
}

for j := range requirement.Statements {
statement := &requirement.Statements[j]
for k := range statement.ByComponents {
if err := validateByComponentImplementationStatus(&statement.ByComponents[k]); err != nil {
return fmt.Errorf("control %q statement %q: %w", requirement.ControlId, statement.StatementId, err)
}
}
}
}
return nil
}

func (h *ImportHandler) processOSCALFile(fileHeader *multipart.FileHeader) ImportFileResult {
result := ImportFileResult{
Filename: fileHeader.Filename,
Expand Down Expand Up @@ -170,6 +197,16 @@ func (h *ImportHandler) processOSCALFile(fileHeader *multipart.FileHeader) Impor
if input.SystemSecurityPlan != nil {
def := &relational.SystemSecurityPlan{}
def.UnmarshalOscal(*input.SystemSecurityPlan)

// Import is the one write path into the SSP tree that never went through
// validateByComponentImplementationStatus, so a bad implementation-status could be
// imported straight into the tree and only blow up later, at read time. Reject it
// here instead.
if err := validateSSPByComponentImplementationStatuses(def); err != nil {
result.Message = fmt.Sprintf("Failed to import system security plan: %v", err)
return result
}

out := h.db.FirstOrCreate(def)
if out.Error != nil {
result.Message = fmt.Sprintf("Failed to import system security plan: %v", out.Error)
Expand All @@ -178,7 +215,19 @@ func (h *ImportHandler) processOSCALFile(fileHeader *multipart.FileHeader) Impor
result.Type = "System Security Plan"
result.Title = def.Metadata.Title
result.Success = true
result.Message = "Successfully imported system security plan"
// FirstOrCreate is keyed on the OSCAL UUID, so re-importing a changed SSP under an
// existing UUID matches the stored row and writes nothing at all. Reporting that as
// "Successfully imported" told the caller their edits had landed when they hadn't.
// This path still reports Success (the file was processed and no error occurred —
// flipping it would turn an idempotent re-import into a 400), but says plainly that
// nothing was written. A real merge/diff import is its own piece of work.
if out.RowsAffected == 0 {
result.Message = fmt.Sprintf(
"System security plan %s already exists; nothing was written (import does not merge into an existing UUID)",
input.SystemSecurityPlan.UUID)
} else {
result.Message = "Successfully imported system security plan"
}
imported = true
}

Expand Down
199 changes: 199 additions & 0 deletions internal/api/handler/oscal/import_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
//go:build integration

package oscal

import (
"bytes"
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"

"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/suite"
"go.uber.org/zap"

"github.com/compliance-framework/api/internal/api"
"github.com/compliance-framework/api/internal/api/handler"
evidencesvc "github.com/compliance-framework/api/internal/service/relational/evidence"
"github.com/compliance-framework/api/internal/tests"
)

type ImportApiIntegrationSuite struct {
tests.IntegrationTestSuite
}

func (suite *ImportApiIntegrationSuite) SetupSuite() {
suite.IntegrationTestSuite.SetupSuite()
}

func TestImportApiIntegrationSuite(t *testing.T) {
suite.Run(t, new(ImportApiIntegrationSuite))
}

// importSSPDocument posts one SSP JSON document to /api/oscal/import and returns the parsed
// per-file result.
func (suite *ImportApiIntegrationSuite) importSSPDocument(server *api.Server, document string) (ImportFileResult, int) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("files", "ssp.json")
suite.Require().NoError(err)
_, err = part.Write([]byte(document))
suite.Require().NoError(err)
suite.Require().NoError(writer.Close())

req := httptest.NewRequest("POST", "/api/oscal/import", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
token, err := suite.GetAuthToken()
suite.Require().NoError(err)
req.Header.Set(echo.HeaderAuthorization, fmt.Sprintf("Bearer %s", *token))

rec := httptest.NewRecorder()
server.E().ServeHTTP(rec, req)

var resp handler.GenericDataResponse[ImportResponse]
suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &resp))
suite.Require().Len(resp.Data.Results, 1)
return resp.Data.Results[0], rec.Code
}

// sspDocument renders a minimal but complete SSP with one statement-anchored by-component
// carrying the given implementation-status state.
func sspDocument(sspUUID, title, implementationState string) string {
return fmt.Sprintf(`{
"system-security-plan": {
"uuid": %q,
"metadata": {
"title": %q,
"last-modified": "2024-01-01T00:00:00Z",
"version": "1.0.0",
"oscal-version": "1.1.3"
},
"import-profile": { "href": "#profile" },
"system-characteristics": {
"system-ids": [{ "id": "sys-1" }],
"system-name": "Test System",
"description": "d",
"security-sensitivity-level": "low",
"system-information": { "information-types": [] },
"security-impact-level": {
"security-objective-confidentiality": "low",
"security-objective-integrity": "low",
"security-objective-availability": "low"
},
"status": { "state": "operational" },
"authorization-boundary": { "description": "b" }
},
"system-implementation": {
"users": [],
"components": [{
"uuid": %q,
"type": "software",
"title": "Component",
"description": "d",
"status": { "state": "operational" }
}]
},
"control-implementation": {
"description": "ci",
"implemented-requirements": [{
"uuid": %q,
"control-id": "ac-2",
"statements": [{
"uuid": %q,
"statement-id": "ac-2_smt.a",
"by-components": [{
"uuid": %q,
"component-uuid": %q,
"description": "bc",
"implementation-status": { "state": %q }
}]
}]
}]
}
}
}`,
sspUUID, title,
uuid.New().String(),
uuid.New().String(),
uuid.New().String(),
uuid.New().String(),
uuid.New().String(),
implementationState,
)
}

func (suite *ImportApiIntegrationSuite) newServer() *api.Server {
logConf := zap.NewDevelopmentConfig()
logConf.Level = zap.NewAtomicLevelAt(zap.ErrorLevel)
logger, _ := logConf.Build()

suite.Require().NoError(suite.Migrator.Refresh())

metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
evidenceSvc := evidencesvc.NewEvidenceService(suite.DB, logger.Sugar(), suite.Config, nil)
RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, evidenceSvc, nil, nil)
return server
}

// TestImportRejectsInvalidImplementationStatus: import was the one write path into the SSP tree
// that never ran validateByComponentImplementationStatus, so a bad state sailed straight into
// the database via FirstOrCreate. It is now rejected.
func (suite *ImportApiIntegrationSuite) TestImportRejectsInvalidImplementationStatus() {
server := suite.newServer()

result, code := suite.importSSPDocument(server, sspDocument(uuid.New().String(), "Bad SSP", "totally-made-up"))

suite.Equal(http.StatusBadRequest, code, "the only file failed, so the whole import is a 400")
suite.False(result.Success)
suite.Contains(result.Message, "totally-made-up")

var count int64
suite.Require().NoError(suite.DB.Table("system_security_plans").Count(&count).Error)
suite.Zero(count, "nothing was written")
}

// TestImportAcceptsValidImplementationStatus is the control for the test above: a valid state
// still imports.
func (suite *ImportApiIntegrationSuite) TestImportAcceptsValidImplementationStatus() {
server := suite.newServer()

result, code := suite.importSSPDocument(server, sspDocument(uuid.New().String(), "Good SSP", "implemented"))

suite.Equal(http.StatusOK, code)
suite.True(result.Success)
suite.Equal("Successfully imported system security plan", result.Message)
suite.Equal("System Security Plan", result.Type)
}

// TestImportReportsNoOpOnExistingUUID: FirstOrCreate is keyed on the OSCAL UUID, so re-importing
// a *changed* SSP under an existing UUID matches the stored row and writes nothing at all.
// Reporting that as "Successfully imported" told the caller their edits had landed when they
// hadn't. The result now says plainly that nothing was written.
func (suite *ImportApiIntegrationSuite) TestImportReportsNoOpOnExistingUUID() {
server := suite.newServer()
sspUUID := uuid.New().String()

first, code := suite.importSSPDocument(server, sspDocument(sspUUID, "Original title", "implemented"))
suite.Require().Equal(http.StatusOK, code)
suite.Require().True(first.Success)
suite.Equal("Successfully imported system security plan", first.Message)

// Same UUID, different content: FirstOrCreate matches and writes nothing.
second, code := suite.importSSPDocument(server, sspDocument(sspUUID, "Revised title", "partial"))
suite.Equal(http.StatusOK, code)
suite.True(second.Success, "processing the file did not error — but nothing was written")
suite.Contains(second.Message, "already exists")
suite.Contains(second.Message, "nothing was written")
suite.NotContains(second.Message, "Successfully imported")

// Proof the no-op really was a no-op: still one SSP, still the original title.
var count int64
suite.Require().NoError(suite.DB.Table("system_security_plans").Count(&count).Error)
suite.Equal(int64(1), count)
}
Loading
Loading