Skip to content

Commit 690ab69

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 d0f7bdd commit 690ab69

File tree

12 files changed

+751
-7
lines changed

12 files changed

+751
-7
lines changed

contrib/package.mill

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,9 @@ object `package` extends mill.Module {
232232
def compileModuleDeps = Seq(build.libs.javalib)
233233
def testModuleDeps = super.testModuleDeps ++ Seq(build.libs.scalalib)
234234
}
235+
236+
object sbom extends ContribModule {
237+
def compileModuleDeps = Seq(build.libs.scalalib)
238+
def testModuleDeps: Seq[JavaModule] = super.testModuleDeps ++ Seq(build.libs.scalalib)
239+
}
235240
}

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 SBOM extend the `mill.contrib.sbom.CycloneDXModuleTests` trait when defining your module.
13+
14+
Quickstart:
15+
16+
.`build.mill`
17+
[source,scala]
18+
----
19+
//| mvnDeps: ["com.lihaoyi::mill-contrib-sbom:$MILL_VERSION"]
20+
package build
21+
import mill.*
22+
import mill.javalib.*
23+
import mill.contrib.sbom.CycloneDXJavaModule
24+
25+
object `sbom-demo` extends JavaModule with CycloneDXJavaModule {
26+
// An example dependency
27+
override def mvnDeps = Seq(mvn"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 SBOM 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+
//| mvnDeps: ["com.lihaoyi::mill-contrib-sbom:$MILL_VERSION"]
46+
package build
47+
import mill.*
48+
import mill.javalib.*
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 mvnDeps = Seq(ivy"ch.qos.logback:logback-classic:1.5.12")
58+
}
59+
----
60+
61+
After that you upload the SBOM:
62+
63+
[source,bash]
64+
----
65+
./mill sbom-demo.sbomUpload
66+
----
67+
68+
69+
70+
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package mill.contrib.sbom
2+
3+
import coursier.Dependency
4+
import os.Path
5+
import upickle.default.macroRW
6+
import upickle.default.ReadWriter
7+
8+
import java.math.BigInteger
9+
import java.security.MessageDigest
10+
import java.time.Instant
11+
import java.util.UUID
12+
13+
object CycloneDX {
14+
case class SbomJson(
15+
bomFormat: String,
16+
specVersion: String,
17+
serialNumber: String,
18+
version: Int,
19+
metadata: MetaData,
20+
components: Seq[Component]
21+
)
22+
23+
case class MetaData(timestamp: String = Instant.now().toString)
24+
25+
case class ComponentHash(alg: String, content: String)
26+
27+
case class LicenseHolder(license: License)
28+
29+
case class License(name: String, url: Option[String] = None)
30+
31+
case class Component(
32+
`type`: String,
33+
`bom-ref`: String,
34+
group: String,
35+
name: String,
36+
version: String,
37+
description: String,
38+
licenses: Seq[LicenseHolder],
39+
hashes: Seq[ComponentHash]
40+
)
41+
42+
object Component {
43+
def fromDeps(path: Path, dep: Dependency, licenses: Seq[coursier.Info.License]): Component = {
44+
val compLicenses = licenses.map { lic =>
45+
LicenseHolder(License(lic.name, lic.url))
46+
}
47+
Component(
48+
"library",
49+
s"pkg:maven/${dep.module.organization.value}/${dep.module.name.value}@${dep.version}?type=jar",
50+
dep.module.organization.value,
51+
dep.module.name.value,
52+
dep.version,
53+
dep.module.orgName,
54+
compLicenses,
55+
Seq(ComponentHash("SHA-256", sha256(path)))
56+
)
57+
}
58+
}
59+
60+
implicit val sbomRW: ReadWriter[SbomJson] = macroRW
61+
implicit val metaRW: ReadWriter[MetaData] = macroRW
62+
implicit val componentHashRW: ReadWriter[ComponentHash] = macroRW
63+
implicit val componentRW: ReadWriter[Component] = macroRW
64+
implicit val licenceHolderRW: ReadWriter[LicenseHolder] = macroRW
65+
implicit val licenceRW: ReadWriter[License] = macroRW
66+
67+
private def sha256(f: Path): String = {
68+
val md = MessageDigest.getInstance("SHA-256")
69+
val fileContent = os.read.bytes(f)
70+
val digest = md.digest(fileContent)
71+
String.format("%0" + (digest.length << 1) + "x", new BigInteger(1, digest))
72+
}
73+
74+
case class SbomHeader(serialNumber: UUID, timestamp: Instant)
75+
76+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package mill.contrib.sbom
2+
3+
import coursier.{Fetch, Resolution, VersionConstraint, core as cs}
4+
import mill.Task
5+
import mill.javalib.{BoundDep, JavaModule}
6+
7+
/**
8+
* Report the Java/Scala/Kotlin dependencies in a SBOM.
9+
* By default, it reports all dependencies in the [[mvnDeps]] and [[runMvnDeps]].
10+
* Other scopes and unmanaged dependencies are not added to the report.
11+
*
12+
* Change this behavior by overriding [[sbomComponents]]
13+
*/
14+
trait CycloneDXJavaModule extends JavaModule with CycloneDXModule {
15+
import CycloneDX.*
16+
17+
/**
18+
* Lists of all components used for this module.
19+
* By default, uses the [[mvnDeps]] and [[runMvnDeps]] for the list of components
20+
*/
21+
def sbomComponents: Task[Seq[Component]] = Task {
22+
val resolved = resolvedRunMvnDepsDetails()()
23+
resolvedSbomComponents(resolved)
24+
}
25+
26+
protected def resolvedSbomComponents(resolved: Fetch.Result): Seq[Component] = {
27+
val distinctDeps = resolved.fullDetailedArtifacts0
28+
.flatMap {
29+
case (dep, _, _, Some(path)) => Some(dep -> path)
30+
case _ => None
31+
}
32+
// Artifacts.Result.files does eliminate duplicates path: Do the same
33+
.distinctBy(_._2)
34+
.map { case (dep, path) =>
35+
val license = findLicenses(resolved.resolution, dep.module, dep.versionConstraint)
36+
Component.fromDeps(os.Path(path), dep, license)
37+
}
38+
distinctDeps
39+
}
40+
41+
/** Based on [[resolvedRunMvnDeps]], but getting the raw artifacts */
42+
private def resolvedRunMvnDepsDetails(): Task[Fetch.Result] = Task.Anon {
43+
millResolver().fetchArtifacts(Seq(
44+
BoundDep(
45+
coursierDependency.withConfiguration(cs.Configuration.runtime),
46+
force = false
47+
)
48+
))
49+
}
50+
51+
private def findLicenses(
52+
resolution: Resolution,
53+
module: coursier.core.Module,
54+
version: VersionConstraint
55+
): Seq[coursier.Info.License] = {
56+
val projects = resolution.projectCache0
57+
val project = projects.get(module -> version)
58+
project match
59+
case None => Seq.empty
60+
case Some((_, proj)) =>
61+
val licences = proj.info.licenseInfo
62+
if (licences.nonEmpty) {
63+
licences
64+
} else {
65+
proj.parent0.map((pm, v) =>
66+
findLicenses(resolution, pm, VersionConstraint.fromVersion(v))
67+
)
68+
.getOrElse(Seq.empty)
69+
}
70+
}
71+
72+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package mill.contrib.sbom
2+
3+
import mill.*
4+
5+
import java.time.Instant
6+
import java.util.UUID
7+
8+
trait CycloneDXModule extends Module {
9+
import CycloneDX.*
10+
11+
/** Lists of all components used for this module. */
12+
def sbomComponents: Task[Seq[Component]]
13+
14+
/**
15+
* Each time the SBOM is generated, a new UUID and timestamp are generated
16+
* Can be overridden to use a more predictable method, eg. for reproducible builds
17+
*/
18+
def sbomHeader(): SbomHeader = SbomHeader(UUID.randomUUID(), Instant.now())
19+
20+
/**
21+
* Generates the SBOM Json for this module, based on the components returned by [[sbomComponents]]
22+
* @return
23+
*/
24+
def sbom: T[SbomJson] = Task {
25+
val header = sbomHeader()
26+
val components = sbomComponents()
27+
28+
SbomJson(
29+
bomFormat = "CycloneDX",
30+
specVersion = "1.2",
31+
serialNumber = s"urn:uuid:${header.serialNumber}",
32+
version = 1,
33+
metadata = MetaData(timestamp = header.timestamp.toString),
34+
components = components
35+
)
36+
}
37+
38+
def sbomJsonFile: T[PathRef] = Task {
39+
val sbomFile = Task.dest / "sbom.json"
40+
os.write(sbomFile, upickle.default.write(sbom(), indent = 2))
41+
PathRef(sbomFile)
42+
}
43+
44+
}
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+
}
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.5.12</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)