Skip to content

Commit 7b74bd8

Browse files
rudsbergfniephaus
andauthored
Integrate Native Image SBOM with GitHub's Dependency Submission API (#119)
Co-authored-by: Fabio Niephaus <[email protected]>
1 parent c09e29b commit 7b74bd8

20 files changed

+1404
-135
lines changed

.github/workflows/test.yml

+37
Original file line numberDiff line numberDiff line change
@@ -420,3 +420,40 @@ jobs:
420420
# popd > /dev/null
421421
- name: Remove components
422422
run: gu remove espresso llvm-toolchain nodejs python ruby wasm
423+
test-sbom:
424+
name: test 'native-image-enable-sbom' option
425+
runs-on: ${{ matrix.os }}
426+
permissions:
427+
contents: write
428+
strategy:
429+
matrix:
430+
java-version: ['24-ea', 'latest-ea']
431+
distribution: ['graalvm']
432+
os: [macos-latest, windows-latest, ubuntu-latest]
433+
set-gds-token: [false]
434+
components: ['']
435+
steps:
436+
- uses: actions/checkout@v4
437+
- name: Run setup-graalvm action
438+
uses: ./
439+
with:
440+
java-version: ${{ matrix.java-version }}
441+
distribution: ${{ matrix.distribution }}
442+
github-token: ${{ secrets.GITHUB_TOKEN }}
443+
components: ${{ matrix.components }}
444+
gds-token: ${{ matrix.set-gds-token && secrets.GDS_TOKEN || '' }}
445+
native-image-enable-sbom: 'true'
446+
- name: Build Maven project and verify that SBOM was generated and its contents
447+
run: |
448+
cd __tests__/sbom/main-test-app
449+
mvn --no-transfer-progress -Pnative package
450+
bash verify-sbom.sh
451+
shell: bash
452+
if: runner.os != 'Windows'
453+
- name: Build Maven project and verify that SBOM was generated and its contents (Windows)
454+
run: |
455+
cd __tests__\sbom\main-test-app
456+
mvn --no-transfer-progress -Pnative package
457+
cmd /c verify-sbom.cmd
458+
shell: cmd
459+
if: runner.os == 'Windows'

.gitignore

+4-1
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,7 @@ Thumbs.db
9696

9797
# Ignore built ts files
9898
__tests__/runner/*
99-
lib/**/*
99+
lib/**/*
100+
101+
# Ignore target directory in __tests__
102+
__tests__/**/target

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ This actions can be configured with the following options:
205205
| `native-image-job-reports` *) | `'false'` | If set to `'true'`, post a job summary containing a Native Image build report. |
206206
| `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]. |
207207
| `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`. |
208+
| `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 by 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)). Only available in Oracle GraalVM for JDK 24 or later. |
208209
| `components` | `''` | Comma-separated list of GraalVM components (e.g., `native-image` or `ruby,nodejs`) that will be installed by the [GraalVM Updater][gu]. |
209210
| `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. |
210211
| `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. |

__tests__/cleanup.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ describe('cleanup', () => {
4949
resetState()
5050
})
5151

52-
it('does not fail nor warn even when the save provess throws a ReserveCacheError', async () => {
52+
it('does not fail nor warn even when the save process throws a ReserveCacheError', async () => {
5353
spyCacheSave.mockImplementation((paths: string[], key: string) =>
5454
Promise.reject(
5555
new cache.ReserveCacheError(

__tests__/sbom.test.ts

+306
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import * as c from '../src/constants'
2+
import {setUpSBOMSupport, processSBOM} from '../src/features/sbom'
3+
import * as core from '@actions/core'
4+
import * as github from '@actions/github'
5+
import * as glob from '@actions/glob'
6+
import {join} from 'path'
7+
import {tmpdir} from 'os'
8+
import {mkdtempSync, writeFileSync, rmSync} from 'fs'
9+
10+
jest.mock('@actions/glob')
11+
jest.mock('@actions/github', () => ({
12+
getOctokit: jest.fn(() => ({
13+
request: jest.fn().mockResolvedValue(undefined)
14+
})),
15+
context: {
16+
repo: {
17+
owner: 'test-owner',
18+
repo: 'test-repo'
19+
},
20+
sha: 'test-sha',
21+
ref: 'test-ref',
22+
workflow: 'test-workflow',
23+
job: 'test-job',
24+
runId: '12345'
25+
}
26+
}))
27+
28+
function mockFindSBOM(files: string[]) {
29+
const mockCreate = jest.fn().mockResolvedValue({
30+
glob: jest.fn().mockResolvedValue(files)
31+
})
32+
;(glob.create as jest.Mock).mockImplementation(mockCreate)
33+
}
34+
35+
// Mocks the GitHub dependency submission API return value
36+
// 'undefined' is treated as a successful request
37+
function mockGithubAPIReturnValue(returnValue: Error | undefined = undefined) {
38+
const mockOctokit = {
39+
request:
40+
returnValue === undefined
41+
? jest.fn().mockResolvedValue(returnValue)
42+
: jest.fn().mockRejectedValue(returnValue)
43+
}
44+
;(github.getOctokit as jest.Mock).mockReturnValue(mockOctokit)
45+
return mockOctokit
46+
}
47+
48+
describe('sbom feature', () => {
49+
let spyInfo: jest.SpyInstance<void, Parameters<typeof core.info>>
50+
let spyWarning: jest.SpyInstance<void, Parameters<typeof core.warning>>
51+
let spyExportVariable: jest.SpyInstance<
52+
void,
53+
Parameters<typeof core.exportVariable>
54+
>
55+
let workspace: string
56+
let originalEnv: NodeJS.ProcessEnv
57+
const javaVersion = '24.0.0'
58+
const distribution = c.DISTRIBUTION_GRAALVM
59+
60+
beforeEach(() => {
61+
originalEnv = process.env
62+
63+
process.env = {
64+
...process.env,
65+
GITHUB_REPOSITORY: 'test-owner/test-repo',
66+
GITHUB_TOKEN: 'fake-token'
67+
}
68+
69+
workspace = mkdtempSync(join(tmpdir(), 'setup-graalvm-sbom-'))
70+
mockGithubAPIReturnValue()
71+
72+
spyInfo = jest.spyOn(core, 'info').mockImplementation(() => null)
73+
spyWarning = jest.spyOn(core, 'warning').mockImplementation(() => null)
74+
spyExportVariable = jest
75+
.spyOn(core, 'exportVariable')
76+
.mockImplementation(() => null)
77+
jest.spyOn(core, 'getInput').mockImplementation((name: string) => {
78+
if (name === 'native-image-enable-sbom') {
79+
return 'true'
80+
}
81+
if (name === 'github-token') {
82+
return 'fake-token'
83+
}
84+
return ''
85+
})
86+
})
87+
88+
afterEach(() => {
89+
process.env = originalEnv
90+
jest.clearAllMocks()
91+
spyInfo.mockRestore()
92+
spyWarning.mockRestore()
93+
spyExportVariable.mockRestore()
94+
rmSync(workspace, {recursive: true, force: true})
95+
})
96+
97+
describe('setup', () => {
98+
it('should throw an error when the distribution is not Oracle GraalVM', () => {
99+
const not_supported_distributions = [
100+
c.DISTRIBUTION_GRAALVM_COMMUNITY,
101+
c.DISTRIBUTION_MANDREL,
102+
c.DISTRIBUTION_LIBERICA,
103+
''
104+
]
105+
for (const distribution of not_supported_distributions) {
106+
expect(() => setUpSBOMSupport(javaVersion, distribution)).toThrow()
107+
}
108+
})
109+
110+
it('should throw an error when the java-version is not supported', () => {
111+
const not_supported_versions = ['23', '23-ea', '21.0.3', 'dev', '17', '']
112+
for (const version of not_supported_versions) {
113+
expect(() => setUpSBOMSupport(version, distribution)).toThrow()
114+
}
115+
})
116+
117+
it('should not throw an error when the java-version is supported', () => {
118+
const supported_versions = ['24', '24-ea', '24.0.2', 'latest-ea']
119+
for (const version of supported_versions) {
120+
expect(() => setUpSBOMSupport(version, distribution)).not.toThrow()
121+
}
122+
})
123+
124+
it('should set the SBOM option when activated', () => {
125+
setUpSBOMSupport(javaVersion, distribution)
126+
127+
expect(spyExportVariable).toHaveBeenCalledWith(
128+
c.NATIVE_IMAGE_OPTIONS_ENV,
129+
expect.stringContaining('--enable-sbom=export')
130+
)
131+
expect(spyInfo).toHaveBeenCalledWith(
132+
'Enabled SBOM generation for Native Image build'
133+
)
134+
expect(spyWarning).not.toHaveBeenCalled()
135+
})
136+
137+
it('should not set the SBOM option when not activated', () => {
138+
jest.spyOn(core, 'getInput').mockReturnValue('false')
139+
setUpSBOMSupport(javaVersion, distribution)
140+
141+
expect(spyExportVariable).not.toHaveBeenCalled()
142+
expect(spyInfo).not.toHaveBeenCalled()
143+
expect(spyWarning).not.toHaveBeenCalled()
144+
})
145+
})
146+
147+
describe('process', () => {
148+
async function setUpAndProcessSBOM(sbom: object): Promise<void> {
149+
setUpSBOMSupport(javaVersion, distribution)
150+
spyInfo.mockClear()
151+
152+
// Mock 'native-image' invocation by creating the SBOM file
153+
const sbomPath = join(workspace, 'test.sbom.json')
154+
writeFileSync(sbomPath, JSON.stringify(sbom, null, 2))
155+
156+
mockFindSBOM([sbomPath])
157+
158+
await processSBOM()
159+
}
160+
161+
const sampleSBOM = {
162+
bomFormat: 'CycloneDX',
163+
specVersion: '1.5',
164+
version: 1,
165+
serialNumber: 'urn:uuid:52c977f8-6d04-3c07-8826-597a036d61a6',
166+
components: [
167+
{
168+
type: 'library',
169+
group: 'org.json',
170+
name: 'json',
171+
version: '20241224',
172+
purl: 'pkg:maven/org.json/json@20241224',
173+
'bom-ref': 'pkg:maven/org.json/json@20241224',
174+
properties: [
175+
{
176+
name: 'syft:cpe23',
177+
value: 'cpe:2.3:a:json:json:20241224:*:*:*:*:*:*:*'
178+
}
179+
]
180+
},
181+
{
182+
type: 'library',
183+
group: 'com.oracle',
184+
name: 'main-test-app',
185+
version: '1.0-SNAPSHOT',
186+
purl: 'pkg:maven/com.oracle/[email protected]',
187+
'bom-ref': 'pkg:maven/com.oracle/[email protected]'
188+
}
189+
],
190+
dependencies: [
191+
{
192+
ref: 'pkg:maven/com.oracle/[email protected]',
193+
dependsOn: ['pkg:maven/org.json/json@20241224']
194+
},
195+
{
196+
ref: 'pkg:maven/org.json/json@20241224',
197+
dependsOn: []
198+
}
199+
]
200+
}
201+
202+
it('should process SBOM and display components', async () => {
203+
await setUpAndProcessSBOM(sampleSBOM)
204+
205+
expect(spyInfo).toHaveBeenCalledWith(
206+
'Found SBOM: ' + join(workspace, 'test.sbom.json')
207+
)
208+
expect(spyInfo).toHaveBeenCalledWith('=== SBOM Content ===')
209+
expect(spyInfo).toHaveBeenCalledWith('- pkg:maven/org.json/json@20241224')
210+
expect(spyInfo).toHaveBeenCalledWith(
211+
'- pkg:maven/com.oracle/[email protected]'
212+
)
213+
expect(spyInfo).toHaveBeenCalledWith(
214+
' depends on: pkg:maven/org.json/json@20241224'
215+
)
216+
expect(spyWarning).not.toHaveBeenCalled()
217+
})
218+
219+
it('should handle components without purl', async () => {
220+
const sbomWithoutPurl = {
221+
...sampleSBOM,
222+
components: [
223+
{
224+
type: 'library',
225+
name: 'no-purl-package',
226+
version: '1.0.0',
227+
'bom-ref': '[email protected]'
228+
}
229+
]
230+
}
231+
await setUpAndProcessSBOM(sbomWithoutPurl)
232+
233+
expect(spyInfo).toHaveBeenCalledWith('=== SBOM Content ===')
234+
expect(spyInfo).toHaveBeenCalledWith('- [email protected]')
235+
expect(spyWarning).not.toHaveBeenCalled()
236+
})
237+
238+
it('should handle missing SBOM file', async () => {
239+
setUpSBOMSupport(javaVersion, distribution)
240+
spyInfo.mockClear()
241+
242+
mockFindSBOM([])
243+
244+
await expect(processSBOM()).rejects.toBeInstanceOf(Error)
245+
})
246+
247+
it('should throw when JSON contains an invalid SBOM', async () => {
248+
const invalidSBOM = {
249+
'out-of-spec-field': {}
250+
}
251+
try {
252+
await setUpAndProcessSBOM(invalidSBOM)
253+
fail('Expected an error since invalid JSON was passed')
254+
} catch (error) {
255+
expect(error).toBeInstanceOf(Error)
256+
}
257+
})
258+
259+
it('should submit dependencies when processing valid SBOM', async () => {
260+
const mockOctokit = mockGithubAPIReturnValue(undefined)
261+
await setUpAndProcessSBOM(sampleSBOM)
262+
263+
expect(mockOctokit.request).toHaveBeenCalledWith(
264+
'POST /repos/{owner}/{repo}/dependency-graph/snapshots',
265+
expect.objectContaining({
266+
owner: 'test-owner',
267+
repo: 'test-repo',
268+
version: expect.any(Number),
269+
sha: 'test-sha',
270+
ref: 'test-ref',
271+
job: expect.objectContaining({
272+
correlator: 'test-workflow_test-job',
273+
id: '12345'
274+
}),
275+
manifests: expect.objectContaining({
276+
'test.sbom.json': expect.objectContaining({
277+
name: 'test.sbom.json',
278+
resolved: expect.objectContaining({
279+
json: expect.objectContaining({
280+
package_url: 'pkg:maven/org.json/json@20241224',
281+
dependencies: []
282+
}),
283+
'main-test-app': expect.objectContaining({
284+
package_url:
285+
'pkg:maven/com.oracle/[email protected]',
286+
dependencies: ['pkg:maven/org.json/json@20241224']
287+
})
288+
})
289+
})
290+
})
291+
})
292+
)
293+
expect(spyInfo).toHaveBeenCalledWith(
294+
'Dependency snapshot submitted successfully.'
295+
)
296+
})
297+
298+
it('should handle GitHub API submission errors gracefully', async () => {
299+
mockGithubAPIReturnValue(new Error('API submission failed'))
300+
301+
await expect(setUpAndProcessSBOM(sampleSBOM)).rejects.toBeInstanceOf(
302+
Error
303+
)
304+
})
305+
})
306+
})

0 commit comments

Comments
 (0)