Add pre-install npm dependency guard

This commit is contained in:
2026-08-27 12:46:55 +01:00
parent 7574b31b8c
commit f28b9ee87d
22 changed files with 1321 additions and 137 deletions
+1 -136
View File
@@ -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.*
+43
View File
@@ -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.
+21
View File
@@ -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.
+169 -1
View File
@@ -1,3 +1,171 @@
# dependency-guard
Supply-chain verification for npm dependency updates
`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@<reviewed-40-character-commit-sha>
- uses: https://git.nocker.cloud/tony/dependency-guard@<reviewed-40-character-commit-sha>
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.
+42
View File
@@ -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.
+37
View File
@@ -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[@]}"
+8
View File
@@ -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;
});
+106
View File
@@ -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 ?? ""),
},
};
}
+56
View File
@@ -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 <project> [--policy <policy.json>] --report <report.json> [--skip-install]
dependency-guard init --path <project> --output <policy.json>`);
}
+15
View File
@@ -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;
+30
View File
@@ -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 };
}
+77
View File
@@ -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 || "<empty>"}`);
}
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 || "<empty>"}`);
}
}
}
+55
View File
@@ -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}`);
}
}
+99
View File
@@ -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"));
}
+169
View File
@@ -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 };
}
+19
View File
@@ -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"
}
}
}
}
+17
View File
@@ -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"
}
+84
View File
@@ -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);
});
+59
View File
@@ -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 };
};
}
+24
View File
@@ -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/);
});
+35
View File
@@ -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, []);
});
+155
View File
@@ -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")));
});