POC sbom integration (only printing SBOM to console, no Github integration)

This commit is contained in:
Joel Rudsberg 2024-11-29 14:17:48 +01:00
parent 4a200f28cd
commit c7e990f07f
8 changed files with 341 additions and 5 deletions

View File

@ -417,3 +417,24 @@ jobs:
# popd > /dev/null # popd > /dev/null
- name: Remove components - name: Remove components
run: gu remove espresso llvm-toolchain nodejs python ruby wasm 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

135
__tests__/sbom.test.ts Normal file
View File

@ -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<void, Parameters<typeof core.info>>
let spyWarning: jest.SpyInstance<void, Parameters<typeof core.warning>>
let spyExportVariable: jest.SpyInstance<void, Parameters<typeof core.exportVariable>>
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.'
)
})
})
})

View File

@ -51,6 +51,10 @@ inputs:
required: false required: false
description: 'Instead of posting another comment, update an existing PR comment with the latest Native Image build report.' description: 'Instead of posting another comment, update an existing PR comment with the latest Native Image build report.'
default: 'false' 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: version:
required: false required: false
description: 'GraalVM version (release, latest, dev).' description: 'GraalVM version (release, latest, dev).'

9
package-lock.json generated
View File

@ -40,7 +40,7 @@
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-eslint": "^16.3.0", "prettier-eslint": "^16.3.0",
"ts-jest": "^29.1.2", "ts-jest": "^29.1.2",
"typescript": "^5.3.3" "typescript": "^5.7.2"
} }
}, },
"node_modules/@aashutoshrathi/word-wrap": { "node_modules/@aashutoshrathi/word-wrap": {
@ -7644,10 +7644,11 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.4.2", "version": "5.7.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
"integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View File

@ -58,6 +58,6 @@
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-eslint": "^16.3.0", "prettier-eslint": "^16.3.0",
"ts-jest": "^29.1.2", "ts-jest": "^29.1.2",
"typescript": "^5.3.3" "typescript": "^5.7.2"
} }
} }

View File

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

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

@ -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<string | null> {
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<void> {
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<string, {
name: string
file: {
source_location: string
}
resolved: Record<string, {
package_url: string
dependencies?: string[]
}>
}>
}
async function convertSBOMToSnapshot(sbomData: any): Promise<DependencySnapshot> {
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<string, {package_url: string, dependencies?: string[]}> {
const resolved: Record<string, {package_url: string, dependencies?: string[]}> = {}
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<void> {
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
// })
}

View File

@ -14,6 +14,7 @@ import {setUpNativeImageMusl} from './features/musl'
import {setUpWindowsEnvironment} from './msvc' import {setUpWindowsEnvironment} from './msvc'
import {setUpNativeImageBuildReports} from './features/reports' import {setUpNativeImageBuildReports} from './features/reports'
import {exec} from '@actions/exec' import {exec} from '@actions/exec'
import {setUpSBOMSupport} from './features/sbom'
async function run(): Promise<void> { async function run(): Promise<void> {
try { try {
@ -166,6 +167,8 @@ async function run(): Promise<void> {
graalVMVersion graalVMVersion
) )
setUpSBOMSupport()
core.startGroup(`Successfully set up '${basename(graalVMHome)}'`) core.startGroup(`Successfully set up '${basename(graalVMHome)}'`)
await exec(join(graalVMHome, 'bin', `java${c.EXECUTABLE_SUFFIX}`), [ await exec(join(graalVMHome, 'bin', `java${c.EXECUTABLE_SUFFIX}`), [
javaVersion.startsWith('8') ? '-version' : '--version' javaVersion.startsWith('8') ? '-version' : '--version'