Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,6 @@ jobs:

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0

- run: bun install --frozen-lockfile
- uses: ./actions/bun-install

- run: bun test
64 changes: 62 additions & 2 deletions .github/workflows/site-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ on:
(inputs are visible in logs).
type: string
default: '{}'
socket-scanner:
description: >
Run the bunfig.toml install-time security scanner (`[install.security]
scanner`, e.g. Socket's) during the CI install. Defaults to false: it
calls a third-party API on every install and can fail a build for
reasons outside the repo. It still runs on local installs. Bun only;
ignored for pnpm.
type: boolean
default: false
secrets:
GITLEAKS_LICENSE:
description: >
Expand Down Expand Up @@ -187,8 +196,59 @@ jobs:
restore-keys: |
${{ runner.os }}-playwright-

- name: Install NPM Dependencies
run: '"$PM" install --frozen-lockfile'
# Same script as actions/bun-install (a test keeps them identical).
# Inlined because a reusable workflow can't reach `./actions/...` in its
# own repo: checkout gives it the caller's tree, not voidflow's.
- name: Install NPM Dependencies (bun)
id: install
if: inputs.package-manager == 'bun'
shell: bash
env:
SOCKET_SCANNER: ${{ inputs.socket-scanner }}
run: |
# Removes the scanner line as text, then checks the result with a real
# TOML parse: it must equal the original minus install.security.scanner.
# Anything that can't be removed that way fails the install instead of
# running the scanner, or skipping it, silently.
# shellcheck disable=SC2016 # JS source; the ${...} in it is for bun, not bash
strip_scanner='
const src = await Bun.file("bunfig.toml").text()
const parse = (text) => { try { return Bun.TOML.parse(text) } catch (e) { console.log(`::error file=bunfig.toml::Could not parse bunfig.toml: ${e.message}`); process.exit(1) } }
const original = parse(src)
if (original.install?.security?.scanner === undefined) process.exit(10)
const key = /^\s*((install\.)?security\.)?("scanner"|scanner)\s*=/
const stripped = src.split("\n").filter((line) => !key.test(line)).join("\n")
// empty tables left behind by the removal count as absent
const prune = (cfg) => {
delete cfg.install?.security?.scanner
if (cfg.install?.security && Object.keys(cfg.install.security).length === 0) delete cfg.install.security
if (cfg.install && Object.keys(cfg.install).length === 0) delete cfg.install
return cfg
}
const after = parse(stripped)
if (after.install?.security?.scanner !== undefined || !Bun.deepEquals(prune(after), prune(structuredClone(original)))) {
console.log("::error file=bunfig.toml::Could not remove the security scanner from bunfig.toml cleanly; set it as `scanner = \"...\"` under [install.security].")
process.exit(1)
}
await Bun.write(process.env.BUNFIG_OUT, stripped)
'
if [[ "$SOCKET_SCANNER" != "true" ]] && [[ -f bunfig.toml ]]; then
config="$RUNNER_TEMP/bunfig.toml"
rc=0
BUNFIG_OUT="$config" bun --config=/dev/null -e "$strip_scanner" || rc=$?
if [[ $rc -eq 0 ]]; then
echo "::notice file=bunfig.toml::Security scanner skipped in CI. Pass socket-scanner: true to run it here; it still runs on local installs."
bun install --frozen-lockfile --config="$config"
exit
elif [[ $rc -ne 10 ]]; then
exit "$rc"
fi
fi
bun install --frozen-lockfile

- name: Install NPM Dependencies (pnpm)
if: inputs.package-manager == 'pnpm'
run: pnpm install --frozen-lockfile

- name: Detect package scripts
id: scripts
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,40 @@ or `engines` in `package.json`. The lockfile (`bun.lock` or `pnpm-lock.yaml`)
follows the package manager. `build-env` is a JSON object, visible in logs, so
no secrets.

### Install-time security scanner (Socket)

A bun site can set a security scanner in `bunfig.toml`, which checks every
package on install:

```toml
[install.security]
scanner = "@socketsecurity/bun-security-scanner"
```

CI skips it by default. The scanner calls a third-party API on every install,
so a network blip or an API change could fail a build that otherwise passes,
and rerunning the same commit could give a different result. `site-ci.yml`
installs with a copy of `bunfig.toml` minus the scanner line (every other
setting, e.g. `minimumReleaseAge`, still applies) and leaves a notice in the
run. Local `bun install` reads `bunfig.toml` as is, so the scanner still runs
on your machine.

To run it in CI too, pass `socket-scanner: true` to `site-ci.yml`. A workflow
that installs on its own (outside `site-ci.yml`) gets the same default from
the composite action:

```yaml
- uses: oven-sh/setup-bun@<sha> # <version>
- uses: taraxvoid/voidflow/actions/bun-install@<sha> # <version>
# with: { socket-scanner: 'true' }
```

The copy is checked by parsing both files: it must equal your `bunfig.toml`
with only `install.security.scanner` removed. Set the scanner as a plain
`scanner = "..."` line (or the dotted `security.scanner` form); if it can't be
removed cleanly, e.g. an inline table, the install fails rather than run or
skip the scanner silently. pnpm sites are unaffected.

## Workflow Types

### Validation / Checks
Expand Down
64 changes: 64 additions & 0 deletions actions/bun-install/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Bun install
description: >
Runs `bun install --frozen-lockfile`. By default it leaves out the
install-time security scanner set in bunfig.toml (`[install.security]
scanner`, e.g. Socket's), which calls a third-party API on every install and
can fail a build for reasons outside the repo. The scanner still runs on
local installs; set socket-scanner to 'true' to run it in CI as well.

inputs:
socket-scanner:
description: >
'true' runs the bunfig.toml security scanner during the CI install.
Default 'false' installs with a copy of bunfig.toml minus the scanner
(every other setting, e.g. minimumReleaseAge, still applies).
required: false
default: 'false'

runs:
using: composite
steps:
- id: install
shell: bash
env:
SOCKET_SCANNER: ${{ inputs.socket-scanner }}
run: |
# Removes the scanner line as text, then checks the result with a real
# TOML parse: it must equal the original minus install.security.scanner.
# Anything that can't be removed that way fails the install instead of
# running the scanner, or skipping it, silently.
# shellcheck disable=SC2016 # JS source; the ${...} in it is for bun, not bash
strip_scanner='
const src = await Bun.file("bunfig.toml").text()
const parse = (text) => { try { return Bun.TOML.parse(text) } catch (e) { console.log(`::error file=bunfig.toml::Could not parse bunfig.toml: ${e.message}`); process.exit(1) } }
const original = parse(src)
if (original.install?.security?.scanner === undefined) process.exit(10)
const key = /^\s*((install\.)?security\.)?("scanner"|scanner)\s*=/
const stripped = src.split("\n").filter((line) => !key.test(line)).join("\n")
// empty tables left behind by the removal count as absent
const prune = (cfg) => {
delete cfg.install?.security?.scanner
if (cfg.install?.security && Object.keys(cfg.install.security).length === 0) delete cfg.install.security
if (cfg.install && Object.keys(cfg.install).length === 0) delete cfg.install
return cfg
}
const after = parse(stripped)
if (after.install?.security?.scanner !== undefined || !Bun.deepEquals(prune(after), prune(structuredClone(original)))) {
console.log("::error file=bunfig.toml::Could not remove the security scanner from bunfig.toml cleanly; set it as `scanner = \"...\"` under [install.security].")
process.exit(1)
}
await Bun.write(process.env.BUNFIG_OUT, stripped)
'
if [[ "$SOCKET_SCANNER" != "true" ]] && [[ -f bunfig.toml ]]; then
config="$RUNNER_TEMP/bunfig.toml"
rc=0
BUNFIG_OUT="$config" bun --config=/dev/null -e "$strip_scanner" || rc=$?
if [[ $rc -eq 0 ]]; then
echo "::notice file=bunfig.toml::Security scanner skipped in CI. Pass socket-scanner: true to run it here; it still runs on local installs."
bun install --frozen-lockfile --config="$config"
exit
elif [[ $rc -ne 10 ]]; then
exit "$rc"
fi
fi
bun install --frozen-lockfile
205 changes: 205 additions & 0 deletions test/bun-install.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { describe, expect, test } from 'bun:test'
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), 'utf8')

const actionScript: string = Bun.YAML.parse(read('actions/bun-install/action.yml')).runs.steps.find(
(s: any) => s.id === 'install',
).run
const siteCiSteps: any[] = Bun.YAML.parse(read('.github/workflows/site-ci.yml')).jobs.validate.steps
const workflowScript: string = siteCiSteps.find((s: any) => s.id === 'install').run

const socketBunfig = `[install]
minimumReleaseAge = 259200 # 3 days

[install.security]
scanner = "@socketsecurity/bun-security-scanner"
`

// Runs the script in a temp repo with a stub `bun` that records its args and
// the config file it was pointed at.
async function install(script: string, opts: { bunfig?: string; scanner: boolean }) {
const root = mkdtempSync(join(tmpdir(), 'voidflow-bun-install-'))
const repo = join(root, 'repo')
const runnerTemp = join(root, 'runner-temp')
const bin = join(root, 'bin')
for (const d of [repo, runnerTemp, bin]) mkdirSync(d)
if (opts.bunfig !== undefined) writeFileSync(join(repo, 'bunfig.toml'), opts.bunfig)
const log = join(root, 'bun-args')
writeFileSync(
join(bin, 'bun'),
`#!/usr/bin/env bash
# the script's own \`bun -e\` (the TOML check) runs on the real bun; --config=/dev/null keeps the repo bunfig.toml out of it
if [[ " $* " == *" -e "* ]]; then exec ${JSON.stringify(process.execPath)} "$@"; fi
printf '%s\\n' "$@" > ${JSON.stringify(log)}
for a in "$@"; do [[ "$a" == --config=* ]] && cp "\${a#--config=}" ${JSON.stringify(log + '.config')}; done
exit 0
`,
)
chmodSync(join(bin, 'bun'), 0o755)
const proc = Bun.spawn(['bash', '-c', script], {
cwd: repo,
env: { PATH: `${bin}:${process.env.PATH}`, RUNNER_TEMP: runnerTemp, SOCKET_SCANNER: String(opts.scanner) },
stdout: 'pipe',
stderr: 'pipe',
})
const code = await proc.exited
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
return {
code,
stdout,
stderr,
args: existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n') : null,
config: existsSync(log + '.config') ? readFileSync(log + '.config', 'utf8') : null,
bunfigAfter: opts.bunfig !== undefined ? readFileSync(join(repo, 'bunfig.toml'), 'utf8') : null,
}
}

describe('site-ci.yml carries the same install script as actions/bun-install', () => {
test('scripts are identical (no drift)', () => {
expect(workflowScript).toBe(actionScript)
})

test('the inlined bun step only runs for bun; pnpm has its own install', () => {
expect(siteCiSteps.find((s: any) => s.id === 'install').if).toBe("inputs.package-manager == 'bun'")
const pnpm = siteCiSteps.find((s: any) => s.if === "inputs.package-manager == 'pnpm'" && /pnpm install/.test(s.run ?? ''))
expect(pnpm).toBeDefined()
})

test('socket-scanner defaults to off in both', () => {
expect(Bun.YAML.parse(read('actions/bun-install/action.yml')).inputs['socket-scanner'].default).toBe('false')
const wf = Bun.YAML.parse(read('.github/workflows/site-ci.yml'))
expect(wf.on.workflow_call.inputs['socket-scanner'].default).toBe(false)
})

test("voidflow's own ci.yml installs through the action", () => {
const steps = Bun.YAML.parse(read('.github/workflows/ci.yml')).jobs['unit-tests'].steps
expect(steps.some((s: any) => s.uses === './actions/bun-install')).toBe(true)
expect(steps.some((s: any) => /bun install/.test(s.run ?? ''))).toBe(false)
})
})

describe.each([
['action', actionScript],
['workflow', workflowScript],
])('%s script', (_name, script) => {
test('off by default: installs with a scanner-free copy of bunfig.toml, other settings kept', async () => {
const r = await install(script, { bunfig: socketBunfig, scanner: false })
expect(r.code).toBe(0)
expect(r.args?.[0]).toBe('install')
expect(r.args).toContain('--frozen-lockfile')
expect(r.args?.some((a) => a.startsWith('--config='))).toBe(true)
expect(Bun.TOML.parse(r.config!)).toEqual({ install: { minimumReleaseAge: 259200, security: {} } })
expect(r.stdout).toContain('::notice')
// the checkout's bunfig.toml is left untouched
expect(r.bunfigAfter).toBe(socketBunfig)
})

test('on: plain install, scanner left in place', async () => {
const r = await install(script, { bunfig: socketBunfig, scanner: true })
expect(r).toMatchObject({ code: 0, args: ['install', '--frozen-lockfile'] })
expect(r.stdout).not.toContain('::notice')
})

test.each([
['no bunfig.toml', undefined],
['bunfig.toml without a scanner', '[install]\nminimumReleaseAge = 259200\n'],
['a commented-out scanner', '[install.security]\n# scanner = "@socketsecurity/bun-security-scanner"\n'],
])('off with %s: plain install, no notice', async (_label, bunfig) => {
const r = await install(script, { bunfig, scanner: false })
expect(r).toMatchObject({ code: 0, args: ['install', '--frozen-lockfile'] })
expect(r.stdout).not.toContain('::notice')
})

test.each([
['[install] security.scanner', '[install]\nsecurity.scanner = "x"\n'],
['top-level install.security.scanner', 'install.security.scanner = "x"\n'],
])('off strips the dotted form: %s', async (_label, bunfig) => {
const r = await install(script, { bunfig, scanner: false })
expect(r.code).toBe(0)
expect(Bun.TOML.parse(r.config!).install?.security?.scanner).toBeUndefined()
})

test.each([
['an inline table', '[install]\nsecurity = { scanner = "x" }\n'],
['a `scanner` key in another table too', '[install.security]\nscanner = "x"\n\n[other]\nscanner = "y"\n'],
['invalid TOML', '[install.security\nscanner = "x"\n'],
])('off fails closed when it cannot remove the scanner cleanly: %s', async (_label, bunfig) => {
const r = await install(script, { bunfig, scanner: false })
expect(r.code).toBe(1)
expect(r.stdout).toContain('::error')
expect(r.args).toBeNull()
})

test('off only touches the scanner key, not other values that mention "scanner"', async () => {
const bunfig = `[install]
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["foo-scanner"]

[install.security]
scanner = "@socketsecurity/bun-security-scanner"

[other]
tool = { scanner = "y" }
`
const r = await install(script, { bunfig, scanner: false })
expect(r.code).toBe(0)
expect(Bun.TOML.parse(r.config!)).toEqual({
install: { minimumReleaseAge: 259200, minimumReleaseAgeExcludes: ['foo-scanner'], security: {} },
other: { tool: { scanner: 'y' } },
})
})
})

// End-to-end against the real bun: `--config=` must replace (not merge with)
// the repo's bunfig.toml, or the scanner would still run. A local fake scanner
// and a file: dependency keep this offline.
describe('real bun', () => {
function fixture(bunfig: string) {
const repo = mkdtempSync(join(tmpdir(), 'voidflow-bun-real-'))
mkdirSync(join(repo, 'node_modules/fake-scanner'), { recursive: true })
mkdirSync(join(repo, 'dep'))
writeFileSync(
join(repo, 'node_modules/fake-scanner/package.json'),
'{"name":"fake-scanner","version":"1.0.0","type":"module","main":"index.js"}',
)
writeFileSync(
join(repo, 'node_modules/fake-scanner/index.js'),
'export const scanner = { version: "1", async scan() { console.error("FAKE SCANNER RAN"); return [] } }',
)
writeFileSync(join(repo, 'dep/package.json'), '{"name":"dep","version":"1.0.0"}')
writeFileSync(join(repo, 'package.json'), '{"name":"fixture","dependencies":{"dep":"file:./dep"}}')
writeFileSync(join(repo, 'bunfig.toml'), bunfig)
// lockfile for --frozen-lockfile
Bun.spawnSync(['bun', 'install', '--config=/dev/null'], { cwd: repo })
return repo
}

async function run(scanner: boolean) {
const repo = fixture('[install.security]\nscanner = "fake-scanner"\n')
const runnerTemp = mkdtempSync(join(tmpdir(), 'voidflow-runner-temp-'))
const proc = Bun.spawn(['bash', '-c', actionScript], {
cwd: repo,
env: { PATH: process.env.PATH, HOME: process.env.HOME, RUNNER_TEMP: runnerTemp, SOCKET_SCANNER: String(scanner) },
stdout: 'pipe',
stderr: 'pipe',
})
const code = await proc.exited
return { code, output: (await new Response(proc.stdout).text()) + (await new Response(proc.stderr).text()) }
}

test('off: the scanner does not run', async () => {
const r = await run(false)
expect(r.code).toBe(0)
expect(r.output).not.toContain('FAKE SCANNER RAN')
})

test('on: the scanner runs', async () => {
const r = await run(true)
expect(r.code).toBe(0)
expect(r.output).toContain('FAKE SCANNER RAN')
})
})
Loading