diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85fe459..f0d75eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/site-ci.yml b/.github/workflows/site-ci.yml index 1bec857..3a3c934 100644 --- a/.github/workflows/site-ci.yml +++ b/.github/workflows/site-ci.yml @@ -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: > @@ -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 diff --git a/README.md b/README.md index 5dccc88..95065ef 100644 --- a/README.md +++ b/README.md @@ -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@ # +- uses: taraxvoid/voidflow/actions/bun-install@ # + # 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 diff --git a/actions/bun-install/action.yml b/actions/bun-install/action.yml new file mode 100644 index 0000000..bea786b --- /dev/null +++ b/actions/bun-install/action.yml @@ -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 diff --git a/test/bun-install.test.ts b/test/bun-install.test.ts new file mode 100644 index 0000000..bb9da24 --- /dev/null +++ b/test/bun-install.test.ts @@ -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') + }) +})