diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4c493ae..8cb8692 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -164,6 +164,7 @@ jobs: test-ee: needs: test name: EE ${{ matrix.version }} + JDK${{ matrix.java-version }} on ${{ matrix.os }} + if: github.event_name != 'pull_request' runs-on: ${{ matrix.os }} strategy: matrix: diff --git a/__tests__/sbom.test.ts b/__tests__/sbom.test.ts index 968e494..0629883 100644 --- a/__tests__/sbom.test.ts +++ b/__tests__/sbom.test.ts @@ -1,9 +1,9 @@ +import * as c from '../src/constants' import { setUpSBOMSupport, processSBOM, mapToComponentsWithDependencies, - INPUT_NI_SBOM, - NATIVE_IMAGE_OPTIONS_ENV + INPUT_NI_SBOM } from '../src/features/sbom' import * as core from '@actions/core' import * as github from '@actions/github' @@ -50,10 +50,8 @@ describe('sbom feature', () => { let originalEnv: NodeJS.ProcessEnv beforeEach(() => { - // Save original env originalEnv = process.env - // Set up test environment process.env = { ...process.env, GITHUB_REPOSITORY: 'test-owner/test-repo', @@ -93,7 +91,7 @@ describe('sbom feature', () => { it('should set the SBOM option flag when activated', () => { setUpSBOMSupport() expect(spyExportVariable).toHaveBeenCalledWith( - NATIVE_IMAGE_OPTIONS_ENV, + c.NATIVE_IMAGE_OPTIONS_ENV, expect.stringContaining('--enable-sbom=export') ) expect(spyInfo).toHaveBeenCalledWith( @@ -169,6 +167,9 @@ describe('sbom feature', () => { expect(spyInfo).toHaveBeenCalledWith( '- pkg:maven/com.oracle/main-test-app@1.0-SNAPSHOT' ) + expect(spyInfo).toHaveBeenCalledWith( + ' depends on: pkg:maven/org.json/json@20211205' + ) expect(spyWarning).not.toHaveBeenCalled() }) @@ -196,9 +197,8 @@ describe('sbom feature', () => { await processSBOM() expect(spyInfo).toHaveBeenCalledWith('=== SBOM Content ===') - expect(spyInfo).toHaveBeenCalledWith( - '- no-purl-package@1.0.0 (purl not specified, component will not be submitted to GitHub dependency API)' - ) + expect(spyInfo).toHaveBeenCalledWith('- no-purl-package@1.0.0') + expect(spyWarning).not.toHaveBeenCalled() }) it('should handle missing SBOM file', async () => { @@ -209,7 +209,7 @@ describe('sbom feature', () => { await processSBOM() expect(spyWarning).toHaveBeenCalledWith( - 'No SBOM file found. Make sure native-image build completed successfully.' + 'No SBOM file found. Make sure native-image build completed successfully. Skipping submission to GitHub Dependency API.' ) }) @@ -241,7 +241,22 @@ describe('sbom feature', () => { correlator: 'test-workflow_test-job', id: '12345' }), - manifests: expect.any(Object) + manifests: expect.objectContaining({ + 'test.sbom.json': expect.objectContaining({ + name: 'test.sbom.json', + resolved: expect.objectContaining({ + json: expect.objectContaining({ + package_url: 'pkg:maven/org.json/json@20211205', + dependencies: [] + }), + 'main-test-app': expect.objectContaining({ + package_url: + 'pkg:maven/com.oracle/main-test-app@1.0-SNAPSHOT', + dependencies: ['pkg:maven/org.json/json@20211205'] + }) + }) + }) + }) }) ) }) @@ -267,94 +282,4 @@ describe('sbom feature', () => { ) }) }) - - describe('mapToComponents', () => { - it('should map valid SBOM data to components', () => { - const sbomData = { - components: [ - { - name: 'json', - version: '20211205', - purl: 'pkg:maven/org.json/json@20211205', - 'bom-ref': 'pkg:maven/org.json/json@20211205' - }, - { - name: 'main-test-app', - version: '1.0-SNAPSHOT', - purl: 'pkg:maven/com.oracle/main-test-app@1.0-SNAPSHOT', - 'bom-ref': 'pkg:maven/com.oracle/main-test-app@1.0-SNAPSHOT' - } - ], - dependencies: [ - { - ref: 'pkg:maven/com.oracle/main-test-app@1.0-SNAPSHOT', - dependsOn: ['pkg:maven/org.json/json@20211205'] - }, - { - ref: 'pkg:maven/org.json/json@20211205', - dependsOn: [] - } - ] - } - - const result = mapToComponentsWithDependencies(sbomData) - - expect(result).toEqual([ - { - name: 'json', - version: '20211205', - purl: 'pkg:maven/org.json/json@20211205', - dependencies: [], - 'bom-ref': 'pkg:maven/org.json/json@20211205' - }, - { - name: 'main-test-app', - version: '1.0-SNAPSHOT', - purl: 'pkg:maven/com.oracle/main-test-app@1.0-SNAPSHOT', - dependencies: ['pkg:maven/org.json/json@20211205'], - 'bom-ref': 'pkg:maven/com.oracle/main-test-app@1.0-SNAPSHOT' - } - ]) - }) - - it('should handle components without dependencies', () => { - const sbomData = { - components: [ - { - name: 'json', - version: '20211205', - purl: 'pkg:maven/org.json/json@20211205', - 'bom-ref': 'pkg:maven/org.json/json@20211205' - } - ], - dependencies: [] - } - - const result = mapToComponentsWithDependencies(sbomData) - - expect(result).toEqual([ - { - name: 'json', - version: '20211205', - purl: 'pkg:maven/org.json/json@20211205', - dependencies: [], - 'bom-ref': 'pkg:maven/org.json/json@20211205' - } - ]) - }) - - it('should handle missing components', () => { - const sbomData = { - components: [], - dependencies: [] - } - - const result = mapToComponentsWithDependencies(sbomData) - - expect(result).toEqual([]) - expect(spyWarning).toHaveBeenCalledWith( - 'Invalid SBOM data or no components found.' - ) - }) - }) }) diff --git a/action.yml b/action.yml index 52dc54e..bf2bad9 100644 --- a/action.yml +++ b/action.yml @@ -53,7 +53,7 @@ inputs: default: 'false' native-image-enable-sbom: required: false - description: 'Enable SBOM generation for Native Image builds. The SBOM dependencies are shown in the dependency view in Github.' + description: 'Enable SBOM (Software Bill of Materials) generation for Native Image builds. SBOM dependencies are shown in the "Dependency graph" under "Insights" and vulnerability alerts under "Security". This requires the 'Dependency graph' feature to be actived. default: 'false' version: required: false diff --git a/src/constants.ts b/src/constants.ts index b3356a0..7b30729 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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' diff --git a/src/features/reports.ts b/src/features/reports.ts index fd21fd3..eca3898 100644 --- a/src/features/reports.ts +++ b/src/features/reports.ts @@ -26,7 +26,6 @@ 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' @@ -182,11 +181,11 @@ function setNativeImageOption( ) { /* 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] + const existingOptions = process.env[c.NATIVE_IMAGE_OPTIONS_ENV] if (existingOptions) { newOptionValue = `${existingOptions} ${newOptionValue}` } - core.exportVariable(NATIVE_IMAGE_OPTIONS_ENV, newOptionValue) + core.exportVariable(c.NATIVE_IMAGE_OPTIONS_ENV, newOptionValue) } else { const optionsFile = getNativeImageOptionsFile() if (fs.existsSync(optionsFile)) { diff --git a/src/features/sbom.ts b/src/features/sbom.ts index bb934a9..c1bcc71 100644 --- a/src/features/sbom.ts +++ b/src/features/sbom.ts @@ -1,12 +1,11 @@ +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 {ACTION_VERSION, INPUT_GITHUB_TOKEN} from '../constants' import {basename} from 'path' export const INPUT_NI_SBOM = 'native-image-enable-sbom' -export const NATIVE_IMAGE_OPTIONS_ENV = 'NATIVE_IMAGE_OPTIONS' export const SBOM_FILE_SUFFIX = '.sbom.json' export function setUpSBOMSupport(): void { @@ -15,35 +14,62 @@ export function setUpSBOMSupport(): void { return } - let options = process.env[NATIVE_IMAGE_OPTIONS_ENV] || '' + let options = process.env[c.NATIVE_IMAGE_OPTIONS_ENV] || '' if (options.length > 0) { options += ' ' } options += '--enable-sbom=export' - core.exportVariable(NATIVE_IMAGE_OPTIONS_ENV, options) + core.exportVariable(c.NATIVE_IMAGE_OPTIONS_ENV, options) core.info('Enabled SBOM generation for Native Image builds') } -/** - * Finds a single SBOM file in the build directory - * @returns Path to the SBOM file or null if not found or multiple files exist - */ +export async function processSBOM(): Promise { + const isSbomEnabled = core.getInput(INPUT_NI_SBOM) === 'true' + if (!isSbomEnabled) { + return + } + + const sbomPath = await findSBOMFilePath() + if (!sbomPath) { + return + } + + try { + const sbomContent = fs.readFileSync(sbomPath, 'utf8') + const sbomData = parseSBOM(sbomContent) + if (!sbomData) { + return + } + const components = mapToComponentsWithDependencies(sbomData) + if (components.length === 0) { + return + } + + printSBOMContent(components) + const snapshot = convertSBOMToSnapshot(sbomPath, components) + if (snapshot) { + await submitDependencySnapshot(snapshot) + } + } catch (error) { + core.warning( + `Failed to process SBOM file: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + async function findSBOMFilePath(): Promise { const globber = await glob.create(`**/*${SBOM_FILE_SUFFIX}`) const sbomFiles = await globber.glob() if (sbomFiles.length === 0) { - core.warning( + logSkippingSubmission( 'No SBOM file found. Make sure native-image build completed successfully.' ) return null } if (sbomFiles.length > 1) { - core.warning( - `Found multiple SBOM files: ${sbomFiles.join(', ')}. ` + - 'Expected exactly one SBOM file. Skipping SBOM processing.' - ) + logSkippingSubmission(`Found multiple SBOM files: ${sbomFiles.join(', ')}.`) return null } @@ -63,20 +89,34 @@ function parseSBOM(jsonString: string): SBOM | null { } } -function printSBOMContent(components: Component[]): void { - core.info('=== SBOM Content ===') +// Maps the SBOM data to a list of components with their dependencies +export function mapToComponentsWithDependencies(sbom: SBOM): Component[] { + if (!sbom || sbom.components.length === 0) { + logSkippingSubmission('Invalid SBOM data or no components found.') + return [] + } - for (const component of components) { - const version = component.version || 'unknown' + return sbom.components.map((component: Component) => { + const dependencies = + sbom.dependencies?.find( + (dep: Dependency) => dep.ref === component['bom-ref'] + )?.dependsOn || [] - if (component.purl) { - core.info(`- ${component.purl}`) - } else { - core.info( - `- ${component.name}@${version} (purl not specified, component will not be submitted to GitHub dependency API)` - ) + 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(', ')}`) } @@ -85,77 +125,6 @@ function printSBOMContent(components: Component[]): void { core.info('==================') } -export async function processSBOM(): Promise { - const isSbomEnabled = core.getInput(INPUT_NI_SBOM) === 'true' - if (!isSbomEnabled) { - return - } - - const sbomPath = await findSBOMFilePath() - if (!sbomPath) { - return - } - - try { - const sbomContent = fs.readFileSync(sbomPath, 'utf8') - const sbomData = parseSBOM(sbomContent) - if (!sbomData) { - return - } - const components = mapToComponentsWithDependencies(sbomData) - if (components.length === 0) { - core.warning( - 'No components found in SBOM. Skipping submission to GitHub Dependency API.' - ) - return - } - - printSBOMContent(components) - const snapshot = convertSBOMToSnapshot(sbomPath, components) - if (snapshot) { - await submitDependencySnapshot(snapshot) - } - } catch (error) { - core.warning( - `Failed to process SBOM file: ${error instanceof Error ? error.message : String(error)}` - ) - } -} - -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 - // Not including the file property since we cannot use any reasonable value for "source_location" - resolved: Record< - string, - { - package_url: string - relationship?: 'direct' | 'indirect' - scope?: 'runtime' | 'development' - dependencies?: string[] - } - > - } - > -} - function mapToSnapshotResolved( components: Component[] ): Record { @@ -187,8 +156,8 @@ function convertSBOMToSnapshot( const sbomFileName = basename(sbomPath) if (!sbomFileName.endsWith(SBOM_FILE_SUFFIX)) { - core.warning( - `Invalid SBOM file name: ${sbomFileName}. Expected a file ending with ${SBOM_FILE_SUFFIX}. Skipping submission to GitHub Dependency API.` + logSkippingSubmission( + `Invalid SBOM file name: ${sbomFileName}. Expected a file ending with ${SBOM_FILE_SUFFIX}.` ) return null } @@ -204,7 +173,7 @@ function convertSBOMToSnapshot( }, detector: { name: 'graalvm-native-image', - version: ACTION_VERSION, + version: c.ACTION_VERSION, url: 'https://github.com/graalvm/setup-graalvm' }, scanned: new Date().toISOString(), @@ -214,7 +183,7 @@ function convertSBOMToSnapshot( resolved: mapToSnapshotResolved(components), metadata: { generated_by: 'SBOM generated by GraalVM Native Image', - action_version: ACTION_VERSION + action_version: c.ACTION_VERSION } } } @@ -224,7 +193,7 @@ function convertSBOMToSnapshot( async function submitDependencySnapshot( snapshotData: DependencySnapshot ): Promise { - const token = core.getInput(INPUT_GITHUB_TOKEN, {required: true}) + const token = core.getInput(c.INPUT_GITHUB_TOKEN, {required: true}) const octokit = github.getOctokit(token) const context = github.context @@ -273,24 +242,40 @@ interface Dependency { dependsOn: string[] } -export function mapToComponentsWithDependencies(sbom: SBOM): Component[] { - if (!sbom || sbom.components.length === 0) { - core.warning('Invalid SBOM data or no components found.') - return [] +interface DependencySnapshot { + version: number + sha: string + ref: string + job: { + correlator: string + id: string + html_url?: string } - - 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'] + detector: { + name: string + version: string + url: string + } + scanned: string + manifests: Record< + string, + { + name: string + metadata?: Record + // Not including the file property since we cannot use any reasonable value for "source_location" + resolved: Record< + string, + { + package_url: string + relationship?: 'direct' | 'indirect' + scope?: 'runtime' | 'development' + dependencies?: string[] + } + > } - }) + > +} + +function logSkippingSubmission(prefix: string): void { + core.warning(`${prefix} Skipping submission to GitHub Dependency API.`) }