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 +21 -0
- package/README.md +80 -30
- package/bin/forged.js +24 -6
- package/package.json +5 -5
- package/src/commands/gen.js +23 -8
- package/src/commands/scan.js +37 -8
- package/src/utils/freshness.js +37 -0
- package/src/utils/levenshtein.js +6 -1
- package/src/utils/osv.js +79 -0
- package/src/utils/semver.js +48 -0
- package/src/utils/trustedPublishers.js +31 -0
- package/src/utils/verifyIntegrity.js +44 -14
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
|
[](https://www.npmjs.com/package/forged-cli)
|
|
6
6
|
[](https://www.npmjs.com/package/forged-cli)
|
|
7
|
+
[](https://github.com/bkness/forged-cli/actions/workflows/test.yml)
|
|
7
8
|
[](https://github.com/bkness/forged-cli)
|
|
8
9
|
|
|
9
|
-
**Forged** is a
|
|
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.
|
|
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
|
-
##
|
|
41
|
+
## 🔍 Security Scanner
|
|
42
|
+
|
|
43
|
+
`forged scan [path]` audits every package in your `package-lock.json`:
|
|
24
44
|
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
##
|
|
72
|
+
## 🔐 Credential Generator
|
|
40
73
|
|
|
41
|
-
| Command |
|
|
42
|
-
|
|
43
|
-
| `forged
|
|
44
|
-
| `forged
|
|
45
|
-
| `forged
|
|
46
|
-
| `forged
|
|
47
|
-
| `forged
|
|
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
|
-
##
|
|
87
|
+
## 📝 README Generator
|
|
52
88
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
forged init
|
|
93
|
+
## 🚧 Coming Soon
|
|
59
94
|
|
|
60
|
-
|
|
61
|
-
forged new my-app
|
|
95
|
+
These are advertised in `forged help` and currently print "coming soon":
|
|
62
96
|
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
"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://
|
|
15
|
+
"homepage": "https://weballtech-brandon-kellys-projects.vercel.app/",
|
|
16
16
|
"repository": {
|
|
17
17
|
"type": "git",
|
|
18
|
-
"url": "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"
|
package/src/commands/gen.js
CHANGED
|
@@ -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[
|
|
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 =
|
|
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 }, () =>
|
|
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
|
|
55
|
-
|
|
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
|
}
|
package/src/commands/scan.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { readFileSync, existsSync, readdirSync,
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
+
}
|
package/src/utils/levenshtein.js
CHANGED
|
@@ -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 <=
|
|
25
|
+
if (dist > 0 && dist <= limit) {
|
|
21
26
|
suspects.push({ known, distance: dist });
|
|
22
27
|
}
|
|
23
28
|
}
|
package/src/utils/osv.js
ADDED
|
@@ -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 {
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
}
|