From cd36da08d98040af1c6fc3fd487690c2a45d7ab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 28 Aug 2026 15:54:34 +0200 Subject: [PATCH] fix(ci): clear self-report warnings --- .kit-secretsignore | 5 ++++ src/check-security.test.ts | 54 ++++++++++++++++++++++++++++++++++++++ src/check-security.ts | 47 ++++++++++++++++++++++++--------- 3 files changed, 93 insertions(+), 13 deletions(-) diff --git a/.kit-secretsignore b/.kit-secretsignore index d47d4caa..d7ccc2f2 100644 --- a/.kit-secretsignore +++ b/.kit-secretsignore @@ -19,3 +19,8 @@ d9f55afc26db3ed604946b44edf224c293405d3f:src/check-security.test.ts:Postgres d9f55afc26db3ed604946b44edf224c293405d3f:src/check-security.test.ts:Github d9f55afc26db3ed604946b44edf224c293405d3f:*:Postgres + +# 2fabcd5 — same secrets classifier negative-fixture family. The value was reviewed as +# non-issued test data, but it is intentionally real-shaped, so it must be accepted by +# commit+file+detector rather than auto-classified as an example credential. +2fabcd52c67e4f3344ef4b6a6c500e1b00289721:src/check-security.test.ts:Postgres diff --git a/src/check-security.test.ts b/src/check-security.test.ts index d849d618..de41106f 100644 --- a/src/check-security.test.ts +++ b/src/check-security.test.ts @@ -18,6 +18,7 @@ import { checkMemoryInjection, checkGateLiveness, checkDeviceIdOverride, + checkLicenses, unpinnedNodeDeps, auditAllowScripts, checkAllowScripts, @@ -645,6 +646,59 @@ describe(".kit-secretsignore (explicitly accepted historical findings)", () => { }); }); +describe("license check npx fallback", () => { + it("uses an isolated npm cache so a broken user cache does not make the scan red", async () => { + const root = mkdtempSync(join(tmpdir(), "kit-license-root-")); + const bin = join(root, "bin"); + const project = join(root, "project"); + const badCache = join(root, "root-owned-npm-cache"); + mkdirSync(bin, { recursive: true }); + mkdirSync(project, { recursive: true }); + mkdirSync(badCache, { recursive: true }); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "x", version: "1.0.0" })); + + writeFileSync(join(bin, "license-checker"), "#!/bin/sh\nexit 127\n"); + chmodSync(join(bin, "license-checker"), 0o755); + writeFileSync( + join(bin, "npx"), + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then echo "10.0.0"; exit 0; fi', + 'if [ -z "$NPM_CONFIG_CACHE" ]; then echo "missing isolated cache" >&2; exit 7; fi', + 'if [ "$NPM_CONFIG_CACHE" = "$BAD_NPM_CACHE" ]; then echo "used broken user cache" >&2; exit 13; fi', + 'case "$*" in', + ' *license-checker*--json*--production*) printf \'%s\\n\' \'{ "left-pad@1.3.0": { "licenses": "MIT" } }\'; exit 0 ;;', + "esac", + 'echo "unexpected args: $*" >&2', + "exit 2", + ].join("\n") + "\n", + ); + chmodSync(join(bin, "npx"), 0o755); + + const prevPath = process.env.PATH; + const prevNpmCache = process.env.NPM_CONFIG_CACHE; + const prevBadCache = process.env.BAD_NPM_CACHE; + try { + process.env.PATH = [bin, "/usr/bin", "/bin"].join(":"); + process.env.NPM_CONFIG_CACHE = badCache; + process.env.BAD_NPM_CACHE = badCache; + + const result = await checkLicenses(project); + + assert.equal(result.status, "pass"); + assert.equal(result.detail, "no problematic licenses found"); + } finally { + if (prevPath === undefined) delete process.env.PATH; + else process.env.PATH = prevPath; + if (prevNpmCache === undefined) delete process.env.NPM_CONFIG_CACHE; + else process.env.NPM_CONFIG_CACHE = prevNpmCache; + if (prevBadCache === undefined) delete process.env.BAD_NPM_CACHE; + else process.env.BAD_NPM_CACHE = prevBadCache; + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("parseTrivyMisconfigCount", () => { it("counts only HIGH/CRITICAL misconfigurations", () => { const json = JSON.stringify({ diff --git a/src/check-security.ts b/src/check-security.ts index e12077e3..2472a2f8 100644 --- a/src/check-security.ts +++ b/src/check-security.ts @@ -1,10 +1,10 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { readFile, access, readdir } from "node:fs/promises"; +import { readFile, access, readdir, mkdtemp, rm } from "node:fs/promises"; import { readFileSync } from "node:fs"; import { createHash } from "node:crypto"; -import { resolve } from "node:path"; -import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { homedir, tmpdir } from "node:os"; import { execFileNoThrow } from "./utils/execFileNoThrow.js"; import { resolveWorkspaceRoots } from "./workspaces.js"; import { resolveToolBin } from "./utils/resolveTool.js"; @@ -2231,7 +2231,7 @@ async function checkOsvScanner(root: string): Promise { /** * Check dependency licenses for GPL/AGPL that create legal obligations. */ -async function checkLicenses(root: string): Promise { +export async function checkLicenses(root: string): Promise { try { await access(resolve(root, "package.json")); } catch { @@ -2246,7 +2246,7 @@ async function checkLicenses(root: string): Promise { // Try direct binary first (fast). If absent, fall back to `npx --yes // license-checker` so we don't force users to `npm install -g`. // npx first-run can fetch the package, so allow generous timeout. - let runner: { cmd: string; baseArgs: string[] } | null = null; + let runner: { cmd: string; baseArgs: string[]; isolatedNpmCache?: boolean } | null = null; // Resolve mise-first so a `mise use -g` license-checker is found even when mise // isn't activated; otherwise fall back to npx (below). const licenseCheckerBin = (await resolveToolBin("license-checker")) ?? "license-checker"; @@ -2254,9 +2254,11 @@ async function checkLicenses(root: string): Promise { if (direct.ok) { runner = { cmd: licenseCheckerBin, baseArgs: [] }; } else { - const npxAvailable = await execFileNoThrow("npx", ["--version"], { timeout: 5_000 }); + const npxAvailable = await withIsolatedNpmCache((env) => + execFileNoThrow("npx", ["--version"], { timeout: 5_000, env }), + ); if (npxAvailable.ok) { - runner = { cmd: "npx", baseArgs: ["--yes", "license-checker"] }; + runner = { cmd: "npx", baseArgs: ["--yes", "license-checker"], isolatedNpmCache: true }; } } @@ -2275,17 +2277,22 @@ async function checkLicenses(root: string): Promise { } const PROBLEMATIC = ["GPL", "AGPL", "LGPL", "CPAL", "OSL", "EUPL"]; - const result = await execFileNoThrow(runner.cmd, [...runner.baseArgs, "--json", "--production"], { - timeout: 120_000, - cwd: root, - }); + const runLicenseChecker = (env?: NodeJS.ProcessEnv) => + execFileNoThrow(runner.cmd, [...runner.baseArgs, "--json", "--production"], { + timeout: 120_000, + cwd: root, + env, + }); + const result = runner.isolatedNpmCache + ? await withIsolatedNpmCache(runLicenseChecker) + : await runLicenseChecker(); if (!result.ok && !result.stdout) { return { category: "supply-chain", name: "license check", status: "warn", - detail: "license check failed", + detail: licenseFailureDetail(result), severity: "low", }; } @@ -2321,12 +2328,26 @@ async function checkLicenses(root: string): Promise { category: "supply-chain", name: "license check", status: "warn", - detail: "license check failed", + detail: licenseFailureDetail(result), severity: "low", }; } } +async function withIsolatedNpmCache(fn: (env: NodeJS.ProcessEnv) => Promise): Promise { + const cacheDir = await mkdtemp(join(tmpdir(), "kit-npm-cache-")); + try { + return await fn({ ...process.env, NPM_CONFIG_CACHE: cacheDir, npm_config_cache: cacheDir }); + } finally { + await rm(cacheDir, { recursive: true, force: true }); + } +} + +function licenseFailureDetail(result: { stderr: string; stdout: string }): string { + const firstLine = (result.stderr || result.stdout).trim().split("\n")[0]?.trim(); + return firstLine ? `license check failed: ${firstLine.slice(0, 200)}` : "license check failed"; +} + /** * Run static analysis using Semgrep to catch security anti-patterns in source code. */