Skip to content

Commit cdc2cdd

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 727e67f commit cdc2cdd

File tree

12 files changed

+657
-7
lines changed

12 files changed

+657
-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().getArtifacts(
380+
val (_, result) = module.defaultResolver().getArtifacts(
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
@@ -216,4 +216,9 @@ object `package` extends RootModule {
216216
def compileModuleDeps = Seq(build.scalalib)
217217
def testModuleDeps = super.testModuleDeps ++ Seq(build.scalalib)
218218
}
219+
220+
object sbom extends ContribModule {
221+
def compileModuleDeps = Seq(build.scalalib)
222+
def testModuleDeps: Seq[JavaModule] = super.testModuleDeps ++ Seq(build.scalalib)
223+
}
219224
}

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: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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 coursier.params.ResolutionParams
8+
import os.Path
9+
import upickle.default.{ReadWriter, macroRW}
10+
11+
import java.math.BigInteger
12+
import java.security.MessageDigest
13+
import java.time.Instant
14+
import java.util.{UUID}
15+
16+
object CycloneDXModule {
17+
case class SBOM_JSON(
18+
bomFormat: String,
19+
specVersion: String,
20+
serialNumber: String,
21+
version: Int,
22+
metadata: MetaData,
23+
components: Seq[Component]
24+
)
25+
26+
case class MetaData(timestamp: String = Instant.now().toString)
27+
28+
case class ComponentHash(alg: String, content: String)
29+
30+
case class LicenseHolder(license: License)
31+
32+
case class License(name: String, url: Option[String])
33+
34+
case class Component(
35+
`type`: String,
36+
`bom-ref`: String,
37+
group: String,
38+
name: String,
39+
version: String,
40+
description: String,
41+
licenses: Seq[LicenseHolder],
42+
hashes: Seq[ComponentHash]
43+
)
44+
45+
object Component {
46+
def fromDeps(path: Path, dep: Dependency, licenses: Seq[coursier.Info.License]): Component = {
47+
val compLicenses = licenses.map { lic =>
48+
LicenseHolder(License(lic.name, lic.url))
49+
}
50+
Component(
51+
"library",
52+
s"pkg:maven/${dep.module.organization.value}/${dep.module.name.value}@${dep.version}?type=jar",
53+
dep.module.organization.value,
54+
dep.module.name.value,
55+
dep.version,
56+
dep.module.orgName,
57+
compLicenses,
58+
Seq(ComponentHash("SHA-256", sha256(path)))
59+
)
60+
}
61+
}
62+
63+
implicit val sbomRW: ReadWriter[SBOM_JSON] = macroRW
64+
implicit val metaRW: ReadWriter[MetaData] = macroRW
65+
implicit val componentHashRW: ReadWriter[ComponentHash] = macroRW
66+
implicit val componentRW: ReadWriter[Component] = macroRW
67+
implicit val licenceHolderRW: ReadWriter[LicenseHolder] = macroRW
68+
implicit val licenceRW: ReadWriter[License] = macroRW
69+
70+
private def sha256(f: Path): String = {
71+
val md = MessageDigest.getInstance("SHA-256")
72+
val fileContent = os.read.bytes(f)
73+
val digest = md.digest(fileContent)
74+
String.format("%0" + (digest.length << 1) + "x", new BigInteger(1, digest))
75+
}
76+
77+
case class SbomHeader(serialNumber: UUID, timestamp: Instant)
78+
79+
}
80+
81+
trait CycloneDXJavaModule extends JavaModule with CycloneDXModule {
82+
83+
/**
84+
* Lists of all components used for this module.
85+
* By default, uses the [[ivyDeps]] and [[runIvyDeps]] for the list of components
86+
*/
87+
def sbomComponents: Task[Seq[Component]] = Task {
88+
val (resolution, artifacts) = resolvedRunIvyDepsDetails()()
89+
resolvedSbomComponents(resolution, artifacts)
90+
}
91+
92+
protected def resolvedSbomComponents(
93+
resolution: Resolution,
94+
artifacts: Artifacts.Result
95+
): Seq[Component] = {
96+
val distinctDeps = artifacts.fullDetailedArtifacts
97+
.flatMap {
98+
case (dep, _, _, Some(path)) => Some(dep -> path)
99+
case _ => None
100+
}
101+
// Artifacts.Result.files does eliminate duplicates path: Do the same
102+
.distinctBy(_._2)
103+
.map { case (dep, path) =>
104+
val license = findLicenses(resolution, dep.module, dep.versionConstraint)
105+
Component.fromDeps(os.Path(path), dep, license)
106+
}
107+
distinctDeps
108+
}
109+
110+
/** Copied from [[resolvedRunIvyDeps]], but getting the raw artifacts */
111+
private def resolvedRunIvyDepsDetails(): Task[(Resolution, Artifacts.Result)] = Task.Anon {
112+
millResolver().getArtifacts(
113+
Seq(
114+
BoundDep(
115+
coursierDependency.withConfiguration(cs.Configuration.runtime),
116+
force = false
117+
)
118+
),
119+
artifactTypes = Some(artifactTypes()),
120+
resolutionParams = ResolutionParams().withDefaultConfiguration(cs.Configuration.runtime)
121+
)
122+
}
123+
124+
private def findLicenses(
125+
resolution: Resolution,
126+
module: coursier.core.Module,
127+
version: VersionConstraint
128+
): Seq[coursier.Info.License] = {
129+
val projects = resolution.projectCache0
130+
val project = projects.get(module -> version)
131+
project match
132+
case None => Seq.empty
133+
case Some((_, proj)) =>
134+
val licences = proj.info.licenseInfo
135+
if (licences.nonEmpty) {
136+
licences
137+
} else {
138+
proj.parent0.map((pm, v) =>
139+
findLicenses(resolution, pm, VersionConstraint.fromVersion(v))
140+
)
141+
.getOrElse(Seq.empty)
142+
}
143+
}
144+
145+
}
146+
147+
trait CycloneDXModule extends Module {
148+
import CycloneDXModule.*
149+
150+
/** Lists of all components used for this module. */
151+
def sbomComponents: Task[Agg[Component]]
152+
153+
/**
154+
* Each time the SBOM is generated, a new UUID and timestamp are generated
155+
* Can be overridden to use a more predictable method, eg. for reproducible builds
156+
*/
157+
def sbomHeader(): SbomHeader = SbomHeader(UUID.randomUUID(), Instant.now())
158+
159+
/**
160+
* Generates the SBOM Json for this module, based on the components returned by [[sbomComponents]]
161+
* @return
162+
*/
163+
def sbom: T[SBOM_JSON] = Target {
164+
val header = sbomHeader()
165+
val components = sbomComponents()
166+
167+
SBOM_JSON(
168+
bomFormat = "CycloneDX",
169+
specVersion = "1.2",
170+
serialNumber = s"urn:uuid:${header.serialNumber}",
171+
version = 1,
172+
metadata = MetaData(timestamp = header.timestamp.toString),
173+
components = components
174+
)
175+
}
176+
177+
def sbomJsonFile: T[PathRef] = Target {
178+
val sbomFile = Target.dest / "sbom.json"
179+
os.write(sbomFile, upickle.default.write(sbom(), indent = 2))
180+
PathRef(sbomFile)
181+
}
182+
183+
}
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.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)