Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .kit-secretsignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
54 changes: 54 additions & 0 deletions src/check-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
checkMemoryInjection,
checkGateLiveness,
checkDeviceIdOverride,
checkLicenses,
unpinnedNodeDeps,
auditAllowScripts,
checkAllowScripts,
Expand Down Expand Up @@ -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({
Expand Down
47 changes: 34 additions & 13 deletions src/check-security.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -2231,7 +2231,7 @@ async function checkOsvScanner(root: string): Promise<SecurityCheckResult> {
/**
* Check dependency licenses for GPL/AGPL that create legal obligations.
*/
async function checkLicenses(root: string): Promise<SecurityCheckResult> {
export async function checkLicenses(root: string): Promise<SecurityCheckResult> {
try {
await access(resolve(root, "package.json"));
} catch {
Expand All @@ -2246,17 +2246,19 @@ async function checkLicenses(root: string): Promise<SecurityCheckResult> {
// 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";
const direct = await execFileNoThrow(licenseCheckerBin, ["--version"], { timeout: 5_000 });
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 };
}
}

Expand All @@ -2275,17 +2277,22 @@ async function checkLicenses(root: string): Promise<SecurityCheckResult> {
}

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",
};
}
Expand Down Expand Up @@ -2321,12 +2328,26 @@ async function checkLicenses(root: string): Promise<SecurityCheckResult> {
category: "supply-chain",
name: "license check",
status: "warn",
detail: "license check failed",
detail: licenseFailureDetail(result),
severity: "low",
};
}
}

async function withIsolatedNpmCache<T>(fn: (env: NodeJS.ProcessEnv) => Promise<T>): Promise<T> {
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.
*/
Expand Down
Loading