Skip to content

Commit bdb28c2

Browse files
committed
.
1 parent 316a2dd commit bdb28c2

File tree

6 files changed

+162
-139
lines changed

6 files changed

+162
-139
lines changed

contrib/sbom/readme.adoc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ This module has some limitations at the moment:
99
- Only JVM ecosystem libraries are reported.
1010
- Only the CycloneDX JSON format is supported
1111
12-
To declare a module that generates an SBT extend the `mill.contrib.sbom.CycloneDXModuleTests` trait when defining your module.
12+
To declare a module that generates an SBOM extend the `mill.contrib.sbom.CycloneDXModuleTests` trait when defining your module.
1313

1414
Quickstart:
1515

@@ -36,7 +36,7 @@ $ mill show sbom-demo.sbomJsonFile # Creates the SBOM file in the JSON format
3636
----
3737

3838
== Uploading to Dependency Track
39-
Uploading the BOM to (https://dependencytrack.org/)[Dependency Track] is supported.
39+
Uploading the SBOM to https://dependencytrack.org/[Dependency Track] is supported.
4040
Add the `DependencyTrackModule` and provide the necessary details:
4141

4242
.`build.mill`
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])
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: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package mill.contrib.sbom
2+
3+
import coursier.{Artifacts, 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 [[ivyDeps]] and [[runIvyDeps]].
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 [[ivyDeps]] and [[runIvyDeps]] for the list of components
20+
*/
21+
def sbomComponents: Task[Seq[Component]] = Task {
22+
val (resolution, artifacts) = resolvedRunIvyDepsDetails()()
23+
resolvedSbomComponents(resolution, artifacts)
24+
}
25+
26+
protected def resolvedSbomComponents(
27+
resolution: Resolution,
28+
artifacts: Artifacts.Result
29+
): Seq[Component] = {
30+
val distinctDeps = artifacts.fullDetailedArtifacts
31+
.flatMap {
32+
case (dep, _, _, Some(path)) => Some(dep -> path)
33+
case _ => None
34+
}
35+
// Artifacts.Result.files does eliminate duplicates path: Do the same
36+
.distinctBy(_._2)
37+
.map { case (dep, path) =>
38+
val license = findLicenses(resolution, dep.module, dep.versionConstraint)
39+
Component.fromDeps(os.Path(path), dep, license)
40+
}
41+
distinctDeps
42+
}
43+
44+
/** Copied from [[resolvedRunIvyDeps]], but getting the raw artifacts */
45+
private def resolvedRunIvyDepsDetails(): Task[(Resolution, Artifacts.Result)] = Task.Anon {
46+
millResolver().artifacts(Seq(
47+
BoundDep(
48+
coursierDependency.withConfiguration(cs.Configuration.runtime),
49+
force = false
50+
)
51+
))
52+
}
53+
54+
private def findLicenses(
55+
resolution: Resolution,
56+
module: coursier.core.Module,
57+
version: VersionConstraint
58+
): Seq[coursier.Info.License] = {
59+
val projects = resolution.projectCache0
60+
val project = projects.get(module -> version)
61+
project match
62+
case None => Seq.empty
63+
case Some((_, proj)) =>
64+
val licences = proj.info.licenseInfo
65+
if (licences.nonEmpty) {
66+
licences
67+
} else {
68+
proj.parent0.map((pm, v) =>
69+
findLicenses(resolution, pm, VersionConstraint.fromVersion(v))
70+
)
71+
.getOrElse(Seq.empty)
72+
}
73+
}
74+
75+
}

contrib/sbom/src/mill/contrib/sbom/CycloneDXModule.scala

Lines changed: 5 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package mill.contrib.sbom
22

33
import mill.*
4-
import mill.contrib.sbom.CycloneDXModule.Component
54
import mill.javalib.{BoundDep, JavaModule}
65
import coursier.{Artifacts, Dependency, Resolution, VersionConstraint, core as cs}
76
import os.Path
@@ -10,140 +9,13 @@ import upickle.default.{ReadWriter, macroRW}
109
import java.math.BigInteger
1110
import java.security.MessageDigest
1211
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-
}
12+
import java.util.UUID
14113

14214
trait CycloneDXModule extends Module {
143-
import CycloneDXModule.*
15+
import CycloneDX.*
14416

14517
/** Lists of all components used for this module. */
146-
def sbomComponents: Task[Agg[Component]]
18+
def sbomComponents: Task[Seq[Component]]
14719

14820
/**
14921
* Each time the SBOM is generated, a new UUID and timestamp are generated
@@ -155,11 +27,11 @@ trait CycloneDXModule extends Module {
15527
* Generates the SBOM Json for this module, based on the components returned by [[sbomComponents]]
15628
* @return
15729
*/
158-
def sbom: T[SBOM_JSON] = Target {
30+
def sbom: T[SbomJson] = Target {
15931
val header = sbomHeader()
16032
val components = sbomComponents()
16133

162-
SBOM_JSON(
34+
SbomJson(
16335
bomFormat = "CycloneDX",
16436
specVersion = "1.2",
16537
serialNumber = s"urn:uuid:${header.serialNumber}",

contrib/sbom/test/reference/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
<dependency>
2727
<groupId>ch.qos.logback</groupId>
2828
<artifactId>logback-classic</artifactId>
29-
<version>1.2.3</version>
29+
<version>1.5.12</version>
3030
</dependency>
3131
<dependency>
3232
<groupId>commons-io</groupId>

contrib/sbom/test/src/mill/contrib/sbom/CycloneDXModuleTests.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,20 @@ import java.time.Instant
1212

1313
object TestModule extends TestBaseModule {
1414

15-
val fixedHeader = CycloneDXModule.SbomHeader(
15+
val fixedHeader = CycloneDX.SbomHeader(
1616
UUID.fromString("a9d6a1c7-18d4-4901-891c-cbcc8f2c5241"),
1717
Instant.parse("2025-03-17T17:00:56.263933698Z")
1818
)
1919

2020
object noDeps extends JavaModule with CycloneDXJavaModule {}
2121

2222
object withDeps extends JavaModule with CycloneDXJavaModule {
23-
override def sbomHeader(): CycloneDXModule.SbomHeader = fixedHeader
23+
override def sbomHeader(): CycloneDX.SbomHeader = fixedHeader
2424
override def ivyDeps = Agg(ivy"ch.qos.logback:logback-classic:1.5.12")
2525
}
2626

2727
object withModuleDeps extends JavaModule with CycloneDXJavaModule {
28-
override def sbomHeader(): CycloneDXModule.SbomHeader = fixedHeader
28+
override def sbomHeader(): CycloneDX.SbomHeader = fixedHeader
2929
override def moduleDeps = Seq(withDeps)
3030
override def ivyDeps = Agg(ivy"commons-io:commons-io:2.18.0")
3131
}

0 commit comments

Comments
 (0)