From f23497fc64ed952793f0de27a722a6ada280bf67 Mon Sep 17 00:00:00 2001 From: Muneeb Date: Thu, 23 Apr 2026 10:47:29 +0200 Subject: [PATCH 1/2] fix(scanner): drop warn advisories in non-interactive mode Bun prompts ("Continue anyway? [y/N]") whenever the scanner returns any warn-level advisory. In CI / non-TTY environments this either hangs the install or auto-cancels it, even though warns are not supposed to block. Strip warn-level advisories from the scanner's return value when CI=true or stdin is not a TTY, logging each to stderr so they remain visible. Fatal advisories continue to block as before. --- src/__tests__/scanner.test.ts | 58 +++++++++++++++++++++++++++++++++++ src/scanner.ts | 36 +++++++++++++++++++--- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/__tests__/scanner.test.ts b/src/__tests__/scanner.test.ts index 576c956..df1b3dd 100644 --- a/src/__tests__/scanner.test.ts +++ b/src/__tests__/scanner.test.ts @@ -46,8 +46,23 @@ describe('scanner.scan', () => { let fileSpy: ReturnType>; let writeSpy: ReturnType>; let renameSpy: ReturnType>; + let origCI: string | undefined; + let origIsTTYDescriptor: PropertyDescriptor | undefined; beforeEach(() => { + // Force interactive mode for the default suite so warn-level advisories + // are returned (Bun would otherwise hang on the "Continue?" prompt in CI). + origCI = process.env.CI; + origIsTTYDescriptor = Object.getOwnPropertyDescriptor( + process.stdin, + 'isTTY' + ); + process.env.CI = 'false'; + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + fetchSpy = spyOn(globalThis, 'fetch'); // Make cache reads return empty (no cached data) without touching the filesystem. // Use mockImplementation (not mockReturnValue) so that fd-based Bun.file calls @@ -76,6 +91,13 @@ describe('scanner.scan', () => { fileSpy.mockRestore(); writeSpy.mockRestore(); renameSpy.mockRestore(); + if (origCI === undefined) delete process.env.CI; + else process.env.CI = origCI; + if (origIsTTYDescriptor) { + Object.defineProperty(process.stdin, 'isTTY', origIsTTYDescriptor); + } else { + (process.stdin as { isTTY?: boolean }).isTTY = undefined; + } }); test('returns empty array when no packages provided', async () => { @@ -154,6 +176,42 @@ describe('scanner.scan', () => { expect(advisory?.level).toBe(expectedLevel); }); + test('drops warn-level advisories in non-interactive mode (Bun would otherwise prompt and hang)', async () => { + process.env.CI = 'true'; + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + configurable: true, + }); + + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + results: [ + { vulns: [{ id: 'GHSA-warn', modified: '' }] }, + { vulns: [{ id: 'GHSA-fatal', modified: '' }] }, + ], + }) + ) + ); + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify(makeOsvVuln('GHSA-warn', 'LOW', 'Low vuln'))) + ); + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify(makeOsvVuln('GHSA-fatal', 'CRITICAL', 'Crit vuln')) + ) + ); + + const advisories = await scanner.scan({ + packages: [pkg('pkg-warn', '1.0.0'), pkg('pkg-fatal', '2.0.0')], + }); + + // Warn dropped (logged to stderr); fatal kept so it still blocks. + expect(advisories).toHaveLength(1); + expect(advisories[0]?.level).toBe('fatal'); + expect(advisories[0]?.package).toBe('pkg-fatal'); + }); + test('returns advisories for multiple vulnerable packages', async () => { // Two packages, each with one vuln. fetchSpy.mockResolvedValueOnce( diff --git a/src/scanner.ts b/src/scanner.ts index 85168b6..07bf64e 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -48,7 +48,7 @@ export function createScanner(backend: Backend): Bun.Security.Scanner { } if (toQuery.length === 0) - return applyIgnores(cachedAdvisories, ignoreList); + return stripNonBlockingInCI(applyIgnores(cachedAdvisories, ignoreList)); const hitCount = queryable.length - toQuery.length; const spinner = startSpinner( @@ -69,9 +69,11 @@ export function createScanner(backend: Backend): Bun.Security.Scanner { } if (!backend.noCache) void writeCache(cache, backend.cacheFile); - return applyIgnores( - [...cachedAdvisories, ...[...advisoryMap.values()].flat()], - ignoreList + return stripNonBlockingInCI( + applyIgnores( + [...cachedAdvisories, ...[...advisoryMap.values()].flat()], + ignoreList + ) ); } catch (err) { spinner.stop(); @@ -85,12 +87,36 @@ export function createScanner(backend: Backend): Bun.Security.Scanner { process.stderr.write( `\n${backend.name} scan failed (${err instanceof Error ? err.message : err}), skipping.\n` ); - return applyIgnores(cachedAdvisories, ignoreList); + return stripNonBlockingInCI(applyIgnores(cachedAdvisories, ignoreList)); } }, }; } +/** + * In CI / non-interactive sessions, Bun still prompts ("Continue anyway? [y/N]") + * for `warn`-level advisories — which hangs or fails the install. Drop them from + * the returned set (logging to stderr) so only `fatal` advisories block. + * Interactive sessions keep `warn` so the user can see and decide. + */ +function stripNonBlockingInCI( + advisories: Bun.Security.Advisory[] +): Bun.Security.Advisory[] { + const interactive = + process.env.CI !== 'true' && (process.stdin?.isTTY ?? false); + if (interactive) return advisories; + + return advisories.filter((advisory) => { + if (advisory.level === 'warn') { + process.stderr.write( + `[@nebzdev/bun-security-scanner] WARN ${advisory.package}: ${advisory.description ?? ''} (${advisory.url})\n` + ); + return false; + } + return true; + }); +} + /** * Apply the ignore list to a set of advisories: * - `fatal` advisories that are ignored are downgraded to `warn` in From 8069140d8a9ec7156f4a6f3ca0bbb7164c3af2d8 Mon Sep 17 00:00:00 2001 From: Muneeb Date: Thu, 23 Apr 2026 10:53:12 +0200 Subject: [PATCH 2/2] fix(test): restore stdin.isTTY via defineProperty (readonly on CI) --- src/__tests__/scanner.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/__tests__/scanner.test.ts b/src/__tests__/scanner.test.ts index df1b3dd..3d4262c 100644 --- a/src/__tests__/scanner.test.ts +++ b/src/__tests__/scanner.test.ts @@ -96,7 +96,10 @@ describe('scanner.scan', () => { if (origIsTTYDescriptor) { Object.defineProperty(process.stdin, 'isTTY', origIsTTYDescriptor); } else { - (process.stdin as { isTTY?: boolean }).isTTY = undefined; + Object.defineProperty(process.stdin, 'isTTY', { + value: undefined, + configurable: true, + }); } });