diff --git a/.gitignore b/.gitignore index 2309cc8..cfe2094 100644 --- a/.gitignore +++ b/.gitignore @@ -1,138 +1,3 @@ -# ---> Node -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' +dependency-guard-report.json *.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# vitepress build output -**/.vitepress/dist - -# vitepress cache directory -**/.vitepress/cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* - diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7ab0bf5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# Agent Guide + +This repository is a security-sensitive npm supply-chain gate. Keep changes small, +reviewable, dependency-free, and fail-closed. + +## Requirements + +- Support Node.js 20 and newer using ESM and built-in APIs only. +- Do not add runtime or development npm dependencies. +- Use `node:test`; tests must not access live npm or GitHub services. +- Inject or mock HTTP and child-process boundaries in tests. +- Never execute dependency lifecycle scripts. Preserve `--ignore-scripts` on both + `npm ci` and `npm pack`. +- Use argument-array child processes with `shell: false` and bounded timeouts. +- Never log or report tokens, headers, environment values, registry response bodies, + attestation bundles, or other credentials. +- Preserve exact repository, commit, tag, signature, integrity, and maintainer checks. +- Do not silently trust packages that lack a normalized GitHub repository. +- Do not commit generated evidence reports, package archives, fixtures containing + real project data, credentials, or tokens. + +## Project map + +- `bin/dependency-guard.js`: executable entry point. +- `lib/cli.js`: commands and argument validation. +- `lib/project.js`: manifest/lock parsing and pre-install checks. +- `lib/policy.js`: policy defaults and validation. +- `lib/verify.js`: npm metadata, provenance, GitHub commit, and tag checks. +- `lib/check.js`: check orchestration and evidence report generation. +- `lib/init.js`: policy generation from exact locked direct versions. +- `lib/util.js`: URL normalization, bounded HTTP, process execution, and redaction. +- `test/`: offline unit and orchestration tests. +- `action.yml`: reusable Gitea composite action. + +## Workflow + +1. Inspect `git status --short` and preserve unrelated worktree changes. +2. Read the implementation and nearest tests before editing. +3. Add denial-path tests for malformed or adversarial inputs. +4. Run `npm test`, then `npm run check`, then `git diff --check`. +5. Review every spawned npm argument list and every report field before finishing. + +Do not commit or push unless explicitly requested. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1d817a5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tony + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 0f38dbf..c079329 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,171 @@ # dependency-guard -Supply-chain verification for npm dependency updates \ No newline at end of file +`dependency-guard` is a zero-dependency Node.js 20+ CLI for reviewing npm dependency +updates before installation. It checks the lockfile, npm registry metadata, upstream +GitHub history, package contents, and npm's registry signatures, then writes a JSON +evidence report. + +It never intentionally runs dependency lifecycle scripts. Installation uses +`npm ci --ignore-scripts`, and package inspection uses +`npm pack --dry-run --json --ignore-scripts`. + +## Threat model + +The guard is intended to catch: + +- lock entries redirected away from the npm registry or missing cryptographic integrity; +- newly introduced transitive install scripts; +- unexpected direct-package lifecycle hooks; +- unsigned npm registry metadata; +- repository substitution and unexpected npm maintainers; +- provenance that names a different repository or a commit without a matching + upstream version tag; +- packages whose `gitHead` cannot be tied to an upstream version tag; +- unusually fresh releases and unexpectedly large package archives; +- failures reported by `npm audit signatures` or `npm audit --omit=dev`. + +The lockfile and registry metadata are parsed before `npm ci` runs. Any pre-install +failure still produces the requested report. + +## Limitations + +- The CLI cannot prove that an official upstream GitHub account, npm maintainer, + release workflow, signing identity, or source repository itself is uncompromised. +- Presence of an npm registry signature is checked from metadata. Cryptographic + signature and Sigstore attestation verification is delegated to + `npm audit signatures` after the script-disabled install. +- Provenance validation checks the decoded SLSA statement's repository and requires + its source commit to match an upstream version tag. It does not implement Sigstore + cryptography independently. +- Upstream tag verification runs `git ls-remote` against public GitHub repositories. + Private upstream repositories require Git credentials configured outside the tool. +- Only GitHub upstream repositories are currently verifiable. `init` warns and does + not silently trust packages hosted elsewhere. +- `npm audit --omit=dev` follows npm's advisory database and may miss unknown issues. + +This tool is one review gate, not a replacement for source review, least-privilege +CI credentials, lockfile review, or artifact isolation. + +## Usage + +No install is required from a checkout: + +```sh +node bin/dependency-guard.js init \ + --path ../my-project \ + --output ../my-project/dependency-guard.policy.json + +node bin/dependency-guard.js check \ + --path ../my-project \ + --policy ../my-project/dependency-guard.policy.json \ + --report ../my-project/dependency-guard-report.json +``` + +When installed as a CLI, use `dependency-guard` in place of +`node bin/dependency-guard.js`. + +`--policy` is optional and uses the defaults below. `--report` is required. Use +`--skip-install` only when package installation and both npm audits are performed by +a separate, equivalent CI step. Skipping installation reduces assurance. + +`init` refuses to overwrite an existing output file. Review every generated trust +entry before committing the policy; generation is a starting point, not approval. + +## Gitea action + +The repository includes a composite `action.yml`. Pin actions to the reviewed full +commit SHA, never a branch or mutable tag: + +```yaml +steps: + - uses: actions/checkout@ + - uses: https://git.nocker.cloud/tony/dependency-guard@ + with: + path: . + policy: dependency-guard.policy.json + report: dependency-guard-report.json +``` + +The action invokes `node "$GITHUB_ACTION_PATH/bin/dependency-guard.js"` directly, so +it does not install the action's own dependencies. Configure a Node.js 20+ runner. +For private upstream repositories, configure minimally scoped Git credentials on the +runner without placing them in the policy or report. + +## Policy + +```json +{ + "allowInstallScripts": ["fsevents"], + "allowDirectLifecycleScripts": [], + "sourceEvidenceExceptions": {}, + "trustedRepositories": { + "example-package": "https://github.com/example/example-package" + }, + "trustedMaintainers": { + "example-package": ["expected-npm-user"] + }, + "minimumReleaseAgeHours": 72, + "freshReleaseAction": "warn", + "maxTarballSizeBytes": 20971520, + "maxUnpackedSizeBytes": 104857600 +} +``` + +- `allowInstallScripts` permits `hasInstallScript` on any matching lock entry. The + default contains only `fsevents`. +- `allowDirectLifecycleScripts` separately permits direct packages whose registry + manifest declares `preinstall`, `install`, or `postinstall`. +- `sourceEvidenceExceptions` permits only an exact package version to proceed when + source provenance or tag evidence is unavailable. Each entry requires a detailed + `reason` and an `expiresAt` ISO date, and produces a warning. +- `trustedRepositories` pins normalized GitHub repositories by package name. +- `trustedMaintainers` pins the complete npm maintainer set expected in exact-version + metadata. Additions and removals both fail the check. +- `minimumReleaseAgeHours` defines the freshness threshold. +- `freshReleaseAction` is `warn` by default and may be changed to `fail`. +- Archive limits apply to the values emitted by `npm pack --dry-run --json`. + +Unknown policy fields and malformed values fail closed. + +## Exceptions + +Treat allowlists as reviewed security exceptions: + +1. Confirm the exact package and locked version. +2. Inspect the lifecycle script and every executable or downloaded artifact it uses. +3. Confirm the repository, npm maintainers, release tag, and commit independently. +4. Prefer a package-specific exception over broad threshold changes. +5. Record the rationale and reviewer beside the policy change in the pull request. +6. Remove the exception when the package or dependency is removed. + +Do not add a package to both lifecycle allowlists automatically. A transitive +`hasInstallScript` exception and a direct manifest-hook exception represent separate +trust decisions. Scripts remain disabled during the guard's own `npm ci`; an +allowlist permits review to pass but does not execute the script. + +## Evidence report + +The report contains: + +- report version, timestamp, project path, status, and counts; +- lockfile version and number of registry package entries; +- exact direct package versions, repository, provenance path, release age, + maintainer names, archive sizes, failures, and warnings; +- each npm command, exit code, timeout state, and redacted/truncated output; +- aggregate actionable failures and warnings. + +Reports are created with owner-only permissions where supported. They intentionally +exclude environment variables, authentication headers, registry response bodies, +attestation bundles, and GitHub response bodies. Command output is redacted for +common credential labels and URL user information, but reports should still be +handled as CI evidence rather than published indiscriminately. + +## Development + +```sh +npm test +npm run check +``` + +Tests use `node:test`, temporary fixtures, and injected HTTP/process implementations. +They do not access the live network. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e54280a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,42 @@ +# Security Policy + +## Reporting a vulnerability + +Do not open a public issue for a suspected vulnerability. Send a private report to +the repository owner through the private security channel configured for the Gitea +organization. Include: + +- the affected commit and Node.js/npm versions; +- a minimal reproduction using non-secret fixture data; +- the security boundary that was bypassed; +- whether dependency lifecycle code was executed; +- suggested remediation, if known. + +Do not include npm tokens, GitHub tokens, private repository contents, generated +evidence reports, or production lockfiles unless the owner explicitly requests them +through an approved private channel. + +## Supported versions + +Only the current default branch is supported until stable releases are published. +Node.js 20 and newer are required. + +## Security invariants + +- Parse and reject unsafe lockfile state before installation. +- Never execute dependency lifecycle scripts. +- Never invoke npm without an explicit argument array and bounded timeout. +- Keep registry signature presence checks separate from the cryptographic validation + delegated to `npm audit signatures`. +- Fail closed on malformed policy, metadata, provenance, Git refs, and command output. +- Do not write credentials, authorization headers, environment contents, metadata + response bodies, or attestation bundles to reports. +- Do not weaken repository, commit, tag, signature, integrity, or lifecycle checks to + accommodate a package. Require an explicit, reviewed policy exception where one is + supported. + +## Disclosure + +Please allow the maintainers reasonable time to reproduce and fix accepted reports +before public disclosure. The owner will coordinate a release and advisory when +appropriate. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..36e7826 --- /dev/null +++ b/action.yml @@ -0,0 +1,37 @@ +name: Dependency Guard +description: Verify npm dependencies before allowing lifecycle scripts +inputs: + path: + description: Path to the npm project + required: false + default: . + policy: + description: Path to the dependency-guard policy JSON + required: false + default: "" + report: + description: Path for the JSON evidence report + required: false + default: dependency-guard-report.json + skip-install: + description: Skip npm ci and npm audit commands + required: false + default: "false" +runs: + using: composite + steps: + - name: Check Node.js version + shell: bash + run: node -e 'const major=Number(process.versions.node.split(".")[0]); if(major<20) throw new Error("dependency-guard requires Node.js 20 or newer")' + - name: Verify dependencies + shell: bash + env: + INPUT_PATH: ${{ inputs.path }} + INPUT_POLICY: ${{ inputs.policy }} + INPUT_REPORT: ${{ inputs.report }} + INPUT_SKIP_INSTALL: ${{ inputs.skip-install }} + run: | + args=(check --path "$INPUT_PATH" --report "$INPUT_REPORT") + if [[ -n "$INPUT_POLICY" ]]; then args+=(--policy "$INPUT_POLICY"); fi + if [[ "$INPUT_SKIP_INSTALL" == "true" ]]; then args+=(--skip-install); fi + node "$GITHUB_ACTION_PATH/bin/dependency-guard.js" "${args[@]}" diff --git a/bin/dependency-guard.js b/bin/dependency-guard.js new file mode 100755 index 0000000..cbbd1e8 --- /dev/null +++ b/bin/dependency-guard.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import { main } from "../lib/cli.js"; + +main().catch((error) => { + console.error(`dependency-guard: ${error.message}`); + process.exitCode = 2; +}); diff --git a/lib/check.js b/lib/check.js new file mode 100644 index 0000000..623a3ef --- /dev/null +++ b/lib/check.js @@ -0,0 +1,106 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { COMMAND_TIMEOUT_MS, HTTP_TIMEOUT_MS, REPORT_VERSION } from "./constants.js"; +import { inspectLock, readProject } from "./project.js"; +import { fetchPackageDocument, verifyDirectPackage } from "./verify.js"; +import { redact, runCommand } from "./util.js"; + +export async function checkProject(options, injected = {}) { + const projectPath = resolve(options.path); + const run = injected.run ?? runCommand; + const dependencies = { + fetchImpl: injected.fetch ?? globalThis.fetch, + remoteRefs: injected.remoteRefs, + run, + now: injected.now ?? Date.now, + timeoutMs: options.httpTimeoutMs ?? HTTP_TIMEOUT_MS, + }; + const report = { + reportVersion: REPORT_VERSION, + generatedAt: new Date(dependencies.now()).toISOString(), + projectPath, + status: "fail", + summary: {}, + lockfile: {}, + packages: [], + commands: [], + failures: [], + warnings: [], + }; + + try { + const project = await readProject(projectPath); + const lockInspection = inspectLock(project.lock, options.policy); + report.lockfile = { lockfileVersion: project.lock.lockfileVersion, registryEntries: lockInspection.registryEntries }; + report.failures.push(...lockInspection.failures.map((message) => `lockfile: ${message}`)); + + for (const item of project.direct) { + try { + const document = await fetchPackageDocument(item.name, dependencies); + const packageResult = await verifyDirectPackage(item, document, options.policy, dependencies); + const pack = await execute(run, "npm", ["pack", `${item.name}@${item.version}`, "--dry-run", "--json", "--ignore-scripts"], projectPath, options.commandTimeoutMs, 25_000_000); + report.commands.push(pack.evidence); + if (pack.result.code !== 0) { + packageResult.failures.push(`npm pack failed${pack.result.timedOut ? " (timed out)" : ""}`); + } else { + try { + const packed = JSON.parse(pack.result.stdout)[0]; + packageResult.tarballSizeBytes = packed.size; + packageResult.unpackedSizeBytes = packed.unpackedSize; + if (!Number.isFinite(packed.size) || packed.size > options.policy.maxTarballSizeBytes) packageResult.failures.push(`tarball size ${packed.size ?? "unknown"} exceeds ${options.policy.maxTarballSizeBytes}`); + if (!Number.isFinite(packed.unpackedSize) || packed.unpackedSize > options.policy.maxUnpackedSizeBytes) packageResult.failures.push(`unpacked size ${packed.unpackedSize ?? "unknown"} exceeds ${options.policy.maxUnpackedSizeBytes}`); + } catch { + packageResult.failures.push("npm pack returned invalid JSON evidence"); + } + } + report.packages.push(packageResult); + } catch (error) { + report.packages.push({ name: item.name, version: item.version, dev: item.dev, failures: [error.message], warnings: [] }); + } + } + + if (!options.skipInstall) { + for (const args of [ + ["ci", "--ignore-scripts"], + ["audit", "signatures"], + ["audit", "--omit=dev"], + ]) { + const command = await execute(run, "npm", args, projectPath, options.commandTimeoutMs); + report.commands.push(command.evidence); + if (command.result.code !== 0) report.failures.push(`npm ${args.join(" ")} failed${command.result.timedOut ? " (timed out)" : ""}`); + if (command.result.code !== 0 && args[0] === "ci") break; + } + } + } catch (error) { + report.failures.push(error.message); + } + + for (const item of report.packages) { + report.failures.push(...item.failures.map((message) => `${item.name}@${item.version}: ${message}`)); + report.warnings.push(...item.warnings.map((message) => `${item.name}@${item.version}: ${message}`)); + } + report.status = report.failures.length ? "fail" : "pass"; + report.summary = { + directPackages: report.packages.length, + failures: report.failures.length, + warnings: report.warnings.length, + commands: report.commands.length, + }; + await mkdir(dirname(resolve(options.report)), { recursive: true }); + await writeFile(resolve(options.report), `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 }); + return report; +} + +async function execute(run, command, args, cwd, timeoutMs = COMMAND_TIMEOUT_MS, maxOutputBytes = 20_000) { + const result = await run(command, args, { cwd, timeoutMs, maxOutputBytes }); + return { + result, + evidence: { + command: [command, ...args].join(" "), + exitCode: result.code, + timedOut: Boolean(result.timedOut), + stdout: redact(result.stdout ?? ""), + stderr: redact(result.stderr ?? ""), + }, + }; +} diff --git a/lib/cli.js b/lib/cli.js new file mode 100644 index 0000000..610e2e9 --- /dev/null +++ b/lib/cli.js @@ -0,0 +1,56 @@ +import { resolve } from "node:path"; +import { checkProject } from "./check.js"; +import { initPolicy } from "./init.js"; +import { loadPolicy } from "./policy.js"; + +export async function main(argv = process.argv.slice(2)) { + if (argv.includes("--help") || argv.includes("-h")) { + printHelp(); + return; + } + const command = argv.shift(); + if (!["check", "init"].includes(command)) throw new Error("expected command check or init; use --help for usage"); + const args = parseArgs(argv, command === "check" ? new Set(["path", "policy", "report", "skip-install"]) : new Set(["path", "output"])); + if (!args.path) throw new Error("--path is required"); + if (command === "check") { + if (!args.report) throw new Error("--report is required"); + const policy = await loadPolicy(args.policy ? resolve(args.policy) : null); + const report = await checkProject({ path: args.path, report: args.report, policy, skipInstall: args["skip-install"] === true }); + console.log(`dependency-guard: ${report.status.toUpperCase()} - ${report.summary.directPackages} direct package(s), ${report.summary.failures} failure(s), ${report.summary.warnings} warning(s)`); + console.log(`evidence: ${resolve(args.report)}`); + for (const failure of report.failures.slice(0, 10)) console.error(`FAIL: ${failure}`); + for (const warning of report.warnings.slice(0, 10)) console.warn(`WARN: ${warning}`); + if (report.status !== "pass") process.exitCode = 1; + } else { + if (!args.output) throw new Error("--output is required"); + const result = await initPolicy({ path: args.path, output: args.output }); + console.log(`wrote policy: ${result.output}`); + for (const warning of result.skipped) console.warn(`WARN: ${warning}`); + } +} + +function parseArgs(argv, allowed) { + const result = {}; + while (argv.length) { + const token = argv.shift(); + if (!token.startsWith("--")) throw new Error(`unexpected positional argument: ${token}`); + const key = token.slice(2); + if (!allowed.has(key)) throw new Error(`unknown option: ${token}`); + if (key === "skip-install") { + if (result[key]) throw new Error(`${token} specified more than once`); + result[key] = true; + continue; + } + const value = argv.shift(); + if (!value || value.startsWith("--")) throw new Error(`${token} requires a value`); + if (result[key]) throw new Error(`${token} specified more than once`); + result[key] = value; + } + return result; +} + +function printHelp() { + console.log(`Usage: + dependency-guard check --path [--policy ] --report [--skip-install] + dependency-guard init --path --output `); +} diff --git a/lib/constants.js b/lib/constants.js new file mode 100644 index 0000000..7615c91 --- /dev/null +++ b/lib/constants.js @@ -0,0 +1,15 @@ +export const DEFAULT_POLICY = Object.freeze({ + allowInstallScripts: ["fsevents"], + allowDirectLifecycleScripts: [], + sourceEvidenceExceptions: {}, + trustedRepositories: {}, + trustedMaintainers: {}, + minimumReleaseAgeHours: 72, + freshReleaseAction: "warn", + maxTarballSizeBytes: 20 * 1024 * 1024, + maxUnpackedSizeBytes: 100 * 1024 * 1024, +}); + +export const HTTP_TIMEOUT_MS = 15_000; +export const COMMAND_TIMEOUT_MS = 120_000; +export const REPORT_VERSION = 1; diff --git a/lib/init.js b/lib/init.js new file mode 100644 index 0000000..ce4ca99 --- /dev/null +++ b/lib/init.js @@ -0,0 +1,30 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { DEFAULT_POLICY, HTTP_TIMEOUT_MS } from "./constants.js"; +import { readProject } from "./project.js"; +import { fetchPackageDocument } from "./verify.js"; +import { normalizeRepository } from "./util.js"; + +export async function initPolicy(options, injected = {}) { + const project = await readProject(resolve(options.path)); + const policy = structuredClone(DEFAULT_POLICY); + const skipped = []; + const dependencies = { fetchImpl: injected.fetch ?? globalThis.fetch, timeoutMs: options.httpTimeoutMs ?? HTTP_TIMEOUT_MS }; + for (const item of project.direct) { + const document = await fetchPackageDocument(item.name, dependencies); + const versionData = document.versions?.[item.version]; + if (!versionData) throw new Error(`${item.name}@${item.version} is absent from npm registry metadata`); + const repository = normalizeRepository(versionData.repository ?? document.repository); + if (!repository) { + skipped.push(`${item.name}@${item.version}: no GitHub repository; not trusted`); + continue; + } + policy.trustedRepositories[item.name] = repository; + const maintainers = (versionData.maintainers ?? document.maintainers ?? []).map((entry) => entry.name).filter(Boolean).sort(); + if (maintainers.length) policy.trustedMaintainers[item.name] = [...new Set(maintainers)]; + } + const output = resolve(options.output); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, `${JSON.stringify(policy, null, 2)}\n`, { flag: "wx", mode: 0o600 }); + return { policy, skipped, output }; +} diff --git a/lib/policy.js b/lib/policy.js new file mode 100644 index 0000000..e1dc670 --- /dev/null +++ b/lib/policy.js @@ -0,0 +1,77 @@ +import { readFile } from "node:fs/promises"; +import { DEFAULT_POLICY } from "./constants.js"; +import { normalizeRepository } from "./util.js"; + +const KEYS = new Set(Object.keys(DEFAULT_POLICY)); + +export async function loadPolicy(file) { + if (!file) return structuredClone(DEFAULT_POLICY); + let parsed; + try { + parsed = JSON.parse(await readFile(file, "utf8")); + } catch (error) { + throw new Error(`cannot read policy ${file}: ${error.message}`); + } + if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") { + throw new Error("policy must be a JSON object"); + } + for (const key of Object.keys(parsed)) { + if (!KEYS.has(key)) throw new Error(`unknown policy key: ${key}`); + } + const policy = { ...structuredClone(DEFAULT_POLICY), ...parsed }; + validateStringArray(policy, "allowInstallScripts"); + validateStringArray(policy, "allowDirectLifecycleScripts"); + validateSourceExceptions(policy.sourceEvidenceExceptions); + validateMap(policy, "trustedRepositories", false); + validateMap(policy, "trustedMaintainers", true); + if (!["fail", "warn"].includes(policy.freshReleaseAction)) { + throw new Error("freshReleaseAction must be fail or warn"); + } + for (const key of ["minimumReleaseAgeHours", "maxTarballSizeBytes", "maxUnpackedSizeBytes"]) { + if (!Number.isFinite(policy[key]) || policy[key] < 0) throw new Error(`${key} must be a non-negative number`); + } + for (const [name, repository] of Object.entries(policy.trustedRepositories)) { + if (!normalizeRepository(repository)) throw new Error(`trustedRepositories.${name} must be a GitHub repository URL`); + } + return policy; +} + +function validateSourceExceptions(value) { + if (!value || Array.isArray(value) || typeof value !== "object") { + throw new Error("sourceEvidenceExceptions must be an object"); + } + for (const [name, exception] of Object.entries(value)) { + if (!name || !exception || Array.isArray(exception) || typeof exception !== "object") { + throw new Error(`invalid sourceEvidenceExceptions entry for ${name || ""}`); + } + const keys = Object.keys(exception); + if (keys.some((key) => !["version", "reason", "expiresAt"].includes(key))) { + throw new Error(`unknown sourceEvidenceExceptions field for ${name}`); + } + if (typeof exception.version !== "string" || !exception.version) { + throw new Error(`sourceEvidenceExceptions.${name}.version must be a non-empty string`); + } + if (typeof exception.reason !== "string" || exception.reason.trim().length < 20) { + throw new Error(`sourceEvidenceExceptions.${name}.reason must contain at least 20 characters`); + } + if (typeof exception.expiresAt !== "string" || !Number.isFinite(Date.parse(exception.expiresAt))) { + throw new Error(`sourceEvidenceExceptions.${name}.expiresAt must be an ISO date`); + } + } +} + +function validateStringArray(policy, key) { + if (!Array.isArray(policy[key]) || policy[key].some((value) => typeof value !== "string" || !value)) { + throw new Error(`${key} must be an array of non-empty strings`); + } +} + +function validateMap(policy, key, arrays) { + const value = policy[key]; + if (!value || Array.isArray(value) || typeof value !== "object") throw new Error(`${key} must be an object`); + for (const [name, entry] of Object.entries(value)) { + if (!name || (arrays ? !Array.isArray(entry) || entry.some((item) => typeof item !== "string") : typeof entry !== "string")) { + throw new Error(`invalid ${key} entry for ${name || ""}`); + } + } +} diff --git a/lib/project.js b/lib/project.js new file mode 100644 index 0000000..8bf65aa --- /dev/null +++ b/lib/project.js @@ -0,0 +1,55 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { packageNameFromLockPath } from "./util.js"; + +export async function readProject(projectPath) { + const manifest = await readJson(join(projectPath, "package.json"), "package.json"); + const lock = await readJson(join(projectPath, "package-lock.json"), "package-lock.json"); + if (![2, 3].includes(lock.lockfileVersion)) throw new Error("package-lock.json must use lockfileVersion 2 or 3"); + if (!lock.packages || typeof lock.packages !== "object") throw new Error("package-lock.json has no packages map"); + + const requested = { ...(manifest.dependencies ?? {}), ...(manifest.devDependencies ?? {}) }; + const direct = []; + for (const name of Object.keys(requested).sort()) { + const entry = lock.packages[`node_modules/${name}`]; + if (!entry || typeof entry.version !== "string" || entry.link) { + throw new Error(`cannot resolve exact direct version for ${name} from package-lock.json`); + } + direct.push({ name, version: entry.version, dev: name in (manifest.devDependencies ?? {}) }); + } + return { manifest, lock, direct }; +} + +export function inspectLock(lock, policy) { + const failures = []; + let registryEntries = 0; + for (const [lockPath, entry] of Object.entries(lock.packages)) { + if (!lockPath || entry.link) continue; + const name = packageNameFromLockPath(lockPath, entry) ?? lockPath; + if (entry.hasInstallScript && !policy.allowInstallScripts.includes(name)) { + failures.push(`${name}: lock entry declares hasInstallScript; add a reviewed allowInstallScripts exception if intentional`); + } + if (!lockPath.includes("node_modules/")) continue; + registryEntries += 1; + if (typeof entry.integrity !== "string" || !/^sha(256|384|512)-[A-Za-z0-9+/=]+$/.test(entry.integrity)) { + failures.push(`${name}: lock entry has no valid sha256/sha384/sha512 integrity`); + } + try { + const resolved = new URL(entry.resolved); + if (resolved.protocol !== "https:" || resolved.hostname !== "registry.npmjs.org") { + failures.push(`${name}: lock entry is not resolved from https://registry.npmjs.org`); + } + } catch { + failures.push(`${name}: lock entry has no valid registry resolved URL`); + } + } + return { failures, registryEntries }; +} + +async function readJson(file, label) { + try { + return JSON.parse(await readFile(file, "utf8")); + } catch (error) { + throw new Error(`cannot parse ${label}: ${error.message}`); + } +} diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 0000000..4330b6e --- /dev/null +++ b/lib/util.js @@ -0,0 +1,99 @@ +import { spawn } from "node:child_process"; + +export function normalizeRepository(value) { + if (!value) return null; + let raw = typeof value === "string" ? value : value.url; + if (typeof raw !== "string") return null; + raw = raw.trim() + .replace(/^github:/i, "https://github.com/") + .replace(/^github\.com\//i, "https://github.com/") + .replace(/^git\+/, "") + .replace(/^git:\/\/github\.com\//i, "https://github.com/") + .replace(/^ssh:\/\/git@github\.com\//i, "https://github.com/") + .replace(/^git@github\.com:/i, "https://github.com/"); + try { + const url = new URL(raw); + if (url.hostname.toLowerCase() !== "github.com") return null; + const parts = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/"); + if (parts.length !== 2 || !parts.every(Boolean)) return null; + return `https://github.com/${parts[0].toLowerCase()}/${parts[1].toLowerCase()}`; + } catch { + return null; + } +} + +export function repositorySlug(repository) { + return normalizeRepository(repository)?.slice("https://github.com/".length) ?? null; +} + +export function packageNameFromLockPath(lockPath, entry) { + if (typeof entry.name === "string") return entry.name; + const match = lockPath.match(/(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)$/); + return match?.[1] ?? null; +} + +export function redact(value) { + return String(value) + .replace(/(authorization|_?authToken|token|password|secret|npm_[a-z0-9_]*token)\s*[:=]\s*[^\s,]+/gi, "$1=[REDACTED]") + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]") + .replace(/https:\/\/[^\s/@]+@/gi, "https://[REDACTED]@") + .slice(0, 20_000); +} + +export async function fetchJson(url, options = {}) { + const { fetchImpl = globalThis.fetch, timeoutMs = 15_000, headers = {} } = options; + const response = await fetchImpl(url, { + headers: { accept: "application/json", ...headers }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) throw new Error(`HTTP ${response.status} from ${safeUrl(url)}`); + return response.json(); +} + +export function safeUrl(value) { + const url = new URL(value); + url.username = ""; + url.password = ""; + url.search = ""; + return url.toString(); +} + +export function runCommand(command, args, options = {}) { + const { cwd, timeoutMs = 120_000, maxOutputBytes = 20_000 } = options; + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + shell: false, + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => child.kill("SIGKILL"), 2_000).unref(); + }, timeoutMs); + child.stdout.on("data", (chunk) => { stdout = appendBounded(stdout, chunk, maxOutputBytes); }); + child.stderr.on("data", (chunk) => { stderr = appendBounded(stderr, chunk, maxOutputBytes); }); + child.on("error", (error) => { + clearTimeout(timer); + resolve({ code: null, stdout, stderr: error.message, timedOut }); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ code, stdout, stderr, timedOut }); + }); + }); +} + +function appendBounded(current, chunk, limit) { + if (current.length >= limit) return current; + return current + String(chunk).slice(0, limit - current.length); +} + +export function decodeBase64Json(value) { + if (typeof value !== "string") throw new Error("attestation payload is missing"); + return JSON.parse(Buffer.from(value, "base64").toString("utf8")); +} diff --git a/lib/verify.js b/lib/verify.js new file mode 100644 index 0000000..585eb06 --- /dev/null +++ b/lib/verify.js @@ -0,0 +1,169 @@ +import { decodeBase64Json, fetchJson, normalizeRepository, runCommand } from "./util.js"; + +export async function fetchPackageDocument(name, dependencies) { + return fetchJson(`https://registry.npmjs.org/${encodeURIComponent(name)}`, dependencies); +} + +export async function verifyDirectPackage(item, document, policy, dependencies) { + const failures = []; + const warnings = []; + const versionData = document.versions?.[item.version]; + if (!versionData) return result(item, failures.concat("exact version is absent from npm registry metadata"), warnings); + + const signatures = versionData.dist?.signatures; + if (!Array.isArray(signatures) || signatures.length === 0 || signatures.some((entry) => !entry.keyid || !entry.sig)) { + failures.push("npm registry metadata has no valid registry signature"); + } + + const actualRepository = normalizeRepository(versionData.repository ?? document.repository); + const configuredRepository = policy.trustedRepositories[item.name] + ? normalizeRepository(policy.trustedRepositories[item.name]) + : null; + if (configuredRepository && actualRepository !== configuredRepository) { + failures.push(`repository mismatch: expected ${configuredRepository}, registry reports ${actualRepository ?? "no GitHub repository"}`); + } + const repository = configuredRepository ?? actualRepository; + if (!repository) failures.push("no verifiable GitHub upstream repository is available"); + + const trustedMaintainers = policy.trustedMaintainers[item.name]; + if (trustedMaintainers) { + const actual = [...new Set((versionData.maintainers ?? document.maintainers ?? []).map((entry) => entry.name?.toLowerCase()).filter(Boolean))].sort(); + const trusted = [...new Set(trustedMaintainers.map((entry) => entry.toLowerCase()))].sort(); + if (actual.length === 0) failures.push("registry metadata has no maintainers to compare with policy"); + if (actual.join("\n") !== trusted.join("\n")) { + failures.push(`registry maintainer set changed: expected ${trusted.join(", ")}; found ${actual.join(", ") || "none"}`); + } + } + + const lifecycle = ["preinstall", "install", "postinstall"].filter((name) => versionData.scripts?.[name]); + if (lifecycle.length && !policy.allowDirectLifecycleScripts.includes(item.name)) { + failures.push(`direct package declares lifecycle hook(s): ${lifecycle.join(", ")}`); + } + + const publishedAt = document.time?.[item.version]; + let releaseAgeHours = null; + if (!publishedAt || !Number.isFinite(Date.parse(publishedAt))) { + warnings.push("registry metadata has no valid release timestamp"); + } else { + releaseAgeHours = (dependencies.now() - Date.parse(publishedAt)) / 3_600_000; + if (releaseAgeHours < policy.minimumReleaseAgeHours) { + const message = `release is ${Math.max(0, releaseAgeHours).toFixed(1)} hours old; policy requires ${policy.minimumReleaseAgeHours}`; + (policy.freshReleaseAction === "fail" ? failures : warnings).push(message); + } + } + + let provenance = "none"; + if (repository) { + const attestations = versionData.dist?.attestations; + const provenanceUrl = attestations?.provenance?.predicateType === "https://slsa.dev/provenance/v1" + ? attestations.url + : attestations?.provenance?.url; + if (provenanceUrl) { + provenance = "slsa"; + try { + if (new URL(provenanceUrl).protocol !== "https:") throw new Error("attestation URL is not HTTPS"); + await verifyProvenance(provenanceUrl, item, repository, dependencies); + } catch (error) { + const message = `SLSA provenance: ${error.message}`; + if (hasValidSourceException(item, policy, dependencies)) { + provenance = "policy-exception"; + warnings.push(`${message}; accepted by reviewed sourceEvidenceExceptions policy`); + } else { + failures.push(message); + } + } + } else { + provenance = "gitHead-tag"; + try { + await verifyGitFallback(versionData.gitHead, item, repository, dependencies); + } catch (error) { + const message = `git fallback: ${error.message}`; + if (hasValidSourceException(item, policy, dependencies)) { + provenance = "policy-exception"; + warnings.push(`${message}; accepted by reviewed sourceEvidenceExceptions policy`); + } else { + failures.push(message); + } + } + } + } + return { ...result(item, failures, warnings), repository, releaseAgeHours, provenance, maintainers: (versionData.maintainers ?? document.maintainers ?? []).map((entry) => entry.name).filter(Boolean) }; +} + +async function verifyProvenance(url, item, repository, dependencies) { + const data = await fetchJson(url, dependencies); + const attestations = data.attestations ?? (data.bundle ? [data] : []); + const attestation = attestations.find((entry) => String(entry.predicateType ?? "").includes("slsa")) ?? attestations[0]; + const envelope = attestation?.bundle?.dsseEnvelope ?? attestation?.dsseEnvelope; + const statement = decodeBase64Json(envelope?.payload); + const predicate = statement.predicate ?? {}; + const workflow = predicate.buildDefinition?.externalParameters?.workflow ?? predicate.invocation?.configSource ?? {}; + const sourceRepository = normalizeRepository(workflow.repository ?? workflow.uri); + const sourceRef = workflow.ref ?? workflow.digest?.ref; + const dependenciesList = predicate.buildDefinition?.resolvedDependencies ?? predicate.materials ?? []; + const source = dependenciesList.find((entry) => normalizeProvenanceRepository(entry.uri) === normalizeRepository(repository)); + const commit = source?.digest?.gitCommit ?? source?.digest?.sha1; + const resolvedRef = provenanceRef(source?.uri) ?? sourceRef; + if (sourceRepository !== normalizeRepository(repository)) throw new Error(`source repository mismatch (${sourceRepository ?? "missing"})`); + if (!/^[0-9a-f]{7,64}$/i.test(commit ?? "")) throw new Error("source commit is missing"); + if (typeof resolvedRef !== "string" || !resolvedRef.startsWith("refs/")) throw new Error("resolved source ref is missing"); + if (resolvedRef.startsWith("refs/tags/")) { + await requireRemoteTag(repository, resolvedRef, commit, dependencies); + } else { + await requireVersionTag(repository, item.version, commit, dependencies); + } +} + +function normalizeProvenanceRepository(value) { + if (typeof value !== "string") return normalizeRepository(value); + return normalizeRepository(value.replace(/@refs\/(?:tags|heads)\/.*$/, "")); +} + +async function verifyGitFallback(gitHead, item, repository, dependencies) { + if (!/^[0-9a-f]{7,64}$/i.test(gitHead ?? "")) throw new Error("npm gitHead is missing or invalid"); + const refs = await listRemoteTags(repository, `refs/tags/*${item.version}*`, dependencies); + const found = refs.some((ref) => ref.sha.toLowerCase() === gitHead.toLowerCase()); + if (!found) throw new Error(`no Git tag containing ${item.version} points to npm gitHead`); +} + +async function requireRemoteTag(repository, ref, commit, dependencies) { + const refs = await listRemoteTags(repository, ref, dependencies); + if (!refs.some((entry) => entry.sha.toLowerCase() === commit.toLowerCase())) { + throw new Error(`${ref} does not point to provenance commit`); + } +} + +async function requireVersionTag(repository, version, commit, dependencies) { + const refs = await listRemoteTags(repository, `refs/tags/*${version}*`, dependencies); + if (!refs.some((entry) => entry.sha.toLowerCase() === commit.toLowerCase())) { + throw new Error(`no Git tag containing ${version} points to provenance commit`); + } +} + +async function listRemoteTags(repository, pattern, dependencies) { + if (dependencies.remoteRefs) return dependencies.remoteRefs(repository, pattern); + const run = dependencies.run ?? runCommand; + const result = await run("git", ["ls-remote", "--tags", `${repository}.git`, pattern, `${pattern}^{}`], { + timeoutMs: dependencies.timeoutMs, + maxOutputBytes: 2_000_000, + }); + if (result.code !== 0) throw new Error(`git ls-remote failed: ${result.stderr || `exit ${result.code}`}`); + return String(result.stdout).split("\n").filter(Boolean).map((line) => { + const [sha, ref] = line.split(/\s+/, 2); + return { sha, ref }; + }); +} + +function provenanceRef(value) { + if (typeof value !== "string") return null; + return value.match(/@(refs\/(?:tags|heads)\/.*)$/)?.[1] ?? null; +} + +function hasValidSourceException(item, policy, dependencies) { + const exception = policy.sourceEvidenceExceptions[item.name]; + return exception?.version === item.version && Date.parse(exception.expiresAt) > dependencies.now(); +} + +function result(item, failures, warnings) { + return { name: item.name, version: item.version, dev: item.dev, failures, warnings }; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5f5eead --- /dev/null +++ b/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "dependency-guard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dependency-guard", + "version": "0.1.0", + "license": "MIT", + "bin": { + "dependency-guard": "bin/dependency-guard.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7f3087b --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "dependency-guard", + "version": "0.1.0", + "description": "Pre-install supply-chain checks for npm projects", + "type": "module", + "engines": { + "node": ">=20" + }, + "bin": { + "dependency-guard": "bin/dependency-guard.js" + }, + "scripts": { + "test": "node --test test/*.test.js", + "check": "node --check bin/dependency-guard.js && node --check lib/*.js && node --test test/*.test.js" + }, + "license": "MIT" +} diff --git a/test/check.test.js b/test/check.test.js new file mode 100644 index 0000000..e103788 --- /dev/null +++ b/test/check.test.js @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { readFile } from "node:fs/promises"; +import { checkProject } from "../lib/check.js"; +import { DEFAULT_POLICY } from "../lib/constants.js"; +import { jsonResponse, makeProject, packageDocument, passingRun } from "./helpers.js"; + +test("check runs only safe npm commands in the required order and writes evidence", async () => { + const directory = await makeProject(); + const reportPath = join(directory, "evidence", "report.json"); + const calls = []; + const document = packageDocument(); + const gitHead = document.versions["1.2.3"].gitHead; + const fetch = async (url) => { + if (url.startsWith("https://registry.npmjs.org/alpha")) return jsonResponse(document); + throw new Error(`unexpected URL ${url}`); + }; + const report = await checkProject({ path: directory, report: reportPath, policy: structuredClone(DEFAULT_POLICY) }, { + fetch, + remoteRefs: async () => [{ sha: gitHead, ref: "refs/tags/v1.2.3" }], + run: passingRun(calls), + now: () => Date.parse("2025-01-01T00:00:00Z"), + }); + assert.equal(report.status, "pass"); + assert.deepEqual(calls.map((call) => call.slice(0, 3)), [ + ["npm", "pack", "alpha@1.2.3"], + ["npm", "ci", "--ignore-scripts"], + ["npm", "audit", "signatures"], + ["npm", "audit", "--omit=dev"], + ]); + const saved = JSON.parse(await readFile(reportPath, "utf8")); + assert.equal(saved.commands.length, 4); + assert.equal(saved.status, "pass"); +}); + +test("check records npm pack size violations without installing", async () => { + const directory = await makeProject(); + const reportPath = join(directory, "report.json"); + const document = packageDocument(); + const gitHead = document.versions["1.2.3"].gitHead; + const report = await checkProject({ path: directory, report: reportPath, policy: { ...structuredClone(DEFAULT_POLICY), maxTarballSizeBytes: 10 }, skipInstall: true }, { + fetch: async (url) => { + if (url.startsWith("https://registry.npmjs.org/alpha")) return jsonResponse(document); + throw new Error(`unexpected URL ${url}`); + }, + remoteRefs: async () => [{ sha: gitHead, ref: "refs/tags/v1.2.3" }], + run: passingRun(), + now: () => Date.parse("2025-01-01T00:00:00Z"), + }); + assert.equal(report.status, "fail"); + assert.ok(report.failures.some((message) => message.includes("tarball size"))); + assert.equal(report.commands.length, 1); +}); + +test("check parses full npm pack JSON while truncating report evidence", async () => { + const directory = await makeProject(); + const reportPath = join(directory, "report.json"); + const document = packageDocument(); + const gitHead = document.versions["1.2.3"].gitHead; + const packOutput = JSON.stringify([{ + size: 100, + unpackedSize: 500, + files: Array.from({ length: 2_000 }, (_, index) => ({ path: `dist/file-${index}.js`, size: 1 })), + }]); + assert.ok(packOutput.length > 20_000); + + const report = await checkProject({ path: directory, report: reportPath, policy: structuredClone(DEFAULT_POLICY), skipInstall: true }, { + fetch: async (url) => { + if (url.startsWith("https://registry.npmjs.org/alpha")) return jsonResponse(document); + throw new Error(`unexpected URL ${url}`); + }, + remoteRefs: async () => [{ sha: gitHead, ref: "refs/tags/v1.2.3" }], + run: async (_command, _args, options) => { + assert.equal(options.maxOutputBytes, 25_000_000); + return { code: 0, stdout: packOutput, stderr: "", timedOut: false }; + }, + now: () => Date.parse("2025-01-01T00:00:00Z"), + }); + + assert.equal(report.status, "pass"); + assert.equal(report.packages[0].tarballSizeBytes, 100); + assert.equal(report.commands[0].stdout.length, 20_000); +}); diff --git a/test/helpers.js b/test/helpers.js new file mode 100644 index 0000000..cdc7681 --- /dev/null +++ b/test/helpers.js @@ -0,0 +1,59 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +export async function makeProject(overrides = {}) { + const directory = await mkdtemp(join(tmpdir(), "dependency-guard-test-")); + const manifest = overrides.manifest ?? { + name: "fixture", + version: "1.0.0", + dependencies: { alpha: "^1.0.0" }, + }; + const alphaEntry = { + version: "1.2.3", + resolved: "https://registry.npmjs.org/alpha/-/alpha-1.2.3.tgz", + integrity: `sha512-${Buffer.from("integrity").toString("base64")}`, + ...overrides.alphaEntry, + }; + const lock = overrides.lock ?? { + name: "fixture", + version: "1.0.0", + lockfileVersion: 3, + packages: { "": { name: "fixture", version: "1.0.0" }, "node_modules/alpha": alphaEntry }, + }; + await writeFile(join(directory, "package.json"), JSON.stringify(manifest)); + await writeFile(join(directory, "package-lock.json"), JSON.stringify(lock)); + return directory; +} + +export function packageDocument(overrides = {}) { + const version = { + name: "alpha", + version: "1.2.3", + repository: { type: "git", url: "git+https://github.com/example/alpha.git" }, + maintainers: [{ name: "alice" }], + dist: { signatures: [{ keyid: "SHA256:key", sig: "signature" }] }, + gitHead: "abcdef1234567890abcdef1234567890abcdef12", + ...overrides.version, + }; + version.dist = { signatures: [{ keyid: "SHA256:key", sig: "signature" }], ...overrides.version?.dist }; + return { + name: "alpha", + maintainers: [{ name: "alice" }], + time: { "1.2.3": "2024-01-01T00:00:00.000Z" }, + versions: { "1.2.3": version }, + ...overrides.document, + }; +} + +export function jsonResponse(body, status = 200) { + return { ok: status >= 200 && status < 300, status, json: async () => body }; +} + +export function passingRun(calls = []) { + return async (command, args) => { + calls.push([command, ...args]); + if (args[0] === "pack") return { code: 0, stdout: JSON.stringify([{ size: 100, unpackedSize: 500 }]), stderr: "", timedOut: false }; + return { code: 0, stdout: "ok", stderr: "", timedOut: false }; + }; +} diff --git a/test/init.test.js b/test/init.test.js new file mode 100644 index 0000000..2141def --- /dev/null +++ b/test/init.test.js @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { readFile } from "node:fs/promises"; +import { initPolicy } from "../lib/init.js"; +import { jsonResponse, makeProject, packageDocument } from "./helpers.js"; + +test("init pins repository and maintainers from the exact locked version", async () => { + const directory = await makeProject(); + const output = join(directory, "policy.json"); + const result = await initPolicy({ path: directory, output }, { fetch: async () => jsonResponse(packageDocument()) }); + assert.equal(result.policy.trustedRepositories.alpha, "https://github.com/example/alpha"); + assert.deepEqual(result.policy.trustedMaintainers.alpha, ["alice"]); + assert.deepEqual(JSON.parse(await readFile(output, "utf8")), result.policy); +}); + +test("init does not trust a package without a GitHub repository", async () => { + const directory = await makeProject(); + const output = join(directory, "policy.json"); + const document = packageDocument({ version: { repository: "https://gitlab.com/example/alpha" } }); + const result = await initPolicy({ path: directory, output }, { fetch: async () => jsonResponse(document) }); + assert.equal(result.policy.trustedRepositories.alpha, undefined); + assert.match(result.skipped[0], /not trusted/); +}); diff --git a/test/project.test.js b/test/project.test.js new file mode 100644 index 0000000..ab2d285 --- /dev/null +++ b/test/project.test.js @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { inspectLock, readProject } from "../lib/project.js"; +import { DEFAULT_POLICY } from "../lib/constants.js"; +import { makeProject } from "./helpers.js"; + +test("resolves exact direct dependency versions from a v3 lockfile", async () => { + const directory = await makeProject(); + const project = await readProject(directory); + assert.deepEqual(project.direct, [{ name: "alpha", version: "1.2.3", dev: false }]); +}); + +test("lock inspection rejects unexpected install scripts and missing integrity", async () => { + const directory = await makeProject({ alphaEntry: { hasInstallScript: true, integrity: undefined } }); + const project = await readProject(directory); + const result = inspectLock(project.lock, structuredClone(DEFAULT_POLICY)); + assert.equal(result.failures.length, 2); + assert.match(result.failures[0], /hasInstallScript/); + assert.match(result.failures[1], /integrity/); +}); + +test("default lock allowlist permits fsevents install script", () => { + const lock = { + packages: { + "": {}, + "node_modules/fsevents": { + version: "2.3.3", + resolved: "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + integrity: `sha512-${Buffer.from("value").toString("base64")}`, + hasInstallScript: true, + }, + }, + }; + assert.deepEqual(inspectLock(lock, structuredClone(DEFAULT_POLICY)).failures, []); +}); diff --git a/test/verify.test.js b/test/verify.test.js new file mode 100644 index 0000000..545519c --- /dev/null +++ b/test/verify.test.js @@ -0,0 +1,155 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { DEFAULT_POLICY } from "../lib/constants.js"; +import { verifyDirectPackage } from "../lib/verify.js"; +import { jsonResponse, packageDocument } from "./helpers.js"; + +const item = { name: "alpha", version: "1.2.3", dev: false }; +const now = () => Date.parse("2025-01-01T00:00:00Z"); + +test("verifies SLSA source repository, tag ref, and existing commit", async () => { + const commit = "abcdef1234567890abcdef1234567890abcdef12"; + const statement = { + predicate: { + buildDefinition: { + externalParameters: { workflow: { repository: "https://github.com/example/alpha", ref: "refs/tags/v1.2.3" } }, + resolvedDependencies: [{ uri: "git+https://github.com/example/alpha@refs/tags/v1.2.3", digest: { gitCommit: commit } }], + }, + }, + }; + const document = packageDocument({ version: { dist: { + signatures: [{ keyid: "key", sig: "sig" }], + attestations: { + url: "https://registry.npmjs.org/-/npm/v1/attestations/alpha@1.2.3", + provenance: { predicateType: "https://slsa.dev/provenance/v1" }, + }, + } } }); + const fetch = async (url) => { + if (url.includes("attestations")) return jsonResponse({ attestations: [{ predicateType: "https://slsa.dev/provenance/v1", bundle: { dsseEnvelope: { payload: Buffer.from(JSON.stringify(statement)).toString("base64") } } }] }); + throw new Error(`unexpected URL ${url}`); + }; + const result = await verifyDirectPackage(item, document, structuredClone(DEFAULT_POLICY), { + fetchImpl: fetch, + remoteRefs: async () => [{ sha: commit, ref: "refs/tags/v1.2.3" }], + now, + timeoutMs: 100, + }); + assert.deepEqual(result.failures, []); + assert.equal(result.provenance, "slsa"); +}); + +test("accepts branch-built provenance only when a version tag pins its commit", async () => { + const commit = "abcdef1234567890abcdef1234567890abcdef12"; + const statement = { + predicate: { + buildDefinition: { + externalParameters: { workflow: { repository: "https://github.com/example/alpha", ref: "refs/heads/main" } }, + resolvedDependencies: [{ uri: "git+https://github.com/example/alpha@refs/heads/main", digest: { gitCommit: commit } }], + }, + }, + }; + const document = packageDocument({ version: { dist: { + signatures: [{ keyid: "key", sig: "sig" }], + attestations: { + url: "https://registry.npmjs.org/-/npm/v1/attestations/alpha@1.2.3", + provenance: { predicateType: "https://slsa.dev/provenance/v1" }, + }, + } } }); + const result = await verifyDirectPackage(item, document, structuredClone(DEFAULT_POLICY), { + fetchImpl: async () => jsonResponse({ attestations: [{ predicateType: "https://slsa.dev/provenance/v1", bundle: { dsseEnvelope: { payload: Buffer.from(JSON.stringify(statement)).toString("base64") } } }] }), + remoteRefs: async () => [{ sha: commit, ref: "refs/tags/v1.2.3" }], + now, + timeoutMs: 100, + }); + assert.deepEqual(result.failures, []); +}); + +test("rejects branch-built provenance when no version tag pins its commit", async () => { + const commit = "abcdef1234567890abcdef1234567890abcdef12"; + const statement = { + predicate: { + buildDefinition: { + externalParameters: { workflow: { repository: "https://github.com/example/alpha", ref: "refs/heads/main" } }, + resolvedDependencies: [{ uri: "git+https://github.com/example/alpha@refs/heads/main", digest: { gitCommit: commit } }], + }, + }, + }; + const document = packageDocument({ version: { dist: { + signatures: [{ keyid: "key", sig: "sig" }], + attestations: { + url: "https://registry.npmjs.org/-/npm/v1/attestations/alpha@1.2.3", + provenance: { predicateType: "https://slsa.dev/provenance/v1" }, + }, + } } }); + const result = await verifyDirectPackage(item, document, structuredClone(DEFAULT_POLICY), { + fetchImpl: async () => jsonResponse({ attestations: [{ predicateType: "https://slsa.dev/provenance/v1", bundle: { dsseEnvelope: { payload: Buffer.from(JSON.stringify(statement)).toString("base64") } } }] }), + remoteRefs: async () => [], + now, + timeoutMs: 100, + }); + assert.ok(result.failures.some((message) => message.includes("no Git tag containing 1.2.3"))); +}); + +test("fallback accepts an annotated version tag dereferenced to gitHead", async () => { + const document = packageDocument(); + const gitHead = document.versions["1.2.3"].gitHead; + const result = await verifyDirectPackage(item, document, structuredClone(DEFAULT_POLICY), { + remoteRefs: async () => [{ sha: gitHead, ref: "refs/tags/v1.2.3^{}" }], + now, + timeoutMs: 100, + }); + assert.deepEqual(result.failures, []); + assert.equal(result.provenance, "gitHead-tag"); +}); + +test("rejects direct lifecycle scripts and untrusted maintainers", async () => { + const document = packageDocument({ version: { scripts: { postinstall: "node setup.js" }, maintainers: [{ name: "mallory" }] } }); + const policy = structuredClone(DEFAULT_POLICY); + policy.trustedMaintainers.alpha = ["alice"]; + const result = await verifyDirectPackage(item, document, policy, { + remoteRefs: async () => [{ sha: document.versions["1.2.3"].gitHead, ref: "refs/tags/alpha-1.2.3" }], + now, + timeoutMs: 100, + }); + assert.ok(result.failures.some((message) => message.includes("lifecycle hook"))); + assert.ok(result.failures.some((message) => message.includes("registry maintainer set changed"))); +}); + +test("rejects a missing previously trusted maintainer", async () => { + const document = packageDocument({ version: { maintainers: [{ name: "alice" }] } }); + const policy = structuredClone(DEFAULT_POLICY); + policy.trustedMaintainers.alpha = ["alice", "bob"]; + const gitHead = document.versions["1.2.3"].gitHead; + const result = await verifyDirectPackage(item, document, policy, { + remoteRefs: async () => [{ sha: gitHead, ref: "refs/tags/v1.2.3" }], + now, + timeoutMs: 100, + }); + assert.ok(result.failures.some((message) => message.includes("registry maintainer set changed"))); +}); + +test("accepts only a version-pinned unexpired source evidence exception", async () => { + const document = packageDocument({ version: { gitHead: null } }); + const policy = structuredClone(DEFAULT_POLICY); + policy.sourceEvidenceExceptions.alpha = { + version: "1.2.3", + reason: "Upstream does not publish version tags for this package.", + expiresAt: "2025-02-01T00:00:00Z", + }; + const result = await verifyDirectPackage(item, document, policy, { now, timeoutMs: 100 }); + assert.deepEqual(result.failures, []); + assert.equal(result.provenance, "policy-exception"); + assert.ok(result.warnings.some((message) => message.includes("reviewed sourceEvidenceExceptions"))); +}); + +test("rejects an expired source evidence exception", async () => { + const document = packageDocument({ version: { gitHead: null } }); + const policy = structuredClone(DEFAULT_POLICY); + policy.sourceEvidenceExceptions.alpha = { + version: "1.2.3", + reason: "Upstream does not publish version tags for this package.", + expiresAt: "2024-12-31T00:00:00Z", + }; + const result = await verifyDirectPackage(item, document, policy, { now, timeoutMs: 100 }); + assert.ok(result.failures.some((message) => message.includes("gitHead is missing"))); +});