Skip to content

Commit 287f7ba

Browse files
authored
Merge pull request #162 from JayMandala/root-component-metadata
Component Metadata, Sbom Output Path, PURL Adjustment
2 parents 8f67064 + 1654353 commit 287f7ba

File tree

17 files changed

+368
-82
lines changed

17 files changed

+368
-82
lines changed

src/main/scala/com/github/sbt/sbom/BomExtractor.scala

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package com.github.sbt.sbom
66

77
import com.github.packageurl.PackageURL
8+
import com.github.sbt.sbom.BomExtractor.purl
89
import com.github.sbt.sbom.licenses.LicensesArchive
910
import org.cyclonedx.Version
1011
import org.cyclonedx.model.{
@@ -22,11 +23,10 @@ import org.cyclonedx.util.BomUtils
2223
import sbt._
2324
import sbt.librarymanagement.ModuleReport
2425

25-
import java.util
26-
import java.util.UUID
26+
import java.util.{ TreeMap => TM, UUID }
2727
import scala.collection.JavaConverters._
2828

29-
import SbtUpdateReport.ModuleGraph
29+
import SbtUpdateReport.{ ModuleGraph, getModuleQualifier }
3030

3131
class BomExtractor(settings: BomExtractorParams, report: UpdateReport, rootModuleID: ModuleID, log: Logger) {
3232
private val serialNumber: String = "urn:uuid:" + UUID.randomUUID.toString
@@ -52,9 +52,26 @@ class BomExtractor(settings: BomExtractorParams, report: UpdateReport, rootModul
5252
metadata.setTimestamp(null)
5353
}
5454
metadata.addTool(tool)
55+
metadata.setComponent(metadataComponent)
5556
metadata
5657
}
5758

59+
private lazy val metadataComponent: Component = {
60+
val metadataComponent = new Component()
61+
val group: String = rootModuleID.organization
62+
val name: String = rootModuleID.name
63+
val version: String = rootModuleID.revision
64+
65+
metadataComponent.setGroup(group)
66+
metadataComponent.setName(name)
67+
metadataComponent.setBomRef(purl(group, name, version))
68+
metadataComponent.setVersion(version)
69+
metadataComponent.setType(toCycloneDxProjectType(settings.projectType))
70+
metadataComponent.setPurl(purl(group, name, version))
71+
72+
metadataComponent
73+
}
74+
5875
private lazy val tool: Tool = {
5976
val tool = new Tool()
6077
// https://github.com/devops-kung-fu/bomber/blob/main/lib/loader.go#L112 searches for string CycloneDX to detect format
@@ -123,17 +140,20 @@ class BomExtractor(settings: BomExtractorParams, report: UpdateReport, rootModul
123140
- "info.apiURL"
124141
- "info.versionScheme"
125142
*/
143+
126144
val component = new Component()
127145
component.setGroup(group)
128146
component.setName(name)
129147
component.setVersion(version)
130148
component.setModified(false)
131149
component.setType(Component.Type.LIBRARY)
132-
component.setPurl(purl(group, name, version))
150+
151+
component.setPurl(purl(group, name, version, getModuleQualifier(moduleReport, Some(log))))
133152
if (settings.schemaVersion.getVersion >= Version.VERSION_11.getVersion) {
134153
// component bom-refs must be unique
135154
component.setBomRef(component.getPurl)
136155
}
156+
137157
component.setScope(Component.Scope.REQUIRED)
138158
if (settings.includeBomHashes) {
139159
component.setHashes(hashes(artifactPaths(moduleReport)).asJava)
@@ -214,9 +234,6 @@ class BomExtractor(settings: BomExtractorParams, report: UpdateReport, rootModul
214234
}
215235
}
216236

217-
private def purl(group: String, name: String, version: String): String =
218-
new PackageURL(PackageURL.StandardTypes.MAVEN, group, name, version, new util.TreeMap(), null).canonicalize()
219-
220237
private def dependencyTree: Seq[Dependency] = {
221238
val dependencyTree = configurationsForComponents(settings.configuration).flatMap { configuration =>
222239
dependencyTreeForConfiguration(configuration)
@@ -235,25 +252,56 @@ class BomExtractor(settings: BomExtractorParams, report: UpdateReport, rootModul
235252
}
236253

237254
class DependencyTreeExtractor(configurationReport: ConfigurationReport) {
238-
def dependencyTree: Seq[Dependency] =
255+
def dependencyTree: Seq[Dependency] = {
239256
moduleGraph.nodes
257+
.filter(_.evictedByVersion.isEmpty)
240258
.sortBy(_.id.idString)
241259
.map { node =>
242-
val bomRef = purl(node.id.organization, node.id.name, node.id.version)
260+
val bomRef = purl(node.id.organization, node.id.name, node.id.version, node.qualifier)
243261

244262
val dependency = new Dependency(bomRef)
245263

246264
val dependsOn = moduleGraph.dependencyMap.getOrElse(node.id, Nil).sortBy(_.id.idString)
247265
dependsOn.foreach { module =>
248-
val bomRef = purl(module.id.organization, module.id.name, module.id.version)
249-
dependency.addDependency(new Dependency(bomRef))
266+
if (module.evictedByVersion.isEmpty) {
267+
val bomRef = purl(module.id.organization, module.id.name, module.id.version, module.qualifier)
268+
269+
dependency.addDependency(new Dependency(bomRef))
270+
}
250271
}
251272

252273
dependency
253274
}
275+
}
254276

255-
private def moduleGraph: ModuleGraph = SbtUpdateReport.fromConfigurationReport(configurationReport, rootModuleID)
277+
private def moduleGraph: ModuleGraph =
278+
SbtUpdateReport.fromConfigurationReport(configurationReport, rootModuleID, log)
256279
}
280+
281+
private def toCycloneDxProjectType(e: ProjectType): Component.Type = {
282+
e match {
283+
case APPLICATION => Component.Type.APPLICATION
284+
case FRAMEWORK => Component.Type.FRAMEWORK
285+
case LIBRARY => Component.Type.LIBRARY
286+
case CONTAINER => Component.Type.CONTAINER
287+
case PLATFORM => Component.Type.PLATFORM
288+
case OPERATING_SYSTEM => Component.Type.OPERATING_SYSTEM
289+
case DEVICE => Component.Type.DEVICE
290+
case DEVICE_DRIVER => Component.Type.DEVICE_DRIVER
291+
case FIRMWARE => Component.Type.FIRMWARE
292+
case FILE => Component.Type.FILE
293+
case MACHINE_LEARNING_MODEL => Component.Type.MACHINE_LEARNING_MODEL
294+
case DATA => Component.Type.DATA
295+
case CRYPTOGRAPHIC_ASSET =>
296+
if (settings.schemaVersion.getVersion < Version.VERSION_16.getVersion)
297+
throw new UnsupportedOperationException(
298+
"Current cyclonedx version does not support CRYPTOGRAPHIC_ASSET. Use 1.6 or newer"
299+
)
300+
else Component.Type.CRYPTOGRAPHIC_ASSET
301+
302+
}
303+
}
304+
257305
def logComponent(component: Component): Unit = {
258306
log.info(s""""
259307
|${component.getGroup}" % "${component.getName}" % "${component.getVersion}",
@@ -263,3 +311,16 @@ class BomExtractor(settings: BomExtractorParams, report: UpdateReport, rootModul
263311
}
264312

265313
}
314+
315+
object BomExtractor {
316+
private[sbom] def purl(
317+
group: String,
318+
name: String,
319+
version: String,
320+
qualifier: Map[String, String] = Map[String, String]()
321+
): String = {
322+
val convertedMap = new TM[String, String](qualifier.asJava)
323+
324+
new PackageURL(PackageURL.StandardTypes.MAVEN, group, name, version, convertedMap, null).canonicalize()
325+
}
326+
}

src/main/scala/com/github/sbt/sbom/BomExtractorParams.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,6 @@ final case class BomExtractorParams(
1717
enableBomSha3Hashes: Boolean,
1818
includeBomExternalReferences: Boolean,
1919
includeBomDependencyTree: Boolean,
20+
projectType: ProjectType,
21+
bomOutputPath: sbt.File
2022
)

src/main/scala/com/github/sbt/sbom/BomSbtPlugin.scala

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ object BomSbtPlugin extends AutoPlugin {
5757
lazy val bomConfigurations: TaskKey[Seq[Configuration]] = taskKey[Seq[Configuration]](
5858
"Returns the list of configurations whose components are included in the generated bom"
5959
)
60+
lazy val projectType: SettingKey[String] = settingKey[String](
61+
"Project type specification. Project will be assigned as 'Library' by default."
62+
)
63+
lazy val bomOutputPath: SettingKey[String] = settingKey[String](
64+
"Output path of the created BOM file. BOM File will be placed in target/ directory by default"
65+
)
6066
}
6167

6268
import autoImport._
@@ -90,6 +96,8 @@ object BomSbtPlugin extends AutoPlugin {
9096
.taskDyn(BomSbtSettings.listBomTask(Classpaths.updateTask.value, IntegrationTest))
9197
.value,
9298
bomConfigurations := Def.taskDyn(BomSbtSettings.bomConfigurationTask((configuration ?).value)).value,
99+
projectType := "library",
100+
bomOutputPath := "",
93101
packagedArtifacts += {
94102
Artifact(
95103
artifact.value.name,

src/main/scala/com/github/sbt/sbom/BomSbtSettings.scala

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ object BomSbtSettings {
1616
(currentConfiguration / bomFileName).?.value,
1717
bomSchemaVersion.value
1818
)
19+
20+
val outputPath = if (bomOutputPath.value.isEmpty) {
21+
target.value
22+
} else {
23+
sbt.file(bomOutputPath.value)
24+
}
25+
26+
val projType = ProjectType.fromString(projectType.value)
1927
new MakeBomTask(
2028
BomTaskProperties(
2129
report,
@@ -33,8 +41,10 @@ object BomSbtSettings {
3341
enableBomSha3Hashes.value,
3442
includeBomExternalReferences.value,
3543
includeBomDependencyTree.value,
44+
projType,
45+
outputPath
3646
),
37-
target.value / (currentConfiguration / bomFileName).value
47+
outputPath / (currentConfiguration / bomFileName).value
3848
).execute
3949
}
4050

@@ -45,6 +55,7 @@ object BomSbtSettings {
4555
(currentConfiguration / bomFileName).?.value,
4656
bomSchemaVersion.value
4757
)
58+
val projType = ProjectType.fromString(projectType.value)
4859
new ListBomTask(
4960
BomTaskProperties(
5061
report,
@@ -62,6 +73,8 @@ object BomSbtSettings {
6273
enableBomSha3Hashes.value,
6374
includeBomExternalReferences.value,
6475
includeBomDependencyTree.value,
76+
projType,
77+
sbt.file(bomOutputPath.value)
6578
)
6679
).execute
6780
}

src/main/scala/com/github/sbt/sbom/BomTask.scala

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ final case class BomTaskProperties(
2929
enableBomSha3Hashes: Boolean,
3030
includeBomExternalReferences: Boolean,
3131
includeBomDependencyTree: Boolean,
32+
projectType: ProjectType,
33+
bomOutputPath: sbt.File
3234
)
3335

3436
abstract class BomTask[T](protected val properties: BomTaskProperties) {
@@ -84,6 +86,8 @@ abstract class BomTask[T](protected val properties: BomTaskProperties) {
8486
enableBomSha3Hashes,
8587
includeBomExternalReferences,
8688
includeBomDependencyTree,
89+
projectType,
90+
bomOutputPath
8791
)
8892

8993
protected def logBomInfo(params: BomExtractorParams, bom: Bom): Unit = {
@@ -124,4 +128,8 @@ abstract class BomTask[T](protected val properties: BomTaskProperties) {
124128
protected lazy val includeBomExternalReferences: Boolean = properties.includeBomExternalReferences
125129

126130
protected lazy val includeBomDependencyTree: Boolean = properties.includeBomDependencyTree
131+
132+
protected lazy val projectType: ProjectType = properties.projectType
133+
134+
protected lazy val bomOutputPath: sbt.File = properties.bomOutputPath
127135
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// SPDX-FileCopyrightText: The sbt-sbom team
2+
//
3+
// SPDX-License-Identifier: MIT
4+
5+
package com.github.sbt.sbom
6+
7+
sealed trait ProjectType
8+
case object APPLICATION extends ProjectType
9+
case object FRAMEWORK extends ProjectType
10+
case object LIBRARY extends ProjectType
11+
case object CONTAINER extends ProjectType
12+
case object PLATFORM extends ProjectType
13+
case object OPERATING_SYSTEM extends ProjectType
14+
case object DEVICE extends ProjectType
15+
case object DEVICE_DRIVER extends ProjectType
16+
case object FIRMWARE extends ProjectType
17+
case object FILE extends ProjectType
18+
case object MACHINE_LEARNING_MODEL extends ProjectType
19+
case object DATA extends ProjectType
20+
case object CRYPTOGRAPHIC_ASSET extends ProjectType
21+
22+
object ProjectType {
23+
def fromString(t: String): ProjectType = {
24+
val projType = t.trim().toUpperCase().replace("-", "_")
25+
26+
Vector(
27+
APPLICATION,
28+
FRAMEWORK,
29+
LIBRARY,
30+
CONTAINER,
31+
PLATFORM,
32+
OPERATING_SYSTEM,
33+
DEVICE,
34+
DEVICE_DRIVER,
35+
FIRMWARE,
36+
FILE,
37+
MACHINE_LEARNING_MODEL,
38+
DATA,
39+
CRYPTOGRAPHIC_ASSET
40+
).find(_.toString == projType)
41+
.getOrElse(
42+
throw new ClassNotFoundException(
43+
s"Given Project Type not found. Refer to ${ProjectType.getClass.getName} or the list of available type."
44+
)
45+
)
46+
}
47+
}

src/main/scala/com/github/sbt/sbom/SbtUpdateReport.scala

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
package com.github.sbt.sbom
66

77
import sbt.librarymanagement.{ ConfigurationReport, ModuleID, ModuleReport }
8-
import sbt.{ File, OrganizationArtifactReport }
8+
import sbt.{ File, Logger, OrganizationArtifactReport }
99

1010
import scala.collection.mutable
1111

@@ -24,7 +24,8 @@ object SbtUpdateReport {
2424
extraInfo: String = "",
2525
evictedByVersion: Option[String] = None,
2626
jarFile: Option[File] = None,
27-
error: Option[String] = None
27+
error: Option[String] = None,
28+
qualifier: Map[String, String] = Map[String, String]()
2829
)
2930

3031
private type Edge = (GraphModuleId, GraphModuleId)
@@ -62,7 +63,43 @@ object SbtUpdateReport {
6263
GraphModuleId(sbtId.organization, sbtId.name, sbtId.revision)
6364
}
6465

65-
def fromConfigurationReport(report: ConfigurationReport, rootInfo: ModuleID): ModuleGraph = {
66+
def getModuleQualifier(moduleReport: ModuleReport, log: Option[Logger] = None): Map[String, String] = {
67+
val qualifier = new mutable.HashMap[String, String]()
68+
69+
// Getting artifact with the same name as module name as purl qualifier
70+
val moduleArtifacts = moduleReport.artifacts
71+
.filter(ar => {
72+
ar._1.name.equals(moduleReport.module.name)
73+
})
74+
.sortBy { x => (x._1.`type`, x._1.classifier, x._1.hashCode()) }
75+
76+
moduleArtifacts.size match {
77+
case 0 => () // ignore empty found artifacts
78+
case x =>
79+
if (x > 1 && log.isDefined) {
80+
log.foreach(
81+
_.warn(
82+
"Multiple artifacts with the same name as module name are detected. Taking the first artifact match as Purl qualifier."
83+
)
84+
)
85+
}
86+
if (moduleArtifacts.head._1.`type`.nonEmpty) {
87+
// "jar" type will not be shown, since it's the default value of an artifact.
88+
if (moduleArtifacts.head._1.`type` != "jar") {
89+
qualifier.put("type", moduleArtifacts.head._1.`type`)
90+
}
91+
}
92+
moduleArtifacts.head._1.classifier.foreach(classifier =>
93+
if (classifier.nonEmpty) {
94+
qualifier.put("classifier", classifier)
95+
}
96+
)
97+
}
98+
99+
qualifier.toMap
100+
}
101+
102+
def fromConfigurationReport(report: ConfigurationReport, rootInfo: ModuleID, log: Logger): ModuleGraph = {
66103
def moduleEdges(orgArt: OrganizationArtifactReport): Seq[(Module, Seq[Edge])] = {
67104
val chosenVersion = orgArt.modules.find(!_.evicted).map(_.module.revision)
68105
orgArt.modules.map(moduleEdge(chosenVersion))
@@ -80,11 +117,13 @@ object SbtUpdateReport {
80117
license = report.licenses.headOption.map(_._1),
81118
evictedByVersion = evictedByVersion,
82119
jarFile = jarFile,
83-
error = report.problem
120+
error = report.problem,
121+
qualifier = getModuleQualifier(report)
84122
),
85123
report.callers.map(caller => Edge(GraphModuleId(caller.caller), GraphModuleId(report.module)))
86124
)
87125
}
126+
88127
val (nodes, edges) = report.details.flatMap(moduleEdges).unzip
89128
val root = Module(GraphModuleId(rootInfo))
90129

0 commit comments

Comments
 (0)