2025-02-12 20:42:16 +01:00
import * as c from '../constants'
2025-01-21 09:00:42 +01:00
import * as core from '@actions/core'
import * as fs from 'fs'
import * as github from '@actions/github'
import * as glob from '@actions/glob'
2025-02-10 09:16:33 +01:00
import { basename } from 'path'
2025-01-21 09:00:42 +01:00
import * as semver from 'semver'
2025-02-12 20:42:16 +01:00
import { setNativeImageOption } from '../utils'
2025-01-21 09:00:42 +01:00
const INPUT_NI_SBOM = 'native-image-enable-sbom'
const SBOM_FILE_SUFFIX = '.sbom.json'
const MIN_JAVA_VERSION = '24.0.0'
2025-03-03 11:00:17 +01:00
const javaVersionKey = 'javaVersionKey'
2025-01-21 09:00:42 +01:00
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 [ ]
}
>
}
>
}
2025-03-03 11:00:17 +01:00
export function setUpSBOMSupport ( javaVersion : string , distribution : string ) : void {
2025-01-21 09:00:42 +01:00
if ( ! isFeatureEnabled ( ) ) {
return
}
2025-03-03 11:00:17 +01:00
validateJavaVersionAndDistribution ( javaVersion , distribution )
core . saveState ( javaVersionKey , javaVersion )
setNativeImageOption ( javaVersion , '--enable-sbom=export' )
2025-01-21 09:00:42 +01:00
core . info ( 'Enabled SBOM generation for Native Image build' )
}
2025-03-03 11:00:17 +01:00
function validateJavaVersionAndDistribution ( javaVersion : string , distribution : string ) : void {
2025-01-21 09:00:42 +01:00
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 } '. `
)
}
2025-03-03 11:00:17 +01:00
if ( javaVersion === 'dev' ) {
2025-02-10 09:16:33 +01:00
throw new Error ( ` The ' ${ INPUT_NI_SBOM } ' option is not supported for java-version 'dev'. ` )
2025-01-21 09:00:42 +01:00
}
2025-03-03 11:00:17 +01:00
if ( javaVersion === 'latest-ea' ) {
2025-01-21 09:00:42 +01:00
return
}
2025-03-03 11:00:17 +01:00
const coercedJavaVersion = semver . coerce ( javaVersion )
2025-01-21 09:00:42 +01:00
if ( ! coercedJavaVersion || semver . gt ( MIN_JAVA_VERSION , coercedJavaVersion ) ) {
throw new Error (
2025-03-03 11:00:17 +01:00
` The ' ${ INPUT_NI_SBOM } ' option is only supported for GraalVM for JDK ${ MIN_JAVA_VERSION } or later, but found java-version ' ${ javaVersion } '. `
2025-01-21 09:00:42 +01:00
)
}
}
export async function processSBOM ( ) : Promise < void > {
if ( ! isFeatureEnabled ( ) ) {
return
}
2025-03-03 11:00:17 +01:00
const javaVersion = core . getState ( javaVersionKey )
if ( ! javaVersion ) {
2025-01-21 09:00:42 +01:00
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 )
2025-03-03 11:00:17 +01:00
const snapshot = convertSBOMToSnapshot ( javaVersion , sbomPath , components )
2025-01-21 09:00:42 +01:00
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 ) {
2025-02-10 09:16:33 +01:00
throw new Error ( 'No SBOM found. Make sure native-image build completed successfully.' )
2025-01-21 09:00:42 +01:00
}
if ( sbomFiles . length > 1 ) {
2025-02-10 09:16:33 +01:00
throw new Error ( ` Expected one SBOM but found multiple: ${ sbomFiles . join ( ', ' ) } . ` )
2025-01-21 09:00:42 +01:00
}
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 ) {
2025-02-10 09:16:33 +01:00
throw new Error ( ` Failed to parse SBOM JSON: ${ error instanceof Error ? error.message : String ( error ) } ` )
2025-01-21 09:00:42 +01:00
}
}
// 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 ) = > {
2025-02-10 09:16:33 +01:00
const dependencies = sbom . dependencies ? . find ( ( dep : Dependency ) = > dep . ref === component [ 'bom-ref' ] ) ? . dependsOn || [ ]
2025-01-21 09:00:42 +01:00
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 ( '==================' )
}
2025-03-03 11:00:17 +01:00
function convertSBOMToSnapshot ( javaVersion : string , sbomPath : string , components : Component [ ] ) : DependencySnapshot {
2025-01-21 09:00:42 +01:00
const context = github . context
const sbomFileName = basename ( sbomPath )
if ( ! sbomFileName . endsWith ( SBOM_FILE_SUFFIX ) ) {
2025-02-10 09:16:33 +01:00
throw new Error ( ` Invalid SBOM file name: ${ sbomFileName } . Expected a file ending with ${ SBOM_FILE_SUFFIX } . ` )
2025-01-21 09:00:42 +01:00
}
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' ,
2025-03-03 11:00:17 +01:00
version : javaVersion ,
2025-01-21 09:00:42 +01:00
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 [ ]
2025-02-10 09:16:33 +01:00
) : Record < string , { package_url : string ; dependencies ? : string [ ] } > {
2025-01-21 09:00:42 +01:00
return Object . fromEntries (
components
2025-02-10 09:16:33 +01:00
. filter ( ( component ) = > {
2025-01-21 09:00:42 +01:00
if ( ! component . purl ) {
2025-02-10 09:16:33 +01:00
core . info ( ` Component ${ component . name } does not have a valid package URL (purl). Skipping. ` )
2025-01-21 09:00:42 +01:00
}
return component . purl
} )
2025-02-10 09:16:33 +01:00
. map ( ( component ) = > [
2025-01-21 09:00:42 +01:00
component . name ,
{
package_url : component.purl as string ,
dependencies : component.dependencies || [ ]
}
] )
)
}
2025-02-10 09:16:33 +01:00
async function submitDependencySnapshot ( snapshotData : DependencySnapshot ) : Promise < void > {
const token = core . getInput ( c . INPUT_GITHUB_TOKEN , { required : true } )
2025-01-21 09:00:42 +01:00
const octokit = github . getOctokit ( token )
const context = github . context
try {
2025-02-10 09:16:33 +01:00
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'
2025-01-21 09:00:42 +01:00
}
2025-02-10 09:16:33 +01:00
} )
2025-01-21 09:00:42 +01:00
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 ) } `
)
}
}