From e21ae496fdf20b783ac3356f2f548171a13881f4 Mon Sep 17 00:00:00 2001 From: void <1711810+taraxvoid@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:28:26 -0700 Subject: [PATCH 1/2] feat(ci): skip the bunfig security scanner in CI by default Socket's bun scanner calls a third-party API on every install, so a network blip or API change can fail an otherwise-green build, and reruns of the same commit aren't guaranteed to agree. Local installs keep running it. - actions/bun-install: `bun install --frozen-lockfile` with a `socket-scanner` input (default 'false'). When off, installs with a scanner-free copy of bunfig.toml via `--config=` (replaces, not merges; other settings still apply), leaves a notice, and fails closed on a scanner it can't strip. - site-ci.yml: same `socket-scanner` input (default false); the bun install step inlines the action's script, kept identical by a test. pnpm unchanged. - ci.yml: voidflow's own unit-test install goes through the action. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- .github/workflows/site-ci.yml | 42 +++++++- README.md | 32 ++++++ actions/bun-install/action.yml | 42 ++++++++ test/bun-install.test.ts | 184 +++++++++++++++++++++++++++++++++ 5 files changed, 299 insertions(+), 3 deletions(-) create mode 100644 actions/bun-install/action.yml create mode 100644 test/bun-install.test.ts 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..f5e279a 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,37 @@ 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: | + # Lines that set the scanner: `scanner = ...` under [install.security], + # or the dotted `security.scanner` / `install.security.scanner` forms. + scanner_re='^[[:space:]]*((install\.)?security\.)?scanner[[:space:]]*=' + if [[ "$SOCKET_SCANNER" != "true" ]] && [[ -f bunfig.toml ]] && grep -v '^[[:space:]]*#' bunfig.toml | grep -q 'scanner'; then + config="$RUNNER_TEMP/bunfig.toml" + grep -Ev "$scanner_re" bunfig.toml > "$config" || true + # Fail closed if the scanner is set some other way (e.g. an inline + # table) rather than silently running it, or silently not. + if grep -v '^[[:space:]]*#' "$config" | grep -q 'scanner'; then + echo "::error file=bunfig.toml::Could not remove the security scanner from bunfig.toml; set it as 'scanner = \"...\"' under [install.security]." >&2 + exit 1 + fi + 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" + else + bun install --frozen-lockfile + fi + + - 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..96b2207 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,38 @@ 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 scanner must be set as a plain `scanner = "..."` line (or the dotted +`security.scanner` form); any other form fails the install rather than run or +skip it 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..57ff412 --- /dev/null +++ b/actions/bun-install/action.yml @@ -0,0 +1,42 @@ +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: | + # Lines that set the scanner: `scanner = ...` under [install.security], + # or the dotted `security.scanner` / `install.security.scanner` forms. + scanner_re='^[[:space:]]*((install\.)?security\.)?scanner[[:space:]]*=' + if [[ "$SOCKET_SCANNER" != "true" ]] && [[ -f bunfig.toml ]] && grep -v '^[[:space:]]*#' bunfig.toml | grep -q 'scanner'; then + config="$RUNNER_TEMP/bunfig.toml" + grep -Ev "$scanner_re" bunfig.toml > "$config" || true + # Fail closed if the scanner is set some other way (e.g. an inline + # table) rather than silently running it, or silently not. + if grep -v '^[[:space:]]*#' "$config" | grep -q 'scanner'; then + echo "::error file=bunfig.toml::Could not remove the security scanner from bunfig.toml; set it as 'scanner = \"...\"' under [install.security]." >&2 + exit 1 + fi + 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" + else + bun install --frozen-lockfile + fi diff --git a/test/bun-install.test.ts b/test/bun-install.test.ts new file mode 100644 index 0000000..a9be337 --- /dev/null +++ b/test/bun-install.test.ts @@ -0,0 +1,184 @@ +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 +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(r.config).not.toContain('scanner') + expect(r.config).toContain('minimumReleaseAge = 259200') + 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(r.config).not.toContain('scanner') + }) + + test.each([ + ['an inline table', '[install]\nsecurity = { scanner = "x" }\n'], + ['a strippable line plus an inline one', '[install.security]\nscanner = "x"\n\n[other]\nfoo = { scanner = "y" }\n'], + ])('off fails closed on a scanner it cannot strip: %s', async (_label, bunfig) => { + const r = await install(script, { bunfig, scanner: false }) + expect(r.code).toBe(1) + expect(r.stderr).toContain('::error') + expect(r.args).toBeNull() + }) +}) + +// 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') + }) +}) From 8675345c23344060f196473f817d5876964a5379 Mon Sep 17 00:00:00 2001 From: void <1711810+taraxvoid@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:56:40 -0700 Subject: [PATCH 2/2] fix(bun-install): verify the scanner-free bunfig by parsing it, not grep The grep check was a heuristic: a package like "foo-scanner" in minimumReleaseAgeExcludes failed the install, and it couldn't tell an unremovable scanner from an unrelated value that mentions one. Now detection and verification go through Bun.TOML.parse: the copy must equal the original minus install.security.scanner (empty tables count as absent), or the install fails. The check runs with --config=/dev/null so the repo's own bunfig.toml can't break it before it reports. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/site-ci.yml | 50 ++++++++++++++++++++++++---------- README.md | 8 ++++-- actions/bun-install/action.yml | 50 ++++++++++++++++++++++++---------- test/bun-install.test.ts | 33 ++++++++++++++++++---- 4 files changed, 104 insertions(+), 37 deletions(-) diff --git a/.github/workflows/site-ci.yml b/.github/workflows/site-ci.yml index f5e279a..3a3c934 100644 --- a/.github/workflows/site-ci.yml +++ b/.github/workflows/site-ci.yml @@ -206,23 +206,45 @@ jobs: env: SOCKET_SCANNER: ${{ inputs.socket-scanner }} run: | - # Lines that set the scanner: `scanner = ...` under [install.security], - # or the dotted `security.scanner` / `install.security.scanner` forms. - scanner_re='^[[:space:]]*((install\.)?security\.)?scanner[[:space:]]*=' - if [[ "$SOCKET_SCANNER" != "true" ]] && [[ -f bunfig.toml ]] && grep -v '^[[:space:]]*#' bunfig.toml | grep -q 'scanner'; then + # 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" - grep -Ev "$scanner_re" bunfig.toml > "$config" || true - # Fail closed if the scanner is set some other way (e.g. an inline - # table) rather than silently running it, or silently not. - if grep -v '^[[:space:]]*#' "$config" | grep -q 'scanner'; then - echo "::error file=bunfig.toml::Could not remove the security scanner from bunfig.toml; set it as 'scanner = \"...\"' under [install.security]." >&2 - exit 1 + 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 - 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" - else - bun install --frozen-lockfile fi + bun install --frozen-lockfile - name: Install NPM Dependencies (pnpm) if: inputs.package-manager == 'pnpm' diff --git a/README.md b/README.md index 96b2207..95065ef 100644 --- a/README.md +++ b/README.md @@ -115,9 +115,11 @@ the composite action: # with: { socket-scanner: 'true' } ``` -The scanner must be set as a plain `scanner = "..."` line (or the dotted -`security.scanner` form); any other form fails the install rather than run or -skip it silently. pnpm sites are unaffected. +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 diff --git a/actions/bun-install/action.yml b/actions/bun-install/action.yml index 57ff412..bea786b 100644 --- a/actions/bun-install/action.yml +++ b/actions/bun-install/action.yml @@ -23,20 +23,42 @@ runs: env: SOCKET_SCANNER: ${{ inputs.socket-scanner }} run: | - # Lines that set the scanner: `scanner = ...` under [install.security], - # or the dotted `security.scanner` / `install.security.scanner` forms. - scanner_re='^[[:space:]]*((install\.)?security\.)?scanner[[:space:]]*=' - if [[ "$SOCKET_SCANNER" != "true" ]] && [[ -f bunfig.toml ]] && grep -v '^[[:space:]]*#' bunfig.toml | grep -q 'scanner'; then + # 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" - grep -Ev "$scanner_re" bunfig.toml > "$config" || true - # Fail closed if the scanner is set some other way (e.g. an inline - # table) rather than silently running it, or silently not. - if grep -v '^[[:space:]]*#' "$config" | grep -q 'scanner'; then - echo "::error file=bunfig.toml::Could not remove the security scanner from bunfig.toml; set it as 'scanner = \"...\"' under [install.security]." >&2 - exit 1 + 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 - 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" - else - bun install --frozen-lockfile fi + bun install --frozen-lockfile diff --git a/test/bun-install.test.ts b/test/bun-install.test.ts index a9be337..bb9da24 100644 --- a/test/bun-install.test.ts +++ b/test/bun-install.test.ts @@ -31,6 +31,8 @@ async function install(script: string, opts: { bunfig?: string; scanner: boolean 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 @@ -90,8 +92,7 @@ describe.each([ expect(r.args?.[0]).toBe('install') expect(r.args).toContain('--frozen-lockfile') expect(r.args?.some((a) => a.startsWith('--config='))).toBe(true) - expect(r.config).not.toContain('scanner') - expect(r.config).toContain('minimumReleaseAge = 259200') + 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) @@ -119,18 +120,38 @@ describe.each([ ])('off strips the dotted form: %s', async (_label, bunfig) => { const r = await install(script, { bunfig, scanner: false }) expect(r.code).toBe(0) - expect(r.config).not.toContain('scanner') + expect(Bun.TOML.parse(r.config!).install?.security?.scanner).toBeUndefined() }) test.each([ ['an inline table', '[install]\nsecurity = { scanner = "x" }\n'], - ['a strippable line plus an inline one', '[install.security]\nscanner = "x"\n\n[other]\nfoo = { scanner = "y" }\n'], - ])('off fails closed on a scanner it cannot strip: %s', async (_label, bunfig) => { + ['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.stderr).toContain('::error') + 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)