Integrate Native Image SBOM with GitHub's Dependency Submission API (#119)

Co-authored-by: Fabio Niephaus <fabio.niephaus@oracle.com>
This commit is contained in:
Joel Rudsberg
2025-01-21 09:00:42 +01:00
committed by GitHub
parent c09e29bb11
commit 7b74bd8bd8
20 changed files with 1404 additions and 135 deletions

View File

@@ -28,6 +28,7 @@ import * as core from '@actions/core'
import * as constants from './constants'
import {save} from './features/cache'
import {generateReports} from './features/reports'
import {processSBOM} from './features/sbom'
/**
* Check given input and run a save process for the specified package manager
@@ -58,6 +59,7 @@ async function ignoreErrors(promise: Promise<void>): Promise<unknown> {
export async function run(): Promise<void> {
await ignoreErrors(generateReports())
await ignoreErrors(processSBOM())
await ignoreErrors(saveCache())
}

View File

@@ -14,6 +14,8 @@ export const INPUT_CACHE = 'cache'
export const INPUT_CHECK_FOR_UPDATES = 'check-for-updates'
export const INPUT_NI_MUSL = 'native-image-musl'
export const NATIVE_IMAGE_OPTIONS_ENV = 'NATIVE_IMAGE_OPTIONS'
export const IS_LINUX = process.platform === 'linux'
export const IS_MACOS = process.platform === 'darwin'
export const IS_WINDOWS = process.platform === 'win32'

View File

@@ -3,17 +3,17 @@ import * as core from '@actions/core'
import * as fs from 'fs'
import * as github from '@actions/github'
import * as semver from 'semver'
import {join} from 'path'
import {tmpdir} from 'os'
import {
createPRComment,
findExistingPRCommentId,
isPREvent,
toSemVer,
updatePRComment
updatePRComment,
tmpfile,
setNativeImageOption
} from '../utils'
const BUILD_OUTPUT_JSON_PATH = join(tmpdir(), 'native-image-build-output.json')
const BUILD_OUTPUT_JSON_PATH = tmpfile('native-image-build-output.json')
const BYTES_TO_KiB = 1024
const BYTES_TO_MiB = 1024 * 1024
const BYTES_TO_GiB = 1024 * 1024 * 1024
@@ -22,12 +22,6 @@ const DOCS_BASE =
const INPUT_NI_JOB_REPORTS = 'native-image-job-reports'
const INPUT_NI_PR_REPORTS = 'native-image-pr-reports'
const INPUT_NI_PR_REPORTS_UPDATE = 'native-image-pr-reports-update-existing'
const NATIVE_IMAGE_CONFIG_FILE = join(
tmpdir(),
'native-image-options.properties'
)
const NATIVE_IMAGE_OPTIONS_ENV = 'NATIVE_IMAGE_OPTIONS'
const NATIVE_IMAGE_CONFIG_FILE_ENV = 'NATIVE_IMAGE_CONFIG_FILE'
const PR_COMMENT_TITLE = '## GraalVM Native Image Build Report'
interface AnalysisResult {
@@ -169,43 +163,6 @@ function arePRReportsUpdateEnabled(): boolean {
return isPREvent() && core.getInput(INPUT_NI_PR_REPORTS_UPDATE) === 'true'
}
function setNativeImageOption(
javaVersionOrDev: string,
optionValue: string
): void {
const coercedJavaVersionOrDev = semver.coerce(javaVersionOrDev)
if (
(coercedJavaVersionOrDev &&
semver.gte(coercedJavaVersionOrDev, '22.0.0')) ||
javaVersionOrDev === c.VERSION_DEV ||
javaVersionOrDev.endsWith('-ea')
) {
/* NATIVE_IMAGE_OPTIONS was introduced in GraalVM for JDK 22 (so were EA builds). */
let newOptionValue = optionValue
const existingOptions = process.env[NATIVE_IMAGE_OPTIONS_ENV]
if (existingOptions) {
newOptionValue = `${existingOptions} ${newOptionValue}`
}
core.exportVariable(NATIVE_IMAGE_OPTIONS_ENV, newOptionValue)
} else {
const optionsFile = getNativeImageOptionsFile()
if (fs.existsSync(optionsFile)) {
fs.appendFileSync(optionsFile, ` ${optionValue}`)
} else {
fs.writeFileSync(optionsFile, `NativeImageArgs = ${optionValue}`)
}
}
}
function getNativeImageOptionsFile(): string {
let optionsFile = process.env[NATIVE_IMAGE_CONFIG_FILE_ENV]
if (optionsFile === undefined) {
optionsFile = NATIVE_IMAGE_CONFIG_FILE
core.exportVariable(NATIVE_IMAGE_CONFIG_FILE_ENV, optionsFile)
}
return optionsFile
}
function createReport(data: BuildOutput): string {
const context = github.context
const info = data.general_info

300
src/features/sbom.ts Normal file
View File

@@ -0,0 +1,300 @@
import * as c from '../constants'
import * as core from '@actions/core'
import * as fs from 'fs'
import * as github from '@actions/github'
import * as glob from '@actions/glob'
import {basename} from 'path'
import * as semver from 'semver'
import {setNativeImageOption} from '../utils'
const INPUT_NI_SBOM = 'native-image-enable-sbom'
const SBOM_FILE_SUFFIX = '.sbom.json'
const MIN_JAVA_VERSION = '24.0.0'
let javaVersionOrLatestEA: string | null = null
interface SBOM {
components: Component[]
dependencies: Dependency[]
}
interface Component {
name: string
version?: string
purl?: string
dependencies?: string[]
'bom-ref': string
}
interface Dependency {
ref: string
dependsOn: string[]
}
interface DependencySnapshot {
version: number
sha: string
ref: string
job: {
correlator: string
id: string
html_url?: string
}
detector: {
name: string
version: string
url: string
}
scanned: string
manifests: Record<
string,
{
name: string
metadata?: Record<string, string>
// Not including the 'file' property because we cannot specify any reasonable value for 'source_location'
// since the SBOM will not necessarily be saved in the repository of the user.
// GitHub docs: https://docs.github.com/en/rest/dependency-graph/dependency-submission?apiVersion=2022-11-28#create-a-snapshot-of-dependencies-for-a-repository
resolved: Record<
string,
{
package_url: string
relationship?: 'direct'
scope?: 'runtime'
dependencies?: string[]
}
>
}
>
}
export function setUpSBOMSupport(
javaVersionOrDev: string,
distribution: string
): void {
if (!isFeatureEnabled()) {
return
}
validateJavaVersionAndDistribution(javaVersionOrDev, distribution)
javaVersionOrLatestEA = javaVersionOrDev
setNativeImageOption(javaVersionOrLatestEA, '--enable-sbom=export')
core.info('Enabled SBOM generation for Native Image build')
}
function validateJavaVersionAndDistribution(
javaVersionOrDev: string,
distribution: string
): void {
if (distribution !== c.DISTRIBUTION_GRAALVM) {
throw new Error(
`The '${INPUT_NI_SBOM}' option is only supported for Oracle GraalVM (distribution '${c.DISTRIBUTION_GRAALVM}'), but found distribution '${distribution}'.`
)
}
if (javaVersionOrDev === 'dev') {
throw new Error(
`The '${INPUT_NI_SBOM}' option is not supported for java-version 'dev'.`
)
}
if (javaVersionOrDev === 'latest-ea') {
return
}
const coercedJavaVersion = semver.coerce(javaVersionOrDev)
if (!coercedJavaVersion || semver.gt(MIN_JAVA_VERSION, coercedJavaVersion)) {
throw new Error(
`The '${INPUT_NI_SBOM}' option is only supported for GraalVM for JDK ${MIN_JAVA_VERSION} or later, but found java-version '${javaVersionOrDev}'.`
)
}
}
export async function processSBOM(): Promise<void> {
if (!isFeatureEnabled()) {
return
}
if (javaVersionOrLatestEA === null) {
throw new Error('setUpSBOMSupport must be called before processSBOM')
}
const sbomPath = await findSBOMFilePath()
try {
const sbomContent = fs.readFileSync(sbomPath, 'utf8')
const sbomData = parseSBOM(sbomContent)
const components = mapToComponentsWithDependencies(sbomData)
printSBOMContent(components)
const snapshot = convertSBOMToSnapshot(sbomPath, components)
await submitDependencySnapshot(snapshot)
} catch (error) {
throw new Error(
`Failed to process and submit SBOM to the GitHub dependency submission API: ${error instanceof Error ? error.message : String(error)}`
)
}
}
function isFeatureEnabled(): boolean {
return core.getInput(INPUT_NI_SBOM) === 'true'
}
async function findSBOMFilePath(): Promise<string> {
const globber = await glob.create(`**/*${SBOM_FILE_SUFFIX}`)
const sbomFiles = await globber.glob()
if (sbomFiles.length === 0) {
throw new Error(
'No SBOM found. Make sure native-image build completed successfully.'
)
}
if (sbomFiles.length > 1) {
throw new Error(
`Expected one SBOM but found multiple: ${sbomFiles.join(', ')}.`
)
}
core.info(`Found SBOM: ${sbomFiles[0]}`)
return sbomFiles[0]
}
function parseSBOM(jsonString: string): SBOM {
try {
const sbomData: SBOM = JSON.parse(jsonString)
return sbomData
} catch (error) {
throw new Error(
`Failed to parse SBOM JSON: ${error instanceof Error ? error.message : String(error)}`
)
}
}
// Maps the SBOM to a list of components with their dependencies
function mapToComponentsWithDependencies(sbom: SBOM): Component[] {
if (!sbom || sbom.components.length === 0) {
throw new Error('Invalid SBOM data or no components found.')
}
return sbom.components.map((component: Component) => {
const dependencies =
sbom.dependencies?.find(
(dep: Dependency) => dep.ref === component['bom-ref']
)?.dependsOn || []
return {
name: component.name,
version: component.version,
purl: component.purl,
dependencies,
'bom-ref': component['bom-ref']
}
})
}
function printSBOMContent(components: Component[]): void {
core.info('=== SBOM Content ===')
for (const component of components) {
core.info(`- ${component['bom-ref']}`)
if (component.dependencies && component.dependencies.length > 0) {
core.info(` depends on: ${component.dependencies.join(', ')}`)
}
}
core.info('==================')
}
function convertSBOMToSnapshot(
sbomPath: string,
components: Component[]
): DependencySnapshot {
const context = github.context
const sbomFileName = basename(sbomPath)
if (!sbomFileName.endsWith(SBOM_FILE_SUFFIX)) {
throw new Error(
`Invalid SBOM file name: ${sbomFileName}. Expected a file ending with ${SBOM_FILE_SUFFIX}.`
)
}
return {
version: 0,
sha: context.sha,
ref: context.ref,
job: {
correlator: `${context.workflow}_${context.job}`,
id: context.runId.toString(),
html_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
},
detector: {
name: 'Oracle GraalVM',
version: javaVersionOrLatestEA ?? '',
url: 'https://www.graalvm.org/'
},
scanned: new Date().toISOString(),
manifests: {
[sbomFileName]: {
name: sbomFileName,
resolved: mapComponentsToGithubAPIFormat(components),
metadata: {
generated_by: 'SBOM generated by GraalVM Native Image',
action_version: c.ACTION_VERSION
}
}
}
}
}
function mapComponentsToGithubAPIFormat(
components: Component[]
): Record<string, {package_url: string; dependencies?: string[]}> {
return Object.fromEntries(
components
.filter(component => {
if (!component.purl) {
core.info(
`Component ${component.name} does not have a valid package URL (purl). Skipping.`
)
}
return component.purl
})
.map(component => [
component.name,
{
package_url: component.purl as string,
dependencies: component.dependencies || []
}
])
)
}
async function submitDependencySnapshot(
snapshotData: DependencySnapshot
): Promise<void> {
const token = core.getInput(c.INPUT_GITHUB_TOKEN, {required: true})
const octokit = github.getOctokit(token)
const context = github.context
try {
await octokit.request(
'POST /repos/{owner}/{repo}/dependency-graph/snapshots',
{
owner: context.repo.owner,
repo: context.repo.repo,
version: snapshotData.version,
sha: snapshotData.sha,
ref: snapshotData.ref,
job: snapshotData.job,
detector: snapshotData.detector,
metadata: {},
scanned: snapshotData.scanned,
manifests: snapshotData.manifests,
headers: {
'X-GitHub-Api-Version': '2022-11-28'
}
}
)
core.info('Dependency snapshot submitted successfully.')
} catch (error) {
throw new Error(
`Failed to submit dependency snapshot for SBOM: ${error instanceof Error ? error.message : String(error)}`
)
}
}

View File

@@ -14,6 +14,7 @@ import {setUpNativeImageMusl} from './features/musl'
import {setUpWindowsEnvironment} from './msvc'
import {setUpNativeImageBuildReports} from './features/reports'
import {exec} from '@actions/exec'
import {setUpSBOMSupport} from './features/sbom'
async function run(): Promise<void> {
try {
@@ -148,7 +149,6 @@ async function run(): Promise<void> {
if (setJavaHome) {
core.exportVariable('JAVA_HOME', graalVMHome)
}
await setUpGUComponents(
javaVersion,
graalVMVersion,
@@ -165,6 +165,7 @@ async function run(): Promise<void> {
javaVersion,
graalVMVersion
)
setUpSBOMSupport(javaVersion, distribution)
core.startGroup(`Successfully set up '${basename(graalVMHome)}'`)
await exec(join(graalVMHome, 'bin', `java${c.EXECUTABLE_SUFFIX}`), [

View File

@@ -4,11 +4,13 @@ import * as github from '@actions/github'
import * as httpClient from '@actions/http-client'
import * as semver from 'semver'
import * as tc from '@actions/tool-cache'
import * as fs from 'fs'
import {ExecOptions, exec as e} from '@actions/exec'
import {readFileSync, readdirSync} from 'fs'
import {Octokit} from '@octokit/core'
import {createHash} from 'crypto'
import {join} from 'path'
import {tmpdir} from 'os'
// Set up Octokit for github.com only and in the same way as @actions/github (see https://git.io/Jy9YP)
const baseUrl = 'https://api.github.com'
@@ -247,3 +249,47 @@ export async function createPRComment(content: string): Promise<void> {
)
}
}
export function tmpfile(fileName: string) {
return join(tmpdir(), fileName)
}
export function setNativeImageOption(
javaVersionOrDev: string,
optionValue: string
): void {
const coercedJavaVersionOrDev = semver.coerce(javaVersionOrDev)
if (
(coercedJavaVersionOrDev &&
semver.gte(coercedJavaVersionOrDev, '22.0.0')) ||
javaVersionOrDev === c.VERSION_DEV ||
javaVersionOrDev.endsWith('-ea')
) {
/* NATIVE_IMAGE_OPTIONS was introduced in GraalVM for JDK 22 (so were EA builds). */
let newOptionValue = optionValue
const existingOptions = process.env[c.NATIVE_IMAGE_OPTIONS_ENV]
if (existingOptions) {
newOptionValue = `${existingOptions} ${newOptionValue}`
}
core.exportVariable(c.NATIVE_IMAGE_OPTIONS_ENV, newOptionValue)
} else {
const optionsFile = getNativeImageOptionsFile()
if (fs.existsSync(optionsFile)) {
fs.appendFileSync(optionsFile, ` ${optionValue}`)
} else {
fs.writeFileSync(optionsFile, `NativeImageArgs = ${optionValue}`)
}
}
}
const NATIVE_IMAGE_CONFIG_FILE = tmpfile('native-image-options.properties')
const NATIVE_IMAGE_CONFIG_FILE_ENV = 'NATIVE_IMAGE_CONFIG_FILE'
function getNativeImageOptionsFile(): string {
let optionsFile = process.env[NATIVE_IMAGE_CONFIG_FILE_ENV]
if (optionsFile === undefined) {
optionsFile = NATIVE_IMAGE_CONFIG_FILE
core.exportVariable(NATIVE_IMAGE_CONFIG_FILE_ENV, optionsFile)
}
return optionsFile
}