diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index d9ad4f4344..9761749e04 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -44,10 +44,18 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 - name: Run basic checks uses: ./.github/actions/basic-checks + - name: Reject banned files in PR diff + run: | + BASE_SHA="${{ github.event.pull_request.base.sha }}" + git fetch --no-tags origin "${BASE_SHA}" + node scripts/check-banned-files.mjs "${BASE_SHA}" HEAD + - name: Verify platform matrix is in sync run: python3 scripts/generate-platform-docs.py --check diff --git a/scripts/check-banned-files.mjs b/scripts/check-banned-files.mjs new file mode 100644 index 0000000000..550da89546 --- /dev/null +++ b/scripts/check-banned-files.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const BLOCK_RULES = [ + { + id: "env-root", + reason: "environment files may contain API keys or other secrets", + matches: (filePath) => { + const base = path.posix.basename(filePath); + return base === ".env" || (base.startsWith(".env.") && base.length > 5); + }, + }, + { + id: "direnv-file", + reason: "direnv files may contain secrets or machine-local configuration", + matches: (filePath) => path.posix.basename(filePath) === ".envrc", + }, + { + id: "direnv-directory", + reason: "direnv state directories are machine-local and may contain secrets", + matches: (filePath) => /(^|\/)\.direnv(\/|$)/.test(filePath), + }, + { + id: "private-keys", + reason: "private key or certificate bundles must not be committed", + matches: (filePath) => /\.(pem|key|p12|pfx)$/i.test(filePath), + }, + { + id: "ssh-private-keys", + reason: "SSH private keys must not be committed", + matches: (filePath) => { + const base = path.posix.basename(filePath).toLowerCase(); + return /(?:^|_)(rsa|ed25519|ecdsa)$/.test(base); + }, + }, + { + id: "java-keystores", + reason: "Java keystore files may contain private keys or secrets", + matches: (filePath) => /\.(jks|keystore)$/i.test(path.posix.basename(filePath)), + }, + { + id: "cloud-credentials", + reason: "credential JSON files must not be committed", + matches: (filePath) => { + const base = path.posix.basename(filePath); + return base === "credentials.json" || /^service-account.*\.json$/i.test(base); + }, + }, + { + id: "auth-dotfiles", + reason: "auth dotfiles may contain registry or machine credentials", + matches: (filePath) => { + const base = path.posix.basename(filePath).toLowerCase(); + return base === ".netrc" || base === ".npmrc" || base === ".pypirc"; + }, + }, + { + id: "terraform-vars", + reason: "Terraform variable files often contain credentials or environment secrets", + matches: (filePath) => /\.tfvars$/i.test(path.posix.basename(filePath)), + }, + { + id: "secret-manifests", + reason: "secret manifest files must not be committed", + matches: (filePath) => { + const base = path.posix.basename(filePath).toLowerCase(); + return ( + base === "key.json" || + base === "token.json" || + base === "secrets.json" || + base === "secrets.yaml" + ); + }, + }, + { + id: "macos-metadata", + reason: "macOS Finder metadata should never be tracked", + matches: (filePath) => path.posix.basename(filePath) === ".DS_Store", + }, + { + id: "windows-metadata", + reason: "Windows Explorer metadata should never be tracked", + matches: (filePath) => { + const base = path.posix.basename(filePath).toLowerCase(); + return base === "thumbs.db" || base === "desktop.ini"; + }, + }, + { + id: "python-bytecode", + reason: "Python bytecode and cache directories are generated artifacts", + matches: (filePath) => + /(^|\/)__pycache__(\/|$)/.test(filePath) || /\.pyc$/i.test(path.posix.basename(filePath)), + }, + { + id: "node-modules", + reason: "node_modules content is generated locally and must not be committed", + matches: (filePath) => /(^|\/)node_modules(\/|$)/.test(filePath), + }, +]; + +const FIXTURE_ALLOWLIST = [/^testdata\//, /(^|\/)testdata\//, /^test\/fixtures\//, /(^|\/)test\/fixtures\//]; + +function normalizeFilePath(filePath) { + return String(filePath).replace(/\\/g, "/").replace(/^\.\//, ""); +} + +function isFixturePath(filePath) { + return FIXTURE_ALLOWLIST.some((pattern) => pattern.test(filePath)); +} + +function findBlockedFiles(filePaths) { + const findings = []; + for (const rawPath of filePaths) { + const filePath = normalizeFilePath(rawPath); + if (!filePath || isFixturePath(filePath)) { + continue; + } + const rule = BLOCK_RULES.find((candidate) => candidate.matches(filePath)); + if (rule) { + findings.push({ filePath, ruleId: rule.id, reason: rule.reason }); + } + } + return findings; +} + +function getChangedFiles(baseRef, headRef) { + const output = execFileSync( + "git", + ["diff", "--name-only", "--diff-filter=ACMR", `${baseRef}...${headRef}`], + { encoding: "utf-8" }, + ); + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +function main(argv = process.argv.slice(2)) { + if (argv.length !== 2) { + console.error("Usage: node scripts/check-banned-files.mjs "); + return 2; + } + + const [baseRef, headRef] = argv; + let changedFiles; + try { + changedFiles = getChangedFiles(baseRef, headRef); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Failed to list changed files: ${message}`); + return 2; + } + + const findings = findBlockedFiles(changedFiles); + if (findings.length === 0) { + console.log("No banned files found in changed paths."); + return 0; + } + + console.error("Blocked files detected in this PR:"); + for (const finding of findings) { + console.error(`- ${finding.filePath} (${finding.reason})`); + } + console.error(""); + console.error("Please remove these files from the PR or move legitimate fixtures under testdata/ or test/fixtures/."); + return 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exit(main()); +} + +export { BLOCK_RULES, findBlockedFiles, getChangedFiles, isFixturePath, main, normalizeFilePath }; diff --git a/test/banned-files-guard.test.ts b/test/banned-files-guard.test.ts new file mode 100644 index 0000000000..685fa18292 --- /dev/null +++ b/test/banned-files-guard.test.ts @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it } from "vitest"; +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const SCRIPT = path.join(REPO_ROOT, "scripts", "check-banned-files.mjs"); +const TEMP_REPOS: string[] = []; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + env: { + ...process.env, + GIT_AUTHOR_NAME: "Test User", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test User", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + }).trim(); +} + +function makeRepo() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-banned-files-")); + TEMP_REPOS.push(dir); + git(dir, ["init", "-b", "main"]); + fs.writeFileSync(path.join(dir, "README.md"), "# temp\n"); + git(dir, ["add", "README.md"]); + git(dir, ["commit", "-m", "init"]); + const base = git(dir, ["rev-parse", "HEAD"]); + return { dir, base }; +} + +function runGuard(cwd: string, baseRef: string, headRef = "HEAD") { + return spawnSync(process.execPath, [SCRIPT, baseRef, headRef], { + cwd, + encoding: "utf-8", + }); +} + +afterEach(() => { + for (const dir of TEMP_REPOS.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("banned-files guard", () => { + it("fails when a banned secret-like file is added", () => { + const { dir, base } = makeRepo(); + fs.writeFileSync(path.join(dir, ".env.production"), "API_KEY=secret\n"); + git(dir, ["add", ".env.production"]); + git(dir, ["commit", "-m", "add env"]); + + const result = runGuard(dir, base); + + expect(result.status).toBe(1); + expect(`${result.stdout}${result.stderr}`).toContain(".env.production"); + expect(`${result.stdout}${result.stderr}`).toContain("API keys"); + }); + + it("blocks additional secret-like patterns from the repo policy", () => { + const cases = [ + ".netrc", + ".npmrc", + ".pypirc", + ".direnv/credentials", + "terraform/dev.tfvars", + "keys/id_rsa", + "keys/deploy_ed25519", + "keys/build_ecdsa", + "certs/release.keystore", + "certs/debug.jks", + "key.json", + "token.json", + "secrets.yaml", + "secrets.json", + ]; + + for (const filePath of cases) { + const { dir, base } = makeRepo(); + const fullPath = path.join(dir, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, "secret\n"); + git(dir, ["add", filePath]); + git(dir, ["commit", "-m", `add ${filePath}`]); + + const result = runGuard(dir, base); + expect(result.status, filePath).toBe(1); + expect(`${result.stdout}${result.stderr}`, filePath).toContain(filePath); + } + }); + + it("allows fixture files under test/fixtures", () => { + const { dir, base } = makeRepo(); + fs.mkdirSync(path.join(dir, "test", "fixtures"), { recursive: true }); + fs.writeFileSync(path.join(dir, "test", "fixtures", "service-account-demo.json"), "{}\n"); + fs.mkdirSync(path.join(dir, "testdata", "keys"), { recursive: true }); + fs.writeFileSync(path.join(dir, "testdata", "keys", "id_rsa"), "fixture\n"); + git(dir, ["add", "test/fixtures/service-account-demo.json", "testdata/keys/id_rsa"]); + git(dir, ["commit", "-m", "add fixture"]); + + const result = runGuard(dir, base); + + expect(result.status).toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain("No banned files found"); + }); + + it("passes when only normal source files change", () => { + const { dir, base } = makeRepo(); + fs.mkdirSync(path.join(dir, "src"), { recursive: true }); + fs.writeFileSync(path.join(dir, "src", "index.ts"), "export const ok = true;\n"); + git(dir, ["add", "src/index.ts"]); + git(dir, ["commit", "-m", "add source"]); + + const result = runGuard(dir, base); + + expect(result.status).toBe(0); + }); +});