Skip to content

Commit b740cdc

Browse files
committed
Generation Software Bill of Materials (SBOM)
Motivation: In some companies, the development team has to produce Software Bill of Materials (SBOM) for their project for compliance reasons: To track dependencies and licenses across their organisation. Provide a Module that produces SBOMs in JSON format. Changes in the core: Extended the .getArtifact to return the coursier.Resolution as well. This is then used to get the license information. Outside the core: Add a SBOM contrib module - Generate the most basic CycloneDX SBOM files Supporting Java modules for a start - Provide a basic upload to the Dependency Track server
1 parent f5d0c9f commit b740cdc

File tree

12 files changed

+652
-7
lines changed

12 files changed

+652
-7
lines changed

contrib/bloop/src/mill/contrib/bloop/BloopImpl.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ class BloopImpl(
377377

378378
val millBuildDependencies: Task[List[BloopConfig.Module]] = Task.Anon {
379379

380-
val result = module.defaultResolver().artifacts(
380+
val (_, result) = module.defaultResolver().artifacts(
381381
BuildInfo.millAllDistDependencies
382382
.split(',')
383383
.filter(_.nonEmpty)

contrib/package.mill

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,4 +222,9 @@ object `package` extends RootModule {
222222
def compileModuleDeps = Seq(build.scalalib)
223223
def testModuleDeps = super.testModuleDeps ++ Seq(build.scalalib)
224224
}
225+
226+
object sbom extends ContribModule {
227+
def compileModuleDeps = Seq(build.scalalib)
228+
def testModuleDeps: Seq[JavaModule] = super.testModuleDeps ++ Seq(build.scalalib)
229+
}
225230
}

contrib/sbom/readme.adoc

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
= SBOM file
2+
:page-aliases: Plugin_SBOM.adoc
3+
4+
This plugin creates Software Bill of Materials (SBOM)
5+
6+
This module has some limitations at the moment:
7+
8+
- Minimal SBOM, various properties of libraries are missing. e.g. the license.
9+
- Only JVM ecosystem libraries are reported.
10+
- Only the CycloneDX JSON format is supported
11+
12+
To declare a module that generates an SBT extend the `mill.contrib.sbom.CycloneDXModuleTests` trait when defining your module.
13+
14+
Quickstart:
15+
16+
.`build.mill`
17+
[source,scala]
18+
----
19+
package build
20+
import mill.*
21+
import mill.javalib.*
22+
import $ivy.`com.lihaoyi::mill-contrib-sbom:`
23+
import mill.contrib.sbom.CycloneDXJavaModule
24+
25+
object `sbom-demo` extends JavaModule with CycloneDXJavaModule {
26+
// An example dependency
27+
override def ivyDeps = Seq(ivy"ch.qos.logback:logback-classic:1.5.12")
28+
}
29+
----
30+
31+
This provides the `sbomJsonFile` task that produces a CycloneDX JSON file:
32+
33+
[source,bash]
34+
----
35+
$ mill show sbom-demo.sbomJsonFile # Creates the SBOM file in the JSON format
36+
----
37+
38+
== Uploading to Dependency Track
39+
Uploading the BOM to (https://dependencytrack.org/)[Dependency Track] is supported.
40+
Add the `DependencyTrackModule` and provide the necessary details:
41+
42+
.`build.mill`
43+
[source,scala]
44+
----
45+
package build
46+
import mill.*
47+
import mill.javalib.*
48+
import $ivy.`com.lihaoyi::mill-contrib-sbom:`
49+
import mill.contrib.sbom.CycloneDXModule
50+
import mill.contrib.sbom.upload.DependencyTrack
51+
52+
object `sbom-demo` extends JavaModule with CycloneDXJavaModule with DependencyTrackModule {
53+
def depTrackUrl = "http://localhost:8081"
54+
def depTrackProjectID = "7c1a9efd-8f05-4cdb-bb16-602cb5c1d6e0"
55+
def depTrackApiKey = "odt_rTKFk9MCDtWpdun1VKUUfsOsdOumo96q"
56+
// An example dependency
57+
override def ivyDeps = Seq(ivy"ch.qos.logback:logback-classic:1.5.12")
58+
}
59+
----
60+
61+
Affter that you upload the SBOM:
62+
63+
[source,bash]
64+
----
65+
./mill sbom-demo.sbomUpload
66+
----
67+
68+
69+
70+
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package mill.contrib.sbom
2+
3+
import mill.*
4+
import mill.contrib.sbom.CycloneDXModule.Component
5+
import mill.javalib.{BoundDep, JavaModule}
6+
import coursier.{Artifacts, Dependency, Resolution, VersionConstraint, core as cs}
7+
import os.Path
8+
import upickle.default.{ReadWriter, macroRW}
9+
10+
import java.math.BigInteger
11+
import java.security.MessageDigest
12+
import java.time.Instant
13+
import java.util.{UUID}
14+
15+
object CycloneDXModule {
16+
case class SBOM_JSON(
17+
bomFormat: String,
18+
specVersion: String,
19+
serialNumber: String,
20+
version: Int,
21+
metadata: MetaData,
22+
components: Seq[Component]
23+
)
24+
25+
case class MetaData(timestamp: String = Instant.now().toString)
26+
27+
case class ComponentHash(alg: String, content: String)
28+
29+
case class LicenseHolder(license: License)
30+
31+
case class License(name: String, url: Option[String])
32+
33+
case class Component(
34+
`type`: String,
35+
`bom-ref`: String,
36+
group: String,
37+
name: String,
38+
version: String,
39+
description: String,
40+
licenses: Seq[LicenseHolder],
41+
hashes: Seq[ComponentHash]
42+
)
43+
44+
object Component {
45+
def fromDeps(path: Path, dep: Dependency, licenses: Seq[coursier.Info.License]): Component = {
46+
val compLicenses = licenses.map { lic =>
47+
LicenseHolder(License(lic.name, lic.url))
48+
}
49+
Component(
50+
"library",
51+
s"pkg:maven/${dep.module.organization.value}/${dep.module.name.value}@${dep.version}?type=jar",
52+
dep.module.organization.value,
53+
dep.module.name.value,
54+
dep.version,
55+
dep.module.orgName,
56+
compLicenses,
57+
Seq(ComponentHash("SHA-256", sha256(path)))
58+
)
59+
}
60+
}
61+
62+
implicit val sbomRW: ReadWriter[SBOM_JSON] = macroRW
63+
implicit val metaRW: ReadWriter[MetaData] = macroRW
64+
implicit val componentHashRW: ReadWriter[ComponentHash] = macroRW
65+
implicit val componentRW: ReadWriter[Component] = macroRW
66+
implicit val licenceHolderRW: ReadWriter[LicenseHolder] = macroRW
67+
implicit val licenceRW: ReadWriter[License] = macroRW
68+
69+
private def sha256(f: Path): String = {
70+
val md = MessageDigest.getInstance("SHA-256")
71+
val fileContent = os.read.bytes(f)
72+
val digest = md.digest(fileContent)
73+
String.format("%0" + (digest.length << 1) + "x", new BigInteger(1, digest))
74+
}
75+
76+
case class SbomHeader(serialNumber: UUID, timestamp: Instant)
77+
78+
}
79+
80+
trait CycloneDXJavaModule extends JavaModule with CycloneDXModule {
81+
82+
/**
83+
* Lists of all components used for this module.
84+
* By default, uses the [[ivyDeps]] and [[runIvyDeps]] for the list of components
85+
*/
86+
def sbomComponents: Task[Seq[Component]] = Task {
87+
val (resolution, artifacts) = resolvedRunIvyDepsDetails()()
88+
resolvedSbomComponents(resolution, artifacts)
89+
}
90+
91+
protected def resolvedSbomComponents(
92+
resolution: Resolution,
93+
artifacts: Artifacts.Result
94+
): Seq[Component] = {
95+
val distinctDeps = artifacts.fullDetailedArtifacts
96+
.flatMap {
97+
case (dep, _, _, Some(path)) => Some(dep -> path)
98+
case _ => None
99+
}
100+
// Artifacts.Result.files does eliminate duplicates path: Do the same
101+
.distinctBy(_._2)
102+
.map { case (dep, path) =>
103+
val license = findLicenses(resolution, dep.module, dep.versionConstraint)
104+
Component.fromDeps(os.Path(path), dep, license)
105+
}
106+
distinctDeps
107+
}
108+
109+
/** Copied from [[resolvedRunIvyDeps]], but getting the raw artifacts */
110+
private def resolvedRunIvyDepsDetails(): Task[(Resolution, Artifacts.Result)] = Task.Anon {
111+
millResolver().artifacts(Seq(
112+
BoundDep(
113+
coursierDependency.withConfiguration(cs.Configuration.runtime),
114+
force = false
115+
)
116+
))
117+
}
118+
119+
private def findLicenses(
120+
resolution: Resolution,
121+
module: coursier.core.Module,
122+
version: VersionConstraint
123+
): Seq[coursier.Info.License] = {
124+
val projects = resolution.projectCache0
125+
val project = projects.get(module -> version)
126+
project match
127+
case None => Seq.empty
128+
case Some((_, proj)) =>
129+
val licences = proj.info.licenseInfo
130+
if (licences.nonEmpty) {
131+
licences
132+
} else {
133+
proj.parent0.map((pm, v) =>
134+
findLicenses(resolution, pm, VersionConstraint.fromVersion(v))
135+
)
136+
.getOrElse(Seq.empty)
137+
}
138+
}
139+
140+
}
141+
142+
trait CycloneDXModule extends Module {
143+
import CycloneDXModule.*
144+
145+
/** Lists of all components used for this module. */
146+
def sbomComponents: Task[Agg[Component]]
147+
148+
/**
149+
* Each time the SBOM is generated, a new UUID and timestamp are generated
150+
* Can be overridden to use a more predictable method, eg. for reproducible builds
151+
*/
152+
def sbomHeader(): SbomHeader = SbomHeader(UUID.randomUUID(), Instant.now())
153+
154+
/**
155+
* Generates the SBOM Json for this module, based on the components returned by [[sbomComponents]]
156+
* @return
157+
*/
158+
def sbom: T[SBOM_JSON] = Target {
159+
val header = sbomHeader()
160+
val components = sbomComponents()
161+
162+
SBOM_JSON(
163+
bomFormat = "CycloneDX",
164+
specVersion = "1.2",
165+
serialNumber = s"urn:uuid:${header.serialNumber}",
166+
version = 1,
167+
metadata = MetaData(timestamp = header.timestamp.toString),
168+
components = components
169+
)
170+
}
171+
172+
def sbomJsonFile: T[PathRef] = Target {
173+
val sbomFile = Target.dest / "sbom.json"
174+
os.write(sbomFile, upickle.default.write(sbom(), indent = 2))
175+
PathRef(sbomFile)
176+
}
177+
178+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package mill.contrib.sbom.upload
2+
3+
import java.util.Base64
4+
import java.nio.charset.StandardCharsets
5+
import mill._
6+
import mill.contrib.sbom.CycloneDXModule
7+
import upickle.default.{ReadWriter, macroRW}
8+
9+
object DependencyTrackModule {
10+
case class Payload(project: String, bom: String)
11+
12+
implicit val depTrackPayload: ReadWriter[Payload] = macroRW
13+
}
14+
trait DependencyTrackModule extends CycloneDXModule {
15+
import DependencyTrackModule._
16+
17+
def depTrackUrl: T[String]
18+
def depTrackProjectID: T[String]
19+
def depTrackApiKey: T[String]
20+
21+
/**
22+
* Uploads the generated SBOM to the configured dependency track instance
23+
*/
24+
def sbomUpload(): Command[Unit] = Task.Command {
25+
val url = depTrackUrl()
26+
val projectId = depTrackProjectID()
27+
val apiKey = depTrackApiKey()
28+
29+
val bomString = upickle.default.write(sbom())
30+
val payload = Payload(
31+
projectId,
32+
Base64.getEncoder.encodeToString(
33+
bomString.getBytes(StandardCharsets.UTF_8)
34+
)
35+
)
36+
val body = upickle.default.stream[Payload](payload)
37+
val bodyBytes = requests.RequestBlob.ByteSourceRequestBlob(body)(identity)
38+
val r = requests.put(
39+
s"$url/api/v1/bom",
40+
headers = Map(
41+
"Content-Type" -> "application/json",
42+
"X-API-Key" -> apiKey
43+
),
44+
data = bodyBytes
45+
)
46+
assert(r.is2xx)
47+
}
48+
49+
def myCmdC(test: String) = Task.Command { println("hi above"); 34 }
50+
51+
}

contrib/sbom/test/reference/pom.xml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<project xmlns="http://maven.apache.org/POM/4.0.0"
2+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
4+
<modelVersion>4.0.0</modelVersion>
5+
6+
<groupId>com.example</groupId>
7+
<artifactId>sbom-example-reference</artifactId>
8+
<packaging>jar</packaging>
9+
<version>1.1-SNAPSHOT</version>
10+
11+
<name>CycloneDX reference</name>
12+
<description>
13+
This is a reference on how the CycloneDX Maven plugin generates an SBOM.
14+
This way we can inspect differences between Mill and the wildly used Maven plugin.
15+
Run: mvn package, then inspect the target/bom.json
16+
</description>
17+
18+
<properties>
19+
<java.version>11</java.version>
20+
<maven.compiler.source>${java.version}</maven.compiler.source>
21+
<maven.compiler.target>${java.version}</maven.compiler.target>
22+
</properties>
23+
24+
25+
<dependencies>
26+
<dependency>
27+
<groupId>ch.qos.logback</groupId>
28+
<artifactId>logback-classic</artifactId>
29+
<version>1.2.3</version>
30+
</dependency>
31+
<dependency>
32+
<groupId>commons-io</groupId>
33+
<artifactId>commons-io</artifactId>
34+
<version>2.18.0</version>
35+
</dependency>
36+
</dependencies>
37+
38+
<build>
39+
<plugins>
40+
<plugin>
41+
<groupId>org.cyclonedx</groupId>
42+
<artifactId>cyclonedx-maven-plugin</artifactId>
43+
<executions>
44+
<execution>
45+
<phase>package</phase>
46+
<goals>
47+
<goal>makeAggregateBom</goal>
48+
</goals>
49+
</execution>
50+
</executions>
51+
</plugin>
52+
</plugins>
53+
</build>
54+
</project>

0 commit comments

Comments
 (0)