Skip to content

Commit f9cee31

Browse files
feat: add @stacksjs/gh-audit + buddy-bot security command
@stacksjs/gh-audit lands as a workspace-internal package — a TypeScript static analyser for GitHub Actions workflows focused on supply-chain hardening. Six rules ship by default: - unpinned-action: third-party uses: refs on a tag instead of SHA - excessive-permissions: missing top-level perms, write-all, risky id-token / actions: write - dangerous-pull-request-target: PR-author code under elevated trigger - bash-injection: untrusted ${{ }} interpolated into run: blocks - missing-timeout: jobs without timeout-minutes - self-hosted-exposure: bare runs-on: self-hosted Three reporters (pretty / json / github) — the github reporter emits workflow-command annotations so findings surface inline on PRs. Default reporter is auto-detected from $GITHUB_ACTIONS so the same command works on a runner and locally. buddy-bot integration: - Root package.json gains workspaces: ["packages/*"] - New `buddy-bot security [path]` subcommand wraps gh-audit - Setup now generates a gh-audit.yml workflow alongside buddy-bot.yml, triggered on push/PR to .github/workflows/**, weekly schedule, and manual dispatch 41 new tests, 699 total / 0 fail. Zero new runtime dependencies — uses Bun.YAML.parse natively.
1 parent 860fb47 commit f9cee31

32 files changed

Lines changed: 1565 additions & 0 deletions

bin/cli.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2051,6 +2051,42 @@ cli
20512051
}
20522052
})
20532053

2054+
cli
2055+
.command('security [path]', '🛡 Static-analyse GitHub Actions workflows for supply-chain footguns (uses @stacksjs/gh-audit)')
2056+
.option('-f, --format <format>', 'Reporter (pretty | json | github). Defaults to github on a runner, pretty otherwise.')
2057+
.option('--ignore <ids>', 'Comma-separated rule ids to skip (e.g. missing-timeout,unpinned-action)')
2058+
.option('--no-color', 'Disable ANSI colours in pretty output')
2059+
.action(async (path: string | undefined, options: { format?: string, ignore?: string, color?: boolean }) => {
2060+
const { audit } = await import('../packages/gh-audit/src/engine')
2061+
const { formatPretty } = await import('../packages/gh-audit/src/reporters/pretty')
2062+
const { formatJson } = await import('../packages/gh-audit/src/reporters/json')
2063+
const { formatGitHub } = await import('../packages/gh-audit/src/reporters/github')
2064+
2065+
const ignore = options.ignore?.split(',').filter(Boolean) ?? []
2066+
const result = await audit(path ?? '.', { ignore })
2067+
2068+
const format = options.format ?? (process.env.GITHUB_ACTIONS === 'true' ? 'github' : 'pretty')
2069+
let output: string
2070+
switch (format) {
2071+
case 'json':
2072+
output = formatJson(result)
2073+
break
2074+
case 'github':
2075+
output = formatGitHub(result)
2076+
break
2077+
case 'pretty':
2078+
output = formatPretty(result, { color: options.color !== false })
2079+
break
2080+
default:
2081+
process.stderr.write(`Unknown --format ${format}. Use pretty | json | github.\n`)
2082+
process.exit(2)
2083+
return
2084+
}
2085+
if (output)
2086+
process.stdout.write(`${output}\n`)
2087+
process.exit(result.failed ? 1 : 0)
2088+
})
2089+
20542090
cli.command('version', 'Show the version of Buddy Bot').action(() => {
20552091
console.log(version)
20562092
})

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@
4242
"bin",
4343
"dist"
4444
],
45+
"workspaces": [
46+
"packages/*"
47+
],
4548
"scripts": {
4649
"build": "bun build.ts",
4750
"fresh": "bunx rimraf node_modules/ bun.lock && bun i",

packages/gh-audit/README.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# @stacksjs/gh-audit
2+
3+
Static analysis for GitHub Actions workflows. Catches the supply-chain footguns that put repos at risk: unpinned third-party actions, excessive token permissions, `pull_request_target` traps, `${{ }}` shell injection, missing job timeouts, exposed self-hosted runners.
4+
5+
Built standalone — call it from CI directly, or let `buddy-bot security` run it for you.
6+
7+
## Why
8+
9+
Worm-class supply-chain attacks (Shai-Hulud and friends) exploit predictable misconfigurations: a GitHub Action pinned to a moving tag, a workflow that grants `permissions: write-all`, a `run:` block that interpolates `${{ github.event.pull_request.title }}` into a shell. Each one of these is a finding here.
10+
11+
Six rules ship by default, all focused on supply-chain hardening:
12+
13+
| Rule | Severity | What it catches |
14+
|---|---|---|
15+
| `unpinned-action` | warning | Third-party `uses:` references on a tag/branch instead of a SHA |
16+
| `excessive-permissions` | warning + error | Missing top-level `permissions`, `write-all`, risky `id-token: write` / `actions: write` |
17+
| `dangerous-pull-request-target` | error | `pull_request_target` workflows that check out PR-author code |
18+
| `bash-injection` | error | Untrusted `${{ ... }}` interpolated into `run:` blocks |
19+
| `missing-timeout` | warning | Jobs without an explicit `timeout-minutes` |
20+
| `self-hosted-exposure` | warning | `runs-on: self-hosted` without scoping labels |
21+
22+
`error`-severity findings cause a non-zero exit, so the tool slots straight into a CI gate.
23+
24+
## Install
25+
26+
Standalone:
27+
28+
```bash
29+
bunx --bun @stacksjs/gh-audit
30+
```
31+
32+
Or as part of `buddy-bot setup` — the security workflow gets generated alongside the dependency-update one.
33+
34+
## Usage
35+
36+
```bash
37+
# Scan the current repo (.github/workflows/*.yml)
38+
gh-audit
39+
40+
# Scan a different repo
41+
gh-audit ../other-repo
42+
43+
# Machine-readable output
44+
gh-audit --format json
45+
46+
# GitHub Actions inline annotations (auto-detected on a runner)
47+
gh-audit --format github
48+
49+
# Skip a noisy rule
50+
gh-audit --ignore missing-timeout
51+
52+
# Disable colour
53+
gh-audit --no-color
54+
```
55+
56+
## In a workflow
57+
58+
```yaml
59+
name: GH Actions Security Audit
60+
61+
on:
62+
push:
63+
branches: [main]
64+
paths: ['.github/workflows/**']
65+
pull_request:
66+
paths: ['.github/workflows/**']
67+
schedule:
68+
- cron: '0 6 * * 1'
69+
workflow_dispatch:
70+
71+
permissions:
72+
contents: read
73+
74+
jobs:
75+
audit:
76+
runs-on: ubuntu-latest
77+
timeout-minutes: 5
78+
steps:
79+
- uses: actions/checkout@v4
80+
- uses: oven-sh/setup-bun@v2
81+
- run: bunx --bun @stacksjs/gh-audit
82+
```
83+
84+
## Programmatic API
85+
86+
```ts
87+
import { audit } from '@stacksjs/gh-audit'
88+
89+
const result = await audit('.', { ignore: ['missing-timeout'] })
90+
if (result.failed) {
91+
for (const f of result.findings) {
92+
console.error(`${f.file}:${f.line ?? '?'} ${f.severity} ${f.message}`)
93+
}
94+
process.exit(1)
95+
}
96+
```
97+
98+
## Output formats
99+
100+
- `pretty` (default off-runner) — colored, grouped by file
101+
- `github` (default on-runner) — `::error file=…,line=…::msg` workflow commands → inline PR annotations
102+
- `json` — machine-readable, shape:
103+
104+
```jsonc
105+
{
106+
"ok": false,
107+
"summary": { "workflows": 3, "findings": 58, "errors": 33, "warnings": 25, "info": 0 },
108+
"findings": [
109+
{
110+
"ruleId": "bash-injection",
111+
"severity": "error",
112+
"message": "Job `build`, step 2: `github.event.pull_request.title` is interpolated into a shell.",
113+
"file": ".github/workflows/ci.yml",
114+
"line": 42,
115+
"fix": "Put the value in `env:` and reference it with `\"$NAME\"` instead."
116+
}
117+
]
118+
}
119+
```
120+
121+
## Relationship to zizmor
122+
123+
Inspiration only. [zizmor](https://github.com/zizmorcore/zizmor) is a Rust-based GH Actions linter with a broader rule catalog. We focus on a tight, supply-chain-first ruleset implemented in TypeScript so it slots into Bun-native projects without a foreign-tool dependency. Both tools defend the same surface — pick whichever fits your stack.
124+
125+
## License
126+
127+
MIT.

packages/gh-audit/bin/cli.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
#!/usr/bin/env bun
2+
import { audit } from '../src/engine'
3+
import { formatGitHub } from '../src/reporters/github'
4+
import { formatJson } from '../src/reporters/json'
5+
import { formatPretty } from '../src/reporters/pretty'
6+
7+
interface Args {
8+
root: string
9+
format: 'pretty' | 'json' | 'github'
10+
ignore: string[]
11+
noColor: boolean
12+
showHelp: boolean
13+
showVersion: boolean
14+
}
15+
16+
function parseArgs(argv: string[]): Args {
17+
const args: Args = {
18+
root: '.',
19+
format: detectDefaultFormat(),
20+
ignore: [],
21+
noColor: !!process.env.NO_COLOR,
22+
showHelp: false,
23+
showVersion: false,
24+
}
25+
26+
for (let i = 0; i < argv.length; i++) {
27+
const a = argv[i]
28+
if (a === '--help' || a === '-h') {
29+
args.showHelp = true
30+
}
31+
else if (a === '--version' || a === '-v') {
32+
args.showVersion = true
33+
}
34+
else if (a === '--format' || a === '-f') {
35+
const next = argv[++i]
36+
if (next !== 'pretty' && next !== 'json' && next !== 'github') {
37+
throw new Error(`Unknown --format ${next}. Use pretty | json | github.`)
38+
}
39+
args.format = next
40+
}
41+
else if (a.startsWith('--format=')) {
42+
const v = a.slice('--format='.length)
43+
if (v !== 'pretty' && v !== 'json' && v !== 'github')
44+
throw new Error(`Unknown --format ${v}. Use pretty | json | github.`)
45+
args.format = v
46+
}
47+
else if (a === '--ignore') {
48+
args.ignore.push(...argv[++i].split(','))
49+
}
50+
else if (a.startsWith('--ignore=')) {
51+
args.ignore.push(...a.slice('--ignore='.length).split(','))
52+
}
53+
else if (a === '--no-color') {
54+
args.noColor = true
55+
}
56+
else if (!a.startsWith('-')) {
57+
args.root = a
58+
}
59+
else {
60+
throw new Error(`Unknown argument: ${a}`)
61+
}
62+
}
63+
64+
return args
65+
}
66+
67+
function detectDefaultFormat(): 'pretty' | 'json' | 'github' {
68+
// When invoked from a GitHub Actions runner, default to the
69+
// workflow-command format so findings surface as inline annotations.
70+
if (process.env.GITHUB_ACTIONS === 'true')
71+
return 'github'
72+
return 'pretty'
73+
}
74+
75+
function help(): string {
76+
return `gh-audit — static analysis for GitHub Actions workflows
77+
78+
USAGE
79+
gh-audit [path] [options]
80+
81+
OPTIONS
82+
-f, --format <pretty|json|github> Reporter (default: github when on a runner, pretty otherwise)
83+
--ignore <id,id,...> Skip specific rule ids
84+
--no-color Disable ANSI colours in pretty output
85+
-h, --help Show this help
86+
-v, --version Show version
87+
88+
EXAMPLES
89+
gh-audit Audit .github/workflows in CWD
90+
gh-audit ../other-repo Audit a different repo
91+
gh-audit --format json Machine-readable output
92+
gh-audit --ignore missing-timeout Disable a noisy rule
93+
`
94+
}
95+
96+
async function main(): Promise<void> {
97+
let args: Args
98+
try {
99+
args = parseArgs(process.argv.slice(2))
100+
}
101+
catch (err) {
102+
process.stderr.write(`${(err as Error).message}\n\n${help()}`)
103+
process.exit(2)
104+
return
105+
}
106+
107+
if (args.showHelp) {
108+
process.stdout.write(help())
109+
return
110+
}
111+
if (args.showVersion) {
112+
const pkg = await Bun.file(`${import.meta.dir}/../package.json`).json() as { version: string }
113+
process.stdout.write(`${pkg.version}\n`)
114+
return
115+
}
116+
117+
const result = await audit(args.root, { ignore: args.ignore })
118+
119+
let output: string
120+
switch (args.format) {
121+
case 'json':
122+
output = formatJson(result)
123+
break
124+
case 'github':
125+
output = formatGitHub(result)
126+
break
127+
default:
128+
output = formatPretty(result, { color: !args.noColor })
129+
}
130+
if (output)
131+
process.stdout.write(`${output}\n`)
132+
133+
process.exit(result.failed ? 1 : 0)
134+
}
135+
136+
main().catch((err) => {
137+
process.stderr.write(`gh-audit: ${(err as Error).message}\n`)
138+
process.exit(2)
139+
})

packages/gh-audit/package.json

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"name": "@stacksjs/gh-audit",
3+
"type": "module",
4+
"version": "0.1.0",
5+
"description": "Static analysis for GitHub Actions workflows. Catches supply-chain footguns (unpinned actions, excessive token permissions, pull_request_target traps, ${{ }} injection, missing timeouts, self-hosted runner exposure).",
6+
"author": "Chris Breuer <chris@stacksjs.org>",
7+
"license": "MIT",
8+
"homepage": "https://github.com/stacksjs/buddy-bot/tree/main/packages/gh-audit#readme",
9+
"repository": {
10+
"type": "git",
11+
"url": "git+https://github.com/stacksjs/buddy-bot.git",
12+
"directory": "packages/gh-audit"
13+
},
14+
"bugs": {
15+
"url": "https://github.com/stacksjs/buddy-bot/issues"
16+
},
17+
"keywords": [
18+
"github-actions",
19+
"security",
20+
"static-analysis",
21+
"supply-chain",
22+
"linter",
23+
"workflow",
24+
"bun",
25+
"typescript"
26+
],
27+
"exports": {
28+
".": {
29+
"types": "./dist/index.d.ts",
30+
"import": "./dist/src/index.js"
31+
}
32+
},
33+
"module": "./dist/src/index.js",
34+
"types": "./dist/index.d.ts",
35+
"bin": {
36+
"gh-audit": "./bin/cli.ts"
37+
},
38+
"files": [
39+
"README.md",
40+
"bin",
41+
"dist"
42+
],
43+
"scripts": {
44+
"build": "bun build.ts",
45+
"test": "bun test",
46+
"lint": "bunx --bun pickier .",
47+
"lint:fix": "bunx --bun pickier . --fix",
48+
"typecheck": "bunx tsc --noEmit"
49+
}
50+
}

0 commit comments

Comments
 (0)