Skip to content

Commit 1553c3c

Browse files
Add advance security upload
1 parent dc69e92 commit 1553c3c

13 files changed

Lines changed: 31126 additions & 2026 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,5 @@ jobs:
8383
- name: Test Local Action
8484
id: test-action
8585
uses: ./
86+
with:
87+
github-token: ${{ secrets.GITHUB_TOKEN }}

README.md

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,70 @@
11
# gha-security-scanner
22

3-
Generates and uploads SARIF files to GitHub Advanced Security.
3+
A GitHub Action that runs static code analysis using [Semgrep](https://semgrep.dev/) and uploads the results (SARIF) to [GitHub Advanced Security](https://docs.github.com/en/get-started/learning-about-github/about-github-advanced-security) (Code Scanning).
44

5-
## Rebuilding `dist`
5+
## Features
66

7-
If [check-dist.yaml](.github/workflows/check-dist.yml) fails, it probably means
8-
that a transient dependency has changed. To fix it, rebuild `dist` like this and
9-
commit it.
7+
- Installs and runs Semgrep automatically — no setup required
8+
- Uploads SARIF results to GitHub Code Scanning
9+
- Automatically dismisses suppressed finding alerts
10+
- Supports `.semgrepignore` generation from an `aviary.yaml` exclude list
11+
- Excludes common false-positive rules out of the box
1012

13+
## Usage
14+
15+
```yaml
16+
name: Security Scan
17+
18+
on:
19+
push:
20+
branches: [main]
21+
pull_request:
22+
branches: [main]
23+
24+
permissions:
25+
security-events: write
26+
27+
jobs:
28+
scan:
29+
runs-on: ubuntu-latest
30+
steps:
31+
- uses: actions/checkout@v6
32+
- uses: Workiva/gha-security-scanner@v1.0.0
1133
```
12-
$ rm -rf dist node-modules
13-
$ npm install
14-
$ npm run bundle
34+
35+
## Inputs
36+
37+
| Name | Description | Required | Default |
38+
| -------------- | --------------------------------------------------------------------------- | -------- | --------------------- |
39+
| `scanner` | Static code analysis tool to use. Currently only `semgrep` is supported. | No | `semgrep` |
40+
| `github-token` | GitHub token with `security-events: write` permission. | No | `${{ github.token }}` |
41+
42+
## Outputs
43+
44+
| Name | Description |
45+
| ---------- | ---------------------------------------------------- |
46+
| `sarif-id` | The ID of the SARIF upload to GitHub Code Scanning. |
47+
48+
## Excluding Files from Scanning
49+
50+
If your repository does not already have a `.semgrepignore` file, the action will look for an `aviary.yaml` (or `aviary.yml`) file in the repository root and generate a `.semgrepignore` from its `exclude` patterns.
51+
52+
Example `aviary.yaml`:
53+
54+
```yaml
55+
version: 1
56+
57+
exclude:
58+
- ^__tests__/
59+
- ^docs/
1560
```
61+
62+
Each entry is a regular expression matched against file paths. Matching files and directories are excluded from the scan.
63+
64+
If you already have a `.semgrepignore` file, the action will use it as-is.
65+
66+
## Requirements
67+
68+
- The workflow must have `security-events: write` permission.
69+
- GitHub Advanced Security must be enabled on the repository.
70+
- Runs on `ubuntu-latest` (Linux runners). Python 3 must be available in the runner tool cache.

__fixtures__/advancedSecurity.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import type { uploadVulnScansToGHAS as uploadFn } from '../src/internal/advancedSecurity.js'
2+
import { jest } from '@jest/globals'
3+
4+
export const uploadVulnScansToGHAS = jest.fn<typeof uploadFn>()
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import {
2+
jest,
3+
describe,
4+
it,
5+
expect,
6+
beforeEach,
7+
afterEach
8+
} from '@jest/globals'
9+
10+
import * as core from '../../__fixtures__/core.js'
11+
12+
const mockUploadSarif = jest.fn<() => Promise<{ data: { id: string } }>>()
13+
14+
jest.unstable_mockModule('@actions/core', () => core)
15+
jest.unstable_mockModule('@actions/github', () => ({
16+
getOctokit: () => ({
17+
rest: {
18+
codeScanning: {
19+
uploadSarif: mockUploadSarif
20+
}
21+
}
22+
})
23+
}))
24+
jest.unstable_mockModule('fs', () => ({
25+
promises: {
26+
readFile: jest.fn<() => Promise<string>>().mockResolvedValue('{"runs":[]}')
27+
}
28+
}))
29+
30+
const { uploadVulnScansToGHAS } =
31+
await import('../../src/internal/advancedSecurity.js')
32+
33+
describe('uploadVulnScansToGHAS', () => {
34+
beforeEach(() => {
35+
jest.clearAllMocks()
36+
process.env.GITHUB_REPOSITORY = 'owner/repo'
37+
process.env.GITHUB_REF = 'refs/heads/main'
38+
process.env.GITHUB_SHA = 'abc123'
39+
mockUploadSarif.mockResolvedValue({ data: { id: 'sarif-id-123' } })
40+
})
41+
42+
afterEach(() => {
43+
delete process.env.GITHUB_REPOSITORY
44+
delete process.env.GITHUB_REF
45+
delete process.env.GITHUB_SHA
46+
})
47+
48+
it('should upload a valid SARIF file', async () => {
49+
const result = await uploadVulnScansToGHAS(
50+
'/tmp/results.sarif',
51+
'trivy',
52+
'fake-token'
53+
)
54+
55+
expect(result).toBe('sarif-id-123')
56+
expect(mockUploadSarif).toHaveBeenCalledWith(
57+
expect.objectContaining({
58+
owner: 'owner',
59+
repo: 'repo',
60+
tool_name: 'trivy',
61+
ref: 'refs/heads/main',
62+
commit_sha: 'abc123'
63+
})
64+
)
65+
66+
expect(core.info).toHaveBeenCalledWith(
67+
'✅ Successfully uploaded SARIF file /tmp/results.sarif.\n' +
68+
' API endpoint: https://api.github.com/repos/owner/repo/code-scanning/analyses?sarif_id=sarif-id-123'
69+
)
70+
})
71+
72+
it('should reject non-SARIF files with a warning and error', async () => {
73+
await expect(
74+
uploadVulnScansToGHAS('/tmp/results.json', 'trivy', 'fake-token')
75+
).rejects.toThrow(
76+
'Failed to upload SARIF file: /tmp/results.json is not a SARIF file'
77+
)
78+
79+
expect(mockUploadSarif).not.toHaveBeenCalled()
80+
expect(core.warning).toHaveBeenCalledWith(
81+
'Skipping non-SARIF file: /tmp/results.json'
82+
)
83+
})
84+
85+
it('should throw when SARIF upload returns no ID', async () => {
86+
mockUploadSarif.mockResolvedValue({ data: { id: '' } } as {
87+
data: { id: string }
88+
})
89+
90+
await expect(
91+
uploadVulnScansToGHAS('/tmp/results.sarif', 'trivy', 'fake-token')
92+
).rejects.toThrow(
93+
'SARIF upload succeeded but no ID was returned for /tmp/results.sarif'
94+
)
95+
})
96+
97+
it('does not retry on 403 errors', async () => {
98+
const error = new Error('Forbidden') as Error & { status?: number }
99+
error.status = 403
100+
mockUploadSarif.mockRejectedValue(error)
101+
102+
await expect(
103+
uploadVulnScansToGHAS('/tmp/results.sarif', 'trivy', 'fake-token')
104+
).rejects.toThrow('Forbidden')
105+
106+
expect(mockUploadSarif).toHaveBeenCalledTimes(1)
107+
})
108+
})

__tests__/internal/retry.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { jest, describe, it, expect, beforeEach } from '@jest/globals'
2+
3+
import * as core from '../../__fixtures__/core.js'
4+
5+
jest.unstable_mockModule('@actions/core', () => core)
6+
7+
const { promiseRetry } = await import('../../src/internal/retry.js')
8+
9+
describe('promiseRetry', () => {
10+
beforeEach(() => {
11+
jest.clearAllMocks()
12+
jest.useFakeTimers()
13+
})
14+
15+
it('resolves on first attempt if fn succeeds', async () => {
16+
const fn = jest.fn<() => Promise<string>>().mockResolvedValue('ok')
17+
const result = await promiseRetry(fn)
18+
expect(result).toBe('ok')
19+
expect(fn).toHaveBeenCalledTimes(1)
20+
})
21+
22+
it('retries on failure and resolves on subsequent success', async () => {
23+
const fn = jest
24+
.fn<() => Promise<string>>()
25+
.mockRejectedValueOnce(new Error('fail'))
26+
.mockResolvedValue('ok')
27+
28+
const promise = promiseRetry(fn, { delay: 100 })
29+
await jest.runAllTimersAsync()
30+
const result = await promise
31+
32+
expect(result).toBe('ok')
33+
expect(fn).toHaveBeenCalledTimes(2)
34+
expect(core.info).toHaveBeenCalledWith('fail')
35+
})
36+
37+
it('throws after exhausting all retries', async () => {
38+
jest.useRealTimers()
39+
const error = new Error('always fails')
40+
const fn = jest.fn<() => Promise<string>>().mockRejectedValue(error)
41+
42+
await expect(promiseRetry(fn, { maxRetries: 2, delay: 1 })).rejects.toThrow(
43+
'always fails'
44+
)
45+
expect(fn).toHaveBeenCalledTimes(3)
46+
})
47+
48+
it('should call refreshCreds before retrying', async () => {
49+
const refreshCreds = jest
50+
.fn<() => Promise<void>>()
51+
.mockResolvedValue(undefined)
52+
const fn = jest
53+
.fn<() => Promise<string>>()
54+
.mockRejectedValueOnce(new Error('fail'))
55+
.mockResolvedValue('ok')
56+
57+
const promise = promiseRetry(fn, { delay: 100, refreshCreds })
58+
await jest.runAllTimersAsync()
59+
await promise
60+
61+
expect(refreshCreds).toHaveBeenCalledTimes(1)
62+
})
63+
64+
it('stops retrying when shouldRetry returns false', async () => {
65+
const error = new Error('do not retry')
66+
const fn = jest.fn<() => Promise<string>>().mockRejectedValue(error)
67+
const shouldRetry = jest.fn<(e: Error) => boolean>().mockReturnValue(false)
68+
69+
const promise = promiseRetry(fn, { maxRetries: 3, delay: 100, shouldRetry })
70+
71+
await expect(promise).rejects.toThrow('do not retry')
72+
expect(fn).toHaveBeenCalledTimes(1)
73+
expect(shouldRetry).toHaveBeenCalledWith(error)
74+
})
75+
76+
it('should uses exponential backoff for delays', async () => {
77+
const fn = jest
78+
.fn<() => Promise<string>>()
79+
.mockRejectedValueOnce(new Error('1'))
80+
.mockRejectedValueOnce(new Error('2'))
81+
.mockResolvedValue('ok')
82+
83+
const spy = jest.spyOn(global, 'setTimeout')
84+
85+
const promise = promiseRetry(fn, { delay: 1000 })
86+
await jest.runAllTimersAsync()
87+
await promise
88+
89+
const delays = spy.mock.calls.map(c => c[1]).filter(d => d !== undefined)
90+
expect(delays).toContain(1000)
91+
expect(delays).toContain(4000)
92+
93+
spy.mockRestore()
94+
})
95+
})

__tests__/scanner.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@ import * as core from '../__fixtures__/core.js'
66
import * as exec from '../__fixtures__/exec.js'
77
import * as io from '../__fixtures__/io.js'
88
import * as tc from '../__fixtures__/tool-cache.js'
9+
import * as advancedSecurity from '../__fixtures__/advancedSecurity.js'
910

1011
jest.unstable_mockModule('@actions/core', () => core)
1112
jest.unstable_mockModule('@actions/exec', () => exec)
1213
jest.unstable_mockModule('@actions/io', () => io)
1314
jest.unstable_mockModule('@actions/tool-cache', () => tc)
15+
jest.unstable_mockModule(
16+
'../src/internal/advancedSecurity.js',
17+
() => advancedSecurity
18+
)
1419

1520
// Module under test.
1621
const scanner = await import('../src/scanner.js')
@@ -306,6 +311,8 @@ describe('run', () => {
306311
it('should run the scanner successfully', async () => {
307312
io.which.mockResolvedValue(`/usr/local/bin/${semgrep.command}`)
308313
exec.exec.mockResolvedValue(0)
314+
core.getInput.mockReturnValue('fake-token')
315+
advancedSecurity.uploadVulnScansToGHAS.mockResolvedValue('sarif-id-123')
309316

310317
await scanner.run(semgrep)
311318

@@ -314,6 +321,15 @@ describe('run', () => {
314321
`${semgrep.command} ${semgrep.args.join(' ')}`
315322
)
316323
expect(exec.exec).toHaveBeenCalledWith(semgrep.command, semgrep.args)
324+
expect(core.getInput).toHaveBeenCalledWith('github-token', {
325+
required: true
326+
})
327+
expect(advancedSecurity.uploadVulnScansToGHAS).toHaveBeenCalledWith(
328+
'semgrep.sarif',
329+
'semgrep',
330+
'fake-token'
331+
)
332+
expect(core.setOutput).toHaveBeenCalledWith('sarif-id', 'sarif-id-123')
317333
})
318334

319335
it('should throw an error if the scanner command returns a non-zero exit code', async () => {

action.yml

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ inputs:
77
description: 'Static code analysis tool to use. Options: semgrep'
88
required: false
99
default: semgrep
10+
github-token:
11+
description: |
12+
GitHub token to use for authenticating with this instance of GitHub. The token must be the built-in GitHub Actions token, and the workflow must have the `security-events: write` permission. Most of the time it is advisable to avoid specifying this input so that the workflow falls back to using the default value.
13+
required: true
14+
default: ${{ github.token }}
1015

1116
runs:
1217
using: composite
@@ -17,20 +22,12 @@ runs:
1722
node-version: '24.13.0'
1823
package-manager-cache: false
1924
- name: 'Generate SARIF file'
25+
id: upload
2026
run: node '${{ github.action_path }}/dist/index.js'
2127
env:
2228
INPUT_SCANNER: '${{ inputs.scanner }}'
29+
INPUT_GITHUB-TOKEN: '${{ inputs.github-token }}'
2330
shell: bash
24-
- name: 'Upload SARIF file as artifact'
25-
uses: actions/upload-artifact@v7
26-
with:
27-
name: semgrep.sarif
28-
path: semgrep.sarif
29-
- name: 'Upload SARIF file to GitHub Advanced Security'
30-
id: upload
31-
uses: github/codeql-action/upload-sarif@v4
32-
with:
33-
sarif_file: semgrep.sarif
3431
- name: 'Dismiss suppressed finding alerts'
3532
uses: advanced-security/dismiss-alerts@v2.0.2
3633
with:

0 commit comments

Comments
 (0)