diff --git a/src/__tests__/scanner.test.ts b/src/__tests__/scanner.test.ts index 576c956..3d4262c 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,16 @@ 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 { + Object.defineProperty(process.stdin, 'isTTY', { + value: undefined, + configurable: true, + }); + } }); test('returns empty array when no packages provided', async () => { @@ -154,6 +179,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