Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP – Integrate with GitHub SBOM/dependency #118

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
820 changes: 423 additions & 397 deletions .github/workflows/test.yml

Large diffs are not rendered by default.

136 changes: 136 additions & 0 deletions __tests__/sbom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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'

jest.mock('@actions/glob')

// sbom.ts uses glob to find the SBOM file
// This helper function mocks the glob module to return 'files'
function mockGlobResult(files: string[]) {
const mockCreate = jest.fn().mockResolvedValue({
glob: jest.fn().mockResolvedValue(files)
})
;(glob.create as jest.Mock).mockImplementation(mockCreate)
}

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/[email protected]",
"bom-ref": "pkg:maven/com.oracle/[email protected]"
}
],
dependencies: [
{
ref: "pkg:maven/com.oracle/[email protected]",
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))

mockGlobResult([sbomPath])

await processSBOM()

expect(spyInfo).toHaveBeenCalledWith('Found SBOM file: ' + sbomPath)
expect(spyInfo).toHaveBeenCalledWith('=== SBOM Content ===')
expect(spyInfo).toHaveBeenCalledWith('Found 2 components:')
expect(spyInfo).toHaveBeenCalledWith('- json@20211205')
expect(spyInfo).toHaveBeenCalledWith('- [email protected]')
expect(spyWarning).not.toHaveBeenCalled()
})

it('should handle missing SBOM file', async () => {
setUpSBOMSupport()
spyInfo.mockClear()

mockGlobResult([])

await processSBOM()
expect(spyWarning).toHaveBeenCalledWith(
'No SBOM file found. Make sure native-image build completed successfully.'
)
})
})
})
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).'
Expand Down
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
2 changes: 2 additions & 0 deletions src/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -58,6 +59,7 @@ async function ignoreErrors(promise: Promise<void>): Promise<unknown> {

export async function run(): Promise<void> {
await ignoreErrors(generateReports())
await ignoreErrors(processSBOM())
await ignoreErrors(saveCache())
}

Expand Down
170 changes: 170 additions & 0 deletions src/features/sbom.ts
Original file line number Diff line number Diff line change
@@ -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} components:`)
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
// })
}
3 changes: 3 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
try {
Expand Down Expand Up @@ -166,6 +167,8 @@ async function run(): Promise<void> {
graalVMVersion
)

setUpSBOMSupport()

core.startGroup(`Successfully set up '${basename(graalVMHome)}'`)
await exec(join(graalVMHome, 'bin', `java${c.EXECUTABLE_SUFFIX}`), [
javaVersion.startsWith('8') ? '-version' : '--version'
Expand Down
Loading