diff --git a/src/main/scala/io/github/siculo/sbtbom/BomError.scala b/src/main/scala/io/github/siculo/sbtbom/BomError.scala new file mode 100644 index 0000000..bfa2d00 --- /dev/null +++ b/src/main/scala/io/github/siculo/sbtbom/BomError.scala @@ -0,0 +1,3 @@ +package io.github.siculo.sbtbom + +class BomError(message: String) extends Exception(message) diff --git a/src/main/scala/io/github/siculo/sbtbom/BomExtractor.scala b/src/main/scala/io/github/siculo/sbtbom/BomExtractor.scala index 11b5e5b..1bd00c9 100644 --- a/src/main/scala/io/github/siculo/sbtbom/BomExtractor.scala +++ b/src/main/scala/io/github/siculo/sbtbom/BomExtractor.scala @@ -9,6 +9,7 @@ import sbt._ import java.util import java.util.UUID import scala.collection.JavaConverters._ +import scala.collection.immutable class BomExtractor(settings: BomExtractorParams, report: UpdateReport, log: Logger) { private val serialNumber: String = "urn:uuid:" + UUID.randomUUID.toString @@ -61,9 +62,9 @@ class BomExtractor(settings: BomExtractorParams, report: UpdateReport, log: Logg class ComponentExtractor(moduleReport: ModuleReport) { def component: Component = { - val group = moduleReport.module.organization - val name = moduleReport.module.name - val version = moduleReport.module.revision + val group: String = moduleReport.module.organization + val name: String = moduleReport.module.name + val version: String = moduleReport.module.revision /* moduleReport.extraAttributes found keys are: - "info.apiURL" @@ -72,7 +73,7 @@ class BomExtractor(settings: BomExtractorParams, report: UpdateReport, log: Logg val component = new Component() component.setGroup(group) component.setName(name) - component.setVersion(moduleReport.module.revision) + component.setVersion(version) component.setModified(false) component.setType(Component.Type.LIBRARY) component.setPurl( @@ -97,16 +98,20 @@ class BomExtractor(settings: BomExtractorParams, report: UpdateReport, log: Logg } private def licenseChoice: Option[LicenseChoice] = { - if (moduleReport.licenses.isEmpty) + val licenses: Seq[model.License] = moduleReport.licenses.map { + case (name, mayBeUrl) => + model.License(name, mayBeUrl) + } + if (licenses.isEmpty) None else { val choice = new LicenseChoice() - moduleReport.licenses.foreach { - case (name, mayBeUrl) => + licenses.foreach { + modelLicense => val license = new License() - license.setName(name) + license.setName(modelLicense.name) if (settings.schemaVersion != CycloneDxSchema.Version.VERSION_10) { - mayBeUrl.foreach(license.setUrl) + modelLicense.url.foreach(license.setUrl) } choice.addLicense(license) } diff --git a/src/main/scala/io/github/siculo/sbtbom/BomSbtPlugin.scala b/src/main/scala/io/github/siculo/sbtbom/BomSbtPlugin.scala index 7286a90..a8f74f8 100644 --- a/src/main/scala/io/github/siculo/sbtbom/BomSbtPlugin.scala +++ b/src/main/scala/io/github/siculo/sbtbom/BomSbtPlugin.scala @@ -1,8 +1,8 @@ package io.github.siculo.sbtbom +import io.github.siculo.sbtbom.PluginConstants._ import org.cyclonedx.model.Component import sbt.Keys.{artifact, configuration, version} -import sbt.SlashSyntax.RichConfiguration import sbt.{Def, _} import scala.language.postfixOps @@ -18,6 +18,7 @@ object BomSbtPlugin extends AutoPlugin { object autoImport { lazy val bomFileName: SettingKey[String] = settingKey[String]("bom file name") + lazy val bomSchemaVersion: SettingKey[String] = settingKey[String](s"bom schema version; must be one of ${supportedVersionsDescr}; default is ${defaultSupportedVersionDescr}") lazy val makeBom: TaskKey[sbt.File] = taskKey[sbt.File]("Generates bom file") lazy val listBom: TaskKey[String] = taskKey[String]("Returns the bom") lazy val components: TaskKey[Component] = taskKey[Component]("Returns the bom") @@ -36,6 +37,7 @@ object BomSbtPlugin extends AutoPlugin { } Seq( bomFileName := bomFileNameSetting.value, + bomSchemaVersion := defaultSupportedVersion.getVersionString, makeBom := Def.taskDyn(BomSbtSettings.makeBomTask(Classpaths.updateTask.value, Compile)).value, listBom := Def.taskDyn(BomSbtSettings.listBomTask(Classpaths.updateTask.value, Compile)).value, Test / makeBom := Def.taskDyn(BomSbtSettings.makeBomTask(Classpaths.updateTask.value, Test)).value, diff --git a/src/main/scala/io/github/siculo/sbtbom/BomSbtSettings.scala b/src/main/scala/io/github/siculo/sbtbom/BomSbtSettings.scala index 85436d8..1af5531 100644 --- a/src/main/scala/io/github/siculo/sbtbom/BomSbtSettings.scala +++ b/src/main/scala/io/github/siculo/sbtbom/BomSbtSettings.scala @@ -1,50 +1,20 @@ package io.github.siculo.sbtbom -import _root_.io.github.siculo.sbtbom.BomSbtPlugin.autoImport._ -import org.apache.commons.io.FileUtils -import org.cyclonedx.model.Bom -import org.cyclonedx.{BomGeneratorFactory, CycloneDxSchema} -import sbt.Keys.{configuration, sLog, target} -import sbt.{Compile, Configuration, Def, File, IntegrationTest, Provided, Runtime, Test, _} - -import java.nio.charset.Charset +import io.github.siculo.sbtbom.BomSbtPlugin.autoImport._ +import sbt.Keys.{sLog, target} +import sbt._ object BomSbtSettings { - val schemaVersion: CycloneDxSchema.Version = CycloneDxSchema.Version.VERSION_10 - def makeBomTask(report: UpdateReport, currentConfiguration: Configuration): Def.Initialize[Task[sbt.File]] = Def.task[File] { - val log: Logger = sLog.value - - val bomFile = target.value / (currentConfiguration / bomFileName).value - - log.info(s"Creating bom file ${bomFile.getAbsolutePath}") - - val params = extractorParams(currentConfiguration) - val bom: Bom = new BomExtractor(params, report, log).bom - val bomText: String = getXmlText(bom, schemaVersion) - logBomInfo(log, params, bom) - - FileUtils.write(bomFile, bomText, Charset.forName("UTF-8"), false) - - log.info(s"Bom file ${bomFile.getAbsolutePath} created") - - bomFile + new MakeBomTask( + BomTaskProperties(report, currentConfiguration, sLog.value, bomSchemaVersion.value), + target.value / (currentConfiguration / bomFileName).value + ).execute } def listBomTask(report: UpdateReport, currentConfiguration: Configuration): Def.Initialize[Task[String]] = Def.task[String] { - val log: Logger = sLog.value - - log.info("Creating bom") - - val params = extractorParams(currentConfiguration) - val bom: Bom = new BomExtractor(params, report, log).bom - val bomText: String = getXmlText(bom, schemaVersion) - logBomInfo(log, params, bom) - - log.info("Bom created") - - bomText + new ListBomTask(BomTaskProperties(report, currentConfiguration, sLog.value, bomSchemaVersion.value)).execute } def bomConfigurationTask(currentConfiguration: Option[Configuration]): Def.Initialize[Task[Seq[Configuration]]] = @@ -76,20 +46,4 @@ object BomSbtSettings { } } - private def extractorParams(currentConfiguration: Configuration): BomExtractorParams = - BomExtractorParams(schemaVersion, currentConfiguration) - - private def logBomInfo(log: Logger, params: BomExtractorParams, bom: Bom): Unit = { - log.info(s"Schema version: ${schemaVersion.getVersionString}") - log.info(s"Serial number : ${bom.getSerialNumber}") - log.info(s"Scope : ${params.configuration.id}") - } - - private def getXmlText(bom: Bom, schemaVersion: CycloneDxSchema.Version) = { - val bomGenerator = BomGeneratorFactory.createXml(schemaVersion, bom) - bomGenerator.generate - val bomText = bomGenerator.toXmlString - bomText - } - } diff --git a/src/main/scala/io/github/siculo/sbtbom/BomTask.scala b/src/main/scala/io/github/siculo/sbtbom/BomTask.scala new file mode 100644 index 0000000..62ed7a7 --- /dev/null +++ b/src/main/scala/io/github/siculo/sbtbom/BomTask.scala @@ -0,0 +1,81 @@ +package io.github.siculo.sbtbom + +import io.github.siculo.sbtbom.PluginConstants._ +import org.apache.commons.io.FileUtils +import org.cyclonedx.model.Bom +import org.cyclonedx.parsers.XmlParser +import org.cyclonedx.{BomGeneratorFactory, CycloneDxSchema} +import sbt._ + +import java.nio.charset.Charset +import scala.collection.JavaConverters._ + +case class BomTaskProperties(report: UpdateReport, currentConfiguration: Configuration, log: Logger, schemaVersion: String) + +abstract class BomTask[T](protected val properties: BomTaskProperties) { + + def execute: T + + protected def getBomText: String = { + val params: BomExtractorParams = extractorParams(currentConfiguration) + val bom: Bom = new BomExtractor(params, report, log).bom + val bomText: String = getXmlText(bom) + logBomInfo(params, bom) + bomText + } + + protected def writeToFile(destFile: File, text: String): Unit = { + FileUtils.write(destFile, text, Charset.forName("UTF-8"), false) + } + + protected def validateBomFile(bomFile: File): Unit = { + val parser = new XmlParser() + val exceptions = parser.validate(bomFile, schemaVersion).asScala + if (exceptions.nonEmpty) { + val message = s"The BOM file ${bomFile.getAbsolutePath} does not conform to the CycloneDX BOM standard as defined by the XSD" + log.error(s"$message:") + exceptions.foreach { + exception => + log.error(s"- ${exception.getMessage}") + } + throw new BomError(message) + } + } + + @throws[BomError] + protected def raiseException(message: String): Unit = { + log.error(message) + throw new BomError(message) + } + + private def extractorParams(currentConfiguration: Configuration): BomExtractorParams = + BomExtractorParams(schemaVersion, currentConfiguration) + + private def getXmlText(bom: Bom): String = { + val bomGenerator = BomGeneratorFactory.createXml(schemaVersion, bom) + bomGenerator.generate + val bomText = bomGenerator.toXmlString + bomText + } + + protected def logBomInfo(params: BomExtractorParams, bom: Bom): Unit = { + log.info(s"Schema version: ${schemaVersion.getVersionString}") + // log.info(s"Serial number : ${bom.getSerialNumber}") + log.info(s"Scope : ${params.configuration.id}") + } + + protected def report: UpdateReport = properties.report + + protected def currentConfiguration: Configuration = properties.currentConfiguration + + protected def log: Logger = properties.log + + protected lazy val schemaVersion: CycloneDxSchema.Version = + supportedVersions.find(_.getVersionString == properties.schemaVersion) match { + case Some(foundVersion) => foundVersion + case None => + val message = s"Unsupported schema version ${properties.schemaVersion}" + log.error(message) + throw new BomError(message) + } +} diff --git a/src/main/scala/io/github/siculo/sbtbom/ListBomTask.scala b/src/main/scala/io/github/siculo/sbtbom/ListBomTask.scala new file mode 100644 index 0000000..df67692 --- /dev/null +++ b/src/main/scala/io/github/siculo/sbtbom/ListBomTask.scala @@ -0,0 +1,10 @@ +package io.github.siculo.sbtbom + +class ListBomTask(properties: BomTaskProperties) extends BomTask[String](properties) { + override def execute: String = { + log.info("Creating bom") + val bomText = getBomText + log.info("Bom created") + bomText + } +} diff --git a/src/main/scala/io/github/siculo/sbtbom/MakeBomTask.scala b/src/main/scala/io/github/siculo/sbtbom/MakeBomTask.scala new file mode 100644 index 0000000..816658e --- /dev/null +++ b/src/main/scala/io/github/siculo/sbtbom/MakeBomTask.scala @@ -0,0 +1,18 @@ +package io.github.siculo.sbtbom + +import sbt._ + +class MakeBomTask(properties: BomTaskProperties, + bomFile: File) + extends BomTask[File](properties) { + + override def execute: File = { + log.info(s"Creating bom file ${bomFile.getAbsolutePath}") + val bomText = getBomText + writeToFile(bomFile, bomText) + validateBomFile(bomFile) + log.info(s"Bom file ${bomFile.getAbsolutePath} created") + bomFile + } +} + diff --git a/src/main/scala/io/github/siculo/sbtbom/PluginConstants.scala b/src/main/scala/io/github/siculo/sbtbom/PluginConstants.scala new file mode 100644 index 0000000..8eecb1d --- /dev/null +++ b/src/main/scala/io/github/siculo/sbtbom/PluginConstants.scala @@ -0,0 +1,22 @@ +package io.github.siculo.sbtbom + +import org.cyclonedx.CycloneDxSchema + +object PluginConstants { + val supportedVersions: Seq[CycloneDxSchema.Version] = Seq( + CycloneDxSchema.Version.VERSION_10, + CycloneDxSchema.Version.VERSION_11, + CycloneDxSchema.Version.VERSION_12, + CycloneDxSchema.Version.VERSION_13, + CycloneDxSchema.Version.VERSION_14 + ) + val defaultSupportedVersion = CycloneDxSchema.Version.VERSION_10 + val supportedVersionsDescr: String = { + supportedVersions.take(supportedVersions.size - 1).map(schemaVersionDescr).mkString(", ") + " or " + schemaVersionDescr(supportedVersions.last) + } + val defaultSupportedVersionDescr: String = schemaVersionDescr(defaultSupportedVersion) + + private def schemaVersionDescr(version: CycloneDxSchema.Version): String = { + s""""${version.getVersionString}"""" + } +} diff --git a/src/main/scala/io/github/siculo/sbtbom/model/License.scala b/src/main/scala/io/github/siculo/sbtbom/model/License.scala new file mode 100644 index 0000000..895eb59 --- /dev/null +++ b/src/main/scala/io/github/siculo/sbtbom/model/License.scala @@ -0,0 +1,3 @@ +package io.github.siculo.sbtbom.model + +case class License(name: String, url: Option[String]) diff --git a/src/main/scala/io/github/siculo/sbtbom/model/Module.scala b/src/main/scala/io/github/siculo/sbtbom/model/Module.scala new file mode 100644 index 0000000..955a8d7 --- /dev/null +++ b/src/main/scala/io/github/siculo/sbtbom/model/Module.scala @@ -0,0 +1,13 @@ +package io.github.siculo.sbtbom.model + +import org.cyclonedx.model.Component.{Scope, Type} + +case class Module( + group: String, + name: String, + version: String, + modified: Boolean, + componentType: Type, + componentScope: Scope, + licenses: Seq[License] + ) diff --git a/src/sbt-test/schemaVersion/unsupported/build.sbt b/src/sbt-test/schemaVersion/unsupported/build.sbt new file mode 100644 index 0000000..f029da0 --- /dev/null +++ b/src/sbt-test/schemaVersion/unsupported/build.sbt @@ -0,0 +1,23 @@ +import sbt.Keys._ + +lazy val root = (project in file(".")) + .settings( + name := "dependencies", + version := "0.1", + libraryDependencies ++= Dependencies.library, + Test / bomFileName := "bom.xml", + scalaVersion := "2.12.8", + bomSchemaVersion := "999", + check := Def.sequential( + Compile / clean, + Compile / compile, + checkTask + ).value + ) + +lazy val check = taskKey[Unit]("check") +lazy val checkTask = Def.task { + val s: TaskStreams = streams.value + s.log.info("Verifying makeBom param validation...") + (Test / makeBom).value +} diff --git a/src/sbt-test/schemaVersion/unsupported/project/Dependencies.scala b/src/sbt-test/schemaVersion/unsupported/project/Dependencies.scala new file mode 100644 index 0000000..1de6282 --- /dev/null +++ b/src/sbt-test/schemaVersion/unsupported/project/Dependencies.scala @@ -0,0 +1,15 @@ +import sbt._ + +object Dependencies { + + private val circeVersion = "0.10.0" + private val scalatestVersion = "3.0.5" + + lazy val library = Seq( + "io.circe" %% "circe-core" % circeVersion, + "io.circe" %% "circe-generic" % circeVersion, + "io.circe" %% "circe-parser" % circeVersion, + "org.scalatest" %% "scalatest" % scalatestVersion % Test, + ) + +} diff --git a/src/sbt-test/schemaVersion/unsupported/project/build.properties b/src/sbt-test/schemaVersion/unsupported/project/build.properties new file mode 100644 index 0000000..19479ba --- /dev/null +++ b/src/sbt-test/schemaVersion/unsupported/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.5.2 diff --git a/src/sbt-test/schemaVersion/unsupported/project/plugins.sbt b/src/sbt-test/schemaVersion/unsupported/project/plugins.sbt new file mode 100644 index 0000000..20b5061 --- /dev/null +++ b/src/sbt-test/schemaVersion/unsupported/project/plugins.sbt @@ -0,0 +1,17 @@ +( + sys.props.get("plugin.version"), + sys.props.get("plugin.organization") +) match { + case (Some(version), Some(organization)) => + addSbtPlugin(organization % "sbt-bom" % version) + case (None, _) => + sys.error( + """|The system property 'plugin.version' is not defined. + |Specify this property using the scriptedLaunchOpts -D.""".stripMargin + ) + case (_, None) => + sys.error( + """|The system property 'plugin.organization' is not defined. + |Specify this property using the scriptedLaunchOpts -D.""".stripMargin + ) +} diff --git a/src/sbt-test/schemaVersion/unsupported/test b/src/sbt-test/schemaVersion/unsupported/test new file mode 100644 index 0000000..3c2c893 --- /dev/null +++ b/src/sbt-test/schemaVersion/unsupported/test @@ -0,0 +1 @@ +-> check diff --git a/src/test/scala/io/github/siculo/sbtbom/PluginConstantsSpec.scala b/src/test/scala/io/github/siculo/sbtbom/PluginConstantsSpec.scala new file mode 100644 index 0000000..c584866 --- /dev/null +++ b/src/test/scala/io/github/siculo/sbtbom/PluginConstantsSpec.scala @@ -0,0 +1,12 @@ +package io.github.siculo.sbtbom + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class PluginConstantsSpec extends AnyWordSpec with Matchers { + "PluginConstants" should { + "return the description of the supported versions" in { + PluginConstants.supportedVersionsDescr shouldBe """"1.0", "1.1", "1.2", "1.3" or "1.4"""" + } + } +}