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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to this project are documented here. This project follows [S

## Unreleased

## 0.1.1

### Fixed

- A crafted tarball with a negative entry size made stagecheck loop forever. Such tarballs are now rejected. Found by the new fuzz tests.
- A new dependency whose name matches a built-in object property, such as `constructor`, was not reported as added.
- When npm fails, stagecheck now shows npm's error message instead of the path to npm's log file, and says that a release whose approval failed is still staged.

### Added

- `stagecheck ci <file.tgz>` reviews a tarball you packed yourself, and with `--stage-publish` stages exactly that file.
- Property-based fuzz tests (fast-check) for the tar reader, the rules and the reports.
- GitHub releases carry the npm tarball and a signed SLSA build provenance attestation.

## 0.1.0

First release.
Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,12 @@ Not on GitHub? `npx stagecheck ci` works in any CI. It writes a Markdown report
npx stagecheck ci --stage-publish -- --provenance --access public
```

The tarball that gets staged is the exact file that was reviewed. It is not packed a second time.
The tarball that gets staged is the exact file that was reviewed. It is not packed a second time. If your pipeline already builds the tarball, for example to sign it or attach it to a release, pass it in and stagecheck reviews and stages that file:

```sh
npm pack
npx stagecheck ci my-lib-1.2.0.tgz --stage-publish
```

## What it checks

Expand Down Expand Up @@ -211,6 +216,7 @@ An entry is a rule id, optionally followed by `:` and a path glob (`*` matches w
--no-color Plain output (NO_COLOR is respected too)

ci only:
[<file.tgz>] Review this tarball instead of packing the project
--stage-publish Stage the reviewed tarball if the review passes
--summary <file> Also write the Markdown report to this file
```
Expand Down Expand Up @@ -238,6 +244,15 @@ stagecheck is a reviewing aid, not a malware scanner. It uses heuristics on the

For consumers, tools such as [GuardDog](https://github.com/DataDog/guarddog) or [Socket](https://socket.dev) scan packages and their dependency trees. `npm diff` shows a raw diff without any analysis. stagecheck focuses on the publisher's moment of decision: is this exact tarball what I meant to release?

## Verifying stagecheck itself

Every release since 0.1.1 is reviewed by stagecheck, staged by the [release workflow](.github/workflows/release.yml) and approved by a maintainer with 2FA. It ships with npm provenance. Each [GitHub release](https://github.com/daniel63194/stagecheck/releases) carries the npm tarball and a signed [SLSA build provenance](https://slsa.dev/provenance) attestation (`.sigstore.json`), so you can check that the file on npm was built from this repository:

```sh
npm pack stagecheck@0.1.1
gh attestation verify stagecheck-0.1.1.tgz --repo daniel63194/stagecheck
```

## Contributing

Found a false positive, or a real attack that slipped through? Please [open an issue](https://github.com/daniel63194/stagecheck/issues/new/choose). Both make the rules better. See [CONTRIBUTING.md](CONTRIBUTING.md) for how rules are added and tested.
Expand Down
45 changes: 43 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "stagecheck",
"version": "0.1.0",
"version": "0.1.1",
"description": "Review npm releases before they go live: diffs a staged (or any) package version against the last one and flags supply-chain red flags such as new install scripts, credential access, obfuscated code and exfiltration endpoints. Zero dependencies.",
"keywords": [
"npm",
Expand Down Expand Up @@ -53,6 +53,7 @@
},
"devDependencies": {
"@types/node": "26.6.5",
"fast-check": "4.10.2",
"typescript": "7.0.2",
"vitest": "5.0.3"
}
Expand Down
26 changes: 19 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Usage:
stagecheck list List pending staged releases
stagecheck review <new> Compare a version with the one before it
stagecheck review <old> <new> Compare two versions
stagecheck ci Pack this project and compare it with the published version (for CI)
stagecheck ci [<file.tgz>] Compare this project, or a tarball you packed, with npm (for CI)

<old> and <new> are package specs (name@1.2.3, name@next) or .tgz files.

Expand Down Expand Up @@ -177,7 +177,7 @@ async function cmdInteractive(opts: Options): Promise<Review[]> {
}
}
const ok = await stageDecide('approve', item.id);
process.stdout.write(ok ? `Approved ${item.packageName}@${item.version}.\n\n` : 'Approval did not complete.\n\n');
process.stdout.write(ok ? `Approved ${item.packageName}@${item.version}.\n\n` : 'Approval did not complete (see npm\'s message above). The release is still staged: run stagecheck again to retry.\n\n');
} else if (choice === 'r' || choice === 'reject') {
const sure = await ask(`Rejecting permanently deletes this staged version. Reject ${item.packageName}@${item.version}? [y/N] `);
if (sure === 'y' || sure === 'yes') {
Expand Down Expand Up @@ -210,13 +210,25 @@ async function cmdList(opts: Options): Promise<void> {
}
}

async function cmdCi(opts: Options, passthrough: string[]): Promise<Review> {
async function cmdCi(args: string[], opts: Options, passthrough: string[]): Promise<Review> {
if (args.length > 1) throw new UsageError('ci takes at most one .tgz file');
const given = args[0];
if (given !== undefined && (!TARBALL.test(given) || !existsSync(given))) throw new UsageError(`No such tarball: ${given}`);
if (opts.stagePublish) await ensureStageSupport();
const dir = scratchDir();
try {
const after = await packDirectory(process.cwd());
const tarballPath = join(dir.path, 'package.tgz');
writeFileSync(tarballPath, after);
// Either the tarball given, or this project packed exactly as npm would.
// Whichever it is, the file that is reviewed is the file that gets staged.
let after: Buffer;
let tarballPath: string;
if (given !== undefined) {
tarballPath = resolve(given);
after = readTgz(tarballPath);
} else {
after = await packDirectory(process.cwd());
tarballPath = join(dir.path, 'package.tgz');
writeFileSync(tarballPath, after);
}
const { name, version, tag } = manifestOf(after);
// Before a version bump (e.g. on a pull request) the local version is already
// on npm: compare with it, so the report shows only the unreleased changes.
Expand Down Expand Up @@ -344,7 +356,7 @@ async function main(argv: string[]): Promise<number> {
return failsAt(review, opts.failOn) ? 1 : 0;
}
case 'ci': {
const review = await cmdCi(opts, passthrough);
const review = await cmdCi(rest, opts, passthrough);
return failsAt(review, opts.failOn) ? 1 : 0;
}
default:
Expand Down
28 changes: 18 additions & 10 deletions src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,15 @@ export interface ManifestDiff {
readonly entryPoints: Record<string, { from: string | null; to: string | null }>;
}

/**
* An object without a prototype, so keys such as "constructor" or "__proto__"
* (which are valid in package.json) behave like any other key.
*/
const record = <T>(): Record<string, T> => Object.create(null) as Record<string, T>;

function asStringMap(value: Json | undefined): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const out: Record<string, string> = {};
const out = record<string>();
if (!value || typeof value !== 'object' || Array.isArray(value)) return out;
for (const [k, v] of Object.entries(value)) out[k] = typeof v === 'string' ? v : JSON.stringify(v);
return out;
}
Expand All @@ -79,20 +85,22 @@ function binMap(manifest: Manifest): Record<string, string> {
const bin = manifest.bin;
if (typeof bin === 'string') {
const name = typeof manifest.name === 'string' ? manifest.name.replace(/^@[^/]+\//, '') : 'bin';
return { [name]: bin };
const out = record<string>();
out[name] = bin;
return out;
}
return asStringMap(bin);
}

export function diffMaps(before: Record<string, string>, after: Record<string, string>): MapDiff {
const added: Record<string, string> = {};
const removed: Record<string, string> = {};
const changed: Record<string, { from: string; to: string }> = {};
const added = record<string>();
const removed = record<string>();
const changed = record<{ from: string; to: string }>();
for (const [k, v] of Object.entries(after)) {
if (!(k in before)) added[k] = v;
if (!Object.hasOwn(before, k)) added[k] = v;
else if (before[k] !== v) changed[k] = { from: before[k] as string, to: v };
}
for (const [k, v] of Object.entries(before)) if (!(k in after)) removed[k] = v;
for (const [k, v] of Object.entries(before)) if (!Object.hasOwn(after, k)) removed[k] = v;
return { added, removed, changed };
}

Expand All @@ -106,8 +114,8 @@ export function diffManifests(before: Manifest | null, after: Manifest): Manifes
}
const entryPoints: ManifestDiff['entryPoints'] = {};
for (const field of ENTRY_FIELDS) {
const from = field in old ? JSON.stringify(old[field]) : null;
const to = field in after ? JSON.stringify(after[field]) : null;
const from = Object.hasOwn(old, field) ? JSON.stringify(old[field]) : null;
const to = Object.hasOwn(after, field) ? JSON.stringify(after[field]) : null;
if (from !== to) entryPoints[field] = { from, to };
}
return {
Expand Down
18 changes: 14 additions & 4 deletions src/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,23 @@ export class NpmError extends Error {
}
}

function lastLine(text: string): string {
return text.trim().split(/\r?\n/).filter(Boolean).pop() ?? '';
const NOISE = /^(?:A complete log of this run|Log files were not written|errno |syscall |path )/i;

/** npm's own explanation of a failure: the error code and its first message lines. */
export function npmErrorDetail(r: NpmResult): string {
const lines = `${r.stderr}\n${r.stdout}`.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const errors = lines
.filter((l) => /^npm (?:error|ERR!)/.test(l))
.map((l) => l.replace(/^npm (?:error|ERR!)\s*/, ''))
.filter((l) => l !== '' && !NOISE.test(l));
const code = errors.find((l) => /^code /.test(l))?.slice(5);
const message = errors.filter((l) => !/^code /.test(l)).slice(0, 2).join(' ');
if (code || message) return [code, message].filter(Boolean).join(': ');
return lines.filter((l) => !NOISE.test(l)).pop() ?? `exit code ${r.code}`;
}

function fail(what: string, r: NpmResult): never {
const detail = lastLine(r.stderr) || lastLine(r.stdout) || `exit code ${r.code}`;
throw new NpmError(`${what} failed: ${detail.replace(/^npm (?:error|ERR!)\s*/, '')}`, r);
throw new NpmError(`${what} failed: ${npmErrorDetail(r)}`, r);
}

/** A package spec we are willing to pass to npm: a name, optionally @ an exact version or dist-tag. */
Expand Down
2 changes: 2 additions & 0 deletions src/tar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export function readTarball(archive: Uint8Array, options: ReadTarballOptions = {
if (header.every((b) => b === 0)) break; // end of archive

const size = octal(header, 124, 12);
// A negative size would move the cursor backwards and loop forever.
if (!Number.isSafeInteger(size) || size < 0) throw new Error('Invalid tar header: bad entry size');
const type = String.fromCharCode(header[156] ?? 0);
const dataStart = offset + BLOCK;
const dataEnd = dataStart + size;
Expand Down
25 changes: 25 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,21 @@ describe('stagecheck ci', () => {
expect(readFileSync(summary, 'utf8')).toContain('npm stage approve 11111111-2222-4333-8444-555555555555');
});

it("shows npm's reason when staging fails, not the log file path", () => {
const stagePublishError = [
'npm error code ENEEDAUTH',
'npm error need auth This command requires you to be logged in to https://registry.npmjs.org/',
'npm error need auth You need to authorize this machine using `npm adduser`',
'npm error A complete log of this run can be found in: /Users/x/.npm/_logs/debug-0.log',
'',
].join('\n');
const { state } = setState({ ...released, stagePublishError });
const r = run(['ci', '--stage-publish'], { FAKE_NPM_STATE: state });
expect(r.code).toBe(2);
expect(r.stderr).toContain('npm stage publish failed: ENEEDAUTH: need auth This command requires you to be logged in');
expect(r.stderr).not.toContain('complete log');
});

it('refuses to stage a release with high-risk findings', () => {
const { state, log } = setState({ packCwd: tgz.worm, packages: { demo: { versions: { '1.0.1': tgz.clean }, tags: { latest: '1.0.1' } } } });
const r = run(['ci', '--stage-publish'], { FAKE_NPM_STATE: state });
Expand All @@ -175,6 +190,16 @@ describe('stagecheck ci', () => {
expect(calls(log).some((c) => c[1] === 'publish')).toBe(false);
});

it('reviews and stages a tarball you packed yourself, without packing again', () => {
const { state, log } = setState(released);
const r = run(['ci', tgz.clean as string, '--stage-publish'], { FAKE_NPM_STATE: state });
expect(r.code).toBe(0);
expect(r.stdout).toContain('demo 1.0.0 → 1.0.1');
expect(calls(log).some((c) => c[0] === 'pack' && c.includes('--pack-destination'))).toBe(false);
expect(calls(log).find((c) => c[1] === 'publish')?.[2]).toBe(tgz.clean);
expect(run(['ci', 'missing.tgz'], { FAKE_NPM_STATE: state }).stderr).toContain('No such tarball');
});

it('compares with the same version when it is already published', () => {
const { state } = setState();
const r = run(['ci'], { FAKE_NPM_STATE: state });
Expand Down
3 changes: 3 additions & 0 deletions test/fake-npm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ if (cmd === '--version') {
out(`${item.packageName}-${item.version}-${item.id}.tgz`);
} else if (cmd === 'stage' && (sub === 'approve' || sub === 'reject')) {
out(`Staged package ${third} ${sub === 'approve' ? 'approved and published successfully' : 'has been rejected'}.`);
} else if (cmd === 'stage' && sub === 'publish' && state.stagePublishError) {
process.stderr.write(state.stagePublishError);
process.exit(1);
} else if (cmd === 'stage' && sub === 'publish') {
out(`+ ${state.stagePublishAs ?? 'demo@1.0.1'} (staged with id 11111111-2222-4333-8444-555555555555)`);
} else {
Expand Down
Loading
Loading