diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 6dd9a131..95df0547 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -27,7 +27,8 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: ">=1.20" + go-version-file: go.mod + cache: false - name: Run golangci-lint uses: golangci/golangci-lint-action@v6 diff --git a/cmd/compliance.go b/cmd/compliance.go index 6c6f8d03..aa49a089 100644 --- a/cmd/compliance.go +++ b/cmd/compliance.go @@ -41,6 +41,12 @@ var complianceCmd = &cobra.Command{ # Check a OpenChain Telco compliance against a SBOM in a JSON output sbomqs compliance --oct --json samples/sbomqs-spdx-syft.json + + # Check a V3 Framing document compliance against a SBOM in a table output + sbomqs compliance --fsct + + # Check a V3 Framing document compliance against a SBOM in a JSON output + sbomqs compliance --fsct -j `, Args: func(cmd *cobra.Command, args []string) error { if err := cobra.ExactArgs(1)(cmd, args); err != nil { @@ -75,6 +81,7 @@ func setupEngineParams(cmd *cobra.Command, args []string) *engine.Params { // engParams.Ntia, _ = cmd.Flags().GetBool("ntia") engParams.Bsi, _ = cmd.Flags().GetBool("bsi") engParams.Oct, _ = cmd.Flags().GetBool("oct") + engParams.Fsct, _ = cmd.Flags().GetBool("fsct") engParams.Debug, _ = cmd.Flags().GetBool("debug") @@ -101,4 +108,5 @@ func init() { complianceCmd.Flags().BoolP("bsi", "c", false, "BSI TR-03183-2 v1.1 compliance") // complianceCmd.MarkFlagsMutuallyExclusive("ntia", "cra") complianceCmd.Flags().BoolP("oct", "t", false, "OpenChainTelco compliance") + complianceCmd.Flags().BoolP("fsct", "f", false, "V3 Framing document compliance") } diff --git a/golangci.yml b/golangci.yml index 4a08f60d..cab91adb 100644 --- a/golangci.yml +++ b/golangci.yml @@ -13,7 +13,6 @@ linters: - stylecheck - staticcheck - unconvert - - whitespace linters-settings: unparam: diff --git a/pkg/compliance/bsi.go b/pkg/compliance/bsi.go index 602430df..3debba63 100644 --- a/pkg/compliance/bsi.go +++ b/pkg/compliance/bsi.go @@ -19,6 +19,8 @@ import ( "fmt" "strings" + "github.com/interlynk-io/sbomqs/pkg/compliance/common" + db "github.com/interlynk-io/sbomqs/pkg/compliance/db" "github.com/interlynk-io/sbomqs/pkg/logger" "github.com/interlynk-io/sbomqs/pkg/sbom" "github.com/samber/lo" @@ -75,6 +77,7 @@ const ( SBOM_DELIVERY_METHOD SBOM_SCOPE PACK_INFO + SBOM_TYPE PACK_EXT_REF ) @@ -82,31 +85,31 @@ func bsiResult(ctx context.Context, doc sbom.Document, fileName string, outForma log := logger.FromContext(ctx) log.Debug("compliance.bsiResult()") - db := newDB() + dtb := db.NewDB() - db.addRecord(bsiSpec(doc)) - db.addRecord(bsiSpecVersion(doc)) - db.addRecord(bsiBuildPhase(doc)) - db.addRecord(bsiSbomDepth(doc)) - db.addRecord(bsiCreator(doc)) - db.addRecord(bsiTimestamp(doc)) - db.addRecord(bsiSbomURI(doc)) - db.addRecords(bsiComponents(doc)) + dtb.AddRecord(bsiSpec(doc)) + dtb.AddRecord(bsiSpecVersion(doc)) + dtb.AddRecord(bsiBuildPhase(doc)) + dtb.AddRecord(bsiSbomDepth(doc)) + dtb.AddRecord(bsiCreator(doc)) + dtb.AddRecord(bsiTimestamp(doc)) + dtb.AddRecord(bsiSbomURI(doc)) + dtb.AddRecords(bsiComponents(doc)) if outFormat == "json" { - bsiJSONReport(db, fileName) + bsiJSONReport(dtb, fileName) } if outFormat == "basic" { - bsiBasicReport(db, fileName) + bsiBasicReport(dtb, fileName) } if outFormat == "detailed" { - bsiDetailedReport(db, fileName) + bsiDetailedReport(dtb, fileName) } } -func bsiSpec(doc sbom.Document) *record { +func bsiSpec(doc sbom.Document) *db.Record { v := doc.Spec().GetSpecType() vToLower := strings.Trim(strings.ToLower(v), " ") result := "" @@ -119,10 +122,10 @@ func bsiSpec(doc sbom.Document) *record { result = v score = 10.0 } - return newRecordStmt(SBOM_SPEC, "doc", result, score) + return db.NewRecordStmt(SBOM_SPEC, "doc", result, score, "") } -func bsiSpecVersion(doc sbom.Document) *record { +func bsiSpecVersion(doc sbom.Document) *db.Record { spec := doc.Spec().GetSpecType() version := doc.Spec().GetVersion() @@ -143,10 +146,10 @@ func bsiSpecVersion(doc sbom.Document) *record { } } - return newRecordStmt(SBOM_SPEC_VERSION, "doc", result, score) + return db.NewRecordStmt(SBOM_SPEC_VERSION, "doc", result, score, "") } -func bsiBuildPhase(doc sbom.Document) *record { +func bsiBuildPhase(doc sbom.Document) *db.Record { lifecycles := doc.Lifecycles() result := "" score := 0.0 @@ -158,10 +161,10 @@ func bsiBuildPhase(doc sbom.Document) *record { score = 10.0 } - return newRecordStmt(SBOM_BUILD, "doc", result, score) + return db.NewRecordStmt(SBOM_BUILD, "doc", result, score, "") } -func bsiSbomDepth(doc sbom.Document) *record { +func bsiSbomDepth(doc sbom.Document) *db.Record { result, score := "", 0.0 // for doc.Components() totalDependencies := doc.PrimaryComp().GetTotalNoOfDependencies() @@ -171,10 +174,30 @@ func bsiSbomDepth(doc sbom.Document) *record { } result = fmt.Sprintf("doc has %d dependencies", totalDependencies) - return newRecordStmt(SBOM_DEPTH, "doc", result, score) + // if len(doc.Relations()) == 0 { + // return db.NewRecordStmt(SBOM_DEPTH, "doc", "no-relationships", 0.0, "") + // } + + // primary, _ := lo.Find(doc.Components(), func(c sbom.GetComponent) bool { + // return c.IsPrimaryComponent() + // }) + + // if !primary.HasRelationShips() { + // return db.NewRecordStmt(SBOM_DEPTH, "doc", "no-primary-relationships", 0.0, "") + // } + + // if primary.RelationShipState() == "complete" { + // return db.NewRecordStmt(SBOM_DEPTH, "doc", "complete", 10.0, "") + // } + + // if primary.HasRelationShips() { + // return db.NewRecordStmt(SBOM_DEPTH, "doc", "unattested-has-relationships", 5.0, "") + // } + + return db.NewRecordStmt(SBOM_DEPTH, "doc", result, score, "") } -func bsiCreator(doc sbom.Document) *record { +func bsiCreator(doc sbom.Document) *db.Record { result := "" score := 0.0 @@ -187,7 +210,7 @@ func bsiCreator(doc sbom.Document) *record { } if result != "" { - return newRecordStmt(SBOM_CREATOR, "doc", result, score) + return db.NewRecordStmt(SBOM_CREATOR, "doc", result, score, "") } supplier := doc.Supplier() @@ -199,7 +222,7 @@ func bsiCreator(doc sbom.Document) *record { } if result != "" { - return newRecordStmt(SBOM_CREATOR, "doc", result, score) + return db.NewRecordStmt(SBOM_CREATOR, "doc", result, score, "") } if supplier.GetURL() != "" { @@ -208,20 +231,20 @@ func bsiCreator(doc sbom.Document) *record { } if result != "" { - return newRecordStmt(SBOM_CREATOR, "doc", result, score) + return db.NewRecordStmt(SBOM_CREATOR, "doc", result, score, "") } if supplier.GetContacts() != nil { for _, contact := range supplier.GetContacts() { - if contact.Email() != "" { - result = contact.Email() + if contact.GetEmail() != "" { + result = contact.GetEmail() score = 10.0 break } } if result != "" { - return newRecordStmt(SBOM_CREATOR, "doc", result, score) + return db.NewRecordStmt(SBOM_CREATOR, "doc", result, score, "") } } } @@ -235,7 +258,7 @@ func bsiCreator(doc sbom.Document) *record { } if result != "" { - return newRecordStmt(SBOM_CREATOR, "doc", result, score) + return db.NewRecordStmt(SBOM_CREATOR, "doc", result, score, "") } if manufacturer.GetURL() != "" { @@ -244,27 +267,27 @@ func bsiCreator(doc sbom.Document) *record { } if result != "" { - return newRecordStmt(SBOM_CREATOR, "doc", result, score) + return db.NewRecordStmt(SBOM_CREATOR, "doc", result, score, "") } if manufacturer.GetContacts() != nil { for _, contact := range manufacturer.GetContacts() { - if contact.Email() != "" { - result = contact.Email() + if contact.GetEmail() != "" { + result = contact.GetEmail() score = 10.0 break } } if result != "" { - return newRecordStmt(SBOM_CREATOR, "doc", result, score) + return db.NewRecordStmt(SBOM_CREATOR, "doc", result, score, "") } } } - return newRecordStmt(SBOM_CREATOR, "doc", "", 0.0) + return db.NewRecordStmt(SBOM_CREATOR, "doc", "", 0.0, "") } -func bsiTimestamp(doc sbom.Document) *record { +func bsiTimestamp(doc sbom.Document) *db.Record { score := 0.0 result := doc.Spec().GetCreationTimestamp() @@ -272,29 +295,44 @@ func bsiTimestamp(doc sbom.Document) *record { score = 10.0 } - return newRecordStmt(SBOM_TIMESTAMP, "doc", result, score) + return db.NewRecordStmt(SBOM_TIMESTAMP, "doc", result, score, "") } -func bsiSbomURI(doc sbom.Document) *record { +func bsiSbomURI(doc sbom.Document) *db.Record { uri := doc.Spec().URI() if uri != "" { - return newRecordStmt(SBOM_URI, "doc", uri, 10.0) + brokenResult := breakLongString(uri, 50) + result := strings.Join(brokenResult, "\n") + return db.NewRecordStmt(SBOM_URI, "doc", result, 10.0, "") } - return newRecordStmt(SBOM_URI, "doc", "", 0) + return db.NewRecordStmt(SBOM_URI, "doc", "", 0, "") } -func bsiComponents(doc sbom.Document) []*record { - records := []*record{} +var ( + bsiCompIDWithName = make(map[string]string) + bsiComponentList = make(map[string]bool) + bsiPrimaryDependencies = make(map[string]bool) + bsiGetAllPrimaryDepenciesByName = []string{} +) + +func bsiComponents(doc sbom.Document) []*db.Record { + records := []*db.Record{} if len(doc.Components()) == 0 { - records := append(records, newRecordStmt(SBOM_COMPONENTS, "doc", "", 0.0)) + records := append(records, db.NewRecordStmt(SBOM_COMPONENTS, "doc", "", 0.0, "")) return records } - // map package ID to Package Name - for _, component := range doc.Components() { - CompIDWithName[component.GetID()] = component.GetName() + + bsiCompIDWithName = common.ComponentsNamesMapToIDs(doc) + bsiComponentList = common.ComponentsLists(doc) + bsiPrimaryDependencies = common.MapPrimaryDependencies(doc) + dependencies := common.GetAllPrimaryComponentDependencies(doc) + isBsiAllDepesPresentInCompList := common.CheckPrimaryDependenciesInComponentList(dependencies, bsiComponentList) + + if isBsiAllDepesPresentInCompList { + bsiGetAllPrimaryDepenciesByName = common.GetDependenciesByName(dependencies, bsiCompIDWithName) } for _, component := range doc.Components() { @@ -310,49 +348,73 @@ func bsiComponents(doc sbom.Document) []*record { records = append(records, bsiComponentOtherUniqIDs(component)) } - records = append(records, newRecordStmt(SBOM_COMPONENTS, "doc", "present", 10.0)) + records = append(records, db.NewRecordStmt(SBOM_COMPONENTS, "doc", "present", 10.0, "")) return records } -func bsiComponentDepth(doc sbom.Document, component sbom.GetComponent) *record { +func bsiComponentDepth(doc sbom.Document, component sbom.GetComponent) *db.Record { result, score := "", 0.0 - var fResults []string + var dependencies []string + var allDepByName []string - dependencies := doc.GetRelationships(component.GetID()) - if dependencies == nil { - return newRecordStmt(COMP_DEPTH, component.GetName(), "no-relationships", 0.0) - } - - for _, d := range dependencies { - state := component.GetComposition(d) - if state == "complete" { - componentName := extractName(d) - fResults = append(fResults, componentName) + if doc.Spec().GetSpecType() == "spdx" { + if component.GetPrimaryCompInfo().IsPresent() { + result = strings.Join(bsiGetAllPrimaryDepenciesByName, ", ") score = 10.0 - } else { - componentName := extractName(d) - // state := "(unattested-has-relationships)" - fResults = append(fResults, componentName) - score = 5.0 + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), result, score, "") } - } - if fResults != nil { - result = strings.Join(fResults, ", ") - } else { - result += "no-relationships" + dependencies = doc.GetRelationships(common.GetID(component.GetSpdxID())) + if dependencies == nil { + if bsiPrimaryDependencies[common.GetID(component.GetSpdxID())] { + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), "included-in", 10.0, "") + } + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), "no-relationship", 0.0, "") + } + allDepByName = common.GetDependenciesByName(dependencies, bsiCompIDWithName) + if bsiPrimaryDependencies[common.GetID(component.GetSpdxID())] { + allDepByName = append([]string{"included-in"}, allDepByName...) + result = strings.Join(allDepByName, ", ") + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), result, 10.0, "") + } + result = strings.Join(allDepByName, ", ") + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), result, 10.0, "") + + } else if doc.Spec().GetSpecType() == "cyclonedx" { + if component.GetPrimaryCompInfo().IsPresent() { + result = strings.Join(bsiGetAllPrimaryDepenciesByName, ", ") + score = 10.0 + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), result, score, "") + } + id := component.GetID() + dependencies = doc.GetRelationships(id) + if len(dependencies) == 0 { + if bsiPrimaryDependencies[id] { + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), "included-in", 10.0, "") + } + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), "no-relationship", 0.0, "") + } + allDepByName = common.GetDependenciesByName(dependencies, bsiCompIDWithName) + if bsiPrimaryDependencies[id] { + allDepByName = append([]string{"included-in"}, allDepByName...) + result = strings.Join(allDepByName, ", ") + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), result, 10.0, "") + } + result = strings.Join(allDepByName, ", ") + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), result, 10.0, "") } - return newRecordStmt(COMP_DEPTH, component.GetName(), result, score) + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), "no-relationships", 0.0, "") } -func bsiComponentLicense(component sbom.GetComponent) *record { +func bsiComponentLicense(component sbom.GetComponent) *db.Record { licenses := component.Licenses() score := 0.0 if len(licenses) == 0 { - return newRecordStmt(COMP_LICENSE, component.GetName(), "not-compliant", score) + // fmt.Printf("component %s : %s has no licenses\n", component.Name(), component.Version()) + return db.NewRecordStmt(COMP_LICENSE, component.GetName(), "not-compliant", score, "") } var spdx, aboutcode, custom int @@ -380,13 +442,13 @@ func bsiComponentLicense(component sbom.GetComponent) *record { if total != len(licenses) { score = 0.0 - return newRecordStmt(COMP_LICENSE, component.GetName(), "not-compliant", score) + return db.NewRecordStmt(COMP_LICENSE, component.GetName(), "not-compliant", score, "") } - return newRecordStmt(COMP_LICENSE, component.GetName(), "compliant", 10.0) + return db.NewRecordStmt(COMP_LICENSE, component.GetName(), "compliant", 10.0, "") } -func bsiComponentSourceHash(component sbom.GetComponent) *record { +func bsiComponentSourceHash(component sbom.GetComponent) *db.Record { result := "" score := 0.0 @@ -395,10 +457,10 @@ func bsiComponentSourceHash(component sbom.GetComponent) *record { score = 10.0 } - return newRecordStmtOptional(COMP_SOURCE_HASH, component.GetName(), result, score) + return db.NewRecordStmtOptional(COMP_SOURCE_HASH, component.GetName(), result, score) } -func bsiComponentOtherUniqIDs(component sbom.GetComponent) *record { +func bsiComponentOtherUniqIDs(component sbom.GetComponent) *db.Record { result := "" score := 0.0 @@ -408,7 +470,7 @@ func bsiComponentOtherUniqIDs(component sbom.GetComponent) *record { result = string(purl[0]) score = 10.0 - return newRecordStmtOptional(COMP_OTHER_UNIQ_IDS, component.GetName(), result, score) + return db.NewRecordStmtOptional(COMP_OTHER_UNIQ_IDS, component.GetName(), result, score) } cpes := component.GetCpes() @@ -417,32 +479,32 @@ func bsiComponentOtherUniqIDs(component sbom.GetComponent) *record { result = string(cpes[0]) score = 10.0 - return newRecordStmtOptional(COMP_OTHER_UNIQ_IDS, component.GetName(), result, score) + return db.NewRecordStmtOptional(COMP_OTHER_UNIQ_IDS, component.GetName(), result, score) } - return newRecordStmtOptional(COMP_OTHER_UNIQ_IDS, component.GetName(), "", 0.0) + return db.NewRecordStmtOptional(COMP_OTHER_UNIQ_IDS, component.GetName(), "", 0.0) } -func bsiComponentDownloadURL(component sbom.GetComponent) *record { +func bsiComponentDownloadURL(component sbom.GetComponent) *db.Record { result := component.GetDownloadLocationURL() if result != "" { - return newRecordStmtOptional(COMP_DOWNLOAD_URL, component.GetName(), result, 10.0) + return db.NewRecordStmtOptional(COMP_DOWNLOAD_URL, component.GetName(), result, 10.0) } - return newRecordStmtOptional(COMP_DOWNLOAD_URL, component.GetName(), "", 0.0) + return db.NewRecordStmtOptional(COMP_DOWNLOAD_URL, component.GetName(), "", 0.0) } -func bsiComponentSourceCodeURL(component sbom.GetComponent) *record { +func bsiComponentSourceCodeURL(component sbom.GetComponent) *db.Record { result := component.SourceCodeURL() if result != "" { - return newRecordStmtOptional(COMP_SOURCE_CODE_URL, component.GetName(), result, 10.0) + return db.NewRecordStmtOptional(COMP_SOURCE_CODE_URL, component.GetName(), result, 10.0) } - return newRecordStmtOptional(COMP_SOURCE_CODE_URL, component.GetName(), "", 0.0) + return db.NewRecordStmtOptional(COMP_SOURCE_CODE_URL, component.GetName(), "", 0.0) } -func bsiComponentHash(component sbom.GetComponent) *record { +func bsiComponentHash(component sbom.GetComponent) *db.Record { result := "" algos := []string{"SHA256", "SHA-256", "sha256", "sha-256"} score := 0.0 @@ -457,30 +519,30 @@ func bsiComponentHash(component sbom.GetComponent) *record { } } - return newRecordStmt(COMP_HASH, component.GetName(), result, score) + return db.NewRecordStmt(COMP_HASH, component.GetName(), result, score, "") } -func bsiComponentVersion(component sbom.GetComponent) *record { +func bsiComponentVersion(component sbom.GetComponent) *db.Record { result := component.GetVersion() if result != "" { - return newRecordStmt(COMP_VERSION, component.GetName(), result, 10.0) + return db.NewRecordStmt(COMP_VERSION, component.GetName(), result, 10.0, "") } - return newRecordStmt(COMP_VERSION, component.GetName(), "", 0.0) + return db.NewRecordStmt(COMP_VERSION, component.GetName(), "", 0.0, "") } -func bsiComponentName(component sbom.GetComponent) *record { +func bsiComponentName(component sbom.GetComponent) *db.Record { result := component.GetName() if result != "" { - return newRecordStmt(COMP_NAME, component.GetName(), result, 10.0) + return db.NewRecordStmt(COMP_NAME, component.GetName(), result, 10.0, "") } - return newRecordStmt(COMP_NAME, component.GetName(), "", 0.0) + return db.NewRecordStmt(COMP_NAME, component.GetName(), "", 0.0, "") } -func bsiComponentCreator(component sbom.GetComponent) *record { +func bsiComponentCreator(component sbom.GetComponent) *db.Record { result := "" score := 0.0 @@ -492,7 +554,7 @@ func bsiComponentCreator(component sbom.GetComponent) *record { } if result != "" { - return newRecordStmt(COMP_CREATOR, component.GetName(), result, score) + return db.NewRecordStmt(COMP_CREATOR, component.GetName(), result, score, "") } if supplier.GetURL() != "" { @@ -501,20 +563,20 @@ func bsiComponentCreator(component sbom.GetComponent) *record { } if result != "" { - return newRecordStmt(COMP_CREATOR, component.GetName(), result, score) + return db.NewRecordStmt(COMP_CREATOR, component.GetName(), result, score, "") } if supplier.GetContacts() != nil { for _, contact := range supplier.GetContacts() { - if contact.Email() != "" { - result = contact.Email() + if contact.GetEmail() != "" { + result = contact.GetEmail() score = 10.0 break } } if result != "" { - return newRecordStmt(COMP_CREATOR, component.GetName(), result, score) + return db.NewRecordStmt(COMP_CREATOR, component.GetName(), result, score, "") } } } @@ -528,7 +590,7 @@ func bsiComponentCreator(component sbom.GetComponent) *record { } if result != "" { - return newRecordStmt(COMP_CREATOR, component.GetName(), result, score) + return db.NewRecordStmt(COMP_CREATOR, component.GetName(), result, score, "") } if manufacturer.GetURL() != "" { @@ -537,23 +599,23 @@ func bsiComponentCreator(component sbom.GetComponent) *record { } if result != "" { - return newRecordStmt(COMP_CREATOR, component.GetName(), result, score) + return db.NewRecordStmt(COMP_CREATOR, component.GetName(), result, score, "") } if manufacturer.GetContacts() != nil { for _, contact := range manufacturer.GetContacts() { - if contact.Email() != "" { - result = contact.Email() + if contact.GetEmail() != "" { + result = contact.GetEmail() score = 10.0 break } } if result != "" { - return newRecordStmt(COMP_CREATOR, component.GetName(), result, score) + return db.NewRecordStmt(COMP_CREATOR, component.GetName(), result, score, "") } } } - return newRecordStmt(COMP_CREATOR, component.GetName(), "", 0.0) + return db.NewRecordStmt(COMP_CREATOR, component.GetName(), "", 0.0, "") } diff --git a/pkg/compliance/bsi_report.go b/pkg/compliance/bsi_report.go index fc424022..37c5a9d7 100644 --- a/pkg/compliance/bsi_report.go +++ b/pkg/compliance/bsi_report.go @@ -18,9 +18,11 @@ import ( "encoding/json" "fmt" "os" + "sort" "time" "github.com/google/uuid" + db "github.com/interlynk-io/sbomqs/pkg/compliance/db" "github.com/olekukonko/tablewriter" "sigs.k8s.io/release-utils/version" ) @@ -101,11 +103,11 @@ func newJSONReport() *bsiComplianceReport { } } -func bsiJSONReport(db *db, fileName string) { +func bsiJSONReport(dtb *db.DB, fileName string) { jr := newJSONReport() jr.Run.FileName = fileName - score := bsiAggregateScore(db) + score := bsiAggregateScore(dtb) summary := Summary{} summary.MaxScore = 10.0 summary.TotalScore = score.totalScore() @@ -113,45 +115,68 @@ func bsiJSONReport(db *db, fileName string) { summary.TotalOptionalScore = score.totalOptionalScore() jr.Summary = summary - jr.Sections = constructSections(db) + jr.Sections = constructSections(dtb) o, _ := json.MarshalIndent(jr, "", " ") fmt.Println(string(o)) } -func constructSections(db *db) []bsiSection { +func constructSections(dtb *db.DB) []bsiSection { var sections []bsiSection - allIDs := db.getAllIDs() + allIDs := dtb.GetAllIDs() for _, id := range allIDs { - records := db.getRecordsByID(id) + records := dtb.GetRecordsByID(id) for _, r := range records { - section := bsiSectionDetails[r.checkKey] + section := bsiSectionDetails[r.CheckKey] newSection := bsiSection{ Title: section.Title, ID: section.ID, DataField: section.DataField, Required: section.Required, } - score := bsiKeyIDScore(db, r.checkKey, r.id) + score := bsiKeyIDScore(dtb, r.CheckKey, r.ID) newSection.Score = score.totalScore() - if r.id == "doc" { - newSection.ElementID = "sbom" + if r.ID == "doc" { + newSection.ElementID = "SBOM" } else { - newSection.ElementID = r.id + newSection.ElementID = r.ID } - newSection.ElementResult = r.checkValue + newSection.ElementResult = r.CheckValue sections = append(sections, newSection) } } - return sections + // Group sections by ElementID + sectionsByElementID := make(map[string][]bsiSection) + for _, section := range sections { + sectionsByElementID[section.ElementID] = append(sectionsByElementID[section.ElementID], section) + } + + // Sort each group of sections by section.ID and ensure "SBOM" comes first within its group if it exists + var sortedSections []bsiSection + var sbomLevelSections []bsiSection + for elementID, group := range sectionsByElementID { + sort.Slice(group, func(i, j int) bool { + return group[i].ID < group[j].ID + }) + if elementID == "SBOM" { + sbomLevelSections = group + } else { + sortedSections = append(sortedSections, group...) + } + } + + // Place "SBOM Level" sections at the top + sortedSections = append(sbomLevelSections, sortedSections...) + + return sortedSections } -func bsiDetailedReport(db *db, fileName string) { +func bsiDetailedReport(dtb *db.DB, fileName string) { table := tablewriter.NewWriter(os.Stdout) - score := bsiAggregateScore(db) + score := bsiAggregateScore(dtb) fmt.Printf("BSI TR-03183-2 v1.1 Compliance Report \n") fmt.Printf("Compliance score by Interlynk Score:%0.1f RequiredScore:%0.1f OptionalScore:%0.1f for %s\n", score.totalScore(), score.totalRequiredScore(), score.totalOptionalScore(), fileName) @@ -160,7 +185,16 @@ func bsiDetailedReport(db *db, fileName string) { table.SetRowLine(true) table.SetAutoMergeCellsByColumnIndex([]int{0}) - sections := constructSections(db) + sections := constructSections(dtb) + + // Sort sections by ElementId and then by SectionId + sort.Slice(sections, func(i, j int) bool { + if sections[i].ElementID == sections[j].ElementID { + return sections[i].ID < sections[j].ID + } + return sections[i].ElementID < sections[j].ElementID + }) + for _, section := range sections { sectionID := section.ID if !section.Required { @@ -171,8 +205,8 @@ func bsiDetailedReport(db *db, fileName string) { table.Render() } -func bsiBasicReport(db *db, fileName string) { - score := bsiAggregateScore(db) +func bsiBasicReport(dtb *db.DB, fileName string) { + score := bsiAggregateScore(dtb) fmt.Printf("BSI TR-03183-2 v1.1 Compliance Report\n") fmt.Printf("Score:%0.1f RequiredScore:%0.1f OptionalScore:%0.1f for %s\n", score.totalScore(), score.totalRequiredScore(), score.totalOptionalScore(), fileName) } diff --git a/pkg/compliance/bsi_score.go b/pkg/compliance/bsi_score.go index d7ea71f7..265adb22 100644 --- a/pkg/compliance/bsi_score.go +++ b/pkg/compliance/bsi_score.go @@ -14,6 +14,8 @@ package compliance +import "github.com/interlynk-io/sbomqs/pkg/compliance/db" + type bsiScoreResult struct { id string requiredScore float64 @@ -58,8 +60,8 @@ func (r *bsiScoreResult) totalOptionalScore() float64 { return r.optionalScore / float64(r.optionalRecords) } -func bsiKeyIDScore(db *db, key int, id string) *bsiScoreResult { - records := db.getRecordsByKeyID(key, id) +func bsiKeyIDScore(dtb *db.DB, key int, id string) *bsiScoreResult { + records := dtb.GetRecordsByKeyID(key, id) if len(records) == 0 { return newBsiScoreResult(id) @@ -72,11 +74,11 @@ func bsiKeyIDScore(db *db, key int, id string) *bsiScoreResult { optionalRecs := 0 for _, r := range records { - if r.required { - requiredScore += r.score + if r.Required { + requiredScore += r.Score requiredRecs++ } else { - optionalScore += r.score + optionalScore += r.Score optionalRecs++ } } @@ -90,8 +92,8 @@ func bsiKeyIDScore(db *db, key int, id string) *bsiScoreResult { } } -func bsiIDScore(db *db, id string) *bsiScoreResult { - records := db.getRecordsByID(id) +func bsiIDScore(dtb *db.DB, id string) *bsiScoreResult { + records := dtb.GetRecordsByID(id) if len(records) == 0 { return newBsiScoreResult(id) @@ -104,11 +106,11 @@ func bsiIDScore(db *db, id string) *bsiScoreResult { optionalRecs := 0 for _, r := range records { - if r.required { - requiredScore += r.score + if r.Required { + requiredScore += r.Score requiredRecs++ } else { - optionalScore += r.score + optionalScore += r.Score optionalRecs++ } } @@ -122,13 +124,13 @@ func bsiIDScore(db *db, id string) *bsiScoreResult { } } -func bsiAggregateScore(db *db) *bsiScoreResult { +func bsiAggregateScore(dtb *db.DB) *bsiScoreResult { var results []bsiScoreResult var finalResult bsiScoreResult - ids := db.getAllIDs() + ids := dtb.GetAllIDs() for _, id := range ids { - results = append(results, *bsiIDScore(db, id)) + results = append(results, *bsiIDScore(dtb, id)) } for _, r := range results { diff --git a/pkg/compliance/common/common.go b/pkg/compliance/common/common.go new file mode 100644 index 00000000..4807c40e --- /dev/null +++ b/pkg/compliance/common/common.go @@ -0,0 +1,360 @@ +// Copyright 2024 Interlynk.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package common + +import ( + "strings" + "time" + + "github.com/interlynk-io/sbomqs/pkg/cpe" + "github.com/interlynk-io/sbomqs/pkg/omniborid" + "github.com/interlynk-io/sbomqs/pkg/purl" + "github.com/interlynk-io/sbomqs/pkg/sbom" + "github.com/interlynk-io/sbomqs/pkg/swhid" + "github.com/interlynk-io/sbomqs/pkg/swid" + "github.com/samber/lo" +) + +func CheckTools(tools []sbom.GetTool) (string, bool) { + var result []string + + for _, tool := range tools { + if name := tool.GetName(); name != "" { + if version := tool.GetVersion(); version != "" { + result = append(result, name+"-"+version) + } else { + result = append(result, name) + } + } + } + + if len(result) == 0 { + return "", false + } + + return strings.Join(result, ", "), true +} + +func CheckAuthors(authors []sbom.GetAuthor) (string, bool) { + var result []string + + for _, author := range authors { + if author.GetType() != "person" { + continue + } + + var parts []string + name := author.GetName() + email := author.GetEmail() + phone := author.GetPhone() + + if name != "" { + parts = append(parts, name) + } + + var contactDetails []string + if email != "" { + contactDetails = append(contactDetails, email) + } + if phone != "" { + contactDetails = append(contactDetails, phone) + } + + if len(contactDetails) > 0 { + parts = append(parts, "("+strings.Join(contactDetails, ", ")+")") + } + + if len(parts) > 0 { + result = append(result, strings.Join(parts, " ")) + } + } + + if len(result) == 0 { + return "", false + } + + return strings.Join(result, ", "), true +} + +func CheckSupplier(supplier sbom.GetSupplier) (string, bool) { + var parts []string + + // Check for name and email + if name := supplier.GetName(); name != "" { + if email := supplier.GetEmail(); email != "" { + parts = append(parts, name+", "+email) + } else { + parts = append(parts, name) + } + } else if email := supplier.GetEmail(); email != "" { + parts = append(parts, email) + } + + // Check for URL + if url := supplier.GetURL(); url != "" { + parts = append(parts, url) + } + + // Check for contacts + if contacts := supplier.GetContacts(); contacts != nil { + var contactParts []string + for _, contact := range contacts { + if contactName := contact.GetName(); contactName != "" { + if contactEmail := contact.GetEmail(); contactEmail != "" { + contactParts = append(contactParts, contactName+", "+contactEmail) + } else { + contactParts = append(contactParts, contactName) + } + } else if contactEmail := contact.GetEmail(); contactEmail != "" { + contactParts = append(contactParts, contactEmail) + } + } + if len(contactParts) > 0 { + parts = append(parts, "("+strings.Join(contactParts, ", ")+")") + } + } + + if len(parts) == 0 { + return "", false + } + + // Combine parts into the final string + finalResult := strings.Join(parts, ", ") + return finalResult, true +} + +func CheckManufacturer(manufacturer sbom.Manufacturer) (string, bool) { + if email := manufacturer.GetEmail(); email != "" { + return email, true + } + + if url := manufacturer.GetURL(); url != "" { + return url, true + } + + if contacts := manufacturer.GetContacts(); contacts != nil { + for _, contact := range contacts { + if email := contact.GetEmail(); email != "" { + return email, true + } + } + } + return "", false +} + +func CheckTimestamp(timestamp string) (string, bool) { + _, err := time.Parse(time.RFC3339, timestamp) + if err != nil { + return timestamp, false + } + return timestamp, true +} + +func CheckPurls(purls []purl.PURL) (string, bool) { + results := []string{} + for _, p := range purls { + if result := string(p); result != "" { + results = append(results, result) + } + } + if len(results) > 0 { + return strings.Join(results, ", "), true + } + return "", false +} + +func CheckCpes(cpes []cpe.CPE) (string, bool) { + results := []string{} + for _, c := range cpes { + if result := string(c); result != "" { + results = append(results, result) + } + } + if len(results) > 0 { + return strings.Join(results, ", "), true + } + return "", false +} + +func CheckOmnibor(omni []omniborid.OMNIBORID) (string, bool) { + results := []string{} + for _, o := range omni { + if result := string(o); result != "" { + results = append(results, result) + } + } + if len(results) > 0 { + return strings.Join(results, ", "), true + } + return "", false +} + +func CheckSwhid(swhid []swhid.SWHID) (string, bool) { + results := []string{} + for _, s := range swhid { + if result := string(s); result != "" { + results = append(results, result) + } + } + if len(results) > 0 { + return strings.Join(results, ", "), true + } + return "", false +} + +func CheckSwid(swid []swid.SWID) (string, bool) { + results := []string{} + for _, s := range swid { + if s.GetTagID() != "" && s.GetName() != "" { + result := s.GetTagID() + ", " + s.GetName() + results = append(results, result) + } + } + if len(results) > 0 { + return strings.Join(results, ", "), true + } + return "", false +} + +func CheckHash(checksums []sbom.GetChecksum) (string, bool, bool) { + lowerAlgos := []string{"SHA1", "SHA-1", "sha1", "sha-1", "MD5", "md5"} + higherAlgos := []string{"SHA-512", "SHA256", "SHA-256", "sha256", "sha-256"} + + containLowerAlgo := lo.ContainsBy(checksums, func(checksum sbom.GetChecksum) bool { + return lo.Contains(lowerAlgos, checksum.GetAlgo()) + }) + + containHigherAlgo := lo.ContainsBy(checksums, func(checksum sbom.GetChecksum) bool { + return lo.Contains(higherAlgos, checksum.GetAlgo()) + }) + + var res []string + for _, checksum := range checksums { + if content := checksum.GetContent(); content != "" { + res = append(res, checksum.GetAlgo()) + } + } + // results := ConvertMapToString(result) + return strings.Join(res, ", "), containLowerAlgo, containHigherAlgo +} + +// ConvertMapToString converts a map of type map[string]string into a string +// representation where each key-value pair is formatted as "key:value". +func ConvertMapToString(m map[string]string) string { + var result []string + + for key, value := range m { + result = append(result, key+": "+value) + } + + return strings.Join(result, ", ") +} + +func CheckCopyright(cp string) (string, bool) { + return cp, cp != "NOASSERTION" && cp != "NONE" +} + +// ComponentsLists return component lists as a map +func ComponentsLists(doc sbom.Document) map[string]bool { + componentList := make(map[string]bool) + for _, component := range doc.Components() { + if doc.Spec().GetSpecType() == "spdx" { + id := "SPDXRef-" + component.GetSpdxID() + componentList[id] = true + + } else if doc.Spec().GetSpecType() == "cyclonedx" { + componentList[component.GetID()] = true + } + } + return componentList +} + +// ComponentsNamesMapToIDs returns map of component ID as key and component Name as value +func ComponentsNamesMapToIDs(doc sbom.Document) map[string]string { + compIDWithName := make(map[string]string) + for _, component := range doc.Components() { + if doc.Spec().GetSpecType() == "spdx" { + id := "SPDXRef-" + component.GetSpdxID() + compIDWithName[id] = component.GetName() + + } else if doc.Spec().GetSpecType() == "cyclonedx" { + compIDWithName[component.GetID()] = component.GetName() + } + } + return compIDWithName +} + +// GetAllPrimaryComponentDependencies return all list of primary component dependencies by it's ID. +func GetAllPrimaryComponentDependencies(doc sbom.Document) []string { + var dependencies []string + for _, component := range doc.Components() { + if doc.Spec().GetSpecType() == "spdx" { + if component.GetPrimaryCompInfo().IsPresent() { + id := "SPDXRef-" + component.GetSpdxID() + dependencies = doc.GetRelationships(id) + } + } else if doc.Spec().GetSpecType() == "cyclonedx" { + if component.GetPrimaryCompInfo().IsPresent() { + dependencies = doc.GetRelationships(component.GetID()) + } + } + } + return dependencies +} + +// MapPrimaryDependencies returns a map of all primary dependencies with bool. +// Primary dependencies marked as true else false. +func MapPrimaryDependencies(doc sbom.Document) map[string]bool { + primaryDependencies := make(map[string]bool) + for _, component := range doc.Components() { + if doc.Spec().GetSpecType() == "spdx" { + id := "SPDXRef-" + component.GetSpdxID() + if component.GetPrimaryCompInfo().IsPresent() { + dependencies := doc.GetRelationships(id) + for _, d := range dependencies { + primaryDependencies[d] = true + } + } + } else if doc.Spec().GetSpecType() == "cyclonedx" { + if component.GetPrimaryCompInfo().IsPresent() { + dependencies := doc.GetRelationships(component.GetID()) + for _, d := range dependencies { + primaryDependencies[d] = true + } + } + } + } + return primaryDependencies +} + +// CheckPrimaryDependenciesInComponentList checks if all primary dependencies are part of the component list. +func CheckPrimaryDependenciesInComponentList(dependencies []string, componentList map[string]bool) bool { + return lo.EveryBy(dependencies, func(id string) bool { + return componentList[id] + }) +} + +// GetDependenciesByName returns the names of all dependencies based on their IDs. +func GetDependenciesByName(dependencies []string, compIDWithName map[string]string) []string { + var allDepByName []string + for _, dep := range dependencies { + allDepByName = append(allDepByName, compIDWithName[dep]) + } + return allDepByName +} + +func GetID(componentID string) string { + return "SPDXRef-" + componentID +} diff --git a/pkg/compliance/compliance.go b/pkg/compliance/compliance.go index 122cd99d..cda2e25f 100644 --- a/pkg/compliance/compliance.go +++ b/pkg/compliance/compliance.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" + "github.com/interlynk-io/sbomqs/pkg/compliance/fsct" "github.com/interlynk-io/sbomqs/pkg/logger" "github.com/interlynk-io/sbomqs/pkg/sbom" ) @@ -28,14 +29,24 @@ const ( BSI_REPORT = "BSI" NTIA_REPORT = "NTIA" OCT_TELCO = "OCT" + FSCT_V3 = "FSCT" ) +func validReportTypes() map[string]bool { + return map[string]bool{ + BSI_REPORT: true, + NTIA_REPORT: true, + OCT_TELCO: true, + FSCT_V3: true, + } +} + //nolint:revive,stylecheck func ComplianceResult(ctx context.Context, doc sbom.Document, reportType, fileName, outFormat string) error { log := logger.FromContext(ctx) log.Debug("compliance.ComplianceResult()") - if reportType != BSI_REPORT && reportType != NTIA_REPORT && reportType != OCT_TELCO { + if !validReportTypes()[reportType] { log.Debugf("Invalid report type: %s\n", reportType) return errors.New("invalid report type") } @@ -55,20 +66,26 @@ func ComplianceResult(ctx context.Context, doc sbom.Document, reportType, fileNa return errors.New("output format is empty") } - if reportType == BSI_REPORT { + switch { + case reportType == BSI_REPORT: bsiResult(ctx, doc, fileName, outFormat) - } - if reportType == NTIA_REPORT { + case reportType == NTIA_REPORT: ntiaResult(ctx, doc, fileName, outFormat) - } - if reportType == OCT_TELCO { + case reportType == OCT_TELCO: if doc.Spec().GetSpecType() != "spdx" { fmt.Println("The Provided SBOM spec is other than SPDX. Open Chain Telco only support SPDX specs SBOMs.") return nil } octResult(ctx, doc, fileName, outFormat) + + case reportType == FSCT_V3: + fsct.Result(ctx, doc, fileName, outFormat) + + default: + fmt.Println("No compliance type is provided") + } return nil diff --git a/pkg/compliance/db.go b/pkg/compliance/db.go deleted file mode 100644 index 210735cc..00000000 --- a/pkg/compliance/db.go +++ /dev/null @@ -1,88 +0,0 @@ -package compliance - -import ( - "fmt" -) - -type db struct { - keyRecords map[int][]*record // store record as a value of a Map with a key as a "check_key" - idRecords map[string][]*record // store record as a value of a Map with a key as a "id" - idKeyRecords map[string]map[int][]*record // store record as a value of a Map with a key as a "check_key an id" - allIDs map[string]struct{} // Set of all unique ids -} - -// newDB initializes and returns a new database instance. -func newDB() *db { - return &db{ - keyRecords: make(map[int][]*record), - idRecords: make(map[string][]*record), - idKeyRecords: make(map[string]map[int][]*record), - allIDs: make(map[string]struct{}), - } -} - -// addRecord adds a single record to the database -func (d *db) addRecord(r *record) { - // store record using a key - d.keyRecords[r.checkKey] = append(d.keyRecords[r.checkKey], r) - - // store record using a id - d.idRecords[r.id] = append(d.idRecords[r.id], r) - if d.idKeyRecords[r.id] == nil { - d.idKeyRecords[r.id] = make(map[int][]*record) - } - - // store record using a key and id - d.idKeyRecords[r.id][r.checkKey] = append(d.idKeyRecords[r.id][r.checkKey], r) - - d.allIDs[r.id] = struct{}{} -} - -// addRecords adds multiple records to the database -func (d *db) addRecords(rs []*record) { - for _, r := range rs { - d.addRecord(r) - } -} - -// getRecords retrieves records by the given "check_key" -func (d *db) getRecords(key int) []*record { - return d.keyRecords[key] -} - -// getAllIDs retrieves all unique ids in the database -func (d *db) getAllIDs() []string { - ids := make([]string, 0, len(d.allIDs)) - for id := range d.allIDs { - ids = append(ids, id) - } - return ids -} - -// getRecordsByID retrieves records by the given "id" -func (d *db) getRecordsByID(id string) []*record { - return d.idRecords[id] -} - -// getRecordsByKeyID retrieves records by the given "check_key" and "id" -func (d *db) getRecordsByKeyID(key int, id string) []*record { - return d.idKeyRecords[id][key] -} - -// dumpAll prints all records, optionally filtered by the given keys -// nolint -func (d *db) dumpAll(keys []int) { - for _, records := range d.keyRecords { - for _, r := range records { - if len(keys) == 0 { - fmt.Printf("id: %s, key: %d, value: %s\n", r.id, r.checkKey, r.checkValue) - continue - } - for _, k := range keys { - if r.checkKey == k { - fmt.Printf("id: %s, key: %d, value: %s\n", r.id, r.checkKey, r.checkValue) - } - } - } - } -} diff --git a/pkg/compliance/db/db.go b/pkg/compliance/db/db.go new file mode 100644 index 00000000..b92a44be --- /dev/null +++ b/pkg/compliance/db/db.go @@ -0,0 +1,102 @@ +// Copyright 2024 Interlynk.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package db + +import ( + "fmt" +) + +type DB struct { + keyRecords map[int][]*Record // store record as a value of a Map with a key as a "check_key" + idRecords map[string][]*Record // store record as a value of a Map with a key as a "id" + idKeyRecords map[string]map[int][]*Record // store record as a value of a Map with a key as a "check_key an id" + allIDs map[string]struct{} // Set of all unique ids +} + +// newDB initializes and returns a new database instance. +func NewDB() *DB { + return &DB{ + keyRecords: make(map[int][]*Record), + idRecords: make(map[string][]*Record), + idKeyRecords: make(map[string]map[int][]*Record), + allIDs: make(map[string]struct{}), + } +} + +// addRecord adds a single record to the database +func (d *DB) AddRecord(r *Record) { + // store record using a key + d.keyRecords[r.CheckKey] = append(d.keyRecords[r.CheckKey], r) + + // store record using a id + d.idRecords[r.ID] = append(d.idRecords[r.ID], r) + if d.idKeyRecords[r.ID] == nil { + d.idKeyRecords[r.ID] = make(map[int][]*Record) + } + + // store record using a key and id + d.idKeyRecords[r.ID][r.CheckKey] = append(d.idKeyRecords[r.ID][r.CheckKey], r) + + d.allIDs[r.ID] = struct{}{} +} + +// addRecords adds multiple records to the database +func (d *DB) AddRecords(rs []*Record) { + for _, r := range rs { + d.AddRecord(r) + } +} + +// getRecords retrieves records by the given "check_key" +func (d *DB) GetRecords(key int) []*Record { + return d.keyRecords[key] +} + +// getAllIDs retrieves all unique ids in the database +func (d *DB) GetAllIDs() []string { + ids := make([]string, 0, len(d.allIDs)) + for id := range d.allIDs { + ids = append(ids, id) + } + return ids +} + +// getRecordsByID retrieves records by the given "id" +func (d *DB) GetRecordsByID(id string) []*Record { + return d.idRecords[id] +} + +// getRecordsByKeyID retrieves records by the given "check_key" and "id" +func (d *DB) GetRecordsByKeyID(key int, id string) []*Record { + return d.idKeyRecords[id][key] +} + +// dumpAll prints all records, optionally filtered by the given keys +// nolint +func (d *DB) dumpAll(keys []int) { + for _, records := range d.keyRecords { + for _, r := range records { + if len(keys) == 0 { + fmt.Printf("id: %s, key: %d, value: %s\n", r.ID, r.CheckKey, r.CheckValue) + continue + } + for _, k := range keys { + if r.CheckKey == k { + fmt.Printf("id: %s, key: %d, value: %s\n", r.ID, r.CheckKey, r.CheckValue) + } + } + } + } +} diff --git a/pkg/compliance/db/db_test.go b/pkg/compliance/db/db_test.go new file mode 100644 index 00000000..8905a86f --- /dev/null +++ b/pkg/compliance/db/db_test.go @@ -0,0 +1,83 @@ +// Copyright 2024 Interlynk.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package db + +import ( + "fmt" + "math/rand" + "testing" +) + +const ( + numRecords = 1000000 // Number of records to test with +) + +// Generate large data set +func generateRecords(n int) []*Record { + var records []*Record + for i := 0; i < n; i++ { + records = append(records, &Record{ + CheckKey: rand.Intn(1000), // #nosec + CheckValue: fmt.Sprintf("value_%d", i), + ID: fmt.Sprintf("id_%d", rand.Intn(1000)), // #nosec + Score: rand.Float64() * 100, // #nosec + Required: rand.Intn(2) == 0, // #nosec + }) + } + return records +} + +// Benchmark original db implementation +func BenchmarkOriginalDB(b *testing.B) { + records := generateRecords(numRecords) + db := NewDB() + + // Benchmark insertion + b.Run("Insert", func(b *testing.B) { + for i := 0; i < b.N; i++ { + db.AddRecords(records) + } + }) + + // Benchmark retrieval by key + b.Run("GetByKey", func(b *testing.B) { + for i := 0; i < b.N; i++ { + db.GetRecords(rand.Intn(1000)) // #nosec + } + }) + + // Benchmark retrieval by ID + b.Run("GetByID", func(b *testing.B) { + for i := 0; i < b.N; i++ { + db.GetRecordsByID(fmt.Sprintf("id_%d", rand.Intn(1000))) // #nosec + } + }) + + // Benchmark for combined retrieval by key and ID case + b.Run("GetByKeyAndIDTogether", func(b *testing.B) { + for i := 0; i < b.N; i++ { + key := rand.Intn(1000) // #nosec + id := fmt.Sprintf("id_%d", rand.Intn(1000)) // #nosec + db.GetRecordsByKeyID(key, id) + } + }) + + // Benchmark for retrieval of all IDs case + b.Run("GetAllIDs", func(b *testing.B) { + for i := 0; i < b.N; i++ { + db.GetAllIDs() + } + }) +} diff --git a/pkg/compliance/db/record.go b/pkg/compliance/db/record.go new file mode 100644 index 00000000..58a72497 --- /dev/null +++ b/pkg/compliance/db/record.go @@ -0,0 +1,49 @@ +// Copyright 2024 Interlynk.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package db + +type Record struct { + CheckKey int + CheckValue string + ID string + Score float64 + Required bool + Maturity string +} + +func NewRecord() *Record { + return &Record{} +} + +func NewRecordStmt(key int, id, value string, score float64, maturity string) *Record { + r := NewRecord() + r.CheckKey = key + r.CheckValue = value + r.ID = id + r.Score = score + r.Required = true + r.Maturity = maturity + return r +} + +func NewRecordStmtOptional(key int, id, value string, score float64) *Record { + r := NewRecord() + r.CheckKey = key + r.CheckValue = value + r.ID = id + r.Score = score + r.Required = false + return r +} diff --git a/pkg/compliance/db_test.go b/pkg/compliance/db_test.go deleted file mode 100644 index 66744929..00000000 --- a/pkg/compliance/db_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package compliance - -import ( - "fmt" - "math/rand" - "testing" -) - -const ( - numRecords = 1000000 // Number of records to test with -) - -// Generate large data set -func generateRecords(n int) []*record { - var records []*record - for i := 0; i < n; i++ { - records = append(records, &record{ - checkKey: rand.Intn(1000), // #nosec - checkValue: fmt.Sprintf("value_%d", i), - id: fmt.Sprintf("id_%d", rand.Intn(1000)), // #nosec - score: rand.Float64() * 100, // #nosec - required: rand.Intn(2) == 0, // #nosec - }) - } - return records -} - -// Benchmark original db implementation -func BenchmarkOriginalDB(b *testing.B) { - records := generateRecords(numRecords) - db := newDB() - - // Benchmark insertion - b.Run("Insert", func(b *testing.B) { - for i := 0; i < b.N; i++ { - db.addRecords(records) - } - }) - - // Benchmark retrieval by key - b.Run("GetByKey", func(b *testing.B) { - for i := 0; i < b.N; i++ { - db.getRecords(rand.Intn(1000)) // #nosec - } - }) - - // Benchmark retrieval by ID - b.Run("GetByID", func(b *testing.B) { - for i := 0; i < b.N; i++ { - db.getRecordsByID(fmt.Sprintf("id_%d", rand.Intn(1000))) // #nosec - } - }) - - // Benchmark for combined retrieval by key and ID case - b.Run("GetByKeyAndIDTogether", func(b *testing.B) { - for i := 0; i < b.N; i++ { - key := rand.Intn(1000) // #nosec - id := fmt.Sprintf("id_%d", rand.Intn(1000)) // #nosec - db.getRecordsByKeyID(key, id) - } - }) - - // Benchmark for retrieval of all IDs case - b.Run("GetAllIDs", func(b *testing.B) { - for i := 0; i < b.N; i++ { - db.getAllIDs() - } - }) -} diff --git a/pkg/compliance/fsct/fsct.go b/pkg/compliance/fsct/fsct.go new file mode 100644 index 00000000..fc080411 --- /dev/null +++ b/pkg/compliance/fsct/fsct.go @@ -0,0 +1,480 @@ +// Copyright 2024 Interlynk.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fsct + +import ( + "context" + "strings" + + "github.com/interlynk-io/sbomqs/pkg/compliance/common" + "github.com/interlynk-io/sbomqs/pkg/compliance/db" + "github.com/interlynk-io/sbomqs/pkg/logger" + "github.com/interlynk-io/sbomqs/pkg/sbom" + "github.com/samber/lo" +) + +func Result(ctx context.Context, doc sbom.Document, fileName string, outFormat string) { + log := logger.FromContext(ctx) + log.Debug("fsct compliance") + + dtb := db.NewDB() + + // SBOM Level + dtb.AddRecord(SbomAuthor(doc)) + dtb.AddRecord(SbomTimestamp(doc)) + dtb.AddRecord(SbomType(doc)) + dtb.AddRecord(SbomPrimaryComponent(doc)) + + // component Level + dtb.AddRecords(Components(doc)) + + if outFormat == "json" { + fsctJSONReport(dtb, fileName) + } + + if outFormat == "basic" { + fsctBasicReport(dtb, fileName) + } + + if outFormat == "detailed" { + fsctDetailedReport(dtb, fileName) + } +} + +func SbomPrimaryComponent(doc sbom.Document) *db.Record { + result, score, maturity := "", 0.0, "None" + + // waiting for NTIA to get merged + primary := doc.PrimaryComp().IsPresent() + + if primary { + result = doc.PrimaryComp().GetName() + score = 10.0 + maturity = "Minimum" + } + return db.NewRecordStmt(SBOM_PRIMARY_COMPONENT, "doc", result, score, maturity) +} + +func SbomType(doc sbom.Document) *db.Record { + result, score, maturity := "", 0.0, "None" + + lifecycles := doc.Lifecycles() + + // get the first element of the lifecycles slice. + if firstLifecycle, ok := lo.First(lifecycles); ok && firstLifecycle != "" { + score = 15.0 + maturity = "Aspirational" + result = firstLifecycle + } + + return db.NewRecordStmt(SBOM_TYPE, "doc", result, score, maturity) +} + +func SbomTimestamp(doc sbom.Document) *db.Record { + result, score, maturity := "", 0.0, "None" + + if result = doc.Spec().GetCreationTimestamp(); result != "" { + if _, isTimeCorrect := common.CheckTimestamp(result); isTimeCorrect { + score = 10.0 + maturity = "Minimum" + } + } + return db.NewRecordStmt(SBOM_TIMESTAMP, "doc", result, score, maturity) +} + +func SbomAuthor(doc sbom.Document) *db.Record { + result, score, maturity := "", 0.0, "" + authorPresent, toolPresent := false, false + toolResult, authorResult := "", "" + + // Check for tools + if tools := doc.Tools(); tools != nil { + toolResult, toolPresent = common.CheckTools(tools) + } + + // Check for authors + if authors := doc.Authors(); authors != nil { + authorResult, authorPresent = common.CheckAuthors(authors) + } + + // Determine maturity level using switch + switch { + case authorPresent && toolPresent: + score = 12.0 + maturity = "Recommended" + result = authorResult + ", " + toolResult + case authorPresent: + score = 10.0 + maturity = "Minimum" + result = authorResult + case toolPresent: + score = 0.0 + maturity = "None" + result = toolResult + default: + score = 0.0 + maturity = "None" + } + + return db.NewRecordStmt(SBOM_AUTHOR, "doc", result, score, maturity) +} + +var ( + CompIDWithName = make(map[string]string) + ComponentList = make(map[string]bool) + PrimaryDependencies = make(map[string]bool) + IsMinimimRequirementFulfilled bool + GetAllPrimaryDepenciesByName = []string{} +) + +func getDepByName(dependencies []string) []string { + var allDepByName []string + for _, dep := range dependencies { + allDepByName = append(allDepByName, CompIDWithName[dep]) + } + return allDepByName +} + +func areAllDependenciesPresentInCompList(dependencies []string) bool { + return lo.EveryBy(dependencies, func(id string) bool { + return ComponentList[id] + }) +} + +func Components(doc sbom.Document) []*db.Record { + records := []*db.Record{} + + if len(doc.Components()) == 0 { + return records + } + CompIDWithName = common.ComponentsNamesMapToIDs(doc) + ComponentList = common.ComponentsLists(doc) + PrimaryDependencies = common.MapPrimaryDependencies(doc) + + dependencies := common.GetAllPrimaryComponentDependencies(doc) + if dependencies == nil { + if len(doc.Components()) == 1 { + IsMinimimRequirementFulfilled = true + } + } + areAllDepesPresentInCompList := common.CheckPrimaryDependenciesInComponentList(dependencies, ComponentList) + allDepByName := common.GetDependenciesByName(dependencies, CompIDWithName) + + if areAllDepesPresentInCompList { + IsMinimimRequirementFulfilled = true + GetAllPrimaryDepenciesByName = allDepByName + } + + for _, component := range doc.Components() { + records = append(records, fsctPackageName(component)) + records = append(records, fsctPackageVersion(component)) + records = append(records, fsctPackageSupplier(component)) + records = append(records, fsctPackageUniqIDs(component)) + records = append(records, fsctPackageHash(doc, component)) + records = append(records, fsctPackageDependencies(doc, component)) + records = append(records, fsctPackageLicense(component)) + records = append(records, fsctPackageCopyright(component)) + } + return records +} + +func fsctPackageName(component sbom.GetComponent) *db.Record { + result, score, maturity := "", 0.0, "None" + + if result = component.GetName(); result != "" { + score = 10.0 + maturity = "Minimum" + } + return db.NewRecordStmt(COMP_NAME, component.GetName(), result, score, maturity) +} + +func fsctPackageVersion(component sbom.GetComponent) *db.Record { + result, score, maturity := "", 0.0, "None" + + if result = component.GetVersion(); result != "" { + score = 10.0 + maturity = "Minimum" + } + + return db.NewRecordStmt(COMP_VERSION, component.GetName(), result, score, maturity) +} + +func fsctPackageSupplier(component sbom.GetComponent) *db.Record { + result, score, maturity := "", 0.0, "" + supplierResult, supplierPresent := "", false + + if supplier := component.Suppliers(); supplier != nil { + supplierResult, supplierPresent = common.CheckSupplier(supplier) + } + + // Determine maturity level using switch + switch { + case supplierPresent: + score = 10.0 + maturity = "Minimum" + result = supplierResult + default: + score = 0.0 + maturity = "None" + } + + return db.NewRecordStmt(COMP_SUPPLIER, component.GetName(), result, score, maturity) +} + +func fsctPackageUniqIDs(component sbom.GetComponent) *db.Record { + result, score, maturity := "", 0.0, "None" + uniqIDCount := 0 + uniqIDResults := []string{} + + if purl := component.GetPurls(); len(purl) > 0 { + if uniqIDResult, uniqIDPresent := common.CheckPurls(purl); uniqIDPresent { + uniqIDCount++ + uniqIDResults = append(uniqIDResults, uniqIDResult) + } + } + if cpe := component.GetCpes(); len(cpe) > 0 { + if uniqIDResult, uniqIDPresent := common.CheckCpes(cpe); uniqIDPresent { + uniqIDCount++ + uniqIDResults = append(uniqIDResults, uniqIDResult) + } + } + if omni := component.OmniborIDs(); len(omni) > 0 { + if uniqIDResult, uniqIDPresent := common.CheckOmnibor(omni); uniqIDPresent { + uniqIDCount++ + uniqIDResults = append(uniqIDResults, uniqIDResult) + } + } + if swhid := component.Swhids(); len(swhid) > 0 { + if uniqIDResult, uniqIDPresent := common.CheckSwhid(swhid); uniqIDPresent { + uniqIDCount++ + uniqIDResults = append(uniqIDResults, uniqIDResult) + } + } + if swids := component.Swids(); len(swids) > 0 { + if uniqIDResult, uniqIDPresent := common.CheckSwid(swids); uniqIDPresent { + uniqIDCount++ + uniqIDResults = append(uniqIDResults, uniqIDResult) + } + } + + if uniqIDCount > 0 { + score = 10.0 + maturity = "Minimum" + result = strings.Join(uniqIDResults, ", ") + } + return db.NewRecordStmt(COMP_UNIQ_ID, component.GetName(), result, score, maturity) +} + +func fsctPackageHash(doc sbom.Document, component sbom.GetComponent) *db.Record { + result, score, maturity := "", 0.0, "" + hashResult, lowAlgoHashPresent, highAlgoHashPresent := "", false, false + var checksums []sbom.GetChecksum + var isPrimaryComp bool + + primaryComp := doc.PrimaryComp().GetID() + checksums = component.GetChecksums() + + if strings.Contains(primaryComp, component.GetSpdxID()) { + isPrimaryComp = true + } + + if checksums != nil { + hashResult, lowAlgoHashPresent, highAlgoHashPresent = common.CheckHash(checksums) + } + + switch { + case hashResult != "" && isPrimaryComp && highAlgoHashPresent: + score = 12.0 + maturity = "Recommended" + result = hashResult + case hashResult != "" && (highAlgoHashPresent || lowAlgoHashPresent): + score = 10.0 + maturity = "Minimum" + result = hashResult + default: + score = 0.0 + maturity = "None" + } + + return db.NewRecordStmt(COMP_CHECKSUM, component.GetName(), result, score, maturity) +} + +func IsComponentPartOfPrimaryDependency(id string) bool { + return PrimaryDependencies[id] +} + +func fsctPackageDependencies(doc sbom.Document, component sbom.GetComponent) *db.Record { + result, score, maturity := "", 0.0, "" + var dependencies []string + compWithIncludedRel := false + var compWithNoRel bool + var compWithRel bool + var compWithRelAndIncluded bool + var allDepByName []string + if doc.Spec().GetSpecType() == "spdx" { + + if component.GetPrimaryCompInfo().IsPresent() { + result = strings.Join(GetAllPrimaryDepenciesByName, ", ") + score = 10.0 + maturity = "Minimum" + return db.NewRecordStmt(COMP_RELATIONSHIP, component.GetName(), result, score, maturity) + } + + // get dependencies for normal component + dependencies = doc.GetRelationships(common.GetID(component.GetSpdxID())) + if dependencies == nil { + // Check if the component is a part of primary dependency + if IsComponentPartOfPrimaryDependency(common.GetID(component.GetSpdxID())) { + // it's dependecy type will be "included-in" + compWithIncludedRel = true + } else { + // no dependency + compWithNoRel = true + } + } else { + allDepByName = getDepByName(dependencies) + + if IsComponentPartOfPrimaryDependency(common.GetID(component.GetSpdxID())) { + compWithRelAndIncluded = true + allDepByName = append([]string{"included-in"}, allDepByName...) + } else { + // no dependency + compWithRel = true + } + } + } else if doc.Spec().GetSpecType() == "cyclonedx" { + if component.GetPrimaryCompInfo().IsPresent() { + result = strings.Join(GetAllPrimaryDepenciesByName, ", ") + score = 10.0 + maturity = "Minimum" + return db.NewRecordStmt(COMP_RELATIONSHIP, component.GetName(), result, score, maturity) + } + + dependencies = doc.GetRelationships(component.GetID()) + if len(dependencies) == 0 { + // Check if any one of the dependencies exists in the ComponentList + if PrimaryDependencies[component.GetID()] { + compWithIncludedRel = true + } else { + compWithNoRel = true + } + } else { + allDepByName = getDepByName(dependencies) + if PrimaryDependencies[component.GetID()] { + compWithRelAndIncluded = true + allDepByName = append([]string{"included-in"}, allDepByName...) + } + compWithRel = true + } + } + switch { + case IsMinimimRequirementFulfilled && compWithIncludedRel: + score = 12.0 + maturity = "Recommended" + result = "included-in" + case IsMinimimRequirementFulfilled && compWithNoRel: + score = 12.0 + maturity = "Recommended" + result = "no-relationship" + case IsMinimimRequirementFulfilled && compWithRelAndIncluded: + score = 12.0 + maturity = "Recommended" + result = strings.Join(allDepByName, ", ") + case IsMinimimRequirementFulfilled && compWithRel: + score = 12.0 + maturity = "Recommended" + result = strings.Join(allDepByName, ", ") + default: + score = 0.0 + maturity = "None" + + } + + return db.NewRecordStmt(COMP_RELATIONSHIP, component.GetName(), result, score, maturity) +} + +func fsctPackageLicense(component sbom.GetComponent) *db.Record { + result, score, maturity := "", 0.0, "None" + + licenses := component.Licenses() + if len(licenses) == 0 { + return db.NewRecordStmt(COMP_LICENSE, component.GetName(), result, score, maturity) + } + + hasFullName, hasIdentifier, hasText, hasURL, hasSpdx := false, false, false, false, false + var licenseContent string + + for _, license := range licenses { + if license.Name() != "" { + hasFullName = true + } + if license.ShortID() != "" { + result = license.ShortID() + hasIdentifier = true + } + if license.Source() != "" { + licenseContent = license.Source() + hasText = true + } + if license.Source() == "spdx" { + hasSpdx = true + } + // Assuming URL is part of the license source or text + if strings.HasPrefix(license.Source(), "http") { + hasURL = true + } + } + switch { + case hasFullName && hasIdentifier && hasText && hasURL && hasSpdx: + score = 15.0 + maturity = "Aspirational" + case hasFullName && hasIdentifier && (hasText || hasURL): + score = 12.0 + maturity = "Recommended" + default: + score = 10 + maturity = "Minimum" + + } + // Truncate license content to 1-2 lines + _ = truncateContent(licenseContent, 100) // Adjust the length as needed + + return db.NewRecordStmt(COMP_LICENSE, component.GetName(), result, score, maturity) +} + +// Helper function to truncate content +func truncateContent(content string, maxLength int) string { + if len(content) <= maxLength { + return content + } + return content[:maxLength] + "..." +} + +func fsctPackageCopyright(component sbom.GetComponent) *db.Record { + result, score, maturity := "", 0.0, "None" + isCopyrightPresent := false + + if result = component.GetCopyRight(); result != "" { + _, isCopyrightPresent = common.CheckCopyright(result) + } + + if isCopyrightPresent { + score = 10.0 + maturity = "Minimum" + result = truncateContent(result, 50) + } + + return db.NewRecordStmt(COMP_COPYRIGHT, component.GetName(), result, score, maturity) +} diff --git a/pkg/compliance/fsct/fsct_report.go b/pkg/compliance/fsct/fsct_report.go new file mode 100644 index 00000000..c7358529 --- /dev/null +++ b/pkg/compliance/fsct/fsct_report.go @@ -0,0 +1,256 @@ +// Copyright 2024 Interlynk.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fsct + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "time" + + "github.com/google/uuid" + "github.com/interlynk-io/sbomqs/pkg/compliance/db" + "github.com/olekukonko/tablewriter" + "sigs.k8s.io/release-utils/version" +) + +// nolint +const ( + SBOM_AUTHOR = iota + SBOM_TIMESTAMP + SBOM_TYPE + SBOM_PRIMARY_COMPONENT + COMP_NAME + COMP_VERSION + COMP_SUPPLIER + COMP_UNIQ_ID + COMP_CHECKSUM + COMP_RELATIONSHIP + COMP_LICENSE + COMP_COPYRIGHT +) + +var fsctSectionDetails = map[int]fsctSection{ + SBOM_AUTHOR: {Title: "SBOM Level", ID: "2.2.1.1", Required: true, DataField: "SBOM Author"}, + SBOM_TIMESTAMP: {Title: "SBOM Level", ID: "2.2.1.2", Required: true, DataField: "SBOM Timestamp"}, + SBOM_TYPE: {Title: "SBOM Level", ID: "2.2.1.3", Required: false, DataField: "SBOM Type"}, + SBOM_PRIMARY_COMPONENT: {Title: "SBOM Level", ID: "2.2.1.4", Required: true, DataField: "Primary Component"}, + COMP_NAME: {Title: "Component Level", ID: "2.2.2.1", Required: true, DataField: "Component Name"}, + COMP_VERSION: {Title: "Component Level", ID: "2.2.2.2", Required: true, DataField: "Component Version"}, + COMP_SUPPLIER: {Title: "Component Level", ID: "2.2.2.3", Required: true, DataField: "Component Supplier"}, + COMP_UNIQ_ID: {Title: "Component Level", ID: "2.2.2.4", Required: true, DataField: "Component Unique ID"}, + COMP_CHECKSUM: {Title: "Component Level", ID: "2.2.2.5", Required: true, DataField: "Component Checksum"}, + COMP_RELATIONSHIP: {Title: "Component Level", ID: "2.2.2.6", Required: true, DataField: "Component Relationship"}, + COMP_LICENSE: {Title: "Component Level", ID: "2.2.2.7", Required: true, DataField: "Component License"}, + COMP_COPYRIGHT: {Title: "Component Level", ID: "2.2.2.8", Required: true, DataField: "Component Copyright"}, +} + +type fsctSection struct { + Title string `json:"section_title"` + ID string `json:"section_id"` + DataField string `json:"section_data_field"` + Required bool `json:"required"` + ElementID string `json:"element_id"` + ElementResult string `json:"element_result"` + Score float64 `json:"score"` + Maturity string `json:"maturity"` +} +type run struct { + ID string `json:"id"` + GeneratedAt string `json:"generated_at"` + FileName string `json:"file_name"` + EngineVersion string `json:"compliance_engine_version"` +} +type tool struct { + Name string `json:"name"` + Version string `json:"version"` + Vendor string `json:"vendor"` +} +type Summary struct { + TotalScore float64 `json:"total_score"` + MaxScore float64 `json:"max_score"` + TotalRequiredScore float64 `json:"required_elements_score"` + TotalOptionalScore float64 `json:"optional_elements_score"` +} + +type fsctComplianceReport struct { + Name string `json:"report_name"` + Subtitle string `json:"subtitle"` + Revision string `json:"revision"` + Run run `json:"run"` + Tool tool `json:"tool"` + Summary Summary `json:"summary"` + Sections []fsctSection `json:"sections"` +} + +func newFsctJSONReport() *fsctComplianceReport { + return &fsctComplianceReport{ + Name: "V3 Framing Software Component Transparency", + Subtitle: "NTIA Minimum Elelments 3rd Edition", + Revision: "3rd Edition", + Run: run{ + ID: uuid.New().String(), + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + FileName: "", + EngineVersion: "1", + }, + Tool: tool{ + Name: "sbomqs", + Version: version.GetVersionInfo().GitVersion, + Vendor: "Interlynk (support@interlynk.io)", + }, + } +} + +func fsctJSONReport(db *db.DB, fileName string) { + jr := newFsctJSONReport() + jr.Run.FileName = fileName + + score := fsctAggregateScore(db) + summary := Summary{} + summary.MaxScore = 10.0 + summary.TotalScore = score.totalScore() + summary.TotalRequiredScore = score.totalRequiredScore() + summary.TotalOptionalScore = score.totalOptionalScore() + + jr.Summary = summary + jr.Sections = fsctConstructSections(db) + + o, _ := json.MarshalIndent(jr, "", " ") + fmt.Println(string(o)) +} + +func fsctConstructSections(db *db.DB) []fsctSection { + var sections []fsctSection + allIDs := db.GetAllIDs() + for _, id := range allIDs { + records := db.GetRecordsByID(id) + for _, r := range records { + section := fsctSectionDetails[r.CheckKey] + newSection := fsctSection{ + Title: section.Title, + ID: section.ID, + DataField: section.DataField, + Required: section.Required, + Maturity: r.Maturity, + } + score := fsctKeyIDScore(db, r.CheckKey, r.ID) + newSection.Score = score.totalScore() + if r.ID == "doc" { + newSection.ElementID = "SBOM Level" + } else { + newSection.ElementID = r.ID + } + + newSection.ElementResult = r.CheckValue + + sections = append(sections, newSection) + } + } + + // Group sections by ElementID + sectionsByElementID := make(map[string][]fsctSection) + for _, section := range sections { + sectionsByElementID[section.ElementID] = append(sectionsByElementID[section.ElementID], section) + } + + // Sort each group of sections by section.ID and ensure "SBOM Level" comes first within its group if it exists + var sortedSections []fsctSection + var sbomLevelSections []fsctSection + for elementID, group := range sectionsByElementID { + sort.Slice(group, func(i, j int) bool { + return group[i].ID < group[j].ID + }) + if elementID == "SBOM Level" { + sbomLevelSections = group + } else { + sortedSections = append(sortedSections, group...) + } + } + + // Place "SBOM Level" sections at the top + sortedSections = append(sbomLevelSections, sortedSections...) + + return sortedSections +} + +func fsctDetailedReport(db *db.DB, fileName string) { + table := tablewriter.NewWriter(os.Stdout) + score := fsctAggregateScore(db) + + fmt.Printf("V3 Framing Software Component Transparency\n") + fmt.Printf("Compliance score by Interlynk Score:%0.1f RequiredScore:%0.1f OptionalScore:%0.1f for %s\n", score.totalScore(), score.totalRequiredScore(), score.totalOptionalScore(), fileName) + fmt.Printf("* indicates optional fields\n") + table.SetHeader([]string{"ElementId", "Section", "Datafield", "Element Result", "Score", "Maturity"}) + table.SetRowLine(true) + table.SetAutoMergeCellsByColumnIndex([]int{0}) + + // Set header colors + table.SetHeaderColor( + tablewriter.Colors{tablewriter.FgHiWhiteColor, tablewriter.Bold}, + tablewriter.Colors{tablewriter.FgHiWhiteColor, tablewriter.Bold}, + tablewriter.Colors{tablewriter.FgHiWhiteColor, tablewriter.Bold}, + tablewriter.Colors{tablewriter.FgHiWhiteColor, tablewriter.Bold}, + tablewriter.Colors{tablewriter.FgHiWhiteColor, tablewriter.Bold}, + tablewriter.Colors{tablewriter.FgHiWhiteColor, tablewriter.Bold}, + ) + + sections := fsctConstructSections(db) + + for _, section := range sections { + sectionID := section.ID + if !section.Required { + sectionID = sectionID + "*" + } + + // Set color based on maturity level + var maturityColor tablewriter.Colors + switch section.Maturity { + case "None": + maturityColor = tablewriter.Colors{tablewriter.FgRedColor, tablewriter.Bold} + case "Minimum": + maturityColor = tablewriter.Colors{tablewriter.FgGreenColor, tablewriter.Bold} + case "Recommended": + maturityColor = tablewriter.Colors{tablewriter.FgCyanColor, tablewriter.Bold} + case "Aspirational": + maturityColor = tablewriter.Colors{tablewriter.FgHiYellowColor, tablewriter.Bold} + default: + maturityColor = tablewriter.Colors{} + } + table.Rich([]string{ + section.ElementID, + sectionID, + section.DataField, + section.ElementResult, + fmt.Sprintf("%0.1f", section.Score), + section.Maturity, + }, []tablewriter.Colors{ + {tablewriter.FgHiMagentaColor, tablewriter.Bold}, + {}, + {tablewriter.FgHiBlueColor, tablewriter.Bold}, + {tablewriter.FgHiWhiteColor, tablewriter.Bold}, + maturityColor, + maturityColor, + }) + } + table.Render() +} + +func fsctBasicReport(db *db.DB, fileName string) { + score := fsctAggregateScore(db) + fmt.Printf("V3 Framing Software Component Transparency\n") + fmt.Printf("Score:%0.1f RequiredScore:%0.1f OptionalScore:%0.1f for %s\n", score.totalScore(), score.totalRequiredScore(), score.totalOptionalScore(), fileName) +} diff --git a/pkg/compliance/fsct/fsct_score.go b/pkg/compliance/fsct/fsct_score.go new file mode 100644 index 00000000..0c477476 --- /dev/null +++ b/pkg/compliance/fsct/fsct_score.go @@ -0,0 +1,144 @@ +// Copyright 2024 Interlynk.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fsct + +import "github.com/interlynk-io/sbomqs/pkg/compliance/db" + +type fsctScoreResult struct { + id string + requiredScore float64 + optionalScore float64 + requiredRecords int + optionalRecords int +} + +func newFsctScoreResult(id string) *fsctScoreResult { + return &fsctScoreResult{id: id} +} + +func (r *fsctScoreResult) totalScore() float64 { + if r.requiredRecords == 0 && r.optionalRecords == 0 { + return 0.0 + } + + if r.requiredRecords != 0 && r.optionalRecords != 0 { + return (r.totalRequiredScore() + r.totalOptionalScore()) / 2 + } + + if r.requiredRecords == 0 && r.optionalRecords != 0 { + return r.totalOptionalScore() + } + + return r.totalRequiredScore() +} + +func (r *fsctScoreResult) totalRequiredScore() float64 { + if r.requiredRecords == 0 { + return 0.0 + } + + return r.requiredScore / float64(r.requiredRecords) +} + +func (r *fsctScoreResult) totalOptionalScore() float64 { + if r.optionalRecords == 0 { + return 0.0 + } + + return r.optionalScore / float64(r.optionalRecords) +} + +func fsctKeyIDScore(db *db.DB, key int, id string) *fsctScoreResult { + records := db.GetRecordsByKeyID(key, id) + + if len(records) == 0 { + return newFsctScoreResult(id) + } + + requiredScore := 0.0 + optionalScore := 0.0 + + requiredRecs := 0 + optionalRecs := 0 + + for _, r := range records { + if r.Required { + requiredScore += r.Score + requiredRecs++ + } else { + optionalScore += r.Score + optionalRecs++ + } + } + + return &fsctScoreResult{ + id: id, + requiredScore: requiredScore, + optionalScore: optionalScore, + requiredRecords: requiredRecs, + optionalRecords: optionalRecs, + } +} + +func fsctIDScore(db *db.DB, id string) *fsctScoreResult { + records := db.GetRecordsByID(id) + + if len(records) == 0 { + return newFsctScoreResult(id) + } + + requiredScore := 0.0 + optionalScore := 0.0 + + requiredRecs := 0 + optionalRecs := 0 + + for _, r := range records { + if r.Required { + requiredScore += r.Score + requiredRecs++ + } else { + optionalScore += r.Score + optionalRecs++ + } + } + + return &fsctScoreResult{ + id: id, + requiredScore: requiredScore, + optionalScore: optionalScore, + requiredRecords: requiredRecs, + optionalRecords: optionalRecs, + } +} + +func fsctAggregateScore(db *db.DB) *fsctScoreResult { + var results []fsctScoreResult + var finalResult fsctScoreResult + + ids := db.GetAllIDs() + for _, id := range ids { + results = append(results, *fsctIDScore(db, id)) + } + + for _, r := range results { + finalResult.requiredScore += r.requiredScore + finalResult.optionalScore += r.optionalScore + finalResult.requiredRecords += r.requiredRecords + finalResult.optionalRecords += r.optionalRecords + } + + return &finalResult +} diff --git a/pkg/compliance/fsct/fsct_test.go b/pkg/compliance/fsct/fsct_test.go new file mode 100644 index 00000000..bf7bd439 --- /dev/null +++ b/pkg/compliance/fsct/fsct_test.go @@ -0,0 +1,1487 @@ +package fsct + +import ( + "testing" + + "github.com/interlynk-io/sbomqs/pkg/compliance/db" + "github.com/interlynk-io/sbomqs/pkg/cpe" + "github.com/interlynk-io/sbomqs/pkg/omniborid" + "github.com/interlynk-io/sbomqs/pkg/purl" + "github.com/interlynk-io/sbomqs/pkg/sbom" + "github.com/interlynk-io/sbomqs/pkg/swhid" + "github.com/interlynk-io/sbomqs/pkg/swid" + "github.com/samber/lo" + "gotest.tools/assert" +) + +type desired struct { + score float64 + result string + key int + id string + maturity string +} + +func cdxDocWithSbomAuthorNameEmailAndContact() sbom.Document { + authors := []sbom.GetAuthor{} + author := sbom.Author{} + author.Name = "Samantha Wright" + author.AuthorType = "person" + author.Email = "samantha.wright@example.com" + author.Phone = "800-555-1212" + authors = append(authors, author) + + doc := sbom.CdxDoc{ + CdxAuthors: authors, + } + return doc +} + +func cdxDocWithSbomAuthorNameAndEmail() sbom.Document { + authors := []sbom.GetAuthor{} + author := sbom.Author{} + author.Name = "Samantha Wright" + author.AuthorType = "person" + author.Email = "samantha.wright@example.com" + authors = append(authors, author) + + doc := sbom.CdxDoc{ + CdxAuthors: authors, + } + return doc +} + +func cdxDocWithSbomAuthorNameAndContact() sbom.Document { + authors := []sbom.GetAuthor{} + author := sbom.Author{} + author.Name = "Samantha Wright" + author.Phone = "800-555-1212" + author.AuthorType = "person" + authors = append(authors, author) + + doc := sbom.CdxDoc{ + CdxAuthors: authors, + } + return doc +} + +func cdxDocWithSbomAuthorName() sbom.Document { + authors := []sbom.GetAuthor{} + author := sbom.Author{} + author.Name = "Samantha Wright" + author.AuthorType = "person" + authors = append(authors, author) + + doc := sbom.CdxDoc{ + CdxAuthors: authors, + } + return doc +} + +func cdxDocWithTool() sbom.Document { + tools := []sbom.GetTool{} + tool := sbom.Tool{} + tool.Name = "sbom-tool" + tool.Version = "9.1.2" + tools = append(tools, tool) + + doc := sbom.CdxDoc{ + CdxTools: tools, + } + return doc +} + +func cdxDocWithMultipleTools() sbom.Document { + tools := []sbom.GetTool{} + componentTool := sbom.Tool{} + componentTool.Name = "sbom-tool" + componentTool.Version = "9.1.2" + tools = append(tools, componentTool) + + serviceTool := sbom.Tool{} + serviceTool.Name = "syft" + serviceTool.Version = "1.1.2" + tools = append(tools, serviceTool) + + doc := sbom.CdxDoc{ + CdxTools: tools, + } + return doc +} + +func cdxDocWithAuthorAndTools() sbom.Document { + tools := []sbom.GetTool{} + tool := sbom.Tool{} + tool.Name = "sbom-tool" + tool.Version = "9.1.2" + tools = append(tools, tool) + + authors := []sbom.GetAuthor{} + author := sbom.Author{} + author.Name = "Samantha Wright" + author.AuthorType = "person" + author.Email = "samantha.wright@example.com" + author.Phone = "800-555-1212" + authors = append(authors, author) + + doc := sbom.CdxDoc{ + CdxAuthors: authors, + CdxTools: tools, + } + return doc +} + +func TestFsctCDXSbomAuthorFields(t *testing.T) { + testCases := []struct { + name string + actual *db.Record + expected desired + }{ + { + name: "CDX SBOM with author name only", + actual: SbomAuthor(cdxDocWithSbomAuthorName()), + expected: desired{ + score: 10.0, + result: "Samantha Wright", + key: SBOM_AUTHOR, + id: "doc", + maturity: "Minimum", + }, + }, + { + name: "CDX SBOM with author name and email", + actual: SbomAuthor(cdxDocWithSbomAuthorNameAndEmail()), + expected: desired{ + score: 10.0, + result: "Samantha Wright (samantha.wright@example.com)", + key: SBOM_AUTHOR, + id: "doc", + maturity: "Minimum", + }, + }, + { + name: "CDX SBOM with author name and contact", + actual: SbomAuthor(cdxDocWithSbomAuthorNameAndContact()), + expected: desired{ + score: 10.0, + result: "Samantha Wright (800-555-1212)", + key: SBOM_AUTHOR, + id: "doc", + maturity: "Minimum", + }, + }, + { + name: "CDX SBOM with author name, email and contact", + actual: SbomAuthor(cdxDocWithSbomAuthorNameEmailAndContact()), + expected: desired{ + score: 10.0, + result: "Samantha Wright (samantha.wright@example.com, 800-555-1212)", + key: SBOM_AUTHOR, + id: "doc", + maturity: "Minimum", + }, + }, + { + name: "CDX SBOM with a tool", + actual: SbomAuthor(cdxDocWithTool()), + expected: desired{ + score: 0.0, + result: "sbom-tool-9.1.2", + key: SBOM_AUTHOR, + id: "doc", + maturity: "None", + }, + }, + { + name: "CDX SBOM with multiple tools", + actual: SbomAuthor(cdxDocWithMultipleTools()), + expected: desired{ + score: 0.0, + result: "sbom-tool-9.1.2, syft-1.1.2", + key: SBOM_AUTHOR, + id: "doc", + maturity: "None", + }, + }, + { + name: "CDX SBOM with a Author and tool", + actual: SbomAuthor(cdxDocWithAuthorAndTools()), + expected: desired{ + score: 12.0, + result: "Samantha Wright (samantha.wright@example.com, 800-555-1212), sbom-tool-9.1.2", + key: SBOM_AUTHOR, + id: "doc", + maturity: "Recommended", + }, + }, + } + for _, test := range testCases { + assert.Equal(t, test.expected.score, test.actual.Score, "Score mismatch for %s", test.name) + assert.Equal(t, test.expected.key, test.actual.CheckKey, "Key mismatch for %s", test.name) + assert.Equal(t, test.expected.id, test.actual.ID, "ID mismatch for %s", test.name) + assert.Equal(t, test.expected.result, test.actual.CheckValue, "Result mismatch for %s", test.name) + assert.Equal(t, test.expected.maturity, test.actual.Maturity, "Maturity mismatch for %s", test.name) + } +} + +func cdxDocWithPrimaryComponent() sbom.Document { + primary := sbom.PrimaryComp{} + primary.Present = true + primary.Name = "git@github.com:interlynk/sbomqs.git" + + doc := sbom.CdxDoc{ + PrimaryComponent: primary, + } + return doc +} + +func cdxDocWithPreDefinedPhaseLifecycles() sbom.Document { + phase := "build" + + doc := sbom.SpdxDoc{ + Lifecycle: phase, + } + return doc +} + +func cdxDocWithCustomPhaseLifecycles() sbom.Document { + name := "platform-integration-testing" + // description := "Integration testing specific to the runtime platform" + doc := sbom.SpdxDoc{ + Lifecycle: name, + } + return doc +} + +func cdxDocWithTimestamp() sbom.Document { + s := sbom.NewSpec() + s.CreationTimestamp = "2020-04-13T20:20:39+00:00" + doc := sbom.CdxDoc{ + CdxSpec: s, + } + return doc +} + +func TestFsctCDXOtherSbomLevelFields(t *testing.T) { + testCases := []struct { + name string + actual *db.Record + expected desired + }{ + { + name: "CDX SBOM with timestamp", + actual: SbomTimestamp(cdxDocWithTimestamp()), + expected: desired{ + score: 10.0, + result: "2020-04-13T20:20:39+00:00", + key: SBOM_TIMESTAMP, + id: "doc", + maturity: "Minimum", + }, + }, + { + name: "CDX SBOM with custom phase lifecycle", + actual: SbomType(cdxDocWithCustomPhaseLifecycles()), + expected: desired{ + score: 15.0, + result: "platform-integration-testing", + key: SBOM_TYPE, + id: "doc", + maturity: "Aspirational", + }, + }, + { + name: "CDX SBOM with pre-defined phase lifecycle", + actual: SbomType(cdxDocWithPreDefinedPhaseLifecycles()), + expected: desired{ + score: 15.0, + result: "build", + key: SBOM_TYPE, + id: "doc", + maturity: "Aspirational", + }, + }, + { + name: "CDX SBOM with primary component", + actual: SbomPrimaryComponent(cdxDocWithPrimaryComponent()), + expected: desired{ + score: 10.0, + result: "git@github.com:interlynk/sbomqs.git", + key: SBOM_PRIMARY_COMPONENT, + id: "doc", + maturity: "Minimum", + }, + }, + } + + for _, test := range testCases { + assert.Equal(t, test.expected.score, test.actual.Score, "Score mismatch for %s", test.name) + assert.Equal(t, test.expected.key, test.actual.CheckKey, "Key mismatch for %s", test.name) + assert.Equal(t, test.expected.id, test.actual.ID, "ID mismatch for %s", test.name) + assert.Equal(t, test.expected.result, test.actual.CheckValue, "Result mismatch for %s", test.name) + assert.Equal(t, test.expected.maturity, test.actual.Maturity, "Maturity mismatch for %s", test.name) + } +} + +func spdxDocWithSbomAuthor() sbom.Document { + authors := []sbom.GetAuthor{} + author := sbom.Author{} + + author.Name = "Jane Doe" + + author.AuthorType = "person" + authors = append(authors, author) + + doc := sbom.SpdxDoc{ + Auths: authors, + } + return doc +} + +func spdxDocWithSbomAuthorAndTool() sbom.Document { + authors := []sbom.GetAuthor{} + tools := []sbom.GetTool{} + + tool := sbom.Tool{} + author := sbom.Author{} + author.Name = "Jane Doe" + tool.Name = "syft" + tool.Version = "1.9.0" + + author.AuthorType = "person" + authors = append(authors, author) + tools = append(tools, tool) + + doc := sbom.SpdxDoc{ + Auths: authors, + SpdxTools: tools, + } + return doc +} + +func spdxDocWithSbomTool() sbom.Document { + tools := []sbom.GetTool{} + + tool := sbom.Tool{} + tool.Name = "syft" + tool.Version = "1.9.0" + + tools = append(tools, tool) + + doc := sbom.SpdxDoc{ + SpdxTools: tools, + } + return doc +} + +func spdxDocWithLifecycles() sbom.Document { + creatorComment := "hellow, this is sbom build phase" + + doc := sbom.SpdxDoc{ + Lifecycle: creatorComment, + } + return doc +} + +func spdxDocWithPrimaryComponent() sbom.Document { + primary := sbom.PrimaryComp{} + primary.Present = true + primary.Name = "SPDXRef-DocumentRoot-File-sbomqs-linux-amd64" + + doc := sbom.CdxDoc{ + PrimaryComponent: primary, + } + return doc +} + +func TestFsctSPDXSbomLevelFields(t *testing.T) { + testCases := []struct { + name string + actual *db.Record + expected desired + }{ + { + name: "SPDX SBOM with lifecycle", + actual: SbomType(spdxDocWithLifecycles()), + expected: desired{ + score: 15.0, + result: "hellow, this is sbom build phase", + key: SBOM_TYPE, + id: "doc", + maturity: "Aspirational", + }, + }, + { + name: "SPDX SBOM with primary component", + actual: SbomPrimaryComponent(spdxDocWithPrimaryComponent()), + expected: desired{ + score: 10.0, + result: "SPDXRef-DocumentRoot-File-sbomqs-linux-amd64", + key: SBOM_PRIMARY_COMPONENT, + id: "doc", + maturity: "Minimum", + }, + }, + { + name: "SPDX SBOM with author name only", + actual: SbomAuthor(spdxDocWithSbomAuthor()), + expected: desired{ + score: 10.0, + result: "Jane Doe", + key: SBOM_AUTHOR, + id: "doc", + maturity: "Minimum", + }, + }, + { + name: "SPDX SBOM with tool only", + actual: SbomAuthor(spdxDocWithSbomTool()), + expected: desired{ + score: 0.0, + result: "syft-1.9.0", + key: SBOM_AUTHOR, + id: "doc", + maturity: "None", + }, + }, + { + name: "SPDX SBOM with Author and tool both", + actual: SbomAuthor(spdxDocWithSbomAuthorAndTool()), + expected: desired{ + score: 12.0, + result: "Jane Doe, syft-1.9.0", + key: SBOM_AUTHOR, + id: "doc", + maturity: "Recommended", + }, + }, + } + + for _, test := range testCases { + assert.Equal(t, test.expected.score, test.actual.Score, "Score mismatch for %s", test.name) + assert.Equal(t, test.expected.key, test.actual.CheckKey, "Key mismatch for %s", test.name) + assert.Equal(t, test.expected.id, test.actual.ID, "ID mismatch for %s", test.name) + assert.Equal(t, test.expected.result, test.actual.CheckValue, "Result mismatch for %s", test.name) + assert.Equal(t, test.expected.maturity, test.actual.Maturity, "Maturity mismatch for %s", test.name) + } +} + +// COMPONENT LEVEL CHECKS + +func compWithName() sbom.GetComponent { + name := "github.com/google/uuid" + + comp := sbom.Component{ + Name: name, + } + return comp +} + +func compWithVersion() sbom.GetComponent { + name := "github.com/google/uuid" + version := "v1.6.0" + + comp := sbom.Component{ + Name: name, + Version: version, + } + return comp +} + +func spdxCompWithSupplierName() sbom.GetComponent { + name := "github.com/google/uuid" + supp := sbom.Supplier{} + supp.Name = "Jane Doe" + + comp := sbom.Component{ + Name: name, + Supplier: supp, + } + return comp +} + +func spdxCompWithSupplierEmail() sbom.GetComponent { + name := "github.com/google/uuid" + supp := sbom.Supplier{} + supp.Email = "jane.doe@example.com" + + comp := sbom.Component{ + Name: name, + Supplier: supp, + } + return comp +} + +func spdxCompWithSupplierNameAndEmail() sbom.GetComponent { + name := "github.com/google/uuid" + supp := sbom.Supplier{} + supp.Email = "jane.doe@example.com" + supp.Name = "Jane Doe" + + comp := sbom.Component{ + Name: name, + Supplier: supp, + } + return comp +} + +func cdxCompWithSupplierName() sbom.GetComponent { + name := "github.com/google/uuid" + supp := sbom.Supplier{} + supp.Name = "Acme, Inc" + + comp := sbom.Component{ + Name: name, + Supplier: supp, + } + return comp +} + +func cdxCompWithSupplierURL() sbom.GetComponent { + name := "github.com/google/uuid" + supp := sbom.Supplier{} + supp.URL = "https://example.com" + + comp := sbom.Component{ + Name: name, + Supplier: supp, + } + return comp +} + +func cdxCompWithSupplierNameAndURL() sbom.GetComponent { + name := "github.com/google/uuid" + supp := sbom.Supplier{} + supp.Name = "Acme, Inc" + supp.URL = "https://example.com" + + comp := sbom.Component{ + Name: name, + Supplier: supp, + } + return comp +} + +func cdxCompWithSupplierContactInfo() sbom.GetComponent { + name := "github.com/google/uuid" + + supp := sbom.Supplier{} + contact := sbom.Contact{} + + contact.Name = "Acme Distribution" + contact.Email = "distribution@example.com" + supp.Contacts = []sbom.Contact{contact} + + comp := sbom.Component{ + Name: name, + Supplier: supp, + } + return comp +} + +func cdxCompWithSupplierAndContactInfo() sbom.GetComponent { + name := "github.com/google/uuid" + + supp := sbom.Supplier{} + supp.Name = "Acme, Inc" + supp.URL = "https://example.com" + + contact := sbom.Contact{} + contact.Name = "Acme Distribution" + contact.Email = "distribution@example.com" + supp.Contacts = []sbom.Contact{contact} + + comp := sbom.Component{ + Name: name, + Supplier: supp, + } + return comp +} + +func compWithSmallContentCopyright() sbom.GetComponent { + copyright := "2013-2023 The Cobra Authors" + comp := sbom.NewComponent() + comp.CopyRight = copyright + comp.Name = "cobra" + comp.Spdxid = "pkg:github/spf13/cobra@e94f6d0dd9a5e5738dca6bce03c4b1207ffbc0ec" + + return comp +} + +func compWithBigContentCopyright() sbom.GetComponent { + copyright := "2014 Sam Ghods\n staring in 2011 when the project was ported over:\n2006-2010 Kirill Simonov\n2006-2011 Kirill Simonov\n2011-2019 Canonical Ltd\n2012 The Go Authors. All rights reserved.\n2006 Kirill Simonov" + comp := sbom.NewComponent() + comp.CopyRight = copyright + comp.Name = "yaml" + comp.Spdxid = "pkg:github/kubernetes-sigs/yaml@c3772b51db126345efe2dfe4ff8dac83b8141684" + + return comp +} + +func TestFsctComponentLevelOnSpdxAndCdx(t *testing.T) { + testCases := []struct { + name string + actual *db.Record + expected desired + }{ + { + name: "Comp with Name", + actual: fsctPackageName(compWithName()), + expected: desired{ + score: 10.0, + result: "github.com/google/uuid", + key: COMP_NAME, + id: compWithName().GetName(), + maturity: "Minimum", + }, + }, + { + name: "Comp with Version", + actual: fsctPackageVersion(compWithVersion()), + expected: desired{ + score: 10.0, + result: "v1.6.0", + key: COMP_VERSION, + id: compWithVersion().GetName(), + maturity: "Minimum", + }, + }, + { + name: "SPDX Comp with Supplier Name only", + actual: fsctPackageSupplier(spdxCompWithSupplierName()), + expected: desired{ + score: 10.0, + result: "Jane Doe", + key: COMP_SUPPLIER, + id: spdxCompWithSupplierName().GetName(), + maturity: "Minimum", + }, + }, + { + name: "SPDX Comp with Supplier Email", + actual: fsctPackageSupplier(spdxCompWithSupplierEmail()), + expected: desired{ + score: 10.0, + result: "jane.doe@example.com", + key: COMP_SUPPLIER, + id: spdxCompWithSupplierEmail().GetName(), + maturity: "Minimum", + }, + }, + { + name: "SPDX Comp with Supplier Name and Email", + actual: fsctPackageSupplier(spdxCompWithSupplierNameAndEmail()), + expected: desired{ + score: 10.0, + result: "Jane Doe, jane.doe@example.com", + key: COMP_SUPPLIER, + id: spdxCompWithSupplierNameAndEmail().GetName(), + maturity: "Minimum", + }, + }, + { + name: "CDX Comp with Supplier Name", + actual: fsctPackageSupplier(cdxCompWithSupplierName()), + expected: desired{ + score: 10.0, + result: "Acme, Inc", + key: COMP_SUPPLIER, + id: cdxCompWithSupplierName().GetName(), + maturity: "Minimum", + }, + }, + { + name: "CDX Comp with Supplier URL", + actual: fsctPackageSupplier(cdxCompWithSupplierURL()), + expected: desired{ + score: 10.0, + result: "https://example.com", + key: COMP_SUPPLIER, + id: cdxCompWithSupplierURL().GetName(), + maturity: "Minimum", + }, + }, + { + name: "CDX Comp with Supplier Name and URL", + actual: fsctPackageSupplier(cdxCompWithSupplierNameAndURL()), + expected: desired{ + score: 10.0, + result: "Acme, Inc, https://example.com", + key: COMP_SUPPLIER, + id: cdxCompWithSupplierNameAndURL().GetName(), + maturity: "Minimum", + }, + }, + { + name: "CDX Comp with Supplier Contact Info Only", + actual: fsctPackageSupplier(cdxCompWithSupplierContactInfo()), + expected: desired{ + score: 10.0, + result: "(Acme Distribution, distribution@example.com)", + key: COMP_SUPPLIER, + id: cdxCompWithSupplierContactInfo().GetName(), + maturity: "Minimum", + }, + }, + { + name: "CDX Comp with Supplier and Contact Info", + actual: fsctPackageSupplier(cdxCompWithSupplierAndContactInfo()), + expected: desired{ + score: 10.0, + result: "Acme, Inc, https://example.com, (Acme Distribution, distribution@example.com)", + key: COMP_SUPPLIER, + id: cdxCompWithSupplierAndContactInfo().GetName(), + maturity: "Minimum", + }, + }, + { + name: "SPDX Comp with small content copyright", + actual: fsctPackageCopyright(compWithSmallContentCopyright()), + expected: desired{ + score: 10.0, + result: "2013-2023 The Cobra Authors", + key: COMP_COPYRIGHT, + id: compWithSmallContentCopyright().GetName(), + maturity: "Minimum", + }, + }, + { + name: "SPDX Comp with small content copyright", + actual: fsctPackageCopyright(compWithBigContentCopyright()), + expected: desired{ + score: 10.0, + result: "2014 Sam Ghods\n staring in 2011 when the project w...", + key: COMP_COPYRIGHT, + id: compWithBigContentCopyright().GetName(), + maturity: "Minimum", + }, + }, + } + for _, test := range testCases { + assert.Equal(t, test.expected.score, test.actual.Score, "Score mismatch for %s", test.name) + assert.Equal(t, test.expected.key, test.actual.CheckKey, "Key mismatch for %s", test.name) + assert.Equal(t, test.expected.id, test.actual.ID, "ID mismatch for %s", test.name) + assert.Equal(t, test.expected.result, test.actual.CheckValue, "Result mismatch for %s", test.name) + assert.Equal(t, test.expected.maturity, test.actual.Maturity, "Maturity mismatch for %s", test.name) + } +} + +func primaryCompWithHigherChecksum() (sbom.Document, sbom.GetComponent) { + primary := sbom.PrimaryComp{} + + chks := []sbom.GetChecksum{} + + ck1 := sbom.Checksum{} + ck1.Alg = "SHA256" + ck1.Content = "11b6d3ee554eedf79299905a98f9b9a04e498210b59f15094c916c91d150efcd" + + ck2 := sbom.Checksum{} + ck2.Alg = "SHA1" + ck2.Content = "85ed0817af83a24ad8da68c2b5094de69833983c" + + chks = append(chks, ck1, ck2) + + comp := sbom.Component{ + Spdxid: "DocumentRoot-File-sbomqs-linux-amd64", + Name: "sbomqs-linux-amd64", + Checksums: chks, + } + + primary.Present = true + primary.ID = "SPDXRef-DocumentRoot-File-sbomqs-linux-amd64" + + doc := sbom.SpdxDoc{ + PrimaryComponent: primary, + // Comps: []sbom.GetComponent{comp}, + } + + return doc, comp +} + +func primaryCompWithLowerChecksum() (sbom.Document, sbom.GetComponent) { + primary := sbom.PrimaryComp{} + + chks := []sbom.GetChecksum{} + + ck1 := sbom.Checksum{} + ck1.Alg = "MD5" + ck1.Content = "624c1abb3664f4b35547e7c73864ad24" + + ck2 := sbom.Checksum{} + ck2.Alg = "SHA1" + ck2.Content = "85ed0817af83a24ad8da68c2b5094de69833983c" + + chks = append(chks, ck1, ck2) + + comp := sbom.Component{ + Spdxid: "DocumentRoot-File-sbomqs-linux-amd64", + Name: "sbomqs-linux-amd64", + Checksums: chks, + } + + primary.Present = true + primary.ID = "SPDXRef-DocumentRoot-File-sbomqs-linux-amd64" + + doc := sbom.SpdxDoc{ + PrimaryComponent: primary, + } + + return doc, comp +} + +func compWithHigherChecksum() (sbom.Document, sbom.GetComponent) { + primary := sbom.PrimaryComp{} + chks := []sbom.GetChecksum{} + + ck1 := sbom.Checksum{} + ck1.Alg = "SHA256" + ck1.Content = "11b6d3ee554eedf79299905a98f9b9a04e498210b59f15094c916c91d150efcd" + + ck2 := sbom.Checksum{} + ck2.Alg = "SHA1" + ck2.Content = "85ed0817af83a24ad8da68c2b5094de69833983c" + + chks = append(chks, ck1, ck2) + + comp := sbom.Component{ + Spdxid: "SPDXRef-Package-go-module-stdlib-2dfa88209de0bd8b", + Name: "stdlib", + Checksums: chks, + } + + primary.Present = true + primary.ID = "SPDXRef-DocumentRoot-File-sbomqs-linux-amd64" + + doc := sbom.SpdxDoc{ + PrimaryComponent: primary, + } + return doc, comp +} + +func compWithLowerChecksum() (sbom.Document, sbom.GetComponent) { + primary := sbom.PrimaryComp{} + chks := []sbom.GetChecksum{} + + ck1 := sbom.Checksum{} + ck1.Alg = "MD5" + ck1.Content = "624c1abb3664f4b35547e7c73864ad24" + + ck2 := sbom.Checksum{} + ck2.Alg = "SHA1" + ck2.Content = "85ed0817af83a24ad8da68c2b5094de69833983c" + + chks = append(chks, ck1, ck2) + + comp := sbom.Component{ + Spdxid: "SPDXRef-Package-go-module-stdlib-2dfa88209de0bd8b", + Name: "stdlib", + Checksums: chks, + } + + primary.Present = true + primary.ID = "SPDXRef-DocumentRoot-File-sbomqs-linux-amd64" + + doc := sbom.SpdxDoc{ + PrimaryComponent: primary, + } + return &doc, comp +} + +func TestFsctChecksums(t *testing.T) { + _, pch := primaryCompWithHigherChecksum() + _, pcl := primaryCompWithLowerChecksum() + _, nch := compWithHigherChecksum() + _, ncl := compWithLowerChecksum() + testCases := []struct { + name string + actual *db.Record + expected desired + }{ + { + name: "SPDX primary Comp with higher Checksum", + actual: fsctPackageHash(primaryCompWithHigherChecksum()), + expected: desired{ + score: 12.0, + result: "SHA256, SHA1", + key: COMP_CHECKSUM, + id: pch.GetName(), + maturity: "Recommended", + }, + }, + + { + name: "SPDX primary Comp with lower Checksum", + actual: fsctPackageHash(primaryCompWithLowerChecksum()), + expected: desired{ + score: 10.0, + result: "MD5, SHA1", + key: COMP_CHECKSUM, + id: pcl.GetName(), + maturity: "Minimum", + }, + }, + { + name: "SPDX Comp with higher Checksum", + actual: fsctPackageHash(compWithHigherChecksum()), + expected: desired{ + score: 10.0, + result: "SHA256, SHA1", + key: COMP_CHECKSUM, + id: nch.GetName(), + maturity: "Minimum", + }, + }, + { + name: "SPDX Comp with lower Checksum", + actual: fsctPackageHash(compWithLowerChecksum()), + expected: desired{ + score: 10.0, + result: "MD5, SHA1", + key: COMP_CHECKSUM, + id: ncl.GetName(), + maturity: "Minimum", + }, + }, + } + for _, test := range testCases { + assert.Equal(t, test.expected.score, test.actual.Score, "Score mismatch for %s", test.name) + assert.Equal(t, test.expected.key, test.actual.CheckKey, "Key mismatch for %s", test.name) + assert.Equal(t, test.expected.id, test.actual.ID, "ID mismatch for %s", test.name) + assert.Equal(t, test.expected.result, test.actual.CheckValue, "Result mismatch for %s", test.name) + assert.Equal(t, test.expected.maturity, test.actual.Maturity, "Maturity mismatch for %s", test.name) + } +} + +type ElementRefID struct { + ID string +} +type Relationship struct { + Relationship string + RefA ElementRefID + RefB ElementRefID +} + +func spdxCompWithPrimaryDependency() (sbom.Document, sbom.GetComponent) { + rel1 := Relationship{ + RefA: ElementRefID{ID: "SPDXRef-custom-46261-git-github.com-viveksahu26-sbomqs.git-14e7376fa2b00c102a9ba89fd5ccc7cf26f2f255"}, + RefB: ElementRefID{ID: "SPDXRef-git-github.com-package-url-packageurl-go-7cb81af9593b9512bb946c55c85609948c48aab9"}, + } + + rel2 := Relationship{ + RefA: ElementRefID{ID: "SPDXRef-custom-46261-git-github.com-viveksahu26-sbomqs.git-14e7376fa2b00c102a9ba89fd5ccc7cf26f2f255"}, + RefB: ElementRefID{ID: "SPDXRef-git-github.com-samber-lo-151a075ecca084ddbb519fafd513002df0632716"}, + } + + rel3 := Relationship{ + RefA: ElementRefID{ID: "SPDXRef-custom-46261-git-github.com-viveksahu26-sbomqs.git-14e7376fa2b00c102a9ba89fd5ccc7cf26f2f255"}, + RefB: ElementRefID{ID: "SPDXRef-git-github.com-github-go-spdx-eacf4f37582f0c1b8f0086816ad1afea74d1ac3f"}, + } + + comp := sbom.Component{} + + pc := sbom.PrimaryComp{} + pc.ID = "SPDXRef-custom-46261-git-github.com-viveksahu26-sbomqs.git-14e7376fa2b00c102a9ba89fd5ccc7cf26f2f255" + pc.Present = true + comp.PrimaryCompt = pc + comp.ID = "SPDXRef-custom-46261-git-github.com-viveksahu26-sbomqs.git-14e7376fa2b00c102a9ba89fd5ccc7cf26f2f255" + + dependencies := make(map[string][]string) + dependencies[sbom.CleanKey(rel1.RefA.ID)] = append(dependencies[sbom.CleanKey(rel1.RefA.ID)], sbom.CleanKey(rel1.RefB.ID)) + dependencies[sbom.CleanKey(rel2.RefA.ID)] = append(dependencies[sbom.CleanKey(rel2.RefA.ID)], sbom.CleanKey(rel2.RefB.ID)) + dependencies[sbom.CleanKey(rel3.RefA.ID)] = append(dependencies[sbom.CleanKey(rel3.RefA.ID)], sbom.CleanKey(rel3.RefB.ID)) + + spec := sbom.NewSpec() + spec.SpecType = "spdx" + doc := sbom.SpdxDoc{ + Dependencies: dependencies, + SpdxSpec: spec, + } + id := comp.GetID() + + deps := doc.GetRelationships(id) + areAllDepesPresentInCompList := areAllDependenciesPresentInCompList(deps) + allDepByName := getDepByName(deps) + + if areAllDepesPresentInCompList { + IsMinimimRequirementFulfilled = true + GetAllPrimaryDepenciesByName = allDepByName + } + + GetAllPrimaryDepenciesByName = []string{"go-spdx", "lo", "packageurl-go"} + + return doc, comp +} + +func spdxCompWithTwoDependency() (sbom.Document, sbom.GetComponent) { + rel1 := Relationship{ + RefA: ElementRefID{ID: "SPDXRef-git-github.com-ProtonMail-go-crypto-afb1ddc0824ce0052d72ac0d6917f144a1207424"}, + RefB: ElementRefID{ID: "SPDXRef-git-github.com-cloudflare-circl-75b28edc25ec569e6353a2b944b0b83d48a9c2e8"}, + } + + rel2 := Relationship{ + RefA: ElementRefID{ID: "SPDXRef-git-github.com-ProtonMail-go-crypto-afb1ddc0824ce0052d72ac0d6917f144a1207424"}, + RefB: ElementRefID{ID: "SPDXRef-git-go.googlesource.com-crypto-332fd656f4f013f66e643818fe8c759538456535"}, + } + + comp := sbom.Component{} + + pc := sbom.PrimaryComp{} + pc.Present = false + comp.PrimaryCompt = pc + comp.ID = "git-github.com-ProtonMail-go-crypto-afb1ddc0824ce0052d72ac0d6917f144a1207424" + comp.Spdxid = "git-github.com-ProtonMail-go-crypto-afb1ddc0824ce0052d72ac0d6917f144a1207424" + comp.Name = "crypto" + + dependencies := make(map[string][]string) + dependencies[sbom.CleanKey(rel1.RefA.ID)] = append(dependencies[sbom.CleanKey(rel1.RefA.ID)], sbom.CleanKey(rel1.RefB.ID)) + dependencies[sbom.CleanKey(rel2.RefA.ID)] = append(dependencies[sbom.CleanKey(rel2.RefA.ID)], sbom.CleanKey(rel2.RefB.ID)) + + CompIDWithName[rel1.RefB.ID] = "circl" + CompIDWithName[rel2.RefB.ID] = "crypto" + PrimaryDependencies[rel1.RefB.ID] = false + PrimaryDependencies[rel2.RefB.ID] = false + + spec := sbom.NewSpec() + spec.SpecType = "spdx" + IsMinimimRequirementFulfilled = true + doc := sbom.SpdxDoc{ + Dependencies: dependencies, + SpdxSpec: spec, + } + + return doc, comp +} + +func cdxCompWithPrimaryDependency() (sbom.Document, sbom.GetComponent) { + rel1 := Relationship{ + RefA: ElementRefID{ID: "custom+46261/git@github.com:viveksahu26/sbomqs.git$14e7376fa2b00c102a9ba89fd5ccc7cf26f2f255"}, + RefB: ElementRefID{ID: "pkg:github/DependencyTrack/client-go@2570042a517e97bc9ec0c617706a89a0edad4426"}, + } + + rel2 := Relationship{ + RefA: ElementRefID{ID: "custom+46261/git@github.com:viveksahu26/sbomqs.git$14e7376fa2b00c102a9ba89fd5ccc7cf26f2f255"}, + RefB: ElementRefID{ID: "pkg:github/spf13/cobra@e94f6d0dd9a5e5738dca6bce03c4b1207ffbc0ec"}, + } + + rel3 := Relationship{ + RefA: ElementRefID{ID: "custom+46261/git@github.com:viveksahu26/sbomqs.git$14e7376fa2b00c102a9ba89fd5ccc7cf26f2f255"}, + RefB: ElementRefID{ID: "pkg:github/CycloneDX/cyclonedx-go@98a070df0171240c53e7e1c3c46640eb9b257e08"}, + } + + comp := sbom.Component{} + + pc := sbom.PrimaryComp{} + pc.ID = "custom+46261/git@github.com:viveksahu26/sbomqs.git$14e7376fa2b00c102a9ba89fd5ccc7cf26f2f255" + pc.Present = true + comp.PrimaryCompt = pc + comp.ID = "custom+46261/git@github.com:viveksahu26/sbomqs.git$14e7376fa2b00c102a9ba89fd5ccc7cf26f2f255" + comp.Name = "git@github.com:interlynk/sbomqs.git" + + dependencies := make(map[string][]string) + dependencies[rel1.RefA.ID] = append(dependencies[rel1.RefA.ID], rel1.RefB.ID) + dependencies[rel2.RefA.ID] = append(dependencies[rel2.RefA.ID], rel2.RefB.ID) + dependencies[rel3.RefA.ID] = append(dependencies[rel3.RefA.ID], rel3.RefB.ID) + + spec := sbom.NewSpec() + spec.SpecType = "cyclonedx" + doc := sbom.CdxDoc{ + Dependencies: dependencies, + CdxSpec: spec, + } + + deps := doc.GetRelationships(comp.GetID()) + + CompIDWithName[rel1.RefB.ID] = "client-go" + CompIDWithName[rel2.RefB.ID] = "cobra" + CompIDWithName[rel3.RefB.ID] = "cyclonedx-go" + + ComponentList[rel1.RefB.ID] = true + ComponentList[rel2.RefB.ID] = true + ComponentList[rel3.RefB.ID] = true + + allDepByName := getDepByName(deps) + + areAllDepesPresentInCompList := lo.EveryBy(deps, func(id string) bool { + return ComponentList[id] + }) + + if areAllDepesPresentInCompList { + IsMinimimRequirementFulfilled = true + GetAllPrimaryDepenciesByName = allDepByName + } + return doc, comp +} + +func cdxCompWithThreeDependency() (sbom.Document, sbom.GetComponent) { + rel1 := Relationship{ + RefA: ElementRefID{ID: "pkg:github/google/go-github@0a6474043f9f14c77ba6fa77d1b377a7538a4c8c"}, + RefB: ElementRefID{ID: "pkg:github/ProtonMail/go-crypto@afb1ddc0824ce0052d72ac0d6917f144a1207424"}, + } + + rel2 := Relationship{ + RefA: ElementRefID{ID: "pkg:github/google/go-github@0a6474043f9f14c77ba6fa77d1b377a7538a4c8c"}, + RefB: ElementRefID{ID: "pkg:golang/github.com/google/go-querystring@v1.1.0"}, + } + + rel3 := Relationship{ + RefA: ElementRefID{ID: "pkg:github/google/go-github@0a6474043f9f14c77ba6fa77d1b377a7538a4c8c"}, + RefB: ElementRefID{ID: "git+go.googlesource.com/oauth2$5fd42413edb3b1699004a31b72e485e0e4ba1b13"}, + } + comp := sbom.Component{} + + pc := sbom.PrimaryComp{} + pc.Present = false + comp.PrimaryCompt = pc + comp.ID = "pkg:github/google/go-github@0a6474043f9f14c77ba6fa77d1b377a7538a4c8c" + comp.Name = "go-github" + + dependencies := make(map[string][]string) + dependencies[rel1.RefA.ID] = append(dependencies[rel1.RefA.ID], rel1.RefB.ID) + dependencies[rel2.RefA.ID] = append(dependencies[rel2.RefA.ID], rel2.RefB.ID) + dependencies[rel3.RefA.ID] = append(dependencies[rel3.RefA.ID], rel3.RefB.ID) + + spec := sbom.NewSpec() + spec.SpecType = "cyclonedx" + doc := sbom.CdxDoc{ + Dependencies: dependencies, + CdxSpec: spec, + } + + CompIDWithName[rel1.RefB.ID] = "go-crypto" + CompIDWithName[rel2.RefB.ID] = "github.com/google/go-querystring" + CompIDWithName[rel3.RefB.ID] = "oauth2" + + PrimaryDependencies[rel1.RefB.ID] = false + PrimaryDependencies[rel2.RefB.ID] = false + PrimaryDependencies[rel3.RefB.ID] = false + + IsMinimimRequirementFulfilled = true + return doc, comp +} + +func cdxCompWithZeroDependency() (sbom.Document, sbom.GetComponent) { + rel1 := Relationship{ + RefA: ElementRefID{ID: "pkg:github/Masterminds/semver@e06051f8fcc4c8b4a4990c337b9862a2448722e5"}, + } + comp := sbom.Component{} + + pc := sbom.PrimaryComp{} + pc.Present = false + comp.PrimaryCompt = pc + comp.ID = "pkg:github/Masterminds/semver@e06051f8fcc4c8b4a4990c337b9862a2448722e5" + comp.Name = "semver" + + dependencies := make(map[string][]string) + dependencies[rel1.RefA.ID] = nil + + spec := sbom.NewSpec() + spec.SpecType = "cyclonedx" + doc := sbom.CdxDoc{ + Dependencies: dependencies, + CdxSpec: spec, + } + + PrimaryDependencies[rel1.RefA.ID] = true + IsMinimimRequirementFulfilled = true + + return doc, comp +} + +func TestFsctDependencies(t *testing.T) { + _, a := spdxCompWithPrimaryDependency() + _, b := spdxCompWithTwoDependency() + _, c := cdxCompWithPrimaryDependency() + _, d := cdxCompWithThreeDependency() + _, e := cdxCompWithZeroDependency() + testCases := []struct { + name string + actual *db.Record + expected desired + }{ + { + name: "SPDX primary Comp with dependencies", + actual: fsctPackageDependencies(spdxCompWithPrimaryDependency()), + expected: desired{ + score: 10.0, + result: "go-spdx, lo, packageurl-go", + key: COMP_RELATIONSHIP, + id: a.GetName(), + maturity: "Minimum", + }, + }, + { + name: "SPDX normal Comp with 2 dependencies", + actual: fsctPackageDependencies(spdxCompWithTwoDependency()), + expected: desired{ + score: 12.0, + result: "circl, crypto", + key: COMP_RELATIONSHIP, + id: b.GetName(), + maturity: "Recommended", + }, + }, + + { + name: "CDX primary Comp with dependencies", + actual: fsctPackageDependencies(cdxCompWithPrimaryDependency()), + expected: desired{ + score: 10.0, + result: "client-go, cobra, cyclonedx-go", + key: COMP_RELATIONSHIP, + id: c.GetName(), + maturity: "Minimum", + }, + }, + { + name: "CDX normal Comp with 3 dependencies", + actual: fsctPackageDependencies(cdxCompWithThreeDependency()), + expected: desired{ + score: 12.0, + result: "go-crypto, github.com/google/go-querystring, oauth2", + key: COMP_RELATIONSHIP, + id: d.GetName(), + maturity: "Recommended", + }, + }, + { + name: "CDX normal Comp with 0 dependencies", + actual: fsctPackageDependencies(cdxCompWithZeroDependency()), + expected: desired{ + score: 12.0, + result: "included-in", + key: COMP_RELATIONSHIP, + id: e.GetName(), + maturity: "Recommended", + }, + }, + { + name: "CDX Comp with 0 dependencies and part of Primary", + actual: fsctPackageDependencies(cdxCompWithZeroDependency()), + expected: desired{ + score: 12.0, + result: "included-in", + key: COMP_RELATIONSHIP, + id: e.GetName(), + maturity: "Recommended", + }, + }, + } + for _, test := range testCases { + assert.Equal(t, test.expected.score, test.actual.Score, "Score mismatch for %s", test.name) + assert.Equal(t, test.expected.key, test.actual.CheckKey, "Key mismatch for %s", test.name) + assert.Equal(t, test.expected.id, test.actual.ID, "ID mismatch for %s", test.name) + assert.Equal(t, test.expected.result, test.actual.CheckValue, "Result mismatch for %s", test.name) + assert.Equal(t, test.expected.maturity, test.actual.Maturity, "Maturity mismatch for %s", test.name) + } +} + +type externalRef struct { + refCategory string + refType string + refLocator string +} + +func spdxCompWithPurl() sbom.GetComponent { + urls := []purl.PURL{} + comp := sbom.NewComponent() + + comp.Name = "go-crypto" + comp.Spdxid = "SPDXRef-git-github.com-ProtonMail-go-crypto-afb1ddc0824ce0052d72ac0d6917f144a1207424" + + ext := externalRef{ + refCategory: "PACKAGE-MANAGER", + refType: "purls", + refLocator: "pkg:github/ProtonMail/go-crypto@afb1ddc0824ce0052d72ac0d6917f144a1207424", + } + + prl := purl.NewPURL(ext.refLocator) + urls = append(urls, prl) + comp.Purls = urls + + return comp +} + +func spdxCompWithCpes() sbom.GetComponent { + urls := []cpe.CPE{} + comp := sbom.NewComponent() + + comp.Name = "glibc" + comp.Spdxid = "SPDXRef-git-github.com-glibc-afb1ddc0824ce0052d72ac0d6917f144a1207424" + + ext := externalRef{ + refCategory: "SECURITY", + refType: "cpe23Type", + refLocator: "cpe:2.3:a:pivotal_software:spring_framework:4.1.0:*:*:*:*:*:*:*", + } + + prl := cpe.NewCPE(ext.refLocator) + urls = append(urls, prl) + comp.Cpes = urls + return comp +} + +func cdxCompWithPurl() sbom.GetComponent { + comp := sbom.NewComponent() + comp.Name = "acme" + PackageURL := "pkg:npm/acme/component@1.0.0" + + prl := purl.NewPURL(PackageURL) + comp.Purls = []purl.PURL{prl} + + return comp +} + +// type SWHID string + +func cdxCompWithSwhid() sbom.GetComponent { + comp := sbom.NewComponent() + comp.Name = "packageurl-go" + swh := "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2" + + nswhid := swhid.NewSWHID(swh) + comp.Swhid = append(comp.Swhid, nswhid) + + return comp +} + +func cdxCompWithSwid() sbom.GetComponent { + comp := sbom.NewComponent() + comp.Name = "packageurl-go" + swidTagID := "swidgen-242eb18a-503e-ca37-393b-cf156ef09691_9.1.1" + swidName := "Acme Application" + + nswid := swid.NewSWID(swidTagID, swidName) + comp.Swid = []swid.SWID{nswid} + + return comp +} + +func cdxCompWithOmniBorID() sbom.GetComponent { + comp := sbom.NewComponent() + comp.Name = "packageurl-go" + omniBorID := "gitoid:blob:sha1:a94a8fe5ccb19ba61c4c0873d391e987982fbbd3" + + omni := omniborid.NewOmni(omniBorID) + comp.OmniID = append(comp.OmniID, omni) + + return comp +} + +func cdxCompWithPurlOmniSwhidAndSwid() sbom.GetComponent { + comp := sbom.NewComponent() + comp.Name = "acme" + + PackageURL := "pkg:npm/acme/component@1.0.0" + swh := "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2" + swidTagID := "swidgen-242eb18a-503e-ca37-393b-cf156ef09691_9.1.1" + swidName := "Acme Application" + omniBorID := "gitoid:blob:sha1:a94a8fe5ccb19ba61c4c0873d391e987982fbbd3" + + prl := purl.NewPURL(PackageURL) + comp.Purls = []purl.PURL{prl} + + nswhid := swhid.NewSWHID(swh) + comp.Swhid = append(comp.Swhid, nswhid) + + nswid := swid.NewSWID(swidTagID, swidName) + comp.Swid = []swid.SWID{nswid} + + omni := omniborid.NewOmni(omniBorID) + comp.OmniID = append(comp.OmniID, omni) + + return comp +} + +func TestFsctUniqIDs(t *testing.T) { + testCases := []struct { + name string + actual *db.Record + expected desired + }{ + { + name: "spdxWithPurl", + actual: fsctPackageUniqIDs(spdxCompWithPurl()), + expected: desired{ + score: 10.0, + result: "pkg:github/ProtonMail/go-crypto@afb1ddc0824ce0052d72ac0d6917f144a1207424", + key: COMP_UNIQ_ID, + id: spdxCompWithPurl().GetName(), + maturity: "Minimum", + }, + }, + { + name: "spdxWithCpe", + actual: fsctPackageUniqIDs(spdxCompWithCpes()), + expected: desired{ + score: 10.0, + result: "cpe:2.3:a:pivotal_software:spring_framework:4.1.0:*:*:*:*:*:*:*", + key: COMP_UNIQ_ID, + id: spdxCompWithCpes().GetName(), + maturity: "Minimum", + }, + }, + { + name: "cdxWithPurl", + actual: fsctPackageUniqIDs(cdxCompWithPurl()), + expected: desired{ + score: 10.0, + result: "pkg:npm/acme/component@1.0.0", + key: COMP_UNIQ_ID, + id: cdxCompWithPurl().GetName(), + maturity: "Minimum", + }, + }, + { + name: "cdxCompWithSwhid", + actual: fsctPackageUniqIDs(cdxCompWithSwhid()), + expected: desired{ + score: 10.0, + result: "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2", + key: COMP_UNIQ_ID, + id: cdxCompWithSwhid().GetName(), + maturity: "Minimum", + }, + }, + { + name: "cdxCompWithSwid", + actual: fsctPackageUniqIDs(cdxCompWithSwid()), + expected: desired{ + score: 10.0, + result: "swidgen-242eb18a-503e-ca37-393b-cf156ef09691_9.1.1, Acme Application", + key: COMP_UNIQ_ID, + id: cdxCompWithSwid().GetName(), + maturity: "Minimum", + }, + }, + { + name: "cdxCompWithOmniborID", + actual: fsctPackageUniqIDs(cdxCompWithOmniBorID()), + expected: desired{ + score: 10.0, + result: "gitoid:blob:sha1:a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", + key: COMP_UNIQ_ID, + id: cdxCompWithOmniBorID().GetName(), + maturity: "Minimum", + }, + }, + { + name: "cdxCompWithPurlOmniSwhidAndSwid", + actual: fsctPackageUniqIDs(cdxCompWithPurlOmniSwhidAndSwid()), + expected: desired{ + score: 10.0, + result: "pkg:npm/acme/component@1.0.0, gitoid:blob:sha1:a94a8fe5ccb19ba61c4c0873d391e987982fbbd3, swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2, swidgen-242eb18a-503e-ca37-393b-cf156ef09691_9.1.1, Acme Application", + key: COMP_UNIQ_ID, + id: cdxCompWithPurlOmniSwhidAndSwid().GetName(), + maturity: "Minimum", + }, + }, + } + + for _, test := range testCases { + assert.Equal(t, test.expected.score, test.actual.Score, "Score mismatch for %s", test.name) + assert.Equal(t, test.expected.key, test.actual.CheckKey, "Key mismatch for %s", test.name) + assert.Equal(t, test.expected.id, test.actual.ID, "ID mismatch for %s", test.name) + assert.Equal(t, test.expected.result, test.actual.CheckValue, "Result mismatch for %s", test.name) + assert.Equal(t, test.expected.maturity, test.actual.Maturity, "Maturity mismatch for %s", test.name) + } +} diff --git a/pkg/compliance/ntia.go b/pkg/compliance/ntia.go index 2d017568..642ae5f4 100644 --- a/pkg/compliance/ntia.go +++ b/pkg/compliance/ntia.go @@ -20,6 +20,8 @@ import ( "strings" "time" + "github.com/interlynk-io/sbomqs/pkg/compliance/common" + db "github.com/interlynk-io/sbomqs/pkg/compliance/db" "github.com/interlynk-io/sbomqs/pkg/logger" "github.com/interlynk-io/sbomqs/pkg/sbom" "github.com/samber/lo" @@ -40,13 +42,13 @@ func ntiaResult(ctx context.Context, doc sbom.Document, fileName string, outForm log := logger.FromContext(ctx) log.Debug("compliance.ntiaResult()") - db := newDB() + db := db.NewDB() - db.addRecord(ntiaAutomationSpec(doc)) - db.addRecord(ntiaSbomCreator(doc)) - db.addRecord(ntiaSbomCreatedTimestamp(doc)) - db.addRecord(ntiaSBOMDependency(doc)) - db.addRecords(ntiaComponents(doc)) + db.AddRecord(ntiaAutomationSpec(doc)) + db.AddRecord(ntiaSbomCreator(doc)) + db.AddRecord(ntiaSbomCreatedTimestamp(doc)) + db.AddRecord(ntiaSBOMDependency(doc)) + db.AddRecords(ntiaComponents(doc)) if outFormat == "json" { ntiaJSONReport(db, fileName) @@ -62,7 +64,7 @@ func ntiaResult(ctx context.Context, doc sbom.Document, fileName string, outForm } // format -func ntiaAutomationSpec(doc sbom.Document) *record { +func ntiaAutomationSpec(doc sbom.Document) *db.Record { result, score := "", SCORE_ZERO spec := doc.Spec().GetSpecType() fileFormat := doc.Spec().FileFormat() @@ -73,10 +75,10 @@ func ntiaAutomationSpec(doc sbom.Document) *record { result = spec + ", " + fileFormat score = SCORE_FULL } - return newRecordStmt(SBOM_MACHINE_FORMAT, "Automation Support", result, score) + return db.NewRecordStmt(SBOM_MACHINE_FORMAT, "Automation Support", result, score, "") } -func ntiaSBOMDependency(doc sbom.Document) *record { +func ntiaSBOMDependency(doc sbom.Document) *db.Record { result, score := "", SCORE_ZERO totalRootDependencies := doc.PrimaryComp().GetTotalNoOfDependencies() @@ -85,10 +87,10 @@ func ntiaSBOMDependency(doc sbom.Document) *record { } result = fmt.Sprintf("doc has %d dependencies", totalRootDependencies) - return newRecordStmt(SBOM_DEPENDENCY, "SBOM Data Fields", result, score) + return db.NewRecordStmt(SBOM_DEPENDENCY, "SBOM Data Fields", result, score, "") } -func ntiaSbomCreator(doc sbom.Document) *record { +func ntiaSbomCreator(doc sbom.Document) *db.Record { spec := doc.Spec().GetSpecType() result, score := "", SCORE_ZERO @@ -117,7 +119,7 @@ func ntiaSbomCreator(doc sbom.Document) *record { } } if result != "" { - return newRecordStmt(SBOM_CREATOR, "SBOM Data Fields", result, score) + return db.NewRecordStmt(SBOM_CREATOR, "SBOM Data Fields", result, score, "") } if tools := doc.Tools(); tools != nil { if toolResult, found := getToolInfo(tools); found { @@ -142,7 +144,7 @@ func ntiaSbomCreator(doc sbom.Document) *record { } } - return newRecordStmt(SBOM_CREATOR, "SBOM Data Fields", result, score) + return db.NewRecordStmt(SBOM_CREATOR, "SBOM Data Fields", result, score, "") } func getManufacturerInfo(manufacturer sbom.Manufacturer) (string, bool) { @@ -156,7 +158,7 @@ func getManufacturerInfo(manufacturer sbom.Manufacturer) (string, bool) { return url, true } for _, contact := range manufacturer.GetContacts() { - if email := contact.Email(); email != "" { + if email := contact.GetEmail(); email != "" { return email, true } } @@ -174,7 +176,7 @@ func getSupplierInfo(supplier sbom.GetSupplier) (string, bool) { return url, true } for _, contact := range supplier.GetContacts() { - if email := contact.Email(); email != "" { + if email := contact.GetEmail(); email != "" { return email, true } } @@ -202,7 +204,7 @@ func getToolInfo(tools []sbom.GetTool) (string, bool) { return "", false } -func ntiaSbomCreatedTimestamp(doc sbom.Document) *record { +func ntiaSbomCreatedTimestamp(doc sbom.Document) *db.Record { score := SCORE_ZERO result := doc.Spec().GetCreationTimestamp() @@ -214,32 +216,33 @@ func ntiaSbomCreatedTimestamp(doc sbom.Document) *record { score = SCORE_FULL } } - return newRecordStmt(SBOM_TIMESTAMP, "SBOM Data Fields", result, score) + return db.NewRecordStmt(SBOM_TIMESTAMP, "SBOM Data Fields", result, score, "") } -var CompIDWithName = make(map[string]string) - -func extractName(comp string) string { - for x, y := range CompIDWithName { - if strings.Contains(comp, x) { - return y - } - } - return "" -} +var ( + compIDWithName = make(map[string]string) + componentList = make(map[string]bool) + primaryDependencies = make(map[string]bool) + GetAllPrimaryDepenciesByName = []string{} +) // Required component stuffs -func ntiaComponents(doc sbom.Document) []*record { - records := []*record{} +func ntiaComponents(doc sbom.Document) []*db.Record { + records := []*db.Record{} if len(doc.Components()) == 0 { - records = append(records, newRecordStmt(SBOM_COMPONENTS, "SBOM Data Fields", "absent", SCORE_ZERO)) + records = append(records, db.NewRecordStmt(SBOM_COMPONENTS, "SBOM Data Fields", "absent", SCORE_ZERO, "")) return records } - // map package ID to Package Name - for _, component := range doc.Components() { - CompIDWithName[component.GetID()] = component.GetName() + compIDWithName = common.ComponentsNamesMapToIDs(doc) + componentList = common.ComponentsLists(doc) + primaryDependencies = common.MapPrimaryDependencies(doc) + dependencies := common.GetAllPrimaryComponentDependencies(doc) + areAllDepesPresentInCompList := common.CheckPrimaryDependenciesInComponentList(dependencies, componentList) + + if areAllDepesPresentInCompList { + GetAllPrimaryDepenciesByName = common.GetDependenciesByName(dependencies, compIDWithName) } for _, component := range doc.Components() { @@ -252,14 +255,14 @@ func ntiaComponents(doc sbom.Document) []*record { return records } -func ntiaComponentName(component sbom.GetComponent) *record { +func ntiaComponentName(component sbom.GetComponent) *db.Record { if result := component.GetName(); result != "" { - return newRecordStmt(COMP_NAME, component.GetName(), result, SCORE_FULL) + return db.NewRecordStmt(COMP_NAME, component.GetName(), result, SCORE_FULL, "") } - return newRecordStmt(COMP_NAME, component.GetName(), "", SCORE_ZERO) + return db.NewRecordStmt(COMP_NAME, component.GetName(), "", SCORE_ZERO, "") } -func ntiaComponentCreator(doc sbom.Document, component sbom.GetComponent) *record { +func ntiaComponentCreator(doc sbom.Document, component sbom.GetComponent) *db.Record { spec := doc.Spec().GetSpecType() result, score := "", SCORE_ZERO @@ -289,43 +292,79 @@ func ntiaComponentCreator(doc sbom.Document, component sbom.GetComponent) *recor } } } - return newRecordStmt(COMP_CREATOR, component.GetName(), result, score) + return db.NewRecordStmt(COMP_CREATOR, component.GetName(), result, score, "") } -func ntiaComponentVersion(component sbom.GetComponent) *record { +func ntiaComponentVersion(component sbom.GetComponent) *db.Record { result := component.GetVersion() if result != "" { - return newRecordStmt(COMP_VERSION, component.GetName(), result, SCORE_FULL) + return db.NewRecordStmt(COMP_VERSION, component.GetName(), result, SCORE_FULL, "") } - return newRecordStmt(COMP_VERSION, component.GetName(), "", SCORE_ZERO) + return db.NewRecordStmt(COMP_VERSION, component.GetName(), "", SCORE_ZERO, "") } -func ntiaComponentDependencies(doc sbom.Document, component sbom.GetComponent) *record { +func ntiaComponentDependencies(doc sbom.Document, component sbom.GetComponent) *db.Record { result, score := "", SCORE_ZERO - var results []string + var dependencies []string + var allDepByName []string + + if doc.Spec().GetSpecType() == "spdx" { + if component.GetPrimaryCompInfo().IsPresent() { + result = strings.Join(GetAllPrimaryDepenciesByName, ", ") + score = 10.0 + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), result, score, "") + } - dependencies := doc.GetRelationships(component.GetID()) - if dependencies == nil { - return newRecordStmt(COMP_DEPTH, component.GetName(), "no-relationships", SCORE_ZERO) - } - for _, d := range dependencies { - componentName := extractName(d) - results = append(results, componentName) - score = SCORE_FULL - } + dependencies = doc.GetRelationships(common.GetID(component.GetSpdxID())) + if dependencies == nil { - if results != nil { - result = strings.Join(results, ", ") - } else { - result += "no-relationships" - } + if primaryDependencies[common.GetID(component.GetSpdxID())] { + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), "included-in", 10.0, "") + } + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), "no-relationship", 0.0, "") + + } + allDepByName = common.GetDependenciesByName(dependencies, compIDWithName) + + if primaryDependencies[common.GetID(component.GetSpdxID())] { + allDepByName = append([]string{"included-in"}, allDepByName...) + result = strings.Join(allDepByName, ", ") + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), result, 10.0, "") + } + + result = strings.Join(allDepByName, ", ") + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), result, 10.0, "") - return newRecordStmt(COMP_DEPTH, component.GetName(), result, score) + } else if doc.Spec().GetSpecType() == "cyclonedx" { + if component.GetPrimaryCompInfo().IsPresent() { + result = strings.Join(GetAllPrimaryDepenciesByName, ", ") + score = 10.0 + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), result, score, "") + } + id := component.GetID() + dependencies = doc.GetRelationships(id) + if len(dependencies) == 0 { + if primaryDependencies[id] { + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), "included-in", 10.0, "") + } + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), "no-relationship", 0.0, "") + } + allDepByName = common.GetDependenciesByName(dependencies, compIDWithName) + if primaryDependencies[id] { + allDepByName = append([]string{"included-in"}, allDepByName...) + result = strings.Join(allDepByName, ", ") + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), result, 10.0, "") + } + result = strings.Join(allDepByName, ", ") + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), result, 10.0, "") + + } + return db.NewRecordStmt(COMP_DEPTH, component.GetName(), result, score, "") } -func ntiaComponentOtherUniqIDs(doc sbom.Document, component sbom.GetComponent) *record { +func ntiaComponentOtherUniqIDs(doc sbom.Document, component sbom.GetComponent) *db.Record { spec := doc.Spec().GetSpecType() if spec == "spdx" { @@ -345,7 +384,7 @@ func ntiaComponentOtherUniqIDs(doc sbom.Document, component sbom.GetComponent) * x := fmt.Sprintf(":(%d/%d)", containPurlElement, totalElements) result = result + x } - return newRecordStmt(COMP_OTHER_UNIQ_IDS, component.GetName(), result, score) + return db.NewRecordStmt(COMP_OTHER_UNIQ_IDS, component.GetName(), result, score, "") } else if spec == "cyclonedx" { result := "" @@ -354,7 +393,7 @@ func ntiaComponentOtherUniqIDs(doc sbom.Document, component sbom.GetComponent) * if len(purl) > 0 { result = string(purl[0]) - return newRecordStmtOptional(COMP_OTHER_UNIQ_IDS, component.GetName(), result, SCORE_FULL) + return db.NewRecordStmtOptional(COMP_OTHER_UNIQ_IDS, component.GetName(), result, SCORE_FULL) } cpes := component.GetCpes() @@ -362,10 +401,10 @@ func ntiaComponentOtherUniqIDs(doc sbom.Document, component sbom.GetComponent) * if len(cpes) > 0 { result = string(cpes[0]) - return newRecordStmtOptional(COMP_OTHER_UNIQ_IDS, component.GetName(), result, SCORE_FULL) + return db.NewRecordStmtOptional(COMP_OTHER_UNIQ_IDS, component.GetName(), result, SCORE_FULL) } - return newRecordStmtOptional(COMP_OTHER_UNIQ_IDS, component.GetName(), "", SCORE_ZERO) + return db.NewRecordStmtOptional(COMP_OTHER_UNIQ_IDS, component.GetName(), "", SCORE_ZERO) } - return newRecordStmt(COMP_OTHER_UNIQ_IDS, component.GetName(), "", SCORE_ZERO) + return db.NewRecordStmt(COMP_OTHER_UNIQ_IDS, component.GetName(), "", SCORE_ZERO, "") } diff --git a/pkg/compliance/ntia_report.go b/pkg/compliance/ntia_report.go index 04321bcc..0dc65542 100644 --- a/pkg/compliance/ntia_report.go +++ b/pkg/compliance/ntia_report.go @@ -8,6 +8,7 @@ import ( "time" "github.com/google/uuid" + "github.com/interlynk-io/sbomqs/pkg/compliance/db" "github.com/olekukonko/tablewriter" "sigs.k8s.io/release-utils/version" ) @@ -64,7 +65,7 @@ func newNtiaJSONReport() *ntiaComplianceReport { } } -func ntiaJSONReport(db *db, fileName string) { +func ntiaJSONReport(db *db.DB, fileName string) { jr := newNtiaJSONReport() jr.Run.FileName = fileName @@ -82,37 +83,60 @@ func ntiaJSONReport(db *db, fileName string) { fmt.Println(string(o)) } -func ntiaConstructSections(db *db) []ntiaSection { +func ntiaConstructSections(db *db.DB) []ntiaSection { var sections []ntiaSection - allIDs := db.getAllIDs() + allIDs := db.GetAllIDs() for _, id := range allIDs { - records := db.getRecordsByID(id) + records := db.GetRecordsByID(id) for _, r := range records { - section := ntiaSectionDetails[r.checkKey] + section := ntiaSectionDetails[r.CheckKey] newSection := ntiaSection{ Title: section.Title, ID: section.ID, DataField: section.DataField, Required: section.Required, } - score := ntiaKeyIDScore(db, r.checkKey, r.id) + score := ntiaKeyIDScore(db, r.CheckKey, r.ID) newSection.Score = score.totalScore() - if r.id == "doc" { + if r.ID == "doc" { newSection.ElementID = "sbom" } else { - newSection.ElementID = r.id + newSection.ElementID = r.ID } - newSection.ElementResult = r.checkValue + newSection.ElementResult = r.CheckValue sections = append(sections, newSection) } } - return sections + // Group sections by ElementID + sectionsByElementID := make(map[string][]ntiaSection) + for _, section := range sections { + sectionsByElementID[section.ElementID] = append(sectionsByElementID[section.ElementID], section) + } + + // Sort each group of sections by section.ID and ensure "SBOM Data Fields" comes first within its group if it exists + var sortedSections []ntiaSection + var sbomLevelSections []ntiaSection + for elementID, group := range sectionsByElementID { + sort.Slice(group, func(i, j int) bool { + return group[i].ID < group[j].ID + }) + if elementID == "SBOM Level" { + sbomLevelSections = group + } else { + sortedSections = append(sortedSections, group...) + } + } + + // Place "SBOM Level" sections at the top + sortedSections = append(sbomLevelSections, sortedSections...) + + return sortedSections } -func ntiaDetailedReport(db *db, fileName string) { +func ntiaDetailedReport(db *db.DB, fileName string) { table := tablewriter.NewWriter(os.Stdout) score := ntiaAggregateScore(db) @@ -143,7 +167,7 @@ func ntiaDetailedReport(db *db, fileName string) { table.Render() } -func ntiaBasicReport(db *db, fileName string) { +func ntiaBasicReport(db *db.DB, fileName string) { score := ntiaAggregateScore(db) fmt.Printf("NTIA Report\n") fmt.Printf("Score:%0.1f RequiredScore:%0.1f OptionalScore:%0.1f for %s\n", score.totalScore(), score.totalRequiredScore(), score.totalOptionalScore(), fileName) diff --git a/pkg/compliance/ntia_score.go b/pkg/compliance/ntia_score.go index bfde2eee..53b26869 100644 --- a/pkg/compliance/ntia_score.go +++ b/pkg/compliance/ntia_score.go @@ -1,5 +1,7 @@ package compliance +import "github.com/interlynk-io/sbomqs/pkg/compliance/db" + type ntiaScoreResult struct { id string requiredScore float64 @@ -44,8 +46,8 @@ func (r *ntiaScoreResult) totalOptionalScore() float64 { return r.optionalScore / float64(r.optionalRecords) } -func ntiaKeyIDScore(db *db, key int, id string) *ntiaScoreResult { - records := db.getRecordsByKeyID(key, id) +func ntiaKeyIDScore(db *db.DB, key int, id string) *ntiaScoreResult { + records := db.GetRecordsByKeyID(key, id) if len(records) == 0 { return newNtiaScoreResult(id) @@ -58,11 +60,11 @@ func ntiaKeyIDScore(db *db, key int, id string) *ntiaScoreResult { optionalRecs := 0 for _, r := range records { - if r.required { - requiredScore += r.score + if r.Required { + requiredScore += r.Score requiredRecs++ } else { - optionalScore += r.score + optionalScore += r.Score optionalRecs++ } } @@ -76,11 +78,11 @@ func ntiaKeyIDScore(db *db, key int, id string) *ntiaScoreResult { } } -func ntiaAggregateScore(db *db) *ntiaScoreResult { +func ntiaAggregateScore(db *db.DB) *ntiaScoreResult { var results []ntiaScoreResult var finalResult ntiaScoreResult - ids := db.getAllIDs() + ids := db.GetAllIDs() for _, id := range ids { results = append(results, *ntiaIDScore(db, id)) } @@ -95,8 +97,8 @@ func ntiaAggregateScore(db *db) *ntiaScoreResult { return &finalResult } -func ntiaIDScore(db *db, id string) *ntiaScoreResult { - records := db.getRecordsByID(id) +func ntiaIDScore(db *db.DB, id string) *ntiaScoreResult { + records := db.GetRecordsByID(id) if len(records) == 0 { return newNtiaScoreResult(id) @@ -109,11 +111,11 @@ func ntiaIDScore(db *db, id string) *ntiaScoreResult { optionalRecs := 0 for _, r := range records { - if r.required { - requiredScore += r.score + if r.Required { + requiredScore += r.Score requiredRecs++ } else { - optionalScore += r.score + optionalScore += r.Score optionalRecs++ } } diff --git a/pkg/compliance/ntia_test.go b/pkg/compliance/ntia_test.go index e2068a12..5742f87a 100644 --- a/pkg/compliance/ntia_test.go +++ b/pkg/compliance/ntia_test.go @@ -3,6 +3,7 @@ package compliance import ( "testing" + db "github.com/interlynk-io/sbomqs/pkg/compliance/db" "github.com/interlynk-io/sbomqs/pkg/purl" "github.com/interlynk-io/sbomqs/pkg/sbom" "gotest.tools/assert" @@ -36,7 +37,10 @@ func createSpdxDummyDocumentNtia() sbom.Document { } var primary sbom.PrimaryComp + primary.ID = pack.ID primary.Dependecies = 1 + primary.Present = true + pack.PrimaryCompt = primary var externalReferences []sbom.GetExternalReference externalReferences = append(externalReferences, extRef) @@ -46,9 +50,10 @@ func createSpdxDummyDocumentNtia() sbom.Document { packages = append(packages, pack) relationships := make(map[string][]string) - relationships["github/spdx/tools-golang@9db247b854b9634d0109153d515fd1a9efd5a1b1"] = append(relationships["github/spdx/tools-golang@9db247b854b9634d0109153d515fd1a9efd5a1b1"], "github/spdx/gordf@b735bd5aac89fe25cad4ef488a95bc00ea549edd") + relationships[sbom.CleanKey("github/spdx/tools-golang@9db247b854b9634d0109153d515fd1a9efd5a1b1")] = append(relationships[sbom.CleanKey("github/spdx/tools-golang@9db247b854b9634d0109153d515fd1a9efd5a1b1")], sbom.CleanKey("github/spdx/gordf@b735bd5aac89fe25cad4ef488a95bc00ea549edd")) + GetAllPrimaryDepenciesByName = []string{"gordf"} - CompIDWithName["github/spdx/gordf@b735bd5aac89fe25cad4ef488a95bc00ea549edd"] = "gordf" + compIDWithName["github/spdx/gordf@b735bd5aac89fe25cad4ef488a95bc00ea549edd"] = "gordf" doc := sbom.SpdxDoc{ SpdxSpec: s, Comps: packages, @@ -70,7 +75,7 @@ func TestNtiaSpdxSbomPass(t *testing.T) { doc := createSpdxDummyDocumentNtia() testCases := []struct { name string - actual *record + actual *db.Record expected desiredNtia }{ { @@ -168,10 +173,10 @@ func TestNtiaSpdxSbomPass(t *testing.T) { } for _, test := range testCases { - assert.Equal(t, test.expected.score, test.actual.score, "Score mismatch for %s", test.name) - assert.Equal(t, test.expected.key, test.actual.checkKey, "Key mismatch for %s", test.name) - assert.Equal(t, test.expected.id, test.actual.id, "ID mismatch for %s", test.name) - assert.Equal(t, test.expected.result, test.actual.checkValue, "Result mismatch for %s", test.name) + assert.Equal(t, test.expected.score, test.actual.Score, "Score mismatch for %s", test.name) + assert.Equal(t, test.expected.key, test.actual.CheckKey, "Key mismatch for %s", test.name) + assert.Equal(t, test.expected.id, test.actual.ID, "ID mismatch for %s", test.name) + assert.Equal(t, test.expected.result, test.actual.CheckValue, "Result mismatch for %s", test.name) } } @@ -219,7 +224,7 @@ func createCdxDummyDocumentNtia() sbom.Document { var primary sbom.PrimaryComp primary.Dependecies = 1 - CompIDWithName["github/spdx/gordf@b735bd5aac89fe25cad4ef488a95bc00ea549edd"] = "gordf" + compIDWithName["github/spdx/gordf@b735bd5aac89fe25cad4ef488a95bc00ea549edd"] = "gordf" doc := sbom.CdxDoc{ CdxSpec: cdxSpec, @@ -235,7 +240,7 @@ func TestNtiaCdxSbomPass(t *testing.T) { doc := createCdxDummyDocumentNtia() testCases := []struct { name string - actual *record + actual *db.Record expected desiredNtia }{ { @@ -330,10 +335,10 @@ func TestNtiaCdxSbomPass(t *testing.T) { }, } for _, test := range testCases { - assert.Equal(t, test.expected.score, test.actual.score, "Score mismatch for %s", test.name) - assert.Equal(t, test.expected.key, test.actual.checkKey, "Key mismatch for %s", test.name) - assert.Equal(t, test.expected.id, test.actual.id, "ID mismatch for %s", test.name) - assert.Equal(t, test.expected.result, test.actual.checkValue, "Result mismatch for %s", test.name) + assert.Equal(t, test.expected.score, test.actual.Score, "Score mismatch for %s", test.name) + assert.Equal(t, test.expected.key, test.actual.CheckKey, "Key mismatch for %s", test.name) + assert.Equal(t, test.expected.id, test.actual.ID, "ID mismatch for %s", test.name) + assert.Equal(t, test.expected.result, test.actual.CheckValue, "Result mismatch for %s", test.name) } } @@ -390,7 +395,7 @@ func TestNTIASbomFail(t *testing.T) { doc := createSpdxDummyDocumentFailNtia() testCases := []struct { name string - actual *record + actual *db.Record expected desiredNtia }{ { @@ -430,7 +435,7 @@ func TestNTIASbomFail(t *testing.T) { score: 0.0, result: "", key: COMP_CREATOR, - id: doc.Components()[0].GetID(), + id: doc.Components()[0].GetName(), }, }, @@ -441,7 +446,7 @@ func TestNTIASbomFail(t *testing.T) { score: 0.0, result: "", key: COMP_NAME, - id: doc.Components()[0].GetID(), + id: doc.Components()[0].GetName(), }, }, { @@ -451,7 +456,7 @@ func TestNTIASbomFail(t *testing.T) { score: 0.0, result: "", key: COMP_VERSION, - id: doc.Components()[0].GetID(), + id: doc.Components()[0].GetName(), }, }, { @@ -461,15 +466,15 @@ func TestNTIASbomFail(t *testing.T) { score: 0.0, result: "", key: COMP_OTHER_UNIQ_IDS, - id: doc.Components()[0].GetID(), + id: doc.Components()[0].GetName(), }, }, } for _, test := range testCases { - assert.Equal(t, test.expected.score, test.actual.score, "Score mismatch for %s", test.name) - assert.Equal(t, test.expected.key, test.actual.checkKey, "Key mismatch for %s", test.name) - assert.Equal(t, test.expected.id, test.actual.id, "ID mismatch for %s", test.name) - assert.Equal(t, test.expected.result, test.actual.checkValue, "Result mismatch for %s", test.name) + assert.Equal(t, test.expected.score, test.actual.Score, "Score mismatch for %s", test.name) + assert.Equal(t, test.expected.key, test.actual.CheckKey, "Key mismatch for %s", test.name) + assert.Equal(t, test.expected.id, test.actual.ID, "ID mismatch for %s", test.name) + assert.Equal(t, test.expected.result, test.actual.CheckValue, "Result mismatch for %s", test.name) } } diff --git a/pkg/compliance/oct.go b/pkg/compliance/oct.go index a9da2185..9ba206c6 100644 --- a/pkg/compliance/oct.go +++ b/pkg/compliance/oct.go @@ -20,6 +20,7 @@ import ( "strings" "time" + db "github.com/interlynk-io/sbomqs/pkg/compliance/db" "github.com/interlynk-io/sbomqs/pkg/logger" "github.com/interlynk-io/sbomqs/pkg/sbom" "github.com/samber/lo" @@ -28,40 +29,40 @@ import ( func octResult(ctx context.Context, doc sbom.Document, fileName string, outFormat string) { log := logger.FromContext(ctx) log.Debug("compliance.octResult()") - db := newDB() - - db.addRecord(octSpec(doc)) - db.addRecord(octSpecVersion(doc)) - db.addRecord(octSpecSpdxID(doc)) - db.addRecord(octSbomComment(doc)) - db.addRecord(octSbomNamespace(doc)) - db.addRecord(octSbomLicense(doc)) - db.addRecord(octSbomName(doc)) - db.addRecord(octCreatedTimestamp(doc)) - db.addRecords(octComponents(doc)) - db.addRecord(octMachineFormat(doc)) - db.addRecord(octHumanFormat(doc)) - db.addRecord(octSbomTool(doc)) - db.addRecord(octSbomOrganization(doc)) - db.addRecord(octSbomDeliveryTime(doc)) - db.addRecord(octSbomDeliveryMethod(doc)) - db.addRecord(octSbomScope(doc)) + dtb := db.NewDB() + + dtb.AddRecord(octSpec(doc)) + dtb.AddRecord(octSpecVersion(doc)) + dtb.AddRecord(octSpecSpdxID(doc)) + dtb.AddRecord(octSbomComment(doc)) + dtb.AddRecord(octSbomNamespace(doc)) + dtb.AddRecord(octSbomLicense(doc)) + dtb.AddRecord(octSbomName(doc)) + dtb.AddRecord(octCreatedTimestamp(doc)) + dtb.AddRecords(octComponents(doc)) + dtb.AddRecord(octMachineFormat(doc)) + dtb.AddRecord(octHumanFormat(doc)) + dtb.AddRecord(octSbomTool(doc)) + dtb.AddRecord(octSbomOrganization(doc)) + dtb.AddRecord(octSbomDeliveryTime(doc)) + dtb.AddRecord(octSbomDeliveryMethod(doc)) + dtb.AddRecord(octSbomScope(doc)) if outFormat == "json" { - octJSONReport(db, fileName) + octJSONReport(dtb, fileName) } if outFormat == "basic" { - octBasicReport(db, fileName) + octBasicReport(dtb, fileName) } if outFormat == "detailed" { - octDetailedReport(db, fileName) + octDetailedReport(dtb, fileName) } } // check document data format -func octSpec(doc sbom.Document) *record { +func octSpec(doc sbom.Document) *db.Record { v := doc.Spec().GetSpecType() vToLower := strings.Trim(strings.ToLower(v), " ") result := "" @@ -75,10 +76,10 @@ func octSpec(doc sbom.Document) *record { score = 0 } - return newRecordStmt(SBOM_SPEC, "SBOM Format", result, score) + return db.NewRecordStmt(SBOM_SPEC, "SPDX Elements", result, score, "") } -func octSpecVersion(doc sbom.Document) *record { +func octSpecVersion(doc sbom.Document) *db.Record { version := doc.Spec().GetVersion() result := "" @@ -88,10 +89,10 @@ func octSpecVersion(doc sbom.Document) *record { result = version score = 10.0 } - return newRecordStmt(SBOM_SPEC_VERSION, "SPDX Elements", result, score) + return db.NewRecordStmt(SBOM_SPEC_VERSION, "SPDX Elements", result, score, "") } -func octCreatedTimestamp(doc sbom.Document) *record { +func octCreatedTimestamp(doc sbom.Document) *db.Record { score := 0.0 result := doc.Spec().GetCreationTimestamp() @@ -103,10 +104,10 @@ func octCreatedTimestamp(doc sbom.Document) *record { score = 10.0 } } - return newRecordStmt(SBOM_TIMESTAMP, "SPDX Elements", result, score) + return db.NewRecordStmt(SBOM_TIMESTAMP, "SPDX Elements", result, score, "") } -func octSpecSpdxID(doc sbom.Document) *record { +func octSpecSpdxID(doc sbom.Document) *db.Record { spdxid := doc.Spec().GetSpdxID() result := "" @@ -116,20 +117,20 @@ func octSpecSpdxID(doc sbom.Document) *record { result = spdxid score = 10.0 } - return newRecordStmt(SBOM_SPDXID, "SPDX Elements", result, score) + return db.NewRecordStmt(SBOM_SPDXID, "SPDX Elements", result, score, "") } -func octSbomOrganization(doc sbom.Document) *record { +func octSbomOrganization(doc sbom.Document) *db.Record { result, score := "", 0.0 if org := doc.Spec().GetOrganization(); org != "" { result = org score = 10.0 } - return newRecordStmt(SBOM_ORG, "SBOM Build Information", result, score) + return db.NewRecordStmt(SBOM_ORG, "SPDX Elements", result, score, "") } -func octSbomComment(doc sbom.Document) *record { +func octSbomComment(doc sbom.Document) *db.Record { result, score := "", 0.0 if comment := doc.Spec().GetComment(); comment != "" { @@ -137,21 +138,38 @@ func octSbomComment(doc sbom.Document) *record { score = 10.0 } - return newRecordStmt(SBOM_COMMENT, "SPDX Elements", result, score) + return db.NewRecordStmt(SBOM_COMMENT, "SPDX Elements", result, score, "") } -func octSbomNamespace(doc sbom.Document) *record { +func breakLongString(s string, maxLength int) []string { + if len(s) <= maxLength { + return []string{s} + } + + var result []string + for len(s) > maxLength { + result = append(result, s[:maxLength]) + s = s[maxLength:] + } + result = append(result, s) + return result +} + +func octSbomNamespace(doc sbom.Document) *db.Record { result, score := "", 0.0 if ns := doc.Spec().GetNamespace(); ns != "" { result = ns score = 10.0 } + // Break the result into multiple lines if it's too long + brokenResult := breakLongString(result, 50) + result = strings.Join(brokenResult, "\n") - return newRecordStmt(SBOM_NAMESPACE, "SPDX Elements", result, score) + return db.NewRecordStmt(SBOM_NAMESPACE, "SPDX Elements", result, score, "") } -func octSbomLicense(doc sbom.Document) *record { +func octSbomLicense(doc sbom.Document) *db.Record { var results []string result := "" score := 0.0 @@ -169,10 +187,10 @@ func octSbomLicense(doc sbom.Document) *record { score = 10.0 } - return newRecordStmt(SBOM_LICENSE, "SPDX Elements", result, score) + return db.NewRecordStmt(SBOM_LICENSE, "SPDX Elements", result, score, "") } -func octSbomName(doc sbom.Document) *record { +func octSbomName(doc sbom.Document) *db.Record { result, score := "", 0.0 if name := doc.Spec().GetName(); name != "" { @@ -180,10 +198,10 @@ func octSbomName(doc sbom.Document) *record { score = 10.0 } - return newRecordStmt(SBOM_NAME, "SPDX Elements", result, score) + return db.NewRecordStmt(SBOM_NAME, "SPDX Elements", result, score, "") } -func octSbomTool(doc sbom.Document) *record { +func octSbomTool(doc sbom.Document) *db.Record { result, score, name := "", 0.0, "" if tools := doc.Tools(); tools != nil { @@ -196,10 +214,10 @@ func octSbomTool(doc sbom.Document) *record { } } - return newRecordStmt(SBOM_TOOL, "SBOM Build Information", result, score) + return db.NewRecordStmt(SBOM_TOOL, "SPDX Elements", result, score, "") } -func octMachineFormat(doc sbom.Document) *record { +func octMachineFormat(doc sbom.Document) *db.Record { spec := doc.Spec().GetSpecType() result, score := "", 0.0 @@ -209,10 +227,10 @@ func octMachineFormat(doc sbom.Document) *record { } else { result = spec + ", " + fileFormat } - return newRecordStmt(SBOM_MACHINE_FORMAT, "Machine Readable Data Format", result, score) + return db.NewRecordStmt(SBOM_MACHINE_FORMAT, "SPDX Elements", result, score, "") } -func octHumanFormat(doc sbom.Document) *record { +func octHumanFormat(doc sbom.Document) *db.Record { result, score := "", 0.0 if fileFormat := doc.Spec().FileFormat(); fileFormat == "json" || fileFormat == "tag-value" { @@ -221,32 +239,32 @@ func octHumanFormat(doc sbom.Document) *record { } else { result = fileFormat } - return newRecordStmt(SBOM_HUMAN_FORMAT, "Human Readable Data Format", result, score) + return db.NewRecordStmt(SBOM_HUMAN_FORMAT, "SPDX Elements", result, score, "") } -func octSbomDeliveryMethod(_ sbom.Document) *record { +func octSbomDeliveryMethod(_ sbom.Document) *db.Record { result, score := "unknown", 0.0 - return newRecordStmt(SBOM_DELIVERY_METHOD, "Method of SBOM delivery", result, score) + return db.NewRecordStmt(SBOM_DELIVERY_METHOD, "SPDX Elements", result, score, "") } -func octSbomDeliveryTime(_ sbom.Document) *record { +func octSbomDeliveryTime(_ sbom.Document) *db.Record { result, score := "unknown", 0.0 - return newRecordStmt(SBOM_DELIVERY_TIME, "Timing of SBOM delivery", result, score) + return db.NewRecordStmt(SBOM_DELIVERY_TIME, "SPDX Elements", result, score, "") } -func octSbomScope(_ sbom.Document) *record { +func octSbomScope(_ sbom.Document) *db.Record { result, score := "unknown", 0.0 - return newRecordStmt(SBOM_SCOPE, "SBOM Scope", result, score) + return db.NewRecordStmt(SBOM_SCOPE, "SPDX Elements", result, score, "") } -func octComponents(doc sbom.Document) []*record { - records := []*record{} +func octComponents(doc sbom.Document) []*db.Record { + records := []*db.Record{} if len(doc.Components()) == 0 { - records := append(records, newRecordStmt(SBOM_COMPONENTS, "doc", "", 0.0)) + records := append(records, db.NewRecordStmt(SBOM_COMPONENTS, "doc", "", 0.0, "")) return records } @@ -263,54 +281,57 @@ func octComponents(doc sbom.Document) []*record { records = append(records, octPackageCopyright(component)) records = append(records, octPackageExternalRefs(component)) } - records = append(records, newRecordStmt(PACK_INFO, "SPDX Elements", "present", 10.0)) + records = append(records, db.NewRecordStmt(PACK_INFO, "SPDX Elements", "present", 10.0, "")) return records } -func octPackageName(component sbom.GetComponent) *record { +func octPackageName(component sbom.GetComponent) *db.Record { if result := component.GetName(); result != "" { - return newRecordStmt(PACK_NAME, component.GetName(), result, 10.0) + return db.NewRecordStmt(PACK_NAME, component.GetName(), result, 10.0, "") } - return newRecordStmt(PACK_NAME, component.GetName(), "", 0.0) + + return db.NewRecordStmt(PACK_NAME, component.GetName(), "", 0.0, "") } -func octPackageSpdxID(component sbom.GetComponent) *record { +func octPackageSpdxID(component sbom.GetComponent) *db.Record { if result := component.GetSpdxID(); result != "" { - return newRecordStmt(PACK_SPDXID, component.GetName(), result, 10.0) + return db.NewRecordStmt(PACK_SPDXID, component.GetName(), result, 10.0, "") } - return newRecordStmt(PACK_SPDXID, component.GetName(), "", 0.0) + return db.NewRecordStmt(PACK_SPDXID, component.GetName(), "", 0.0, "") } -func octPackageVersion(component sbom.GetComponent) *record { +func octPackageVersion(component sbom.GetComponent) *db.Record { if result := component.GetVersion(); result != "" { - return newRecordStmt(PACK_VERSION, component.GetName(), result, 10.0) + return db.NewRecordStmt(PACK_VERSION, component.GetName(), result, 10.0, "") } - return newRecordStmt(PACK_VERSION, component.GetName(), "", 0.0) + return db.NewRecordStmt(PACK_VERSION, component.GetName(), "", 0.0, "") } -func octPackageSupplier(component sbom.GetComponent) *record { +func octPackageSupplier(component sbom.GetComponent) *db.Record { if supplier := component.Suppliers().GetEmail(); supplier != "" { - return newRecordStmt(PACK_SUPPLIER, component.GetName(), supplier, 10.0) + return db.NewRecordStmt(PACK_SUPPLIER, component.GetName(), supplier, 10.0, "") } - return newRecordStmt(PACK_SUPPLIER, component.GetName(), "", 0.0) + return db.NewRecordStmt(PACK_SUPPLIER, component.GetName(), "", 0.0, "") } -func octPackageDownloadURL(component sbom.GetComponent) *record { +func octPackageDownloadURL(component sbom.GetComponent) *db.Record { if result := component.GetDownloadLocationURL(); result != "" { - return newRecordStmt(PACK_DOWNLOAD_URL, component.GetName(), result, 10.0) + brokenResult := breakLongString(result, 50) + result = strings.Join(brokenResult, "\n") + return db.NewRecordStmt(PACK_DOWNLOAD_URL, component.GetName(), result, 10.0, "") } - return newRecordStmt(PACK_DOWNLOAD_URL, component.GetName(), "", 0.0) + return db.NewRecordStmt(PACK_DOWNLOAD_URL, component.GetName(), "", 0.0, "") } -func octPackageFileAnalyzed(component sbom.GetComponent) *record { +func octPackageFileAnalyzed(component sbom.GetComponent) *db.Record { if result := component.GetFileAnalyzed(); result { - return newRecordStmt(PACK_FILE_ANALYZED, component.GetName(), "yes", 10.0) + return db.NewRecordStmt(PACK_FILE_ANALYZED, component.GetName(), "yes", 10.0, "") } - return newRecordStmt(PACK_FILE_ANALYZED, component.GetName(), "no", 0.0) + return db.NewRecordStmt(PACK_FILE_ANALYZED, component.GetName(), "no", 0.0, "") } -func octPackageHash(component sbom.GetComponent) *record { +func octPackageHash(component sbom.GetComponent) *db.Record { result, score := "", 0.0 algos := []string{"SHA256", "SHA-256", "sha256", "sha-256"} @@ -324,40 +345,46 @@ func octPackageHash(component sbom.GetComponent) *record { } } - return newRecordStmt(PACK_HASH, component.GetName(), result, score) + return db.NewRecordStmt(PACK_HASH, component.GetName(), result, score, "") } -func octPackageConLicense(component sbom.GetComponent) *record { +func octPackageConLicense(component sbom.GetComponent) *db.Record { result := "" if result = component.GetPackageLicenseConcluded(); result != "" && result != "NOASSERTION" && result != "NONE" { - return newRecordStmt(PACK_LICENSE_CON, component.GetName(), result, 10.0) + return db.NewRecordStmt(PACK_LICENSE_CON, component.GetName(), result, 10.0, "") } - return newRecordStmt(PACK_LICENSE_CON, component.GetName(), result, 0.0) + return db.NewRecordStmt(PACK_LICENSE_CON, component.GetName(), result, 0.0, "") } -func octPackageDecLicense(component sbom.GetComponent) *record { +func octPackageDecLicense(component sbom.GetComponent) *db.Record { result := "" if result = component.GetPackageLicenseDeclared(); result != "" && result != "NOASSERTION" && result != "NONE" { - return newRecordStmt(PACK_LICENSE_DEC, component.GetName(), result, 10.0) + return db.NewRecordStmt(PACK_LICENSE_DEC, component.GetName(), result, 10.0, "") } - return newRecordStmt(PACK_LICENSE_DEC, component.GetName(), result, 0.0) + return db.NewRecordStmt(PACK_LICENSE_DEC, component.GetName(), result, 0.0, "") } -func octPackageCopyright(component sbom.GetComponent) *record { +func octPackageCopyright(component sbom.GetComponent) *db.Record { result := "" if result = component.GetCopyRight(); result != "" && result != "NOASSERTION" && result != "NONE" { - return newRecordStmt(PACK_COPYRIGHT, component.GetName(), result, 10.0) + result = strings.ReplaceAll(result, "\n", " ") + result = strings.ReplaceAll(result, "\t", " ") + + brokenResult := breakLongString(result, 50) + result = strings.Join(brokenResult, "\n") + + return db.NewRecordStmt(PACK_COPYRIGHT, component.GetName(), result, 10.0, "") } - return newRecordStmt(PACK_COPYRIGHT, component.GetName(), result, 0.0) + return db.NewRecordStmt(PACK_COPYRIGHT, component.GetName(), result, 0.0, "") } -func octPackageExternalRefs(component sbom.GetComponent) *record { +func octPackageExternalRefs(component sbom.GetComponent) *db.Record { result, score, totalElements, containPurlElement := "", 0.0, 0, 0 if extRefs := component.ExternalReferences(); extRefs != nil { @@ -374,5 +401,5 @@ func octPackageExternalRefs(component sbom.GetComponent) *record { x := fmt.Sprintf(":(%d/%d)", containPurlElement, totalElements) result = result + x } - return newRecordStmt(PACK_EXT_REF, component.GetName(), result, score) + return db.NewRecordStmt(PACK_EXT_REF, component.GetName(), result, score, "") } diff --git a/pkg/compliance/oct_report.go b/pkg/compliance/oct_report.go index dac01aa8..e64c8a6d 100644 --- a/pkg/compliance/oct_report.go +++ b/pkg/compliance/oct_report.go @@ -4,42 +4,45 @@ import ( "encoding/json" "fmt" "os" + "sort" "time" "github.com/google/uuid" + "github.com/interlynk-io/sbomqs/pkg/compliance/db" "github.com/olekukonko/tablewriter" "sigs.k8s.io/release-utils/version" ) var octSectionDetails = map[int]octSection{ - SBOM_SPEC: {Title: "SBOM Format", ID: "3.1", Required: true, DataField: "SBOM data format"}, - SBOM_SPEC_VERSION: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "Spec version"}, - SBOM_SPDXID: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "Spec spdxid"}, - SBOM_ORG: {Title: "SBOM Build Information", ID: "3.5", Required: true, DataField: "SBOM creator organization"}, - SBOM_COMMENT: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "SBOM creator comment"}, - SBOM_NAMESPACE: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "SBOM namespace"}, - SBOM_LICENSE: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "SBOM license"}, - SBOM_NAME: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "SBOM name"}, - SBOM_TIMESTAMP: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "SBOM timestamp"}, - SBOM_TOOL: {Title: "SBOM Build Information", ID: "3.5", Required: true, DataField: "SBOM creator tool"}, - PACK_INFO: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "Package info"}, - PACK_NAME: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "Package name"}, - PACK_SPDXID: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "Package spdxid"}, - PACK_VERSION: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "Package version"}, - PACK_FILE_ANALYZED: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "FileAnalyze"}, - PACK_DOWNLOAD_URL: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "Package download URL"}, - PACK_HASH: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "Package checksum"}, - PACK_SUPPLIER: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "Package supplier"}, - PACK_LICENSE_CON: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "Package concluded License"}, - PACK_LICENSE_DEC: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "Package declared License"}, - PACK_COPYRIGHT: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "Package copyright"}, - PACK_EXT_REF: {Title: "SPDX Elements", ID: "3.2", Required: true, DataField: "Package external References"}, - SBOM_MACHINE_FORMAT: {Title: "Machine Readable Data Format", ID: "3.3", Required: true, DataField: "SBOM machine readable format"}, - SBOM_HUMAN_FORMAT: {Title: "Human Readable Data Format", ID: "3.4", Required: true, DataField: "SBOM human readable format"}, - SBOM_BUILD_INFO: {Title: "SBOM Build Information", ID: "3.5", Required: true, DataField: "SBOM creator field"}, - SBOM_DELIVERY_TIME: {Title: "Timing of SBOM delivery", ID: "3.6", Required: true, DataField: "SBOM delivery time"}, - SBOM_DELIVERY_METHOD: {Title: "Method of SBOM delivery", ID: "3.7", Required: true, DataField: "SBOM delivery method"}, - SBOM_SCOPE: {Title: "SBOM Scope", ID: "3.8", Required: true, DataField: "SBOM scope"}, + SBOM_SPEC: {Title: "SBOM Format", ID: "3.1.1", Required: true, DataField: "SBOM data format"}, + SBOM_SPEC_VERSION: {Title: "SPDX Elements", ID: "3.1.2", Required: true, DataField: "Spec version"}, + SBOM_SPDXID: {Title: "SPDX Elements", ID: "3.1.3", Required: true, DataField: "Spec spdxid"}, + SBOM_ORG: {Title: "SBOM Build Information", ID: "3.1.4", Required: true, DataField: "SBOM creator organization"}, + SBOM_COMMENT: {Title: "SPDX Elements", ID: "3.1.5", Required: true, DataField: "SBOM creator comment"}, + SBOM_NAMESPACE: {Title: "SPDX Elements", ID: "3.1.6", Required: true, DataField: "SBOM namespace"}, + SBOM_LICENSE: {Title: "SPDX Elements", ID: "3.1.7", Required: true, DataField: "SBOM license"}, + SBOM_NAME: {Title: "SPDX Elements", ID: "3.1.8", Required: true, DataField: "SBOM name"}, + SBOM_TIMESTAMP: {Title: "SPDX Elements", ID: "3.1.9", Required: true, DataField: "SBOM timestamp"}, + SBOM_TOOL: {Title: "SBOM Build Information", ID: "3.1.10", Required: true, DataField: "SBOM creator tool"}, + SBOM_MACHINE_FORMAT: {Title: "Machine Readable Data Format", ID: "3.1.11", Required: true, DataField: "SBOM machine readable format"}, + SBOM_HUMAN_FORMAT: {Title: "Human Readable Data Format", ID: "3.1.12", Required: true, DataField: "SBOM human readable format"}, + SBOM_BUILD_INFO: {Title: "SBOM Build Information", ID: "3.1.13", Required: true, DataField: "SBOM creator field"}, + SBOM_DELIVERY_TIME: {Title: "Timing of SBOM delivery", ID: "3.1.14", Required: true, DataField: "SBOM delivery time"}, + SBOM_DELIVERY_METHOD: {Title: "Method of SBOM delivery", ID: "3.1.15", Required: true, DataField: "SBOM delivery method"}, + SBOM_SCOPE: {Title: "SBOM Scope", ID: "3.1.16", Required: true, DataField: "SBOM scope"}, + + PACK_INFO: {Title: "SPDX Elements", ID: "3.2.1", Required: true, DataField: "Package info"}, + PACK_NAME: {Title: "SPDX Elements", ID: "3.2.2", Required: true, DataField: "Package name"}, + PACK_SPDXID: {Title: "SPDX Elements", ID: "3.2.3", Required: true, DataField: "Package spdxid"}, + PACK_VERSION: {Title: "SPDX Elements", ID: "3.2.4", Required: true, DataField: "Package version"}, + PACK_FILE_ANALYZED: {Title: "SPDX Elements", ID: "3.2.5", Required: true, DataField: "FileAnalyze"}, + PACK_DOWNLOAD_URL: {Title: "SPDX Elements", ID: "3.2.6", Required: true, DataField: "Package download URL"}, + PACK_HASH: {Title: "SPDX Elements", ID: "3.2.7", Required: true, DataField: "Package checksum"}, + PACK_SUPPLIER: {Title: "SPDX Elements", ID: "3.2.8", Required: true, DataField: "Package supplier"}, + PACK_LICENSE_CON: {Title: "SPDX Elements", ID: "3.2.9", Required: true, DataField: "Package concluded License"}, + PACK_LICENSE_DEC: {Title: "SPDX Elements", ID: "3.2.10", Required: true, DataField: "Package declared License"}, + PACK_COPYRIGHT: {Title: "SPDX Elements", ID: "3.2.11", Required: true, DataField: "Package copyright"}, + PACK_EXT_REF: {Title: "SPDX Elements", ID: "3.2.12", Required: true, DataField: "Package external References"}, } type octSection struct { @@ -81,11 +84,11 @@ func newOctJSONReport() *octComplianceReport { } } -func octJSONReport(db *db, fileName string) { +func octJSONReport(dtb *db.DB, fileName string) { jr := newOctJSONReport() jr.Run.FileName = fileName - score := octAggregateScore(db) + score := octAggregateScore(dtb) summary := Summary{} summary.MaxScore = 10.0 summary.TotalScore = score.totalScore() @@ -93,45 +96,68 @@ func octJSONReport(db *db, fileName string) { summary.TotalOptionalScore = score.totalOptionalScore() jr.Summary = summary - jr.Sections = octConstructSections(db) + jr.Sections = octConstructSections(dtb) o, _ := json.MarshalIndent(jr, "", " ") fmt.Println(string(o)) } -func octConstructSections(db *db) []octSection { +func octConstructSections(dtb *db.DB) []octSection { var sections []octSection - allIDs := db.getAllIDs() + allIDs := dtb.GetAllIDs() for _, id := range allIDs { - records := db.getRecordsByID(id) + records := dtb.GetRecordsByID(id) for _, r := range records { - section := octSectionDetails[r.checkKey] + section := octSectionDetails[r.CheckKey] newSection := octSection{ Title: section.Title, ID: section.ID, DataField: section.DataField, Required: section.Required, } - score := octKeyIDScore(db, r.checkKey, r.id) + score := octKeyIDScore(dtb, r.CheckKey, r.ID) newSection.Score = score.totalScore() - if r.id == "doc" { - newSection.ElementID = "sbom" + if r.ID == "SPDX Elements" { + newSection.ElementID = "SPDX Elements" } else { - newSection.ElementID = r.id + newSection.ElementID = r.ID } - newSection.ElementResult = r.checkValue + newSection.ElementResult = r.CheckValue sections = append(sections, newSection) } } - return sections + // Group sections by ElementID + sectionsByElementID := make(map[string][]octSection) + for _, section := range sections { + sectionsByElementID[section.ElementID] = append(sectionsByElementID[section.ElementID], section) + } + + // Sort each group of sections by section.ID and ensure "SPDX Elements" comes first within its group if it exists + var sortedSections []octSection + var sbomLevelSections []octSection + for elementID, group := range sectionsByElementID { + sort.Slice(group, func(i, j int) bool { + return group[i].ID < group[j].ID + }) + if elementID == "SPDX Elements" { + sbomLevelSections = group + } else { + sortedSections = append(sortedSections, group...) + } + } + + // Place "SBOM Level" sections at the top + sortedSections = append(sbomLevelSections, sortedSections...) + + return sortedSections } -func octDetailedReport(db *db, fileName string) { +func octDetailedReport(dtb *db.DB, fileName string) { table := tablewriter.NewWriter(os.Stdout) - score := octAggregateScore(db) + score := octAggregateScore(dtb) fmt.Printf("OpenChain Telco Report\n") fmt.Printf("Compliance score by Interlynk Score:%0.1f RequiredScore:%0.1f OptionalScore:%0.1f for %s\n", score.totalScore(), score.totalRequiredScore(), score.totalOptionalScore(), fileName) @@ -140,7 +166,7 @@ func octDetailedReport(db *db, fileName string) { table.SetRowLine(true) table.SetAutoMergeCellsByColumnIndex([]int{0}) - sections := octConstructSections(db) + sections := octConstructSections(dtb) for _, section := range sections { sectionID := section.ID if !section.Required { @@ -151,8 +177,8 @@ func octDetailedReport(db *db, fileName string) { table.Render() } -func octBasicReport(db *db, fileName string) { - score := octAggregateScore(db) +func octBasicReport(dtb *db.DB, fileName string) { + score := octAggregateScore(dtb) fmt.Printf("OpenChain Telco Report\n") fmt.Printf("Score:%0.1f RequiredScore:%0.1f OptionalScore:%0.1f for %s\n", score.totalScore(), score.totalRequiredScore(), score.totalOptionalScore(), fileName) } diff --git a/pkg/compliance/oct_score.go b/pkg/compliance/oct_score.go index a3515fe5..a09a9b6d 100644 --- a/pkg/compliance/oct_score.go +++ b/pkg/compliance/oct_score.go @@ -1,5 +1,7 @@ package compliance +import "github.com/interlynk-io/sbomqs/pkg/compliance/db" + type octScoreResult struct { id string requiredScore float64 @@ -44,8 +46,8 @@ func (r *octScoreResult) totalOptionalScore() float64 { return r.optionalScore / float64(r.optionalRecords) } -func octKeyIDScore(db *db, key int, id string) *octScoreResult { - records := db.getRecordsByKeyID(key, id) +func octKeyIDScore(dtb *db.DB, key int, id string) *octScoreResult { + records := dtb.GetRecordsByKeyID(key, id) if len(records) == 0 { return newOctScoreResult(id) @@ -58,11 +60,11 @@ func octKeyIDScore(db *db, key int, id string) *octScoreResult { optionalRecs := 0 for _, r := range records { - if r.required { - requiredScore += r.score + if r.Required { + requiredScore += r.Score requiredRecs++ } else { - optionalScore += r.score + optionalScore += r.Score optionalRecs++ } } @@ -76,13 +78,13 @@ func octKeyIDScore(db *db, key int, id string) *octScoreResult { } } -func octAggregateScore(db *db) *octScoreResult { +func octAggregateScore(dtb *db.DB) *octScoreResult { var results []octScoreResult var finalResult octScoreResult - ids := db.getAllIDs() + ids := dtb.GetAllIDs() for _, id := range ids { - results = append(results, *octIDScore(db, id)) + results = append(results, *octIDScore(dtb, id)) } for _, r := range results { @@ -95,8 +97,8 @@ func octAggregateScore(db *db) *octScoreResult { return &finalResult } -func octIDScore(db *db, id string) *octScoreResult { - records := db.getRecordsByID(id) +func octIDScore(dtb *db.DB, id string) *octScoreResult { + records := dtb.GetRecordsByID(id) if len(records) == 0 { return newOctScoreResult(id) @@ -109,11 +111,11 @@ func octIDScore(db *db, id string) *octScoreResult { optionalRecs := 0 for _, r := range records { - if r.required { - requiredScore += r.score + if r.Required { + requiredScore += r.Score requiredRecs++ } else { - optionalScore += r.score + optionalScore += r.Score optionalRecs++ } } diff --git a/pkg/compliance/oct_test.go b/pkg/compliance/oct_test.go index 2655a4bb..21dbe6fc 100644 --- a/pkg/compliance/oct_test.go +++ b/pkg/compliance/oct_test.go @@ -3,6 +3,7 @@ package compliance import ( "testing" + "github.com/interlynk-io/sbomqs/pkg/compliance/db" "github.com/interlynk-io/sbomqs/pkg/licenses" "github.com/interlynk-io/sbomqs/pkg/sbom" "gotest.tools/assert" @@ -82,20 +83,23 @@ type desired struct { func TestOctSbomPass(t *testing.T) { doc := createDummyDocument() testCases := []struct { - actual *record + name string + actual *db.Record expected desired }{ { + name: "octSpec", actual: octSpec(doc), expected: desired{ name: "octSpec", score: 10.0, result: "spdx", key: SBOM_SPEC, - id: "SBOM Format", + id: "SPDX Elements", }, }, { + name: "octSbomName", actual: octSbomName(doc), expected: desired{ name: "octSbomName", @@ -106,26 +110,29 @@ func TestOctSbomPass(t *testing.T) { }, }, { + name: "octSbomNamespace", actual: octSbomNamespace(doc), expected: desired{ name: "octSbomNamespace", score: 10.0, - result: "https://anchore.com/syft/dir/sbomqs-6ec18b03-96cb-4951-b299-929890c1cfc8", + result: "https://anchore.com/syft/dir/sbomqs-6ec18b03-96cb-\n4951-b299-929890c1cfc8", key: SBOM_NAMESPACE, id: "SPDX Elements", }, }, { + name: "octSbomOrganization", actual: octSbomOrganization(doc), expected: desired{ name: "octSbomOrganization", score: 10.0, result: "interlynk", key: SBOM_ORG, - id: "SBOM Build Information", + id: "SPDX Elements", }, }, { + name: "octSbomComment", actual: octSbomComment(doc), expected: desired{ name: "octSbomComment", @@ -136,16 +143,18 @@ func TestOctSbomPass(t *testing.T) { }, }, { + name: "octSbomTool", actual: octSbomTool(doc), expected: desired{ name: "octSbomTool", score: 10.0, result: "syft", key: SBOM_TOOL, - id: "SBOM Build Information", + id: "SPDX Elements", }, }, { + name: "octSbomLicense", actual: octSbomLicense(doc), expected: desired{ name: "octSbomLicense", @@ -156,6 +165,7 @@ func TestOctSbomPass(t *testing.T) { }, }, { + name: "octSpecVersion", actual: octSpecVersion(doc), expected: desired{ name: "octSpecVersion", @@ -166,6 +176,7 @@ func TestOctSbomPass(t *testing.T) { }, }, { + name: "octCreatedTimestamp", actual: octCreatedTimestamp(doc), expected: desired{ name: "octCreatedTimestamp", @@ -176,6 +187,7 @@ func TestOctSbomPass(t *testing.T) { }, }, { + name: "octSpecSpdxID", actual: octSpecSpdxID(doc), expected: desired{ name: "octSpecSpdxID", @@ -185,28 +197,30 @@ func TestOctSbomPass(t *testing.T) { id: "SPDX Elements", }, }, - { + name: "octMachineFormat", actual: octMachineFormat(doc), expected: desired{ name: "octMachineFormat", score: 10.0, result: "spdx, json", key: SBOM_MACHINE_FORMAT, - id: "Machine Readable Data Format", + id: "SPDX Elements", }, }, { + name: "octHumanFormat", actual: octHumanFormat(doc), expected: desired{ name: "octHumanFormat", score: 10.0, result: "json", key: SBOM_HUMAN_FORMAT, - id: "Human Readable Data Format", + id: "SPDX Elements", }, }, { + name: "octPackageName", actual: octPackageName(doc.Components()[0]), expected: desired{ name: "octPackageName", @@ -217,6 +231,7 @@ func TestOctSbomPass(t *testing.T) { }, }, { + name: "octPackageVersion", actual: octPackageVersion(doc.Components()[0]), expected: desired{ name: "octPackageVersion", @@ -227,6 +242,7 @@ func TestOctSbomPass(t *testing.T) { }, }, { + name: "octPackageSpdxID", actual: octPackageSpdxID(doc.Components()[0]), expected: desired{ name: "octPackageSpdxID", @@ -237,6 +253,7 @@ func TestOctSbomPass(t *testing.T) { }, }, { + name: "octPackageSupplier", actual: octPackageSupplier(doc.Components()[0]), expected: desired{ name: "octPackageSupplier", @@ -247,6 +264,7 @@ func TestOctSbomPass(t *testing.T) { }, }, { + name: "octPackageHash", actual: octPackageHash(doc.Components()[0]), expected: desired{ name: "octPackageHash", @@ -257,6 +275,7 @@ func TestOctSbomPass(t *testing.T) { }, }, { + name: "octPackageExternalRefs", actual: octPackageExternalRefs(doc.Components()[0]), expected: desired{ name: "octPackageExternalRefs", @@ -267,6 +286,7 @@ func TestOctSbomPass(t *testing.T) { }, }, { + name: "octPackageCopyright", actual: octPackageCopyright(doc.Components()[0]), expected: desired{ name: "octPackageCopyright", @@ -277,6 +297,7 @@ func TestOctSbomPass(t *testing.T) { }, }, { + name: "octPackageFileAnalyzed", actual: octPackageFileAnalyzed(doc.Components()[0]), expected: desired{ name: "octPackageFileAnalyzed", @@ -287,6 +308,7 @@ func TestOctSbomPass(t *testing.T) { }, }, { + name: "octPackageConLicense", actual: octPackageConLicense(doc.Components()[0]), expected: desired{ name: "octPackageConLicense", @@ -297,6 +319,7 @@ func TestOctSbomPass(t *testing.T) { }, }, { + name: "octPackageDecLicense", actual: octPackageDecLicense(doc.Components()[0]), expected: desired{ name: "octPackageDecLicense", @@ -307,11 +330,12 @@ func TestOctSbomPass(t *testing.T) { }, }, { + name: "octPackageDownloadURL", actual: octPackageDownloadURL(doc.Components()[0]), expected: desired{ name: "octPackageDownloadURL", score: 10.0, - result: "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", + result: "https://registry.npmjs.org/core-js/-/core-js-3.6.5\n.tgz", key: PACK_DOWNLOAD_URL, id: doc.Components()[0].GetName(), }, @@ -319,10 +343,10 @@ func TestOctSbomPass(t *testing.T) { } for _, test := range testCases { - assert.Equal(t, test.expected.score, test.actual.score) - assert.Equal(t, test.expected.key, test.actual.checkKey) - assert.Equal(t, test.expected.id, test.actual.id) - assert.Equal(t, test.expected.result, test.actual.checkValue) + assert.Equal(t, test.expected.score, test.actual.Score, "Score mismatch for %s", test.name) + assert.Equal(t, test.expected.key, test.actual.CheckKey, "Key mismatch for %s", test.name) + assert.Equal(t, test.expected.id, test.actual.ID, "ID mismatch for %s", test.name) + assert.Equal(t, test.expected.result, test.actual.CheckValue, "Result mismatch for %s", test.name) } } @@ -392,7 +416,7 @@ func createFailureDummyDocument() sbom.Document { func TestOctSbomFail(t *testing.T) { doc := createFailureDummyDocument() testCases := []struct { - actual *record + actual *db.Record expected desired }{ { @@ -401,7 +425,7 @@ func TestOctSbomFail(t *testing.T) { score: 0.0, result: "cyclonedx", key: SBOM_SPEC, - id: "SBOM Format", + id: "SPDX Elements", }, }, { @@ -428,7 +452,7 @@ func TestOctSbomFail(t *testing.T) { score: 0.0, result: "", key: SBOM_ORG, - id: "SBOM Build Information", + id: "SPDX Elements", }, }, { @@ -446,7 +470,7 @@ func TestOctSbomFail(t *testing.T) { score: 0.0, result: "", key: SBOM_TOOL, - id: "SBOM Build Information", + id: "SPDX Elements", }, }, { @@ -491,7 +515,7 @@ func TestOctSbomFail(t *testing.T) { score: 0.0, result: "cyclonedx, xml", key: SBOM_MACHINE_FORMAT, - id: "Machine Readable Data Format", + id: "SPDX Elements", }, }, { @@ -500,7 +524,7 @@ func TestOctSbomFail(t *testing.T) { score: 0.0, result: "xml", key: SBOM_HUMAN_FORMAT, - id: "Human Readable Data Format", + id: "SPDX Elements", }, }, { @@ -605,9 +629,9 @@ func TestOctSbomFail(t *testing.T) { } for _, test := range testCases { - assert.Equal(t, test.expected.score, test.actual.score) - assert.Equal(t, test.expected.key, test.actual.checkKey) - assert.Equal(t, test.expected.id, test.actual.id) - assert.Equal(t, test.expected.result, test.actual.checkValue) + assert.Equal(t, test.expected.score, test.actual.Score) + assert.Equal(t, test.expected.key, test.actual.CheckKey) + assert.Equal(t, test.expected.id, test.actual.ID) + assert.Equal(t, test.expected.result, test.actual.CheckValue) } } diff --git a/pkg/engine/compliance.go b/pkg/engine/compliance.go index eeeb28e9..092add2d 100644 --- a/pkg/engine/compliance.go +++ b/pkg/engine/compliance.go @@ -42,20 +42,28 @@ func ComplianceRun(ctx context.Context, ep *Params) error { return err } - reportType := "NTIA" + var reportType string - if ep.Bsi { + switch { + case ep.Bsi: reportType = "BSI" - } else if ep.Oct { + case ep.Oct: reportType = "OCT" + case ep.Fsct: + reportType = "FSCT" + default: + reportType = "NTIA" } - outFormat := "detailed" + var outFormat string - if ep.Basic { + switch { + case ep.Basic: outFormat = "basic" - } else if ep.JSON { + case ep.JSON: outFormat = "json" + default: + outFormat = "detailed" } err = compliance.ComplianceResult(ctx, *doc, reportType, ep.Path[0], outFormat) @@ -78,7 +86,6 @@ func getSbomDocument(ctx context.Context, ep *Params) (*sbom.Document, error) { if IsURL(path) { log.Debugf("Processing Git URL path :%s\n", path) - url, sbomFilePath := path, path var err error diff --git a/pkg/engine/score.go b/pkg/engine/score.go index 88d62958..9e6842c4 100644 --- a/pkg/engine/score.go +++ b/pkg/engine/score.go @@ -56,6 +56,7 @@ type Params struct { Ntia bool Bsi bool Oct bool + Fsct bool } func Run(ctx context.Context, ep *Params) error { diff --git a/pkg/omniborid/omniborid.go b/pkg/omniborid/omniborid.go new file mode 100644 index 00000000..5319e9b3 --- /dev/null +++ b/pkg/omniborid/omniborid.go @@ -0,0 +1,33 @@ +// Copyright 2023 Interlynk.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package omniborid + +import "regexp" + +type OMNIBORID string + +const omniRegex = `^gitoid:blob:sha1:[a-fA-F0-9]{40}$` + +func (omni OMNIBORID) Valid() bool { + return regexp.MustCompile(omniRegex).MatchString(omni.String()) +} + +func NewOmni(omni string) OMNIBORID { + return OMNIBORID(omni) +} + +func (omni OMNIBORID) String() string { + return string(omni) +} diff --git a/pkg/omniborid/omniborid_test.go b/pkg/omniborid/omniborid_test.go new file mode 100644 index 00000000..b6bb40b7 --- /dev/null +++ b/pkg/omniborid/omniborid_test.go @@ -0,0 +1,58 @@ +// Copyright 2023 Interlynk.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package omniborid + +import ( + "testing" +) + +func TestValidOMNIBORID(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {"Is empty value a valid OMNIBORID", "", false}, + {"Is XYZ a valid OMNIBORID", "xyz", false}, + {"Is gitoid:blob:sha1:a94a8fe5ccb19ba61c4c0873d391e987982fbbd3 a valid OMNIBORID", "gitoid:blob:sha1:a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", true}, + {"Is gitoid:blob:sha1:a94a8fe5ccb19ba61c4c0873d391e987982fbbd3a a valid OMNIBORID", "gitoid:blob:sha1:a94a8fe5ccb19ba61c4c0873d391e987982fbbd3a", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + omniInput := NewOmni(tt.input) + if omniInput.Valid() != tt.want { + t.Errorf("got %t, want %t", omniInput.Valid(), tt.want) + } + }) + } +} + +func TestString(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"Empty OMNIBORID value", "", ""}, + {"Valid OMNIBORID", "gitoid:blob:sha1:a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", "gitoid:blob:sha1:a94a8fe5ccb19ba61c4c0873d391e987982fbbd3"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + omniInput := NewOmni(tt.input) + if omniInput.String() != tt.want { + t.Errorf("got %s, want %s", omniInput.String(), tt.want) + } + }) + } +} diff --git a/pkg/sbom/author.go b/pkg/sbom/author.go index c8d8ea6d..5cb947dd 100644 --- a/pkg/sbom/author.go +++ b/pkg/sbom/author.go @@ -19,12 +19,14 @@ type GetAuthor interface { GetName() string GetType() string GetEmail() string + GetPhone() string } type Author struct { Name string Email string AuthorType string // person or org + Phone string } func (a Author) GetName() string { @@ -38,3 +40,7 @@ func (a Author) GetType() string { func (a Author) GetEmail() string { return a.Email } + +func (a Author) GetPhone() string { + return a.Phone +} diff --git a/pkg/sbom/cdx.go b/pkg/sbom/cdx.go index 135f4e67..8d123a53 100644 --- a/pkg/sbom/cdx.go +++ b/pkg/sbom/cdx.go @@ -25,7 +25,10 @@ import ( "github.com/google/uuid" "github.com/interlynk-io/sbomqs/pkg/cpe" "github.com/interlynk-io/sbomqs/pkg/licenses" + "github.com/interlynk-io/sbomqs/pkg/omniborid" "github.com/interlynk-io/sbomqs/pkg/purl" + "github.com/interlynk-io/sbomqs/pkg/swhid" + "github.com/interlynk-io/sbomqs/pkg/swid" "github.com/samber/lo" ) @@ -45,7 +48,7 @@ type CdxDoc struct { CdxTools []GetTool rels []GetRelation logs []string - lifecycles []string + Lifecycle []string supplier GetSupplier manufacturer Manufacturer compositions map[string]string @@ -120,7 +123,7 @@ func (c CdxDoc) Logs() []string { } func (c CdxDoc) Lifecycles() []string { - return c.lifecycles + return c.Lifecycle } func (c CdxDoc) Supplier() GetSupplier { @@ -171,7 +174,7 @@ func (c *CdxDoc) parseDoc() { return } - c.lifecycles = lo.Map(lo.FromPtr(c.doc.Metadata.Lifecycles), func(l cydx.Lifecycle, _ int) string { + c.Lifecycle = lo.Map(lo.FromPtr(c.doc.Metadata.Lifecycles), func(l cydx.Lifecycle, _ int) string { if l.Phase != "" { return string(l.Phase) } @@ -252,7 +255,7 @@ func copyC(cdxc *cydx.Component, c *CdxDoc) *Component { nc.Name = cdxc.Name nc.purpose = string(cdxc.Type) nc.isReqFieldsPresent = c.pkgRequiredFields(cdxc) - + nc.CopyRight = cdxc.Copyright ncpe := cpe.NewCPE(cdxc.CPE) if ncpe.Valid() { nc.Cpes = []cpe.CPE{ncpe} @@ -267,6 +270,43 @@ func copyC(cdxc *cydx.Component, c *CdxDoc) *Component { c.addToLogs(fmt.Sprintf("cdx base doc component %s at index %d invalid purl found", cdxc.Name, -1)) } + if cdxc.SWHID != nil { + for _, swhidStr := range *cdxc.SWHID { + nswhid := swhid.NewSWHID(swhidStr) + if nswhid.Valid() { + nc.Swhid = append(nc.Swhid, nswhid) + } else { + c.addToLogs(fmt.Sprintf("cdx base doc component %s at index %d invalid swhid found", cdxc.Name, -1)) + } + } + } else { + c.addToLogs(fmt.Sprintf("cdx base doc component %s has nil SWHID", cdxc.Name)) + } + + if cdxc.SWID != nil { + nswid := swid.NewSWID(cdxc.SWID.TagID, cdxc.SWID.Name) + if nswid.Valid() { + nc.Swid = []swid.SWID{nswid} + } else { + c.addToLogs(fmt.Sprintf("cdx base doc component %s at index %d invalid swid found", cdxc.Name, -1)) + } + } else { + c.addToLogs(fmt.Sprintf("cdx base doc component %s has nil SWID or SWID.Name", cdxc.Name)) + } + + if cdxc.OmniborID != nil { + for _, omniStr := range *cdxc.OmniborID { + omniID := omniborid.NewOmni(omniStr) + if omniID.Valid() { + nc.OmniID = append(nc.OmniID, omniID) + } else { + c.addToLogs(fmt.Sprintf("cdx base doc component %s at index %d invalid omniborid found", cdxc.Name, -1)) + } + } + } else { + c.addToLogs(fmt.Sprintf("cdx base doc component %s has nil OmniborID", cdxc.Name)) + } + nc.Checksums = c.checksums(cdxc) nc.licenses = c.licenses(cdxc) @@ -294,9 +334,13 @@ func copyC(cdxc *cydx.Component, c *CdxDoc) *Component { } if cdxc.BOMRef == c.PrimaryComponent.ID { + pc := PrimaryComp{} + pc.Name = cdxc.Name + pc.ID = cdxc.BOMRef + pc.Present = true nc.isPrimary = true + nc.PrimaryCompt = pc } - nc.ID = cdxc.BOMRef return nc } @@ -455,6 +499,7 @@ func (c *CdxDoc) parseAuthors() { a := Author{} a.Name = auth.Name a.Email = auth.Email + a.Phone = auth.Phone a.AuthorType = "person" c.CdxAuthors = append(c.CdxAuthors, a) } @@ -476,9 +521,9 @@ func (c *CdxDoc) parseSupplier() { if c.doc.Metadata.Supplier.Contact != nil { for _, cydxContact := range lo.FromPtr(c.doc.Metadata.Supplier.Contact) { - ctt := contact{} - ctt.name = cydxContact.Name - ctt.email = cydxContact.Email + ctt := Contact{} + ctt.Name = cydxContact.Name + ctt.Email = cydxContact.Email supplier.Contacts = append(supplier.Contacts, ctt) } } @@ -502,9 +547,9 @@ func (c *CdxDoc) parseManufacturer() { if c.doc.Metadata.Manufacture.Contact != nil { for _, cydxContact := range lo.FromPtr(c.doc.Metadata.Manufacture.Contact) { - ctt := contact{} - ctt.name = cydxContact.Name - ctt.email = cydxContact.Email + ctt := Contact{} + ctt.Name = cydxContact.Name + ctt.Email = cydxContact.Email m.Contacts = append(m.Contacts, ctt) } } @@ -524,6 +569,7 @@ func (c *CdxDoc) parsePrimaryCompAndRelationships() { c.PrimaryComponent.Present = true c.PrimaryComponent.ID = c.doc.Metadata.Component.BOMRef + c.PrimaryComponent.Name = c.doc.Metadata.Component.Name var totalDependencies int c.rels = []GetRelation{} @@ -534,7 +580,7 @@ func (c *CdxDoc) parsePrimaryCompAndRelationships() { nr.From = r.Ref nr.To = d if r.Ref == c.PrimaryComponent.ID { - c.PrimaryComponent.hasDependencies = true + c.PrimaryComponent.HasDependency = true totalDependencies++ c.rels = append(c.rels, nr) c.Dependencies[c.PrimaryComponent.ID] = append(c.Dependencies[c.PrimaryComponent.ID], d) @@ -608,9 +654,9 @@ func (c *CdxDoc) assignSupplier(comp *cydx.Component) *Supplier { if comp.Supplier.Contact != nil { for _, cydxContact := range lo.FromPtr(comp.Supplier.Contact) { - ctt := contact{} - ctt.name = cydxContact.Name - ctt.email = cydxContact.Email + ctt := Contact{} + ctt.Name = cydxContact.Name + ctt.Email = cydxContact.Email supplier.Contacts = append(supplier.Contacts, ctt) } } diff --git a/pkg/sbom/component.go b/pkg/sbom/component.go index 02fae3be..5cb48cec 100644 --- a/pkg/sbom/component.go +++ b/pkg/sbom/component.go @@ -18,7 +18,10 @@ package sbom import ( "github.com/interlynk-io/sbomqs/pkg/cpe" "github.com/interlynk-io/sbomqs/pkg/licenses" + "github.com/interlynk-io/sbomqs/pkg/omniborid" "github.com/interlynk-io/sbomqs/pkg/purl" + "github.com/interlynk-io/sbomqs/pkg/swhid" + "github.com/interlynk-io/sbomqs/pkg/swid" ) type GetComponent interface { @@ -27,6 +30,9 @@ type GetComponent interface { GetVersion() string GetCpes() []cpe.CPE GetPurls() []purl.PURL + Swhids() []swhid.SWHID + OmniborIDs() []omniborid.OMNIBORID + Swids() []swid.SWID Licenses() []licenses.License GetChecksums() []GetChecksum PrimaryPurpose() string @@ -47,6 +53,7 @@ type GetComponent interface { GetPackageLicenseConcluded() string ExternalReferences() []GetExternalReference GetComposition(string) string + GetPrimaryCompInfo() GetPrimaryComp } type Component struct { @@ -54,6 +61,9 @@ type Component struct { Version string Cpes []cpe.CPE Purls []purl.PURL + Swhid []swhid.SWHID + OmniID []omniborid.OMNIBORID + Swid []swid.SWID licenses []licenses.License Checksums []GetChecksum purpose string @@ -66,6 +76,7 @@ type Component struct { DownloadLocation string sourceCodeHash string isPrimary bool + PrimaryCompt PrimaryComp hasRelationships bool RelationshipState string Spdxid string @@ -81,6 +92,10 @@ func NewComponent() *Component { return &Component{} } +func (c Component) GetPrimaryCompInfo() GetPrimaryComp { + return c.PrimaryCompt +} + func (c Component) GetName() string { return c.Name } @@ -97,6 +112,18 @@ func (c Component) GetCpes() []cpe.CPE { return c.Cpes } +func (c Component) Swhids() []swhid.SWHID { + return c.Swhid +} + +func (c Component) Swids() []swid.SWID { + return c.Swid +} + +func (c Component) OmniborIDs() []omniborid.OMNIBORID { + return c.OmniID +} + func (c Component) Licenses() []licenses.License { return c.licenses } diff --git a/pkg/sbom/contact.go b/pkg/sbom/contact.go index 575c51c1..036a9385 100644 --- a/pkg/sbom/contact.go +++ b/pkg/sbom/contact.go @@ -16,26 +16,26 @@ package sbom //counterfeiter:generate . Contact -type Contact interface { - Name() string - Email() string - Phone() string +type GetContact interface { + GetName() string + GetEmail() string + GetPhone() string } -type contact struct { - name string - email string - phone string +type Contact struct { + Name string + Email string + Phone string } -func (c contact) Name() string { - return c.name +func (c Contact) GetName() string { + return c.Name } -func (c contact) Email() string { - return c.email +func (c Contact) GetEmail() string { + return c.Email } -func (c contact) Phone() string { - return c.phone +func (c Contact) GetPhone() string { + return c.Phone } diff --git a/pkg/sbom/externalReference.go b/pkg/sbom/externalReference.go index 7be6aebd..19b9c2d3 100644 --- a/pkg/sbom/externalReference.go +++ b/pkg/sbom/externalReference.go @@ -2,12 +2,18 @@ package sbom type GetExternalReference interface { GetRefType() string + GetRefLocator() string } type ExternalReference struct { - RefType string + RefType string + RefLocator string } func (e ExternalReference) GetRefType() string { return e.RefType } + +func (e ExternalReference) GetRefLocator() string { + return e.RefLocator +} diff --git a/pkg/sbom/primarycomp.go b/pkg/sbom/primarycomp.go index b1ebfe28..06d10502 100644 --- a/pkg/sbom/primarycomp.go +++ b/pkg/sbom/primarycomp.go @@ -17,28 +17,35 @@ package sbom type GetPrimaryComp interface { IsPresent() bool GetID() string + GetName() string GetTotalNoOfDependencies() int + HasDependencies() bool } type PrimaryComp struct { - Present bool - ID string - Dependecies int - hasDependencies bool + Present bool + ID string + Dependecies int + HasDependency bool + Name string } -func (pc *PrimaryComp) IsPresent() bool { +func (pc PrimaryComp) IsPresent() bool { return pc.Present } -func (pc *PrimaryComp) GetID() string { +func (pc PrimaryComp) GetID() string { return pc.ID } -func (pc *PrimaryComp) GetTotalNoOfDependencies() int { +func (pc PrimaryComp) GetName() string { + return pc.Name +} + +func (pc PrimaryComp) GetTotalNoOfDependencies() int { return pc.Dependecies } -func (pc *PrimaryComp) HasDependencies() bool { - return pc.hasDependencies +func (pc PrimaryComp) HasDependencies() bool { + return pc.HasDependency } diff --git a/pkg/sbom/sbomfakes/fake_author.go b/pkg/sbom/sbomfakes/fake_author.go index 6528087a..64614c0c 100644 --- a/pkg/sbom/sbomfakes/fake_author.go +++ b/pkg/sbom/sbomfakes/fake_author.go @@ -42,6 +42,11 @@ type FakeAuthor struct { invocationsMutex sync.RWMutex } +func (fake *FakeAuthor) GetPhone() string { + // Implement the method as needed + return "" +} + func (fake *FakeAuthor) GetEmail() string { fake.emailMutex.Lock() ret, specificReturn := fake.emailReturnsOnCall[len(fake.emailArgsForCall)] diff --git a/pkg/sbom/spdx.go b/pkg/sbom/spdx.go index 37705316..1f791326 100644 --- a/pkg/sbom/spdx.go +++ b/pkg/sbom/spdx.go @@ -48,12 +48,12 @@ type SpdxDoc struct { ctx context.Context SpdxSpec *Specs Comps []GetComponent - authors []GetAuthor + Auths []GetAuthor SpdxTools []GetTool Rels []GetRelation logs []string PrimaryComponent PrimaryComp - lifecycles string + Lifecycle string Dependencies map[string][]string composition map[string]string } @@ -110,7 +110,7 @@ func (s SpdxDoc) Components() []GetComponent { } func (s SpdxDoc) Authors() []GetAuthor { - return s.authors + return s.Auths } func (s SpdxDoc) Tools() []GetTool { @@ -126,7 +126,7 @@ func (s SpdxDoc) Logs() []string { } func (s SpdxDoc) Lifecycles() []string { - return []string{s.lifecycles} + return []string{s.Lifecycle} } func (s SpdxDoc) Manufacturer() Manufacturer { @@ -141,11 +141,17 @@ func (s SpdxDoc) GetRelationships(componentID string) []string { return s.Dependencies[componentID] } +// Helper function to clean up keys +func CleanKey(key string) string { + return strings.Trim(key, `"`) +} + func (s SpdxDoc) GetComposition(componentID string) string { return s.composition[componentID] } func (s *SpdxDoc) parse() { + s.parseDoc() s.parseSpec() s.parseAuthors() s.parseTool() @@ -153,6 +159,16 @@ func (s *SpdxDoc) parse() { s.parseComps() } +func (s *SpdxDoc) parseDoc() { + if s.doc == nil { + s.addToLogs("cdx doc is not parsable") + return + } + if comment := s.doc.CreationInfo.CreatorComment; comment != "" { + s.Lifecycle = comment + } +} + func (s *SpdxDoc) parseSpec() { sp := NewSpec() sp.Format = string(s.format) @@ -210,11 +226,22 @@ func (s *SpdxDoc) parseComps() { nc.isReqFieldsPresent = s.pkgRequiredFields(index) nc.Purls = s.purls(index) nc.Cpes = s.cpes(index) + nc.OmniID = nil + nc.Swhid = nil + nc.Swid = nil nc.Checksums = s.checksums(index) nc.ExternalRefs = s.externalRefs(index) nc.licenses = s.licenses(index) nc.ID = string(sc.PackageSPDXIdentifier) nc.PackageLicenseConcluded = sc.PackageLicenseConcluded + if strings.Contains(s.PrimaryComponent.ID, string(sc.PackageSPDXIdentifier)) { + pc := PrimaryComp{} + pc.Name = sc.PackageName + pc.ID = string(sc.PackageSPDXIdentifier) + pc.Present = true + nc.isPrimary = true + nc.PrimaryCompt = pc + } manu := s.getManufacturer(index) if manu != nil { @@ -263,7 +290,7 @@ func (s *SpdxDoc) parseComps() { } func (s *SpdxDoc) parseAuthors() { - s.authors = []GetAuthor{} + s.Auths = []GetAuthor{} if s.doc.CreationInfo == nil { return @@ -281,13 +308,12 @@ func (s *SpdxDoc) parseAuthors() { a.Name = entity.name a.Email = entity.email a.AuthorType = ctType - s.authors = append(s.authors, a) + s.Auths = append(s.Auths, a) } } } func (s *SpdxDoc) parsePrimaryCompAndRelationships() { - s.Rels = []GetRelation{} s.Dependencies = make(map[string][]string) var err error var aBytes, bBytes []byte @@ -295,13 +321,13 @@ func (s *SpdxDoc) parsePrimaryCompAndRelationships() { var totalDependencies int for _, r := range s.doc.Relationships { - // check relation type DESCRIBE - if strings.ToUpper(r.Relationship) == spdx_common.TypeRelationshipDescribe { - bBytes, err = r.RefB.ElementRefID.MarshalJSON() + // spdx_common.TypeRelationshipDescribe + if strings.ToUpper(r.Relationship) == "DESCRIBES" { + bBytes, err = r.RefB.MarshalJSON() if err != nil { continue } - primaryComponent = string(bBytes) + primaryComponent = CleanKey(string(bBytes)) s.PrimaryComponent.ID = primaryComponent s.PrimaryComponent.Present = true } @@ -313,28 +339,17 @@ func (s *SpdxDoc) parsePrimaryCompAndRelationships() { if err != nil { continue } - - if string(aBytes) == primaryComponent { - bBytes, err = r.RefB.MarshalJSON() - if err != nil { - continue - } - - nr := Relation{ - From: primaryComponent, - To: string(bBytes), - } + bBytes, err = r.RefB.MarshalJSON() + if err != nil { + continue + } + if CleanKey(string(aBytes)) == s.PrimaryComponent.ID { totalDependencies++ + s.PrimaryComponent.HasDependency = true + s.Dependencies[CleanKey(string(aBytes))] = append(s.Dependencies[CleanKey(string(aBytes))], CleanKey(string(bBytes))) - s.Rels = append(s.Rels, nr) - s.Dependencies[primaryComponent] = append(s.Dependencies[primaryComponent], string(bBytes)) } else { - nr := Relation{ - From: string(aBytes), - To: string(bBytes), - } - s.Dependencies[string(aBytes)] = append(s.Dependencies[string(aBytes)], string(bBytes)) - s.Rels = append(s.Rels, nr) + s.Dependencies[CleanKey(string(aBytes))] = append(s.Dependencies[CleanKey(string(aBytes))], CleanKey(string(bBytes))) } } } @@ -557,6 +572,7 @@ func (s *SpdxDoc) externalRefs(index int) []GetExternalReference { for _, ext := range pkg.PackageExternalReferences { extRef := ExternalReference{} extRef.RefType = ext.RefType + extRef.RefLocator = ext.Locator extRefs = append(extRefs, extRef) } diff --git a/pkg/swhid/swhid.go b/pkg/swhid/swhid.go new file mode 100644 index 00000000..5a68bd98 --- /dev/null +++ b/pkg/swhid/swhid.go @@ -0,0 +1,33 @@ +// Copyright 2023 Interlynk.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package swhid + +import "regexp" + +type SWHID string + +const swhidRegex = `^swh:1:cnt:[a-fA-F0-9]{40}$` + +func (swhid SWHID) Valid() bool { + return regexp.MustCompile(swhidRegex).MatchString(swhid.String()) +} + +func NewSWHID(swhid string) SWHID { + return SWHID(swhid) +} + +func (swhid SWHID) String() string { + return string(swhid) +} diff --git a/pkg/swhid/swhid_test.go b/pkg/swhid/swhid_test.go new file mode 100644 index 00000000..8b8c39be --- /dev/null +++ b/pkg/swhid/swhid_test.go @@ -0,0 +1,59 @@ +// Copyright 2023 Interlynk.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package swhid + +import ( + "testing" +) + +func TestValidSWHID(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {"Is empty value a valid SWHID", "", false}, + {"Is XYZ a valid SWHID", "xyz", false}, + {"Is swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2 a valid SWHID", "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2", true}, + {"Is swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2a a valid SWHID", "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2a", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + swhidInput := NewSWHID(tt.input) + if swhidInput.Valid() != tt.want { + t.Errorf("got %t, want %t", swhidInput.Valid(), tt.want) + } + }) + } +} + +func TestString(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"Empty SWHID value", "", ""}, + {"Valid SWHID", "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2", "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + swhidInput := NewSWHID(tt.input) + if swhidInput.String() != tt.want { + t.Errorf("got %s, want %s", swhidInput.String(), tt.want) + } + }) + } +} diff --git a/pkg/compliance/record.go b/pkg/swid/swid.go similarity index 51% rename from pkg/compliance/record.go rename to pkg/swid/swid.go index 0fccb7b8..8a6b2fee 100644 --- a/pkg/compliance/record.go +++ b/pkg/swid/swid.go @@ -12,36 +12,40 @@ // See the License for the specific language governing permissions and // limitations under the License. -package compliance - -type record struct { - checkKey int - checkValue string - id string - score float64 - required bool +package swid + +type SWID interface { + GetName() string + GetTagID() string + Valid() bool + String() string +} + +type swid struct { + TagID string + Name string +} + +func (s swid) GetName() string { + return s.Name +} + +func (s swid) GetTagID() string { + return s.TagID } -func newRecord() *record { - return &record{} +func (s swid) Valid() bool { + // Basic validation: check if the TagID is a non-empty string + return s.TagID != "" } -func newRecordStmt(key int, id, value string, score float64) *record { - r := newRecord() - r.checkKey = key - r.checkValue = value - r.id = id - r.score = score - r.required = true - return r +func (s swid) String() string { + return s.TagID } -func newRecordStmtOptional(key int, id, value string, score float64) *record { - r := newRecord() - r.checkKey = key - r.checkValue = value - r.id = id - r.score = score - r.required = false - return r +func NewSWID(tagID, name string) SWID { + return swid{ + TagID: tagID, + Name: name, + } } diff --git a/pkg/swid/swid_test.go b/pkg/swid/swid_test.go new file mode 100644 index 00000000..33684bd3 --- /dev/null +++ b/pkg/swid/swid_test.go @@ -0,0 +1,95 @@ +// Copyright 2023 Interlynk.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package swid + +import ( + "testing" +) + +func TestValidSWID(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {"Is empty value a valid SWID", "", false}, + {"Is XYZ a valid SWID", "xyz", true}, + {"Is example-swid a valid SWID", "example-swid", true}, + {"Is example_swid a valid SWID", "example_swid", true}, + {"Is example.swid a valid SWID", "example.swid", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + swidInput := NewSWID(tt.input, "example-name") + if swidInput.Valid() != tt.want { + t.Errorf("got %t, want %t", swidInput.Valid(), tt.want) + } + }) + } +} + +func TestGetName(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"Get name of SWID", "example-swid", "example-name"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + swidInput := NewSWID(tt.input, "example-name") + if swidInput.GetName() != tt.want { + t.Errorf("got %s, want %s", swidInput.GetName(), tt.want) + } + }) + } +} + +func TestGetTagID(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"Get TagID of SWID", "example-swid", "example-swid"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + swidInput := NewSWID(tt.input, "example-name") + if swidInput.GetTagID() != tt.want { + t.Errorf("got %s, want %s", swidInput.GetTagID(), tt.want) + } + }) + } +} + +func TestString(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"String representation of SWID", "example-swid", "example-swid"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + swidInput := NewSWID(tt.input, "example-name") + if swidInput.String() != tt.want { + t.Errorf("got %s, want %s", swidInput.String(), tt.want) + } + }) + } +}