forged-cli 0.3.9 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brandon Kelly
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -4,11 +4,12 @@
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/forged-cli?color=00ff41&style=flat-square)](https://www.npmjs.com/package/forged-cli)
6
6
  [![downloads](https://img.shields.io/npm/dt/forged-cli?color=00ff41&style=flat-square)](https://www.npmjs.com/package/forged-cli)
7
+ [![tests](https://img.shields.io/github/actions/workflow/status/bkness/forged-cli/test.yml?branch=main&label=tests&color=00ff41&style=flat-square)](https://github.com/bkness/forged-cli/actions/workflows/test.yml)
7
8
  [![license](https://img.shields.io/npm/l/forged-cli?color=00ff41&style=flat-square)](https://github.com/bkness/forged-cli)
8
9
 
9
- **Forged** is a modular CLI that builds your shell, automates your workflow, and scans your dependencies. One command to get airborne.
10
+ **Forged** is a CLI toolkit for developers: a dependency security scanner, a credential generator, and a README generator, with shell and workflow tooling on the way.
10
11
 
11
- 🌐 **[weballtech.com](https://weballtech.com)** — full docs and feature overview
12
+ 🌐 **[weballtech-brandon-kellys-projects.vercel.app](https://weballtech-brandon-kellys-projects.vercel.app/)** — full docs and feature overview
12
13
 
13
14
  ---
14
15
 
@@ -18,50 +19,99 @@
18
19
  npm install -g forged-cli
19
20
  ```
20
21
 
22
+ Requires Node.js 18 or newer. Published from GitHub Actions with [npm provenance](https://docs.npmjs.com/generating-provenance-statements), so every release is traceable to the commit that built it.
23
+
24
+ ---
25
+
26
+ ## Quick Start
27
+
28
+ ```sh
29
+ # Scan a project's dependencies
30
+ forged scan ./my-app
31
+
32
+ # Generate a strong password
33
+ forged gen pass
34
+
35
+ # Write a README for the current project
36
+ forged readme
37
+ ```
38
+
21
39
  ---
22
40
 
23
- ## Features
41
+ ## 🔍 Security Scanner
42
+
43
+ `forged scan [path]` audits every package in your `package-lock.json`:
24
44
 
25
- **🔧 Shell Bootstrap**
26
- Modular zsh config, plugins, and hooks — ready in minutes.
45
+ - **Known malware** — checked against [OSV.dev](https://osv.dev), including OpenSSF `MAL-` reports and GitHub malware advisories (CWE-506)
46
+ - **Brand-new versions** — flags versions published in the last 72 hours, the window when hijacked releases usually go unnoticed (packages that routinely release every week or two, like `caniuse-lite`, are skipped)
47
+ - **Tarball integrity** — lockfile hashes vs. the npm registry
48
+ - **Publisher changes** — flags a version whose publisher has never released this package before. Returning maintainers, known teams, and moves to npm trusted publishing are suppressed; security-critical packages like `jsonwebtoken` and `bcrypt` are always flagged for review
49
+ - **Typosquats** — names one or two characters away from popular packages (one for short names, so `tsx` isn't mistaken for `nx`)
50
+ - **Suspicious install scripts** — `curl | sh`, `eval`, base64 decoding and similar
27
51
 
28
- **🔍 Security Scanner**
29
- Typosquat detection, tarball integrity, suspicious install scripts.
52
+ ```
53
+ Summary: 0 error(s), 1 warning(s), 39 suppressed
54
+ ℹ Verified 760 packages against npm registry
55
+ ℹ Checked 760 packages against OSV.dev known-malware database
56
+ ```
30
57
 
31
- **📁 Project Workflows**
32
- Auto-detect, boot, and scaffold Node, Python, Rust, and Go projects.
58
+ | Flag | Description |
59
+ |------|-------------|
60
+ | `--verbose`, `-v` | Show suppressed maintainer rotations |
61
+ | `--report` | Save findings to `forged-report.json` |
62
+ | `--report-md` | Save findings to `forged-report.md` |
33
63
 
34
- **🐙 GitHub Dashboard**
35
- PRs, issues, branches, and repos — all from your terminal.
64
+ **In CI:** `forged scan` exits with code `1` when it finds errors (malware, integrity mismatches, dangerous scripts), so it can fail a build:
65
+
66
+ ```yaml
67
+ - run: npx forged-cli scan
68
+ ```
36
69
 
37
70
  ---
38
71
 
39
- ## Commands
72
+ ## 🔐 Credential Generator
40
73
 
41
- | Command | Description |
42
- |---------|-------------|
43
- | `forged init` | Scaffold a new dev environment |
44
- | `forged new` | Create a new project with GitHub setup |
45
- | `forged install` | Install Forged into an existing shell config |
46
- | `forged scan` | Run security scanner on a project |
47
- | `forged version` | Show version |
74
+ | Command | Output |
75
+ |---------|--------|
76
+ | `forged gen pass` | 20-character password (upper, lower, digit, symbol) |
77
+ | `forged gen pass --safe` | Password using only `-` and `_` as symbols — router and device friendly |
78
+ | `forged gen pass --length=32` | Custom length (`-l=32` also works) |
79
+ | `forged gen secret` | 32-byte hex secret for JWTs and API keys |
80
+ | `forged gen pin` | 6-digit PIN |
81
+ | `forged gen uuid` | UUID v4 |
82
+
83
+ Built on Node's `crypto.randomInt`, so every character is chosen uniformly.
48
84
 
49
85
  ---
50
86
 
51
- ## Quick Start
87
+ ## 📝 README Generator
52
88
 
53
- ```sh
54
- # Install globally
55
- npm install -g forged-cli
89
+ `forged readme` asks a few questions and writes a `README.md`, pre-filling your GitHub username, email, and install/test commands from the project (Node, Python, Rust, or Go).
90
+
91
+ ---
56
92
 
57
- # Set up your dev environment
58
- forged init
93
+ ## 🚧 Coming Soon
59
94
 
60
- # Create a new project
61
- forged new my-app
95
+ These are advertised in `forged help` and currently print "coming soon":
62
96
 
63
- # Scan a project for vulnerabilities
64
- forged scan ./my-app
97
+ | Command | Plan |
98
+ |---------|------|
99
+ | `forged init` | Set up a modular zsh environment — plugins, hooks, project auto-detection, and a GitHub dashboard |
100
+ | `forged new` | Create a new project with GitHub setup |
101
+ | `forged install` | Add Forged to an existing shell config |
102
+
103
+ Want them today? They're running in my [dotfiles](https://github.com/bkness/dotfiles).
104
+
105
+ ---
106
+
107
+ ## Development
108
+
109
+ ```sh
110
+ git clone https://github.com/bkness/forged-cli.git
111
+ cd forged-cli
112
+ npm install
113
+ npm test # node:test, no network needed
114
+ npm link # use your local copy as the global `forged`
65
115
  ```
66
116
 
67
117
  ---
@@ -72,4 +122,4 @@ MIT © [Brandon Kelly](https://github.com/bkness)
72
122
 
73
123
  ## Contact
74
124
 
75
- Brandon Kelly — [GitHub](https://github.com/bkness) · [weballtech.com](https://weballtech.com) · kbrandon863@gmail.com
125
+ Brandon Kelly — [GitHub](https://github.com/bkness) · [weballtech-brandon-kellys-projects.vercel.app](https://weballtech-brandon-kellys-projects.vercel.app/) · kbrandon863@gmail.com
package/bin/forged.js CHANGED
@@ -10,13 +10,17 @@ const { version } = JSON.parse(readFileSync(join(__dirname, '../package.json'),
10
10
  const [,, command, ...args] = process.argv;
11
11
 
12
12
  const commands = {
13
- readme: 'Generate a README.md for the current project',
14
- scan: 'Audit dependencies for typosquats, bad scripts, and suspicious binaries',
13
+ scan: 'Audit dependencies — known malware, integrity, publisher changes, typosquats',
15
14
  gen: 'Generate passwords, secrets, PINs, and UUIDs',
15
+ readme: 'Generate a README.md for the current project',
16
+ version: 'Show Forged version',
17
+ };
18
+
19
+ // Advertised but not built yet — say so instead of treating them as typos
20
+ const planned = {
16
21
  init: 'Scaffold a new dev environment',
17
22
  new: 'Create a new project with GitHub setup',
18
23
  install: 'Install Forged into an existing shell config',
19
- version: 'Show Forged version',
20
24
  };
21
25
 
22
26
  if (!command || command === 'help') {
@@ -28,6 +32,13 @@ if (!command || command === 'help') {
28
32
 
29
33
  Commands:
30
34
  ${Object.entries(commands).map(([cmd, desc]) => ` ${cmd.padEnd(10)} ${desc}`).join('\n')}
35
+
36
+ Coming soon:
37
+ ${Object.entries(planned).map(([cmd, desc]) => ` ${cmd.padEnd(10)} ${desc}`).join('\n')}
38
+
39
+ Scan options:
40
+ forged scan [path] [--verbose|-v] [--report|--report-md]
41
+ Exits 1 when errors are found, so it can fail a CI job.
31
42
  `);
32
43
  process.exit(0);
33
44
  }
@@ -55,10 +66,17 @@ if (command === 'scan') {
55
66
  const report = args.includes('--report') || args.includes('--report-md');
56
67
  const reportFormat = args.includes('--report-md') ? 'markdown' : 'json';
57
68
  const verbose = args.includes('--verbose') || args.includes('-v');
58
- const pathArg = args.find(a => !a.startsWith('--'));
69
+ // Any dash-prefixed arg is a flag, not the path (so `-v` isn't scanned as a dir)
70
+ const pathArg = args.find(a => !a.startsWith('-'));
59
71
  const targetPath = pathArg ? resolve(pathArg) : process.cwd();
60
- await scanCommand(targetPath, { report, reportFormat, verbose });
72
+ const findings = await scanCommand(targetPath, { report, reportFormat, verbose });
73
+ process.exit(findings?.errors.length ? 1 : 0);
74
+ }
75
+
76
+ if (planned[command]) {
77
+ console.log(`⚒ Forged — '${command}' is coming soon.`);
61
78
  process.exit(0);
62
79
  }
63
80
 
64
- console.log(`⚒ Forged — '${command}' coming soon.`);
81
+ console.error(`⚒ Forged — unknown command '${command}'. Run \`forged help\` to see commands.`);
82
+ process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forged-cli",
3
- "version": "0.3.9",
3
+ "version": "0.4.1",
4
4
  "description": "A complete dev environment toolkit — shell, workflows, and project scaffolding in one install.",
5
5
  "keywords": [
6
6
  "cli",
@@ -12,11 +12,10 @@
12
12
  ],
13
13
  "author": "Brandon <DevBrandon@icloud.com> (https://github.com/bkness)",
14
14
  "license": "MIT",
15
- "homepage": "https://www.weballtech.com",
15
+ "homepage": "https://weballtech-brandon-kellys-projects.vercel.app/",
16
16
  "repository": {
17
17
  "type": "git",
18
- "url": "git+ssh://git@github.com/bkness/forged-cli.git",
19
- "directory": "https//www.weballtech.com/manual.html"
18
+ "url": "git+https://github.com/bkness/forged-cli.git"
20
19
  },
21
20
  "bugs": {
22
21
  "url": "https://github.com/bkness/forged-cli/issues"
@@ -29,7 +28,8 @@
29
28
  "node": ">=18"
30
29
  },
31
30
  "scripts": {
32
- "start": "node bin/forged.js"
31
+ "start": "node bin/forged.js",
32
+ "test": "node --test"
33
33
  },
34
34
  "dependencies": {
35
35
  "inquirer": "^13.4.2"
@@ -1,4 +1,4 @@
1
- import { randomBytes, randomUUID } from 'crypto';
1
+ import { randomBytes, randomInt, randomUUID } from 'crypto';
2
2
 
3
3
  const green = '\x1b[32m';
4
4
  const yellow = '\x1b[33m';
@@ -14,11 +14,13 @@ const DIGITS = '0123456789';
14
14
  const SYMBOLS = '!@#$%^&*()-_=+[]{}|;:,.<>?';
15
15
  const SAFE = '-_';
16
16
 
17
+ // randomInt is uniform; `randomBytes(1)[0] % n` over-weights low indexes
18
+ // whenever 256 isn't a multiple of n.
17
19
  function randomChar(charset) {
18
- return charset[randomBytes(1)[0] % charset.length];
20
+ return charset[randomInt(charset.length)];
19
21
  }
20
22
 
21
- function generatePassword(length, safe = false) {
23
+ export function generatePassword(length, safe = false) {
22
24
  const symbols = safe ? SAFE : SYMBOLS;
23
25
  const charset = UPPER + LOWER + DIGITS + symbols;
24
26
  const required = [
@@ -30,7 +32,7 @@ function generatePassword(length, safe = false) {
30
32
  const rest = Array.from({ length: length - required.length }, () => randomChar(charset));
31
33
  const all = [...required, ...rest];
32
34
  for (let i = all.length - 1; i > 0; i--) {
33
- const j = randomBytes(1)[0] % (i + 1);
35
+ const j = randomInt(i + 1);
34
36
  [all[i], all[j]] = [all[j], all[i]];
35
37
  }
36
38
  return all.join('');
@@ -44,15 +46,27 @@ function colorize(password) {
44
46
  }).join('');
45
47
  }
46
48
 
47
- function generatePin(length) {
48
- return Array.from({ length }, () => randomBytes(1)[0] % 10).join('');
49
+ export function generatePin(length) {
50
+ return Array.from({ length }, () => randomInt(10)).join('');
51
+ }
52
+
53
+ // null when no flag given; NaN when the value isn't a whole number
54
+ export function parseLength(args) {
55
+ const lenArg = args.find(a => /^(-l=|--length=)/.test(a));
56
+ if (!lenArg) return null;
57
+ const raw = lenArg.split('=')[1];
58
+ return /^\d+$/.test(raw) ? Number(raw) : NaN;
49
59
  }
50
60
 
51
61
  export async function genCommand(args) {
52
62
  const sub = args[0];
53
63
  const safe = args.includes('--safe') || args.includes('--ascii');
54
- const lenArg = args.find(a => /^(-l=|--length=)/.test(a));
55
- const length = lenArg ? parseInt(lenArg.split('=')[1]) : null;
64
+ const length = parseLength(args);
65
+
66
+ if (Number.isNaN(length)) {
67
+ console.log(` ${red}--length must be a whole number${reset}`);
68
+ return;
69
+ }
56
70
 
57
71
  if (!sub || sub === 'help') {
58
72
  console.log(`
@@ -89,6 +103,7 @@ export async function genCommand(args) {
89
103
 
90
104
  if (sub === 'pin') {
91
105
  const len = length ?? 6;
106
+ if (len < 1) { console.log(` ${red}Length must be at least 1${reset}`); return; }
92
107
  console.log(`\n ${bold}PIN${reset} ${yellow}${generatePin(len)}${reset}\n`);
93
108
  return;
94
109
  }
@@ -1,10 +1,11 @@
1
- import { readFileSync, existsSync, readdirSync, statSync, writeFileSync } from 'fs';
1
+ import { readFileSync, existsSync, readdirSync, lstatSync, writeFileSync } from 'fs';
2
2
  import { createHash } from 'crypto';
3
3
  import { join, basename } from 'path';
4
4
  import { homedir } from 'os';
5
5
  import { isSuspiciousName } from '../utils/levenshtein.js';
6
6
  import { POPULAR_PACKAGES } from '../utils/popularPackages.js';
7
7
  import { verifyTarballIntegrity } from '../utils/verifyIntegrity.js';
8
+ import { queryOsv } from '../utils/osv.js';
8
9
 
9
10
  const green = '\x1b[32m';
10
11
  const yellow = '\x1b[33m';
@@ -12,7 +13,7 @@ const red = '\x1b[31m';
12
13
  const bold = '\x1b[1m';
13
14
  const reset = '\x1b[0m';
14
15
 
15
- function checkPackageJson(cwd) {
16
+ export function checkPackageJson(cwd) {
16
17
  const pkgPath = join(cwd, 'package.json');
17
18
  if (!existsSync(pkgPath)) return { deps: {}, error: 'No package.json found' };
18
19
  try {
@@ -28,14 +29,16 @@ function checkPackageJson(cwd) {
28
29
  }
29
30
  }
30
31
 
31
- function scanBinaries(cwd) {
32
+ export function scanBinaries(cwd) {
32
33
  const binDir = join(cwd, 'node_modules', '.bin');
33
34
  if (!existsSync(binDir)) return [];
34
35
  const results = [];
35
36
  for (const file of readdirSync(binDir)) {
36
37
  const fullPath = join(binDir, file);
37
38
  try {
38
- const stat = statSync(fullPath);
39
+ // lstat, not stat: stat follows the link, so isSymbolicLink() was
40
+ // always false and every large linked binary got flagged
41
+ const stat = lstatSync(fullPath);
39
42
  if (!stat.isSymbolicLink() && stat.size > 50000) {
40
43
  results.push({ file, size: stat.size, flag: 'large non-symlink binary' });
41
44
  }
@@ -44,7 +47,7 @@ function scanBinaries(cwd) {
44
47
  return results;
45
48
  }
46
49
 
47
- function checkDangerousScripts(scripts) {
50
+ export function checkDangerousScripts(scripts) {
48
51
  const dangerous = [];
49
52
  const patterns = [
50
53
  /curl\s+.*\|.*sh/,
@@ -58,6 +61,7 @@ function checkDangerousScripts(scripts) {
58
61
  for (const pattern of patterns) {
59
62
  if (pattern.test(cmd)) {
60
63
  dangerous.push({ script: name, command: cmd });
64
+ break; // one finding per script, even if several patterns match
61
65
  }
62
66
  }
63
67
  }
@@ -147,7 +151,7 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
147
151
 
148
152
  // 4. Tarball integrity verification
149
153
  process.stdout.write(' Verifying tarball integrity');
150
- const { error: integrityError, findings: integrityFindings, total } = await verifyTarballIntegrity(
154
+ const { error: integrityError, findings: integrityFindings, total, packages } = await verifyTarballIntegrity(
151
155
  cwd,
152
156
  (checked, t) => {
153
157
  if (checked % 10 === 0 || checked === t) {
@@ -169,6 +173,29 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
169
173
  if (total) findings.info.push(`Verified ${total} packages against npm registry`);
170
174
  }
171
175
 
176
+ // 4b. Known-malware + vulnerability lookup (OSV.dev)
177
+ if (packages?.length) {
178
+ const osv = await queryOsv(packages);
179
+ if (osv === null) {
180
+ findings.warnings.push({ message: 'Could not reach OSV.dev — skipped known-malware check' });
181
+ } else {
182
+ for (const hit of osv) {
183
+ if (hit.malicious.length) {
184
+ findings.errors.push({
185
+ package: hit.name,
186
+ version: hit.version,
187
+ message: `KNOWN MALICIOUS PACKAGE (${hit.malicious.join(', ')}) — remove it and rotate any secrets on this machine`,
188
+ });
189
+ }
190
+ }
191
+ const vulnerable = osv.filter((h) => h.vulns.length).length;
192
+ findings.info.push(`Checked ${packages.length} packages against OSV.dev known-malware database`);
193
+ if (vulnerable) {
194
+ findings.info.push(`${vulnerable} package(s) have published vulnerabilities — run \`npm audit\` for details`);
195
+ }
196
+ }
197
+ }
198
+
172
199
  // 5. Binary scan
173
200
  for (const b of scanBinaries(cwd)) {
174
201
  findings.warnings.push({
@@ -205,7 +232,7 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
205
232
 
206
233
  // Verbose: show suppressed trusted rotations
207
234
  if (verbose && findings.suppressed.length > 0) {
208
- console.log(`${bold}SUPPRESSED — trusted publisher rotations:${reset}`);
235
+ console.log(`${bold}SUPPRESSED — trusted, returning, or trusted-publishing publishers:${reset}`);
209
236
  for (const s of findings.suppressed) {
210
237
  console.log(` ${green}~${reset} ${s.package}@${s.version}: ${s.message}`);
211
238
  }
@@ -217,7 +244,7 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
217
244
  }
218
245
  if (findings.suppressed.length > 0) {
219
246
  const note = verbose ? '' : ' (run with --verbose to see them)';
220
- console.log(` ${green}ℹ${reset} ${findings.suppressed.length} trusted publisher rotation(s) suppressed${note}`);
247
+ console.log(` ${green}ℹ${reset} ${findings.suppressed.length} publisher change(s) suppressed${note}`);
221
248
  }
222
249
  console.log();
223
250
 
@@ -268,4 +295,6 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
268
295
 
269
296
  console.log(`${green}✔ Report saved: ${reportFile}${reset}\n`);
270
297
  }
298
+
299
+ return findings;
271
300
  }
@@ -0,0 +1,37 @@
1
+ // Brand-new versions are the riskiest window: hijacked releases are usually
2
+ // caught and unpublished within days, so a version this fresh hasn't had
3
+ // time to be vetted by the community yet.
4
+ export const FRESH_HOURS = 72;
5
+
6
+ // Packages that ship data updates every few days (caniuse-lite,
7
+ // electron-to-chromium, ...) are always "fresh" — a hijack stands out as an
8
+ // unexpected release, not another routine one.
9
+ const ROUTINE_SAMPLE = 10; // look at the last N releases before this one
10
+ const ROUTINE_MIN = 5; // need at least this many to judge cadence
11
+ const ROUTINE_GAP_DAYS = 14; // median gap at or under this = routine
12
+
13
+ // Returns hours since publish if the version is younger than `hours`, else null
14
+ export function hoursSincePublish(time, version, now = Date.now(), hours = FRESH_HOURS) {
15
+ const published = Date.parse(time?.[version]);
16
+ if (Number.isNaN(published)) return null;
17
+ const age = (now - published) / 3_600_000;
18
+ return age >= 0 && age < hours ? Math.floor(age) : null;
19
+ }
20
+
21
+ // True when the package normally publishes every couple of weeks or faster
22
+ export function isRoutineRelease(time, version) {
23
+ const published = Date.parse(time?.[version]);
24
+ if (Number.isNaN(published)) return false;
25
+ const earlier = Object.entries(time)
26
+ .filter(([v]) => v !== 'created' && v !== 'modified' && v !== version)
27
+ .map(([, t]) => Date.parse(t))
28
+ .filter((t) => !Number.isNaN(t) && t < published)
29
+ .sort((a, b) => a - b)
30
+ .slice(-ROUTINE_SAMPLE);
31
+ if (earlier.length < ROUTINE_MIN) return false;
32
+
33
+ const stamps = [...earlier, published];
34
+ const gaps = stamps.slice(1).map((t, i) => t - stamps[i]).sort((a, b) => a - b);
35
+ const median = gaps[Math.floor(gaps.length / 2)];
36
+ return median <= ROUTINE_GAP_DAYS * 86_400_000;
37
+ }
@@ -12,12 +12,17 @@ export function levenshtein(a, b) {
12
12
  return dp[m][n];
13
13
  }
14
14
 
15
+ // Names this short are within 2 edits of lots of unrelated packages
16
+ // (tsx → nx, ws, tar), so only a 1-character difference counts for them.
17
+ const SHORT_NAME = 4;
18
+
15
19
  export function isSuspiciousName(name, popularPackages, threshold = 2) {
16
20
  const suspects = [];
17
21
  for (const known of popularPackages) {
18
22
  if (name === known) continue;
23
+ const limit = Math.min(name.length, known.length) <= SHORT_NAME ? Math.min(threshold, 1) : threshold;
19
24
  const dist = levenshtein(name, known);
20
- if (dist > 0 && dist <= threshold) {
25
+ if (dist > 0 && dist <= limit) {
21
26
  suspects.push({ known, distance: dist });
22
27
  }
23
28
  }
@@ -0,0 +1,79 @@
1
+ // OSV.dev — Google's open vulnerability database. Known malware shows up two
2
+ // ways: MAL- advisories (OpenSSF malicious-packages project), and GitHub
3
+ // advisories (GHSA-) tagged CWE-506 "Embedded Malicious Code". The batch
4
+ // endpoint only returns ids, so GHSA details are fetched to check the CWE.
5
+ const OSV_BATCH_URL = 'https://api.osv.dev/v1/querybatch';
6
+ const OSV_VULN_URL = 'https://api.osv.dev/v1/vulns/';
7
+ const BATCH_LIMIT = 1000; // OSV's max queries per request
8
+ const DETAIL_CONCURRENCY = 10;
9
+ const MALWARE_CWE = 'CWE-506';
10
+
11
+ export function isMalwareAdvisory(advisory) {
12
+ return advisory?.database_specific?.cwe_ids?.includes(MALWARE_CWE) ?? false;
13
+ }
14
+
15
+ // pkgs: [{ name, version }], results: OSV batch results (same order),
16
+ // malwareIds: Set of non-MAL ids confirmed as malware
17
+ // → [{ name, version, malicious: [ids], vulns: [ids] }] for packages with hits
18
+ export function classifyOsvResults(pkgs, results, malwareIds = new Set()) {
19
+ const isMalware = (id) => id.startsWith('MAL-') || malwareIds.has(id);
20
+ return pkgs
21
+ .map((pkg, i) => {
22
+ const ids = (results[i]?.vulns ?? []).map((v) => v.id);
23
+ return {
24
+ ...pkg,
25
+ malicious: ids.filter(isMalware),
26
+ vulns: ids.filter((id) => !isMalware(id)),
27
+ };
28
+ })
29
+ .filter((r) => r.malicious.length || r.vulns.length);
30
+ }
31
+
32
+ async function findMalwareIds(ids, fetchImpl) {
33
+ const malware = new Set();
34
+ const queue = [...ids];
35
+ const worker = async () => {
36
+ while (queue.length) {
37
+ const id = queue.shift();
38
+ try {
39
+ const res = await fetchImpl(OSV_VULN_URL + encodeURIComponent(id));
40
+ if (res.ok && isMalwareAdvisory(await res.json())) malware.add(id);
41
+ } catch {
42
+ // detail lookup failed — leave it classified as an ordinary vuln
43
+ }
44
+ }
45
+ };
46
+ await Promise.all(Array.from({ length: DETAIL_CONCURRENCY }, worker));
47
+ return malware;
48
+ }
49
+
50
+ // Returns classified hits, or null if OSV couldn't be reached
51
+ export async function queryOsv(pkgs, fetchImpl = fetch) {
52
+ const results = [];
53
+ try {
54
+ for (let i = 0; i < pkgs.length; i += BATCH_LIMIT) {
55
+ const chunk = pkgs.slice(i, i + BATCH_LIMIT);
56
+ const res = await fetchImpl(OSV_BATCH_URL, {
57
+ method: 'POST',
58
+ headers: { 'content-type': 'application/json' },
59
+ body: JSON.stringify({
60
+ queries: chunk.map(({ name, version }) => ({
61
+ package: { name, ecosystem: 'npm' },
62
+ version,
63
+ })),
64
+ }),
65
+ });
66
+ if (!res.ok) return null;
67
+ const data = await res.json();
68
+ results.push(...(data.results ?? []));
69
+ }
70
+ } catch {
71
+ return null;
72
+ }
73
+
74
+ const ghsaIds = new Set(
75
+ results.flatMap((r) => (r?.vulns ?? []).map((v) => v.id)).filter((id) => !id.startsWith('MAL-')),
76
+ );
77
+ const malwareIds = await findMalwareIds(ghsaIds, fetchImpl);
78
+ return classifyOsvResults(pkgs, results, malwareIds);
79
+ }
@@ -0,0 +1,48 @@
1
+ // Minimal semver ordering — enough to find "the version before this one".
2
+ // Registry `versions` keys are in publish order, so a backport (e.g. 0.34.3
3
+ // published after 0.41.1) would otherwise look like 0.41.2's predecessor.
4
+
5
+ function parse(v) {
6
+ const [core, pre = ''] = String(v).split('+')[0].split(/-(.*)/s);
7
+ const nums = core.split('.').map((n) => Number.parseInt(n, 10) || 0);
8
+ return { nums: [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0], pre };
9
+ }
10
+
11
+ function comparePre(a, b) {
12
+ if (a === b) return 0;
13
+ if (!a) return 1; // release sorts after its prereleases
14
+ if (!b) return -1;
15
+ const as = a.split('.'), bs = b.split('.');
16
+ for (let i = 0; i < Math.max(as.length, bs.length); i++) {
17
+ if (as[i] === undefined) return -1;
18
+ if (bs[i] === undefined) return 1;
19
+ const an = /^\d+$/.test(as[i]), bn = /^\d+$/.test(bs[i]);
20
+ if (an && bn) {
21
+ const d = Number(as[i]) - Number(bs[i]);
22
+ if (d) return Math.sign(d);
23
+ } else if (an !== bn) {
24
+ return an ? -1 : 1; // numeric identifiers sort before alphanumeric
25
+ } else if (as[i] !== bs[i]) {
26
+ return as[i] < bs[i] ? -1 : 1;
27
+ }
28
+ }
29
+ return 0;
30
+ }
31
+
32
+ export function compareVersions(a, b) {
33
+ const pa = parse(a), pb = parse(b);
34
+ for (let i = 0; i < 3; i++) {
35
+ const d = pa.nums[i] - pb.nums[i];
36
+ if (d) return Math.sign(d);
37
+ }
38
+ return comparePre(pa.pre, pb.pre);
39
+ }
40
+
41
+ // Highest version strictly below `version`. Stable releases skip prereleases.
42
+ export function previousVersion(versions, version) {
43
+ const isPre = parse(version).pre !== '';
44
+ return versions
45
+ .filter((v) => compareVersions(v, version) < 0 && (isPre || parse(v).pre === ''))
46
+ .sort(compareVersions)
47
+ .at(-1) ?? null;
48
+ }
@@ -57,8 +57,39 @@ export const TRUSTED_COMMUNITY = new Set([
57
57
  'jcrugzz',
58
58
  // prebuild-install: known npm ecosystem contributor, took over from lovell
59
59
  'vweevers',
60
+ // Fastify core team (safe-regex2 and other fastify-org packages)
61
+ 'climba03003',
62
+ // Better Auth team — now maintains Auth.js / @auth/core
63
+ 'better-gustavo', 'bekacru',
64
+ // Expo core team (bot → human publisher rotations)
65
+ 'brentvatne', 'alanhughes', 'kudochien', 'philpl',
66
+ // Software Mansion team (React Native Reanimated / Screens / Worklets)
67
+ 'kmag', 'matipl01', 'tjzel', 'kkafar',
68
+ // MongoDB Node.js driver team org (replaced addaleax)
69
+ 'dbx-node',
70
+ // Meta/Flow official team org (replaced gkz)
71
+ 'flowtype',
60
72
  ]);
61
73
 
74
+ // Security-critical packages: any publisher change gets a human look, even
75
+ // from a returning maintainer (e.g. jsonwebtoken, julien.wollscheid).
76
+ export const ALWAYS_REVIEW = new Set([
77
+ 'jsonwebtoken', 'jose', 'bcrypt', 'bcryptjs', 'argon2',
78
+ 'node-forge', 'crypto-js', 'tweetnacl', 'express-session', 'cookie-session',
79
+ ]);
80
+
81
+ // "GitHub Actions" as publisher = npm trusted publishing (OIDC from CI)
82
+ const TRUSTED_PUBLISHING = 'GitHub Actions';
83
+
84
+ // → { type: 'warn' | 'info', reason: 'review' | 'oidc' | 'returning' | 'trusted' | 'new' }
85
+ export function publisherChangeSeverity({ pkg, from, to, returning }) {
86
+ if (ALWAYS_REVIEW.has(pkg)) return { type: 'warn', reason: 'review' };
87
+ if (to === TRUSTED_PUBLISHING) return { type: 'info', reason: 'oidc' };
88
+ if (returning) return { type: 'info', reason: 'returning' };
89
+ const type = classifyPublisherChange(from, to);
90
+ return { type, reason: type === 'info' ? 'trusted' : 'new' };
91
+ }
92
+
62
93
  export function classifyPublisherChange(from, to) {
63
94
  const fromTrusted = NPM_TEAM.has(from) || TRUSTED_COMMUNITY.has(from);
64
95
  const toTrusted = NPM_TEAM.has(to) || TRUSTED_COMMUNITY.has(to);
@@ -1,6 +1,8 @@
1
1
  import { readFileSync, existsSync } from 'fs';
2
2
  import { join } from 'path';
3
- import { classifyPublisherChange } from './trustedPublishers.js';
3
+ import { publisherChangeSeverity } from './trustedPublishers.js';
4
+ import { hoursSincePublish, isRoutineRelease, FRESH_HOURS } from './freshness.js';
5
+ import { previousVersion } from './semver.js';
4
6
 
5
7
  // Fetch registry metadata with a simple in-memory cache to avoid duplicate requests
6
8
  const registryCache = new Map();
@@ -108,25 +110,53 @@ export async function verifyTarballIntegrity(cwd, onProgress) {
108
110
  return;
109
111
  }
110
112
 
113
+ // Brand-new versions haven't been vetted yet — the usual window for hijacks
114
+ const ageHours = hoursSincePublish(registryMeta.time, meta.version);
115
+ if (ageHours !== null && !isRoutineRelease(registryMeta.time, meta.version)) {
116
+ findings.push({
117
+ type: 'warning',
118
+ package: name,
119
+ version: meta.version,
120
+ message: `Published ${ageHours}h ago — versions under ${FRESH_HOURS}h old haven't been vetted yet`,
121
+ });
122
+ }
123
+
111
124
  // Check if the author/publisher changed in this version vs the previous one
112
- const versions = Object.keys(registryMeta.versions || {});
113
- const idx = versions.indexOf(meta.version);
114
- if (idx > 0) {
115
- const prevVersion = versions[idx - 1];
116
- const prevPublisher = registryMeta.versions[prevVersion]?._npmUser?.name;
117
- const currPublisher = versionData._npmUser?.name;
125
+ // Compare by version number, not registry order — backports would
126
+ // otherwise look like the previous release.
127
+ const allVersions = Object.keys(registryMeta.versions || {});
128
+ const prevVersion = previousVersion(allVersions, meta.version);
129
+ if (prevVersion) {
130
+ const prevPublisher = registryMeta.versions[prevVersion]?._npmUser?.name;
131
+ const currPublisher = versionData._npmUser?.name;
118
132
  if (prevPublisher && currPublisher && prevPublisher !== currPublisher) {
119
- const severity = classifyPublisherChange(prevPublisher, currPublisher);
120
- findings.push({
121
- type: severity,
122
- package: name,
123
- version: meta.version,
124
- message: `Publisher changed from "${prevPublisher}" to "${currPublisher}" in this version`,
133
+ // An account that already published an earlier version is a returning
134
+ // maintainer. The hijack pattern is a first-time publisher.
135
+ const publishedAt = Date.parse(registryMeta.time?.[meta.version]);
136
+ const returning = allVersions.some((v) =>
137
+ v !== meta.version &&
138
+ registryMeta.versions[v]?._npmUser?.name === currPublisher &&
139
+ Date.parse(registryMeta.time?.[v]) < publishedAt);
140
+ const { type, reason } = publisherChangeSeverity({
141
+ pkg: name, from: prevPublisher, to: currPublisher, returning,
125
142
  });
143
+ const change = `Publisher changed from "${prevPublisher}" to "${currPublisher}"`;
144
+ const message = {
145
+ review: `${change} — security-critical package, review this release`,
146
+ oidc: `Moved from "${prevPublisher}" to npm trusted publishing (GitHub Actions)`,
147
+ returning: `${change} (returning maintainer)`,
148
+ trusted: `${change} (trusted publisher)`,
149
+ new: `${change} — first release by this account`,
150
+ }[reason];
151
+ findings.push({ type, package: name, version: meta.version, message });
126
152
  }
127
153
  }
128
154
  }));
129
155
  }
130
156
 
131
- return { error: null, findings, total: entries.length };
157
+ const scanned = entries.map(([pkgPath, meta]) => ({
158
+ name: pkgPath.replace(/^.*node_modules\//, ''),
159
+ version: meta.version,
160
+ }));
161
+ return { error: null, findings, total: entries.length, packages: scanned };
132
162
  }