Skip to content

Commit

Permalink
Merge pull request #98 from sw360/83-strange-behavior-when-trying-to-…
Browse files Browse the repository at this point in the history
…convert-xml-cyclonedx

feat: SBOM XML conversion and SBOM validation
  • Loading branch information
tngraf authored Nov 11, 2024
2 parents b9f34e2 + 6da5ae2 commit f19ca91
Show file tree
Hide file tree
Showing 17 changed files with 11,412 additions and 44 deletions.
3 changes: 3 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@

* `bom merge` improved: the dependencies are reconstructed, i.e. all dependencies
that existed in the SBOMs before the merge should also exist after the merge.
* `bom convert` improved: we can now convert from and to CycloneDX XML.
* new command `bom validate` to do a siple validation whether a given SBOM
complies with the CycloneDX spec version 1.4, 1.5 or 1.6.

## 2.6.0.dev1

Expand Down
34 changes: 23 additions & 11 deletions capycli/bom/bom_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,16 @@
import capycli.common.json_support
import capycli.common.script_base
from capycli import get_logger
from capycli.bom.csv import CsvSupport
from capycli.bom.html import HtmlConversionSupport
from capycli.bom.legacy import LegacySupport
from capycli.bom.legacy_cx import LegacyCx
from capycli.bom.plaintext import PlainTextSupport
from capycli.common.capycli_bom_support import CaPyCliBom
from capycli.common.print import print_red, print_text
from capycli.common.print import print_red, print_text, print_yellow
from capycli.main.exceptions import CaPyCliException
from capycli.main.result_codes import ResultCode

from .csv import CsvSupport
from .html import HtmlConversionSupport
from .legacy import LegacySupport
from .legacy_cx import LegacyCx
from .plaintext import PlainTextSupport

LOG = get_logger(__name__)


Expand All @@ -47,6 +46,8 @@ class BomFormat(str, Enum):
LEGACY_CX = "legacy-cx"
# HTML
HTML = "html"
# CycloneDX XML
XML = "xml"


class BomConvert(capycli.common.script_base.ScriptBase):
Expand Down Expand Up @@ -75,6 +76,11 @@ def convert(self,
cdx_components = sbom.components
project = sbom.metadata.component
print_text(f" {len(cdx_components)} components read from file {inputfile}")
elif (inputformat == BomFormat.XML):
sbom = CaPyCliBom.read_sbom_xml(inputfile)
cdx_components = sbom.components
project = sbom.metadata.component
print_text(f" {len(cdx_components)} components read from file {inputfile}")
elif inputformat == BomFormat.LEGACY:
cdx_components = SortedSet(LegacySupport.legacy_to_cdx_components(inputfile))
print_text(f" {len(cdx_components)} components read from file {inputfile}")
Expand Down Expand Up @@ -106,6 +112,12 @@ def convert(self,
else:
CaPyCliBom.write_simple_sbom(cdx_components, outputfile)
print_text(f" {len(cdx_components)} components written to file {outputfile}")
elif outputformat == BomFormat.XML:
if sbom:
CaPyCliBom.write_sbom_xml(sbom, outputfile)
print_text(f" {len(sbom.components)} components written to file {outputfile}")
else:
print_yellow(" This command only works for CycloneDX SBOM input files!")
elif outputformat == BomFormat.LEGACY:
LegacySupport.write_cdx_components_as_legacy(cdx_components, outputfile)
print_text(f" {len(cdx_components)} components written to file {outputfile}")
Expand Down Expand Up @@ -139,15 +151,15 @@ def check_arguments(self, args: Any) -> None:

def display_help(self) -> None:
"""Display (local) help."""
print("usage: CaPyCli bom convert [-h] [-i INPUTFILE] [-if {capycli,text,csv,legacy,legacy-cx}]")
print(" [-o OUTPUTFILE] [-of {capycli,text,csv,legacy,legacy-cx,html}]")
print("usage: CaPyCli bom convert [-h] [-i INPUTFILE] [-if {capycli,sbom,text,csv,legacy,legacy-cx,xml}]")
print(" [-o OUTPUTFILE] [-of {capycli,text,csv,legacy,legacy-cx,html,xml}]")
print("")
print("optional arguments:")
print(" -h, --help Show this help message and exit")
print(" -i INPUTFILE Input BOM filename (JSON)")
print(" -o OUTPUTFILE Output BOM filename")
print(" -if INPUTFORMAT Specify input file format: capycli|sbom|text|csv|legacy|legacy-cx")
print(" -of OUTPUTFORMAT Specify output file format: capycli|text|csv|legacy|html")
print(" -if INPUTFORMAT Input file format: capycli|sbom|text|csv|legacy|legacy-cx|xml")
print(" -of OUTPUTFORMAT Output file format: capycli|text|csv|legacy|html|xml")

def run(self, args: Any) -> None:
"""Main method()"""
Expand Down
68 changes: 68 additions & 0 deletions capycli/bom/bom_validate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# -------------------------------------------------------------------------------
# Copyright (c) 2024 Siemens
# All Rights Reserved.
# Author: [email protected]
#
# SPDX-License-Identifier: MIT
# -------------------------------------------------------------------------------

import os
import sys
from typing import Any

import capycli.common.json_support
import capycli.common.script_base
from capycli import get_logger
from capycli.common.capycli_bom_support import CaPyCliBom
from capycli.common.print import print_text
from capycli.main.exceptions import CaPyCliException
from capycli.main.result_codes import ResultCode

LOG = get_logger(__name__)


class BomValidate(capycli.common.script_base.ScriptBase):
def validate(self, inputfile: str, spec_version: str) -> None:
"""Main validation method."""
try:
if not spec_version:
print_text("No CycloneDX spec version specified, defaulting to 1.6")
spec_version = "1.6"
CaPyCliBom.validate_sbom(inputfile, spec_version)
except CaPyCliException as error:
LOG.error(f"Error processing input file: {str(error)}")
sys.exit(ResultCode.RESULT_GENERAL_ERROR)

def check_arguments(self, args: Any) -> None:
"""Check input arguments."""
if not args.inputfile:
LOG.error("No input file specified!")
sys.exit(ResultCode.RESULT_COMMAND_ERROR)

if not os.path.isfile(args.inputfile):
LOG.error("Input file not found!")
sys.exit(ResultCode.RESULT_FILE_NOT_FOUND)

def display_help(self) -> None:
"""Display (local) help."""
print("usage: CaPyCli bom validate [-h] -i INPUTFILE [-version SpecVersion]")
print("")
print("optional arguments:")
print(" -h, --help Show this help message and exit")
print(" -i INPUTFILE Input BOM filename (JSON)")
print(" -version SpecVersion CycloneDX spec version to validate against: allowed are 1.4, 1.5, and 1.6")

def run(self, args: Any) -> None:
"""Main method()"""
print("\n" + capycli.APP_NAME + ", " + capycli.get_app_version() + " - Validate a CaPyCLI/CycloneDX SBOM\n")

if args.help:
self.display_help()
return

self.check_arguments(args)
if args.debug:
global LOG
LOG = get_logger(__name__)

self.validate(args.inputfile, args.version)
16 changes: 12 additions & 4 deletions capycli/bom/handle_bom.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# -------------------------------------------------------------------------------
# Copyright (c) 2019-23 Siemens
# Copyright (c) 2019-24 Siemens
# All Rights Reserved.
# Author: [email protected]
#
Expand All @@ -10,6 +10,7 @@
from typing import Any

import capycli.bom.bom_convert
import capycli.bom.bom_validate
import capycli.bom.check_bom
import capycli.bom.check_bom_item_status
import capycli.bom.check_granularity
Expand Down Expand Up @@ -46,9 +47,10 @@ def run_bom_command(args: Any) -> None:
print(" CreateComponents create new components and releases on SW360 (use with care!)")
print(" DownloadSources download source files from the URL specified in the SBOM")
print(" Granularity check a bill of material for potential component granularity issues")
print(" Diff compare two bills of material.")
print(" Merge merge two bills of material.")
print(" Findsources determine the source code for SBOM items.")
print(" Diff compare two bills of material")
print(" Merge merge two bills of material")
print(" Findsources determine the source code for SBOM items")
print(" Validate validate an SBOM")
return

subcommand = args.command[1].lower()
Expand Down Expand Up @@ -131,5 +133,11 @@ def run_bom_command(args: Any) -> None:
app13.run(args)
return

if subcommand == "validate":
"""Validate an SBOM."""
app14 = capycli.bom.bom_validate.BomValidate()
app14.run(args)
return

print_red("Unknown sub-command: ")
sys.exit(ResultCode.RESULT_COMMAND_ERROR)
135 changes: 122 additions & 13 deletions capycli/common/capycli_bom_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,32 @@
import os
import pathlib
from enum import Enum
from typing import Any, List, Optional, Union
from typing import TYPE_CHECKING, Any, List, Optional, Union

from cyclonedx.exception import MissingOptionalDependencyException
from cyclonedx.factory.license import LicenseFactory
from cyclonedx.model import ExternalReference, ExternalReferenceType, HashAlgorithm, HashType, Property, XsUri
from cyclonedx.model.bom import Bom
from cyclonedx.model.component import Component, ComponentType
from cyclonedx.model.contact import OrganizationalEntity
from cyclonedx.model.definition import Definitions, Standard
from cyclonedx.model.tool import ToolRepository
from cyclonedx.output import make_outputter
from cyclonedx.output.json import JsonV1Dot6
from cyclonedx.schema import OutputFormat, SchemaVersion
from cyclonedx.validation.json import JsonStrictValidator

if TYPE_CHECKING:
from cyclonedx.output.json import Json as JsonOutputter
from cyclonedx.output.xml import Xml as XmlOutputter

from defusedxml import ElementTree as SafeElementTree # type:ignore[import-untyped]
from sortedcontainers import SortedSet

import capycli.common.script_base
from capycli import LOG
from capycli.common import json_support
from capycli.common.print import print_green, print_yellow
from capycli.main.exceptions import CaPyCliException

# -------------------------------------
Expand Down Expand Up @@ -245,6 +256,33 @@ class SbomCreator():
def __init__(self) -> None:
pass

@staticmethod
def remove_all_tools(sbom: Bom) -> None:
"""Remove all existing tool entries."""
if (not sbom) or (not sbom.metadata) or (not sbom.metadata.tools):
return

if not sbom.metadata.tools.components:
return

sbom.metadata.tools.components.clear()

@staticmethod
def has_capycli_tool(sbom: Bom) -> bool:
"""Checks whether CaPyCLI is already set as tool."""
if (not sbom) or (not sbom.metadata) or (not sbom.metadata.tools):
return False

if not sbom.metadata.tools.components:
return False

comp: Component
for comp in sbom.metadata.tools.components:
if comp.name == "CaPyCLI":
return True

return False

@staticmethod
def get_capycli_tool(version: str = "") -> Component:
"""Get CaPyCLI as tool."""
Expand Down Expand Up @@ -386,11 +424,12 @@ def remove_empty_properties_in_sbom(cls, sbom: Bom) -> None:

@classmethod
def write_to_json(cls, sbom: Bom, outputfile: str, pretty_print: bool = False) -> None:
SbomWriter._remove_tool_python_lib(sbom)
if len(sbom.metadata.tools) == 0:
"""Write CaPyCLI/CycloneDX JSON."""
SbomCreator.remove_all_tools(sbom)
if not SbomCreator.has_capycli_tool(sbom):
sbom.metadata.tools.components.add(SbomCreator.get_capycli_tool())

writer = JsonV1Dot6(sbom)
writer: 'JsonOutputter' = JsonV1Dot6(sbom)
cls.remove_empty_properties_in_sbom(sbom)

if pretty_print:
Expand All @@ -399,6 +438,20 @@ def write_to_json(cls, sbom: Bom, outputfile: str, pretty_print: bool = False) -
else:
writer.output_to_file(filename=outputfile, allow_overwrite=True)

@classmethod
def write_to_xml(cls, sbom: Bom, outputfile: str, pretty_print: bool = False) -> None:
"""Write CaPyCLI/CycloneDX XML."""
SbomCreator.remove_all_tools(sbom)
if not SbomCreator.has_capycli_tool(sbom):
sbom.metadata.tools.components.add(SbomCreator.get_capycli_tool())
cls.remove_empty_properties_in_sbom(sbom)

writer: 'XmlOutputter' = make_outputter(sbom, OutputFormat.XML, SchemaVersion.V1_6)
if pretty_print:
writer.output_to_file(outputfile, indent=2)
else:
writer.output_to_file(outputfile)


class CaPyCliBom():
"""
Expand All @@ -420,19 +473,23 @@ def read_sbom(cls, inputfile: str) -> Bom:
except Exception as exp:
raise CaPyCliException("Error reading raw JSON file: " + str(exp))

# my_json_validator = JsonStrictValidator(SchemaVersion.V1_6)
# try:
# validation_errors = my_json_validator.validate_str(json_string)
# if validation_errors:
# raise CaPyCliException("JSON validation error: " + repr(validation_errors))
#
# print_green("JSON file successfully validated")
# except MissingOptionalDependencyException as error:
# print_yellow('JSON-validation was skipped due to', error)
bom = Bom.from_json( # type: ignore[attr-defined]
json_data)
return bom

@classmethod
def read_sbom_xml(cls, inputfile: str) -> Bom:
LOG.debug(f"Reading from file {inputfile}")
with open(inputfile) as fin:
try:
xml_data = fin.read()
except Exception as exp:
raise CaPyCliException("Error reading raw XML file: " + str(exp))

bom = Bom.from_xml( # type: ignore[attr-defined]
SafeElementTree.fromstring(xml_data))
return bom

@classmethod
def write_sbom(cls, sbom: Bom, outputfile: str) -> None:
LOG.debug(f"Writing to file {outputfile}")
Expand All @@ -444,6 +501,22 @@ def write_sbom(cls, sbom: Bom, outputfile: str) -> None:
raise CaPyCliException("Error writing CaPyCLI file: " + str(exp))
LOG.debug("done")

@classmethod
def write_sbom_xml(cls, sbom: Bom, outputfile: str) -> None:
LOG.debug(f"Writing to file {outputfile}")
try:
# always add/update profile
SbomCreator.add_profile(sbom, "clearing")

# ensure that file does exist
if os.path.isfile(outputfile):
os.remove(outputfile)

SbomWriter.write_to_xml(sbom, outputfile, pretty_print=True)
except Exception as exp:
raise CaPyCliException("Error writing CaPyCLI file: " + str(exp))
LOG.debug("done")

@classmethod
def write_simple_sbom(cls, bom: SortedSet, outputfile: str) -> None:
LOG.debug(f"Writing to file {outputfile}")
Expand All @@ -454,3 +527,39 @@ def write_simple_sbom(cls, bom: SortedSet, outputfile: str) -> None:
except Exception as exp:
raise CaPyCliException("Error writing CaPyCLI file: " + str(exp))
LOG.debug("done")

@classmethod
def _string_to_schema_version(cls, spec_version: str) -> SchemaVersion:
"""Convert the given string to a CycloneDX spec version."""
if spec_version == "1.6":
return SchemaVersion.V1_6
if spec_version == "1.5":
return SchemaVersion.V1_5
if spec_version == "1.4":
return SchemaVersion.V1_4

print_yellow("Unknown CycloneDX spec version, defaulting to 1.6")
return SchemaVersion.V1_6

@classmethod
def validate_sbom(cls, inputfile: str, spec_version: str) -> bool:
"""Validate the given SBOM file against the given CycloneDX spec. version."""
LOG.debug(f"Validating SBOM from file {inputfile}")
with open(inputfile) as fin:
try:
json_string = fin.read()
except Exception as exp:
raise CaPyCliException("Error reading raw JSON file: " + str(exp))

my_json_validator = JsonStrictValidator(cls._string_to_schema_version(spec_version))
try:
validation_errors = my_json_validator.validate_str(json_string)
if validation_errors:
raise CaPyCliException("JSON validation error: " + repr(validation_errors))

print_green("JSON file successfully validated.")
return True
except MissingOptionalDependencyException as error:
print_yellow('JSON-validation was skipped due to', error)

return False
Loading

0 comments on commit f19ca91

Please sign in to comment.