Add pre-install npm dependency guard
This commit is contained in:
+1
-136
@@ -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/
|
node_modules/
|
||||||
jspm_packages/
|
dependency-guard-report.json
|
||||||
|
|
||||||
# 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'
|
|
||||||
*.tgz
|
*.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.*
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -1,3 +1,171 @@
|
|||||||
# dependency-guard
|
# 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
@@ -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
@@ -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[@]}"
|
||||||
Executable
+8
@@ -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
@@ -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
@@ -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>`);
|
||||||
|
}
|
||||||
@@ -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
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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>"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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 };
|
||||||
|
}
|
||||||
Generated
+19
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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, []);
|
||||||
|
});
|
||||||
@@ -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")));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user