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 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
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
- **🔍 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.8",
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://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({
@@ -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
+ }
@@ -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
- return { error: null, findings, total: entries.length };
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
  }