272 lines
7.9 KiB
TypeScript
272 lines
7.9 KiB
TypeScript
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'
|
|
const javaVersionKey = 'javaVersionKey'
|
|
|
|
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(javaVersion: string, distribution: string): void {
|
|
if (!isFeatureEnabled()) {
|
|
return
|
|
}
|
|
|
|
validateJavaVersionAndDistribution(javaVersion, distribution)
|
|
core.saveState(javaVersionKey, javaVersion)
|
|
setNativeImageOption(javaVersion, '--enable-sbom=export')
|
|
core.info('Enabled SBOM generation for Native Image build')
|
|
}
|
|
|
|
function validateJavaVersionAndDistribution(javaVersion: 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 (javaVersion === 'dev') {
|
|
throw new Error(`The '${INPUT_NI_SBOM}' option is not supported for java-version 'dev'.`)
|
|
}
|
|
|
|
if (javaVersion === 'latest-ea') {
|
|
return
|
|
}
|
|
|
|
const coercedJavaVersion = semver.coerce(javaVersion)
|
|
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 '${javaVersion}'.`
|
|
)
|
|
}
|
|
}
|
|
|
|
export async function processSBOM(): Promise<void> {
|
|
if (!isFeatureEnabled()) {
|
|
return
|
|
}
|
|
|
|
const javaVersion = core.getState(javaVersionKey)
|
|
if (!javaVersion) {
|
|
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(javaVersion, 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(javaVersion: string, 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: javaVersion,
|
|
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)}`
|
|
)
|
|
}
|
|
}
|