Skip to content

Commit

Permalink
Integrate Native Image SBOM with GitHub's Dependency Submission API
Browse files Browse the repository at this point in the history
  • Loading branch information
rudsberg committed Dec 3, 2024
1 parent 4a200f2 commit 5d338a1
Show file tree
Hide file tree
Showing 16 changed files with 1,583 additions and 213 deletions.
47 changes: 47 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -417,3 +417,50 @@ jobs:
# popd > /dev/null
- name: Remove components
run: gu remove espresso llvm-toolchain nodejs python ruby wasm
test-sbom:
name: test 'native-image-enable-sbom' option
runs-on: ${{ matrix.os }}
permissions:
contents: write
strategy:
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: ./
with:
java-version: 'latest-ea'
distribution: 'graalvm'
native-image-enable-sbom: 'true'
- name: Build Maven project and verify SBOM was generated
run: |
cd __tests__/sbom/main-test-app
mvn -Pnative package
cd target
echo "Checking for 'pkg:maven/org.json/json@20211205'"
grep -q 'pkg:maven/org.json/json@20211205' sbom.sbom.json || exit 1
echo "Checking for 'main-test-app'"
grep -q '"main-test-app"' sbom.sbom.json || exit 1
echo "Checking for 'svm'"
grep -q '"svm"' sbom.sbom.json || exit 1
echo "Checking for 'nativeimage'"
grep -q '"nativeimage"' sbom.sbom.json || exit 1
echo "SBOM was successfully generated and contained the expected contents"
shell: bash
if: runner.os != 'Windows'
- name: Build Maven project and verify SBOM was generated (Windows)
run: |
cd __tests__\sbom\main-test-app
mvn -Pnative package
cd target
echo "Checking for 'pkg:maven/org.json/json@20211205'"
findstr /c:"pkg:maven/org.json/json@20211205" sbom.sbom.json || exit /b 1
echo "Checking for 'main-test-app'"
findstr /c:"\"main-test-app\"" sbom.sbom.json || exit /b 1
echo "Checking for 'svm'"
findstr /c:"\"svm\"" sbom.sbom.json || exit /b 1
echo "Checking for 'nativeimage'"
findstr /c:"\"nativeimage\"" sbom.sbom.json || exit /b 1
echo "SBOM was successfully generated and contained the expected contents"
shell: cmd
if: runner.os == 'Windows'
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,7 @@ Thumbs.db

# Ignore built ts files
__tests__/runner/*
lib/**/*
lib/**/*

# Ignore target directory in test
__tests__/**/target
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ This actions can be configured with the following options:
| `native-image-job-reports` *) | `'false'` | If set to `'true'`, post a job summary containing a Native Image build report. |
| `native-image-pr-reports` *) | `'false'` | If set to `'true'`, post a comment containing a Native Image build report on pull requests. Requires `write` permissions for the [`pull-requests` scope][gha-permissions]. |
| `native-image-pr-reports-update-existing` *) | `'false'` | Instead of posting another comment, update an existing PR comment with the latest Native Image build report. Requires `native-image-pr-reports` to be `true`. |
| `native-image-enable-sbom` | `'false'` | If set to `'true'`, generate a minimal SBOM based on the Native Image static analysis and submit it to GitHub's dependency submission API. This enables the [dependency graph feature](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-the-dependency-graph) for dependency tracking and vulnerability analysis. Requires `write` permissions for the [`contents` scope][gha-permissions] and the dependency graph to be actived (on my default for public repositories - see [how to activate](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/configuring-the-dependency-graph#enabling-and-disabling-the-dependency-graph-for-a-private-repository)). |
| `components` | `''` | Comma-separated list of GraalVM components (e.g., `native-image` or `ruby,nodejs`) that will be installed by the [GraalVM Updater][gu]. |
| `version` | `''` | `X.Y.Z` (e.g., `22.3.0`) for a specific [GraalVM release][releases] up to `22.3.2`<br>`mandrel-X.Y.Z.W` or `X.Y.Z.W-Final` (e.g., `mandrel-21.3.0.0-Final` or `21.3.0.0-Final`) for a specific [Mandrel release][mandrel-releases],<br>`mandrel-latest` or `latest` for the latest Mandrel stable release. |
| `gds-token` | `''` Download token for the GraalVM Download Service. If a non-empty token is provided, the action will set up Oracle GraalVM (see [Oracle GraalVM via GDS template](#template-for-oracle-graalvm-via-graalvm-download-service)) or GraalVM Enterprise Edition (see [GraalVM EE template](#template-for-graalvm-enterprise-edition)) via GDS. |
Expand Down
285 changes: 285 additions & 0 deletions __tests__/sbom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
import * as c from '../src/constants'
import {
setUpSBOMSupport,
processSBOM,
mapToComponentsWithDependencies,
INPUT_NI_SBOM
} from '../src/features/sbom'
import * as core from '@actions/core'
import * as github from '@actions/github'
import * as glob from '@actions/glob'
import {join} from 'path'
import {tmpdir} from 'os'
import {mkdtempSync, writeFileSync, rmSync} from 'fs'

jest.mock('@actions/glob')
jest.mock('@actions/github', () => ({
getOctokit: jest.fn(() => ({
request: jest.fn().mockResolvedValue(undefined)
})),
context: {
repo: {
owner: 'test-owner',
repo: 'test-repo'
},
sha: 'test-sha',
ref: 'test-ref',
workflow: 'test-workflow',
job: 'test-job',
runId: '12345'
}
}))

// 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
let originalEnv: NodeJS.ProcessEnv

beforeEach(() => {
originalEnv = process.env

process.env = {
...process.env,
GITHUB_REPOSITORY: 'test-owner/test-repo',
GITHUB_TOKEN: 'fake-token'
}

workspace = mkdtempSync(join(tmpdir(), 'setup-graalvm-sbom-'))
;(github.getOctokit as jest.Mock).mockReturnValue({
request: jest.fn().mockResolvedValue(undefined)
})

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'
}
if (name === 'github-token') {
return 'fake-token'
}
return ''
})
})

afterEach(() => {
// Restore original env
process.env = originalEnv

jest.clearAllMocks()
rmSync(workspace, {recursive: true, force: true})
})

describe('setup', () => {
it('should set the SBOM option flag when activated', () => {
setUpSBOMSupport()
expect(spyExportVariable).toHaveBeenCalledWith(
c.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('- pkg:maven/org.json/json@20211205')
expect(spyInfo).toHaveBeenCalledWith(
'- pkg:maven/com.oracle/[email protected]'
)
expect(spyInfo).toHaveBeenCalledWith(
' depends on: pkg:maven/org.json/json@20211205'
)
expect(spyWarning).not.toHaveBeenCalled()
})

it('should handle components without purl', async () => {
setUpSBOMSupport()
spyInfo.mockClear()

const sbomWithoutPurl = {
...sampleSBOM,
components: [
{
type: 'library',
name: 'no-purl-package',
version: '1.0.0',
'bom-ref': '[email protected]'
}
]
}

const sbomPath = join(workspace, 'test.sbom.json')
writeFileSync(sbomPath, JSON.stringify(sbomWithoutPurl, null, 2))

mockGlobResult([sbomPath])

await processSBOM()

expect(spyInfo).toHaveBeenCalledWith('=== SBOM Content ===')
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. Skipping submission to GitHub Dependency API.'
)
})

it('should submit dependency snapshot when processing valid SBOM', async () => {
setUpSBOMSupport()
spyInfo.mockClear()

const mockOctokit = {
request: jest.fn().mockResolvedValue(undefined)
}
;(github.getOctokit as jest.Mock).mockReturnValue(mockOctokit)

const sbomPath = join(workspace, 'test.sbom.json')
writeFileSync(sbomPath, JSON.stringify(sampleSBOM, null, 2))

mockGlobResult([sbomPath])

await processSBOM()

expect(mockOctokit.request).toHaveBeenCalledWith(
'POST /repos/{owner}/{repo}/dependency-graph/snapshots',
expect.objectContaining({
owner: 'test-owner',
repo: 'test-repo',
version: expect.any(Number),
sha: 'test-sha',
ref: 'test-ref',
job: expect.objectContaining({
correlator: 'test-workflow_test-job',
id: '12345'
}),
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/[email protected]',
dependencies: ['pkg:maven/org.json/json@20211205']
})
})
})
})
})
)
})

it('should handle GitHub API submission errors gracefully', async () => {
setUpSBOMSupport()
spyInfo.mockClear()

const mockOctokit = {
request: jest.fn().mockRejectedValue(new Error('API submission failed'))
}
;(github.getOctokit as jest.Mock).mockReturnValue(mockOctokit)

const sbomPath = join(workspace, 'test.sbom.json')
writeFileSync(sbomPath, JSON.stringify(sampleSBOM, null, 2))

mockGlobResult([sbomPath])

await processSBOM()

expect(spyWarning).toHaveBeenCalledWith(
expect.stringContaining('Failed to submit dependency snapshot')
)
})
})
})
Loading

0 comments on commit 5d338a1

Please sign in to comment.