Skip to content

Generation Software Bill of Materials (SBOM) #4757

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion contrib/bloop/src/mill/contrib/bloop/BloopImpl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ class BloopImpl(
}
},
sources = true
)
).artifactResult

def moduleOf(dep: coursier.Dependency): BloopConfig.Module =
BloopConfig.Module(
Expand Down
5 changes: 5 additions & 0 deletions contrib/package.mill
Original file line number Diff line number Diff line change
Expand Up @@ -221,4 +221,9 @@ object `package` extends RootModule {
def compileModuleDeps = Seq(build.scalalib)
def testModuleDeps = super.testModuleDeps ++ Seq(build.scalalib)
}

object sbom extends ContribModule {
def compileModuleDeps = Seq(build.scalalib)
def testModuleDeps: Seq[JavaModule] = super.testModuleDeps ++ Seq(build.scalalib)
}
}
70 changes: 70 additions & 0 deletions contrib/sbom/readme.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
= SBOM file
:page-aliases: Plugin_SBOM.adoc

This plugin creates Software Bill of Materials (SBOM)

This module has some limitations at the moment:

- Minimal SBOM, various properties of libraries are missing. e.g. the license.
- Only JVM ecosystem libraries are reported.
- Only the CycloneDX JSON format is supported

To declare a module that generates an SBOM extend the `mill.contrib.sbom.CycloneDXModuleTests` trait when defining your module.

Quickstart:

.`build.mill`
[source,scala]
----
package build
import mill.*
import mill.javalib.*
import $ivy.`com.lihaoyi::mill-contrib-sbom:`
import mill.contrib.sbom.CycloneDXJavaModule

object `sbom-demo` extends JavaModule with CycloneDXJavaModule {
// An example dependency
override def ivyDeps = Seq(ivy"ch.qos.logback:logback-classic:1.5.12")
}
----

This provides the `sbomJsonFile` task that produces a CycloneDX JSON file:

[source,bash]
----
$ mill show sbom-demo.sbomJsonFile # Creates the SBOM file in the JSON format
----

== Uploading to Dependency Track
Uploading the SBOM to https://dependencytrack.org/[Dependency Track] is supported.
Add the `DependencyTrackModule` and provide the necessary details:

.`build.mill`
[source,scala]
----
package build
import mill.*
import mill.javalib.*
import $ivy.`com.lihaoyi::mill-contrib-sbom:`
import mill.contrib.sbom.CycloneDXModule
import mill.contrib.sbom.upload.DependencyTrack

object `sbom-demo` extends JavaModule with CycloneDXJavaModule with DependencyTrackModule {
def depTrackUrl = "http://localhost:8081"
def depTrackProjectID = "7c1a9efd-8f05-4cdb-bb16-602cb5c1d6e0"
def depTrackApiKey = "odt_rTKFk9MCDtWpdun1VKUUfsOsdOumo96q"
// An example dependency
override def ivyDeps = Seq(ivy"ch.qos.logback:logback-classic:1.5.12")
}
----

Affter that you upload the SBOM:

[source,bash]
----
./mill sbom-demo.sbomUpload
----




76 changes: 76 additions & 0 deletions contrib/sbom/src/mill/contrib/sbom/CycloneDX.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package mill.contrib.sbom

import coursier.Dependency
import os.Path
import upickle.default.macroRW
import upickle.default.ReadWriter

import java.math.BigInteger
import java.security.MessageDigest
import java.time.Instant
import java.util.UUID

object CycloneDX {
case class SbomJson(
bomFormat: String,
specVersion: String,
serialNumber: String,
version: Int,
metadata: MetaData,
components: Seq[Component]
)

case class MetaData(timestamp: String = Instant.now().toString)

case class ComponentHash(alg: String, content: String)

case class LicenseHolder(license: License)

case class License(name: String, url: Option[String])

case class Component(
`type`: String,
`bom-ref`: String,
group: String,
name: String,
version: String,
description: String,
licenses: Seq[LicenseHolder],
hashes: Seq[ComponentHash]
)

object Component {
def fromDeps(path: Path, dep: Dependency, licenses: Seq[coursier.Info.License]): Component = {
val compLicenses = licenses.map { lic =>
LicenseHolder(License(lic.name, lic.url))
}
Component(
"library",
s"pkg:maven/${dep.module.organization.value}/${dep.module.name.value}@${dep.version}?type=jar",
dep.module.organization.value,
dep.module.name.value,
dep.version,
dep.module.orgName,
compLicenses,
Seq(ComponentHash("SHA-256", sha256(path)))
)
}
}

implicit val sbomRW: ReadWriter[SbomJson] = macroRW
implicit val metaRW: ReadWriter[MetaData] = macroRW
implicit val componentHashRW: ReadWriter[ComponentHash] = macroRW
implicit val componentRW: ReadWriter[Component] = macroRW
implicit val licenceHolderRW: ReadWriter[LicenseHolder] = macroRW
implicit val licenceRW: ReadWriter[License] = macroRW

private def sha256(f: Path): String = {
val md = MessageDigest.getInstance("SHA-256")
val fileContent = os.read.bytes(f)
val digest = md.digest(fileContent)
String.format("%0" + (digest.length << 1) + "x", new BigInteger(1, digest))
}

case class SbomHeader(serialNumber: UUID, timestamp: Instant)

}
72 changes: 72 additions & 0 deletions contrib/sbom/src/mill/contrib/sbom/CycloneDXJavaModule.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package mill.contrib.sbom

import coursier.{Fetch, Resolution, VersionConstraint, core as cs}
import mill.Task
import mill.javalib.{BoundDep, JavaModule}

/**
* Report the Java/Scala/Kotlin dependencies in a SBOM.
* By default, it reports all dependencies in the [[ivyDeps]] and [[runIvyDeps]].
* Other scopes and unmanaged dependencies are not added to the report.
*
* Change this behavior by overriding [[sbomComponents]]
*/
trait CycloneDXJavaModule extends JavaModule with CycloneDXModule {
import CycloneDX.*

/**
* Lists of all components used for this module.
* By default, uses the [[ivyDeps]] and [[runIvyDeps]] for the list of components
*/
def sbomComponents: Task[Seq[Component]] = Task {
val resolved = resolvedRunIvyDepsDetails()()
resolvedSbomComponents(resolved)
}

protected def resolvedSbomComponents(resolved: Fetch.Result): Seq[Component] = {
val distinctDeps = resolved.fullDetailedArtifacts0
.flatMap {
case (dep, _, _, Some(path)) => Some(dep -> path)
case _ => None
}
// Artifacts.Result.files does eliminate duplicates path: Do the same
.distinctBy(_._2)
.map { case (dep, path) =>
val license = findLicenses(resolved.resolution, dep.module, dep.versionConstraint)
Component.fromDeps(os.Path(path), dep, license)
}
distinctDeps
}

/** Copied from [[resolvedRunIvyDeps]], but getting the raw artifacts */
private def resolvedRunIvyDepsDetails(): Task[Fetch.Result] = Task.Anon {
millResolver().fetch(Seq(
BoundDep(
coursierDependency.withConfiguration(cs.Configuration.runtime),
force = false
)
))
}

private def findLicenses(
resolution: Resolution,
module: coursier.core.Module,
version: VersionConstraint
): Seq[coursier.Info.License] = {
val projects = resolution.projectCache0
val project = projects.get(module -> version)
project match
case None => Seq.empty
case Some((_, proj)) =>
val licences = proj.info.licenseInfo
if (licences.nonEmpty) {
licences
} else {
proj.parent0.map((pm, v) =>
findLicenses(resolution, pm, VersionConstraint.fromVersion(v))
)
.getOrElse(Seq.empty)
}
}

}
50 changes: 50 additions & 0 deletions contrib/sbom/src/mill/contrib/sbom/CycloneDXModule.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package mill.contrib.sbom

import mill.*
import mill.javalib.{BoundDep, JavaModule}
import coursier.{Artifacts, Dependency, Resolution, VersionConstraint, core as cs}
import os.Path
import upickle.default.{ReadWriter, macroRW}

import java.math.BigInteger
import java.security.MessageDigest
import java.time.Instant
import java.util.UUID

trait CycloneDXModule extends Module {
import CycloneDX.*

/** Lists of all components used for this module. */
def sbomComponents: Task[Seq[Component]]

/**
* Each time the SBOM is generated, a new UUID and timestamp are generated
* Can be overridden to use a more predictable method, eg. for reproducible builds
*/
def sbomHeader(): SbomHeader = SbomHeader(UUID.randomUUID(), Instant.now())

/**
* Generates the SBOM Json for this module, based on the components returned by [[sbomComponents]]
* @return
*/
def sbom: T[SbomJson] = Target {
val header = sbomHeader()
val components = sbomComponents()

SbomJson(
bomFormat = "CycloneDX",
specVersion = "1.2",
serialNumber = s"urn:uuid:${header.serialNumber}",
version = 1,
metadata = MetaData(timestamp = header.timestamp.toString),
components = components
)
}

def sbomJsonFile: T[PathRef] = Target {
val sbomFile = Target.dest / "sbom.json"
os.write(sbomFile, upickle.default.write(sbom(), indent = 2))
PathRef(sbomFile)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package mill.contrib.sbom.upload

import java.util.Base64
import java.nio.charset.StandardCharsets
import mill._
import mill.contrib.sbom.CycloneDXModule
import upickle.default.{ReadWriter, macroRW}

object DependencyTrackModule {
case class Payload(project: String, bom: String)

implicit val depTrackPayload: ReadWriter[Payload] = macroRW
}
trait DependencyTrackModule extends CycloneDXModule {
import DependencyTrackModule._

def depTrackUrl: T[String]
def depTrackProjectID: T[String]
def depTrackApiKey: T[String]

/**
* Uploads the generated SBOM to the configured dependency track instance
*/
def sbomUpload(): Command[Unit] = Task.Command {
val url = depTrackUrl()
val projectId = depTrackProjectID()
val apiKey = depTrackApiKey()

val bomString = upickle.default.write(sbom())
val payload = Payload(
projectId,
Base64.getEncoder.encodeToString(
bomString.getBytes(StandardCharsets.UTF_8)
)
)
val body = upickle.default.stream[Payload](payload)
val bodyBytes = requests.RequestBlob.ByteSourceRequestBlob(body)(identity)
val r = requests.put(
s"$url/api/v1/bom",
headers = Map(
"Content-Type" -> "application/json",
"X-API-Key" -> apiKey
),
data = bodyBytes
)
assert(r.is2xx)
}

def myCmdC(test: String) = Task.Command { println("hi above"); 34 }

}
54 changes: 54 additions & 0 deletions contrib/sbom/test/reference/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.example</groupId>
<artifactId>sbom-example-reference</artifactId>
<packaging>jar</packaging>
<version>1.1-SNAPSHOT</version>

<name>CycloneDX reference</name>
<description>
This is a reference on how the CycloneDX Maven plugin generates an SBOM.
This way we can inspect differences between Mill and the wildly used Maven plugin.
Run: mvn package, then inspect the target/bom.json
</description>

<properties>
<java.version>11</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
</properties>


<dependencies>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.12</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.18.0</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.cyclonedx</groupId>
<artifactId>cyclonedx-maven-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>makeAggregateBom</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Loading