forged-cli 0.3.8 → 0.4.0
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/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 +35 -6
- package/src/utils/freshness.js +12 -0
- package/src/utils/osv.js +79 -0
- package/src/utils/trustedPublishers.js +8 -0
- package/src/utils/verifyIntegrity.js +17 -1
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
|
|
47
|
+
- **Tarball integrity** — lockfile hashes vs. the npm registry
|
|
48
|
+
- **Publisher changes** — flags a version published by a different account than the one before it (known maintainer rotations are suppressed)
|
|
49
|
+
- **Typosquats** — names one or two characters away from popular packages
|
|
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.0",
|
|
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({
|
|
@@ -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,12 @@
|
|
|
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
|
+
// Returns hours since publish if the version is younger than `hours`, else null
|
|
7
|
+
export function hoursSincePublish(time, version, now = Date.now(), hours = FRESH_HOURS) {
|
|
8
|
+
const published = Date.parse(time?.[version]);
|
|
9
|
+
if (Number.isNaN(published)) return null;
|
|
10
|
+
const age = (now - published) / 3_600_000;
|
|
11
|
+
return age >= 0 && age < hours ? Math.floor(age) : null;
|
|
12
|
+
}
|
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
|
+
}
|
|
@@ -57,6 +57,14 @@ 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
|
+
// Expo core team (bot → human publisher rotations)
|
|
61
|
+
'brentvatne', 'alanhughes', 'kudochien', 'philpl',
|
|
62
|
+
// Software Mansion team (React Native Reanimated / Screens / Worklets)
|
|
63
|
+
'kmag', 'matipl01', 'tjzel', 'kkafar',
|
|
64
|
+
// MongoDB Node.js driver team org (replaced addaleax)
|
|
65
|
+
'dbx-node',
|
|
66
|
+
// Meta/Flow official team org (replaced gkz)
|
|
67
|
+
'flowtype',
|
|
60
68
|
]);
|
|
61
69
|
|
|
62
70
|
export function classifyPublisherChange(from, to) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { classifyPublisherChange } from './trustedPublishers.js';
|
|
4
|
+
import { hoursSincePublish, FRESH_HOURS } from './freshness.js';
|
|
4
5
|
|
|
5
6
|
// Fetch registry metadata with a simple in-memory cache to avoid duplicate requests
|
|
6
7
|
const registryCache = new Map();
|
|
@@ -108,6 +109,17 @@ export async function verifyTarballIntegrity(cwd, onProgress) {
|
|
|
108
109
|
return;
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
// Brand-new versions haven't been vetted yet — the usual window for hijacks
|
|
113
|
+
const ageHours = hoursSincePublish(registryMeta.time, meta.version);
|
|
114
|
+
if (ageHours !== null) {
|
|
115
|
+
findings.push({
|
|
116
|
+
type: 'warning',
|
|
117
|
+
package: name,
|
|
118
|
+
version: meta.version,
|
|
119
|
+
message: `Published ${ageHours}h ago — versions under ${FRESH_HOURS}h old haven't been vetted yet`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
111
123
|
// Check if the author/publisher changed in this version vs the previous one
|
|
112
124
|
const versions = Object.keys(registryMeta.versions || {});
|
|
113
125
|
const idx = versions.indexOf(meta.version);
|
|
@@ -128,5 +140,9 @@ export async function verifyTarballIntegrity(cwd, onProgress) {
|
|
|
128
140
|
}));
|
|
129
141
|
}
|
|
130
142
|
|
|
131
|
-
|
|
143
|
+
const scanned = entries.map(([pkgPath, meta]) => ({
|
|
144
|
+
name: pkgPath.replace(/^.*node_modules\//, ''),
|
|
145
|
+
version: meta.version,
|
|
146
|
+
}));
|
|
147
|
+
return { error: null, findings, total: entries.length, packages: scanned };
|
|
132
148
|
}
|