RenderSbomViewerMojo.java
package network.ike.plugin;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;
import org.yaml.snakeyaml.Yaml;
import java.io.File;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
/**
* Render a Web-friendly SBOM viewer page from the CycloneDX SBOM
* (ike-issues#341).
*
* <p>The third human-facing view of the same {@code bom.json}:
* <ul>
* <li>{@code licenses.html} — SPDX-grouped slice (#335)</li>
* <li>{@code built-with.html} — narrative + summary slice (#336)</li>
* <li>{@code dependencies.html} <em>(this mojo)</em> — full
* sortable component table</li>
* </ul>
*
* <p>All three derive from the same source of truth. This mojo
* walks every component in the SBOM and emits a single rendered
* page with each component's coordinates, SPDX license, type, and
* (when present) hash digests.
*
* <p>Replaces the auto-generated {@code dependencies} report from
* {@code maven-project-info-reports-plugin}, which scans only
* declared {@code <dependencies>} (not the full transitive graph
* the SBOM captures) and reports licenses verbatim from each POM
* (not SPDX-canonical).
*
* <p>Skip with {@code -Dike.skip.sbom-viewer=true}.
*
* <pre>{@code
* mvn package # produces bom.json
* mvn ike:render-sbom-viewer # produces dependencies.adoc
* mvn site # renders dependencies.html
* }</pre>
*
* @see RenderSpdxLicensesMojo the SPDX-grouped slice
* @see BuiltWithMojo the narrative + summary slice
* @see GenerateBomMojo IKE-specific Maven dependency BOM
* (different concept)
*/
@Mojo(name = "render-sbom-viewer", defaultPhase = "pre-site")
public class RenderSbomViewerMojo implements org.apache.maven.api.plugin.Mojo {
@org.apache.maven.api.di.Inject
private org.apache.maven.api.plugin.Log log;
/**
* Access the Maven logger.
*
* @return the logger
*/
protected org.apache.maven.api.plugin.Log getLog() { return log; }
/**
* Path to the CycloneDX SBOM produced at {@code package} phase
* by {@code cyclonedx-maven-plugin} (ike-issues#333).
*/
@Parameter(property = "ike.bom.path",
defaultValue = "${project.build.directory}/bom.json")
File bomPath;
/**
* Output AsciiDoc file. Default lands in the standard
* generated-site location which Maven Site renders to
* {@code target/site/dependencies.html}.
*/
@Parameter(property = "ike.sbom-viewer.output",
defaultValue = "${project.build.directory}/generated-site/asciidoc/dependencies.adoc")
File outputPath;
/** Skip generation. */
@Parameter(property = "ike.skip.sbom-viewer", defaultValue = "false")
boolean skip;
/** Project artifact ID, used in the rendered page title. */
@Parameter(defaultValue = "${project.artifactId}", readonly = true)
String projectArtifactId;
/** Project version, used in the rendered page title. */
@Parameter(defaultValue = "${project.version}", readonly = true)
String projectVersion;
/** Creates this goal instance. */
public RenderSbomViewerMojo() {}
@Override
public void execute() throws MojoException {
if (skip) {
getLog().info("ike:render-sbom-viewer skipped "
+ "(-Dike.skip.sbom-viewer=true)");
return;
}
if (!bomPath.exists()) {
getLog().warn("Skipping ike:render-sbom-viewer: SBOM not "
+ "found at " + bomPath + ". Run 'mvn package' "
+ "first to produce the SBOM (ike-issues#333).");
return;
}
SbomData bom;
try {
bom = parseSbom(bomPath);
} catch (IOException e) {
throw new MojoException(
"Could not parse SBOM at " + bomPath + ": "
+ e.getMessage(), e);
}
String adoc = renderAdoc(bom);
File parent = outputPath.getParentFile();
if (parent != null && !parent.isDirectory() && !parent.mkdirs()) {
throw new MojoException(
"Could not create output directory: " + parent);
}
try {
Files.writeString(outputPath.toPath(), adoc,
StandardCharsets.UTF_8);
} catch (IOException e) {
throw new MojoException(
"Could not write " + outputPath + ": " + e.getMessage(),
e);
}
getLog().info("Wrote SBOM viewer page: " + outputPath);
getLog().info(" Components: " + bom.components.size());
getLog().info(" Distinct license expressions: "
+ bom.licenseGroupCount);
}
/**
* Parse the SBOM JSON. Uses snakeyaml to read JSON-as-YAML so we
* don't need a Jackson dep on this mojo's classpath (matches
* {@link RenderSpdxLicensesMojo} and {@link BuiltWithMojo}).
*
* @param bom path to {@code bom.json}
* @return parsed component list + summary statistics
* @throws IOException if the file cannot be read or parsed
*/
@SuppressWarnings("unchecked")
private SbomData parseSbom(File bom) throws IOException {
Yaml yaml = new Yaml();
Map<String, Object> root;
try (Reader reader = Files.newBufferedReader(bom.toPath(),
StandardCharsets.UTF_8)) {
root = (Map<String, Object>) yaml.load(reader);
}
if (root == null) {
return new SbomData(new ArrayList<>(), 0);
}
List<Map<String, Object>> rawComponents =
(List<Map<String, Object>>) root.get("components");
if (rawComponents == null) {
return new SbomData(new ArrayList<>(), 0);
}
List<Component> components = new ArrayList<>();
for (Map<String, Object> c : rawComponents) {
String group = stringOrEmpty(c.get("group"));
String name = stringOrEmpty(c.get("name"));
String version = stringOrEmpty(c.get("version"));
String description = stringOrEmpty(c.get("description"));
String purl = stringOrEmpty(c.get("purl"));
String type = stringOrEmpty(c.get("type"));
String spdx = extractSpdxExpression(c);
String sha256 = extractHash(c, "SHA-256");
components.add(new Component(group, name, version,
description, purl, type, spdx, sha256));
}
// Sort by group:name:version for stable, browseable listing.
components.sort(Comparator
.comparing((Component cm) -> cm.group)
.thenComparing(cm -> cm.name)
.thenComparing(cm -> cm.version));
long licenseGroups = components.stream()
.map(cm -> cm.spdx)
.distinct()
.count();
return new SbomData(components, (int) licenseGroups);
}
/**
* Pull an SPDX license expression out of a CycloneDX component.
* Same logic as {@link RenderSpdxLicensesMojo} (kept independent
* to avoid coupling the two mojos via a shared utility).
*
* @param c the component map from the SBOM
* @return the SPDX expression for this component
*/
@SuppressWarnings("unchecked")
private String extractSpdxExpression(Map<String, Object> c) {
Object licensesObj = c.get("licenses");
if (!(licensesObj instanceof List<?> raw) || raw.isEmpty()) {
return "(no license declared)";
}
List<Map<String, Object>> entries = (List<Map<String, Object>>) raw;
if (entries.size() == 1) {
Object exp = entries.get(0).get("expression");
if (exp != null) return exp.toString();
}
List<String> ids = new ArrayList<>();
for (Map<String, Object> entry : entries) {
Map<String, Object> license =
(Map<String, Object>) entry.get("license");
if (license == null) continue;
Object id = license.get("id");
Object name = license.get("name");
if (id != null) ids.add(id.toString());
else if (name != null) ids.add(name.toString());
}
if (ids.isEmpty()) return "(no license declared)";
if (ids.size() == 1) return ids.get(0);
Collections.sort(ids);
return String.join(" OR ", ids);
}
/**
* Pull a specific hash algorithm's content out of a CycloneDX
* component's {@code hashes} array, or empty string if absent.
*
* @param c the component map
* @param alg the hash algorithm name (e.g. {@code "SHA-256"})
* @return the hash content, or empty string if absent
*/
@SuppressWarnings("unchecked")
private String extractHash(Map<String, Object> c, String alg) {
Object hashesObj = c.get("hashes");
if (!(hashesObj instanceof List<?> raw)) return "";
for (Object h : raw) {
if (!(h instanceof Map<?, ?> rawMap)) continue;
Map<String, Object> hashMap = (Map<String, Object>) rawMap;
if (alg.equals(hashMap.get("alg"))) {
Object content = hashMap.get("content");
return content == null ? "" : content.toString();
}
}
return "";
}
/**
* Render the SBOM data to AsciiDoc.
*
* @param bom parsed SBOM
* @return AsciiDoc page source
*/
private String renderAdoc(SbomData bom) {
StringBuilder sb = new StringBuilder();
sb.append("= Dependencies (SBOM)\n");
sb.append(":icons: font\n");
sb.append(":source-highlighter: coderay\n");
sb.append(":toc: preamble\n");
sb.append(":toclevels: 2\n\n");
sb.append("Full transitive dependency graph for `")
.append(projectArtifactId).append("` ")
.append(projectVersion)
.append(", generated from ")
.append("link:bom.json[bom.json] (CycloneDX 1.6) at "
+ "build time. Same SBOM source as the SPDX-"
+ "grouped link:licenses.html[licenses.html] "
+ "and the curated link:built-with.html"
+ "[built-with.html] — three views of the "
+ "same data.\n\n");
sb.append("== Summary\n\n");
sb.append("|===\n");
sb.append("| Total components | ").append(bom.components.size())
.append("\n");
sb.append("| Distinct license expressions | ")
.append(bom.licenseGroupCount).append("\n");
sb.append("|===\n\n");
if (bom.components.isEmpty()) {
sb.append("_No components in this module's SBOM._\n\n");
} else {
sb.append("== Components\n\n");
sb.append("Sorted by group, artifact, version. Click "
+ "link:bom.json[bom.json] for the raw "
+ "machine-readable form (Dependency-Track, Trivy, "
+ "Snyk, GitHub dep-graph all ingest it directly).\n\n");
sb.append("[%autowidth.stretch, options=\"header\"]\n");
sb.append("|===\n");
sb.append("| Group | Artifact | Version | License | Type\n\n");
for (Component c : bom.components) {
sb.append("| `").append(escapeAdoc(c.group)).append("`\n");
sb.append("| `").append(escapeAdoc(c.name)).append("`\n");
sb.append("| `").append(escapeAdoc(c.version)).append("`\n");
if (c.spdx.startsWith("(") && c.spdx.endsWith(")")) {
sb.append("| _").append(c.spdx).append("_\n");
} else {
sb.append("| `").append(escapeAdoc(c.spdx)).append("`\n");
}
sb.append("| ").append(escapeAdoc(
c.type.isEmpty() ? "library" : c.type)).append("\n\n");
}
sb.append("|===\n\n");
}
sb.append("== Download\n\n");
sb.append("* link:bom.json[Software Bill of Materials "
+ "(CycloneDX, JSON)] — raw machine-readable form. "
+ "Includes purls, hashes, and dependency-graph "
+ "edges that this page summarizes.\n");
sb.append("* link:bom.xml[bom.xml] — same content in XML.\n");
sb.append("* As a Maven artifact: pull "
+ "`").append(projectArtifactId)
.append(":") // zero-width space to soften wrap
.append(projectVersion)
.append("` with `<classifier>cyclonedx</classifier>"
+ "<type>json</type>` from Nexus / Maven Central.\n\n");
sb.append("== See also\n\n");
sb.append("* link:licenses.html[Licenses (SPDX)] — same "
+ "components grouped by license expression.\n");
sb.append("* link:built-with.html[Built With] — curated "
+ "narrative + per-license summary.\n");
sb.append("* https://github.com/IKE-Network/ike-issues/issues/341"
+ "[ike-issues#341] — the issue that introduced this "
+ "page.\n");
return sb.toString();
}
/**
* Escape a string for safe inline-code use in AsciiDoc.
*
* @param s input
* @return escaped string
*/
private String escapeAdoc(String s) {
if (s == null) return "";
return s.replace("`", "\\`");
}
/**
* Coerce a possibly-null map value to a non-null string.
*
* @param o the value
* @return the string value, or empty if null
*/
private static String stringOrEmpty(Object o) {
return o == null ? "" : o.toString();
}
/** A single component projected from the SBOM. */
private record Component(
String group, String name, String version,
String description, String purl, String type,
String spdx, String sha256) { }
/** Parsed SBOM with a small summary side-channel. */
private record SbomData(List<Component> components,
int licenseGroupCount) { }
}