diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2642a1f..7385e79 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -417,3 +417,24 @@ jobs: # popd > /dev/null - name: Remove components run: gu remove espresso llvm-toolchain nodejs python ruby wasm + test-sbom: + needs: test + name: test 'native-image-enable-sbom' option + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./ + with: + java-version: '23' + distribution: 'graalvm' + native-image-enable-sbom: 'true' + components: 'native-image' + - name: Verify SBOM was generated + run: | + # Create a simple native image that will generate SBOM + echo 'public class Hello { public static void main(String[] args) { System.out.println("Hello"); } }' > Hello.java + javac Hello.java + # '--enable-sbom=export' should get injected into the native-image command + native-image Hello + # Verify SBOM file exists + find . -name "*.sbom.json" | grep . || exit 1 diff --git a/__tests__/sbom.test.ts b/__tests__/sbom.test.ts new file mode 100644 index 0000000..b977ac6 --- /dev/null +++ b/__tests__/sbom.test.ts @@ -0,0 +1,135 @@ +import {setUpSBOMSupport, processSBOM, INPUT_NI_SBOM, NATIVE_IMAGE_OPTIONS_ENV} from '../src/features/sbom' +import * as core from '@actions/core' +import * as glob from '@actions/glob' +import {join} from 'path' +import {tmpdir} from 'os' +import {mkdtempSync, writeFileSync, rmSync} from 'fs' + +// Module level mock +jest.mock('@actions/glob') + +describe('sbom feature', () => { + let spyInfo: jest.SpyInstance> + let spyWarning: jest.SpyInstance> + let spyExportVariable: jest.SpyInstance> + let workspace: string + + beforeEach(() => { + workspace = mkdtempSync(join(tmpdir(), 'setup-graalvm-sbom-')) + + spyInfo = jest.spyOn(core, 'info').mockImplementation(() => null) + spyWarning = jest.spyOn(core, 'warning').mockImplementation(() => null) + spyExportVariable = jest.spyOn(core, 'exportVariable').mockImplementation(() => null) + jest.spyOn(core, 'getInput').mockImplementation((name: string) => { + if (name === INPUT_NI_SBOM) { + return 'true' + } + return '' + }) + }) + + afterEach(() => { + jest.clearAllMocks() + rmSync(workspace, {recursive: true, force: true}) + }) + + describe('setup', () => { + it('should set the SBOM option flag when activated', () => { + setUpSBOMSupport() + expect(spyExportVariable).toHaveBeenCalledWith( + NATIVE_IMAGE_OPTIONS_ENV, + expect.stringContaining('--enable-sbom=export') + ) + expect(spyInfo).toHaveBeenCalledWith('Enabled SBOM generation for Native Image builds') + }) + + it('should not set the SBOM option flag when not activated', () => { + jest.spyOn(core, 'getInput').mockReturnValue('false') + setUpSBOMSupport() + expect(spyExportVariable).not.toHaveBeenCalled() + expect(spyInfo).not.toHaveBeenCalled() + }) + }) + + describe('process', () => { + const sampleSBOM = { + bomFormat: "CycloneDX", + specVersion: "1.5", + version: 1, + serialNumber: "urn:uuid:52c977f8-6d04-3c07-8826-597a036d61a6", + components: [ + { + type: "library", + group: "org.json", + name: "json", + version: "20211205", + purl: "pkg:maven/org.json/json@20211205", + "bom-ref": "pkg:maven/org.json/json@20211205", + properties: [ + { + name: "syft:cpe23", + value: "cpe:2.3:a:json:json:20211205:*:*:*:*:*:*:*" + } + ] + }, + { + type: "library", + group: "com.oracle", + 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: [] + } + ] + } + + it('should process SBOM file and display components', async () => { + setUpSBOMSupport() + spyInfo.mockClear() + + // Mock 'native-image' invocation by creating the SBOM file + const sbomPath = join(workspace, 'test.sbom.json') + writeFileSync(sbomPath, JSON.stringify(sampleSBOM, null, 2)) + + const mockCreate = jest.fn().mockResolvedValue({ + glob: jest.fn().mockResolvedValue([sbomPath]) + }) + ;(glob.create as jest.Mock).mockImplementation(mockCreate) + + await processSBOM() + + expect(spyInfo).toHaveBeenCalledWith('Found SBOM file: ' + sbomPath) + expect(spyInfo).toHaveBeenCalledWith('=== SBOM Content ===') + expect(spyInfo).toHaveBeenCalledWith('Found 2 dependencies:') + expect(spyInfo).toHaveBeenCalledWith('- json@20211205') + expect(spyInfo).toHaveBeenCalledWith('- main-test-app@1.0-SNAPSHOT') + expect(spyWarning).not.toHaveBeenCalled() + }) + + it('should handle missing SBOM file', async () => { + setUpSBOMSupport() + spyInfo.mockClear() + + // Mock glob to return empty array (no files found) + const mockCreate = jest.fn().mockResolvedValue({ + glob: jest.fn().mockResolvedValue([]) + }) + ;(glob.create as jest.Mock).mockImplementation(mockCreate) + + await processSBOM() + expect(spyWarning).toHaveBeenCalledWith( + 'No SBOM file found. Make sure native-image build completed successfully.' + ) + }) + }) +}) \ No newline at end of file diff --git a/action.yml b/action.yml index ea52128..52dc54e 100644 --- a/action.yml +++ b/action.yml @@ -51,6 +51,10 @@ inputs: required: false description: 'Instead of posting another comment, update an existing PR comment with the latest Native Image build report.' 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.' + default: 'false' version: required: false description: 'GraalVM version (release, latest, dev).' diff --git a/package-lock.json b/package-lock.json index 985ed71..c94a1bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ "prettier": "^3.2.5", "prettier-eslint": "^16.3.0", "ts-jest": "^29.1.2", - "typescript": "^5.3.3" + "typescript": "^5.7.2" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -7644,10 +7644,11 @@ } }, "node_modules/typescript": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", - "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 72547e0..9961aa6 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,6 @@ "prettier": "^3.2.5", "prettier-eslint": "^16.3.0", "ts-jest": "^29.1.2", - "typescript": "^5.3.3" + "typescript": "^5.7.2" } } diff --git a/src/cleanup.ts b/src/cleanup.ts index 3ed1b0c..cc1e9eb 100644 --- a/src/cleanup.ts +++ b/src/cleanup.ts @@ -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): Promise { export async function run(): Promise { await ignoreErrors(generateReports()) + await ignoreErrors(processSBOM()) await ignoreErrors(saveCache()) } diff --git a/src/features/sbom.ts b/src/features/sbom.ts new file mode 100644 index 0000000..3be5e7a --- /dev/null +++ b/src/features/sbom.ts @@ -0,0 +1,170 @@ +import * as core from '@actions/core' +import * as fs from 'fs' +import * as github from '@actions/github' +import * as glob from '@actions/glob' + +export const INPUT_NI_SBOM = 'native-image-enable-sbom' +export const NATIVE_IMAGE_OPTIONS_ENV = 'NATIVE_IMAGE_OPTIONS' + +export function setUpSBOMSupport(): void { + const isSbomEnabled = core.getInput(INPUT_NI_SBOM) === 'true' + if (!isSbomEnabled) { + return + } + + let options = process.env[NATIVE_IMAGE_OPTIONS_ENV] || '' + if (options.length > 0) { + options += ' ' + } + options += '--enable-sbom=export' + core.exportVariable(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 + */ +async function findSBOMFile(): Promise { + const globber = await glob.create('**/*.sbom.json') + const sbomFiles = await globber.glob() + + if (sbomFiles.length === 0) { + core.warning('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.' + ) + return null + } + + core.info(`Found SBOM file: ${sbomFiles[0]}`) + return sbomFiles[0] +} + +function displaySBOMContent(sbomData: any): void { + core.info('=== SBOM Content ===') + + if (sbomData.components) { + core.info(`Found ${sbomData.components.length} dependencies:`) + for (const component of sbomData.components) { + core.info(`- ${component.name}@${component.version || 'unknown'}`) + if (component.dependencies?.length > 0) { + core.info(` Dependencies: ${component.dependencies.join(', ')}`) + } + } + } else { + core.info('No components found in SBOM') + } + + core.info('==================') +} + +export async function processSBOM(): Promise { + const isSbomEnabled = core.getInput(INPUT_NI_SBOM) === 'true' + if (!isSbomEnabled) { + return + } + + const sbomFile = await findSBOMFile() + if (!sbomFile) { + return + } + + try { + const sbomContent = fs.readFileSync(sbomFile, 'utf8') + const sbomData = JSON.parse(sbomContent) + displaySBOMContent(sbomData) + } 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 + } + detector: { + name: string + version: string + url: string + } + scanned: string + manifests: Record + }> +} + +async function convertSBOMToSnapshot(sbomData: any): Promise { + const context = github.context + + return { + version: 0, + sha: context.sha, + ref: context.ref, + job: { + correlator: `${context.workflow}_${context.action}`, + id: context.runId.toString() + }, + detector: { + name: 'graalvm-setup-sbom', + version: '1.0.0', + url: 'https://github.com/graalvm/setup-graalvm' + }, + scanned: new Date().toISOString(), + manifests: { + 'native-image-sbom.json': { + name: 'native-image-sbom.json', + file: { + source_location: 'native-image-sbom.json' + }, + resolved: convertSBOMDependencies(sbomData) + } + } + } +} + +function convertSBOMDependencies(sbomData: any): Record { + const resolved: Record = {} + + if (sbomData.components) { + for (const component of sbomData.components) { + if (component.name && component.version) { + resolved[component.name] = { + package_url: `pkg:${component.type || 'maven'}/${component.name}@${component.version}` + } + + if (component.dependencies?.length > 0) { + resolved[component.name].dependencies = component.dependencies + } + } + } + } + + return resolved +} + +async function submitDependencySnapshot(snapshot: DependencySnapshot): Promise { + const token = core.getInput('github-token') + const octokit = github.getOctokit(token) +// await octokit.rest.dependencyGraph.createSnapshot({ +// owner: github.context.repo.owner, +// repo: github.context.repo.repo, +// ...snapshot +// }) +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 7f32b82..8fa13f8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { try { @@ -166,6 +167,8 @@ async function run(): Promise { graalVMVersion ) + setUpSBOMSupport() + core.startGroup(`Successfully set up '${basename(graalVMHome)}'`) await exec(join(graalVMHome, 'bin', `java${c.EXECUTABLE_SUFFIX}`), [ javaVersion.startsWith('8') ? '-version' : '--version'