forged-cli 0.1.0 → 0.3.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/bin/forged.js CHANGED
@@ -1,8 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const [,, command] = process.argv;
3
+ import { readFileSync } from 'fs';
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname, join } from 'path';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const { version } = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
9
+
10
+ const [,, command, ...args] = process.argv;
4
11
 
5
12
  const commands = {
13
+ readme: 'Generate a README.md for the current project',
14
+ scan: 'Audit dependencies for typosquats, bad scripts, and suspicious binaries',
6
15
  init: 'Scaffold a new dev environment',
7
16
  new: 'Create a new project with GitHub setup',
8
17
  install: 'Install Forged into an existing shell config',
@@ -23,11 +32,26 @@ ${Object.entries(commands).map(([cmd, desc]) => ` ${cmd.padEnd(10)} ${desc}`)
23
32
  }
24
33
 
25
34
  if (command === 'version') {
26
- const { createRequire } = await import('module');
27
- const require = createRequire(import.meta.url);
28
- const { version } = require('../package.json');
29
35
  console.log(`forged-cli v${version}`);
30
36
  process.exit(0);
31
37
  }
32
38
 
39
+ if (command === 'readme') {
40
+ const { readmeCommand } = await import('../src/commands/readme.js');
41
+ await readmeCommand(args[0] || 'README.md');
42
+ process.exit(0);
43
+ }
44
+
45
+ if (command === 'scan') {
46
+ const { scanCommand } = await import('../src/commands/scan.js');
47
+ const { resolve } = await import('path');
48
+ const report = args.includes('--report') || args.includes('--report-md');
49
+ const reportFormat = args.includes('--report-md') ? 'markdown' : 'json';
50
+ const verbose = args.includes('--verbose') || args.includes('-v');
51
+ const pathArg = args.find(a => !a.startsWith('--'));
52
+ const targetPath = pathArg ? resolve(pathArg) : process.cwd();
53
+ await scanCommand(targetPath, { report, reportFormat, verbose });
54
+ process.exit(0);
55
+ }
56
+
33
57
  console.log(`⚒ Forged — '${command}' coming soon.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forged-cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "A complete dev environment toolkit — shell, workflows, and project scaffolding in one install.",
5
5
  "keywords": [
6
6
  "cli",
@@ -29,5 +29,8 @@
29
29
  },
30
30
  "scripts": {
31
31
  "start": "node bin/forged.js"
32
+ },
33
+ "dependencies": {
34
+ "inquirer": "^13.4.2"
32
35
  }
33
36
  }
@@ -0,0 +1,84 @@
1
+ import { createRequire } from 'module';
2
+ import { writeFile, mkdir } from 'fs/promises';
3
+ import { resolve } from 'path';
4
+ import { detectProjectInfo } from '../utils/detectProject.js';
5
+ import { generateMarkdown } from '../utils/generateMarkdown.js';
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const { prompt } = require('inquirer');
9
+
10
+ const green = '\x1b[32m';
11
+ const purple = '\x1b[35m';
12
+ const reset = '\x1b[0m';
13
+
14
+ export async function readmeCommand(outputPath = 'README.md') {
15
+ const detected = detectProjectInfo();
16
+
17
+ console.log(`\n${green}⚒ Forged README Generator${reset}`);
18
+ if (detected.github) console.log(` Detected GitHub user: ${detected.github}`);
19
+ console.log(' Press Enter to accept defaults.\n');
20
+
21
+ const answers = await prompt([
22
+ {
23
+ type: 'input',
24
+ name: 'title',
25
+ message: `${green}Project name:${reset}`,
26
+ default: detected.title,
27
+ },
28
+ {
29
+ type: 'input',
30
+ name: 'description',
31
+ message: `${green}Short description:${reset}`,
32
+ },
33
+ {
34
+ type: 'input',
35
+ name: 'toDo',
36
+ message: `${green}To-do items (leave blank to skip section):${reset}`,
37
+ },
38
+ {
39
+ type: 'list',
40
+ name: 'license',
41
+ message: `${green}License:${reset}`,
42
+ choices: ['MIT', 'APACHE 2.0', 'GPL 3.0', 'BSD 3', 'None'],
43
+ },
44
+ {
45
+ type: 'input',
46
+ name: 'installation',
47
+ message: `${green}Install command:${reset}`,
48
+ default: detected.installCommand,
49
+ },
50
+ {
51
+ type: 'input',
52
+ name: 'test',
53
+ message: `${green}Test command:${reset}`,
54
+ default: detected.testCommand,
55
+ },
56
+ {
57
+ type: 'input',
58
+ name: 'usage',
59
+ message: `${green}Usage instructions:${reset}`,
60
+ },
61
+ {
62
+ type: 'input',
63
+ name: 'contributing',
64
+ message: `${green}Contributing guidelines:${reset}`,
65
+ default: 'Fork the project and open a pull request with your changes',
66
+ },
67
+ {
68
+ type: 'input',
69
+ name: 'github',
70
+ message: `${green}GitHub username:${reset}`,
71
+ default: detected.github,
72
+ },
73
+ {
74
+ type: 'input',
75
+ name: 'email',
76
+ message: `${green}Email address:${reset}`,
77
+ default: detected.email,
78
+ },
79
+ ]);
80
+
81
+ const markdown = generateMarkdown(answers);
82
+ await writeFile(resolve(outputPath), markdown, 'utf8');
83
+ console.log(`\n${purple}✔ README written to ${outputPath}${reset}\n`);
84
+ }
@@ -0,0 +1,248 @@
1
+ import { readFileSync, existsSync, readdirSync, statSync, writeFileSync } from 'fs';
2
+ import { createHash } from 'crypto';
3
+ import { join, basename } from 'path';
4
+ import { isSuspiciousName } from '../utils/levenshtein.js';
5
+ import { POPULAR_PACKAGES } from '../utils/popularPackages.js';
6
+ import { verifyTarballIntegrity } from '../utils/verifyIntegrity.js';
7
+
8
+ const green = '\x1b[32m';
9
+ const yellow = '\x1b[33m';
10
+ const red = '\x1b[31m';
11
+ const bold = '\x1b[1m';
12
+ const reset = '\x1b[0m';
13
+
14
+ function checkPackageJson(cwd) {
15
+ const pkgPath = join(cwd, 'package.json');
16
+ if (!existsSync(pkgPath)) return { deps: {}, error: 'No package.json found' };
17
+ try {
18
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
19
+ return {
20
+ deps: { ...pkg.dependencies, ...pkg.devDependencies },
21
+ scripts: pkg.scripts || {},
22
+ name: pkg.name,
23
+ version: pkg.version,
24
+ };
25
+ } catch {
26
+ return { deps: {}, error: 'Could not parse package.json' };
27
+ }
28
+ }
29
+
30
+ function scanBinaries(cwd) {
31
+ const binDir = join(cwd, 'node_modules', '.bin');
32
+ if (!existsSync(binDir)) return [];
33
+ const results = [];
34
+ for (const file of readdirSync(binDir)) {
35
+ const fullPath = join(binDir, file);
36
+ try {
37
+ const stat = statSync(fullPath);
38
+ if (!stat.isSymbolicLink() && stat.size > 50000) {
39
+ results.push({ file, size: stat.size, flag: 'large non-symlink binary' });
40
+ }
41
+ } catch {}
42
+ }
43
+ return results;
44
+ }
45
+
46
+ function checkDangerousScripts(scripts) {
47
+ const dangerous = [];
48
+ const patterns = [
49
+ /curl\s+.*\|.*sh/,
50
+ /wget\s+.*\|.*sh/,
51
+ /eval\s*\(/,
52
+ /base64\s+--decode/,
53
+ /python\s+-c/,
54
+ /exec\s*\(/,
55
+ ];
56
+ for (const [name, cmd] of Object.entries(scripts)) {
57
+ for (const pattern of patterns) {
58
+ if (pattern.test(cmd)) {
59
+ dangerous.push({ script: name, command: cmd });
60
+ }
61
+ }
62
+ }
63
+ return dangerous;
64
+ }
65
+
66
+ function saveJsonReport(reportPath, data) {
67
+ writeFileSync(reportPath, JSON.stringify(data, null, 2), 'utf8');
68
+ }
69
+
70
+ function saveMarkdownReport(reportPath, data) {
71
+ const { meta, findings } = data;
72
+ const lines = [
73
+ `# Forged Scan Report`,
74
+ ``,
75
+ `**Project:** ${meta.project}`,
76
+ `**Scanned:** ${meta.scannedAt}`,
77
+ `**Packages verified:** ${meta.packagesVerified}`,
78
+ `**Summary:** ${findings.errors.length} error(s), ${findings.warnings.length} warning(s)`,
79
+ ``,
80
+ ];
81
+
82
+ if (findings.errors.length > 0) {
83
+ lines.push(`## Errors`, ``);
84
+ for (const e of findings.errors) {
85
+ lines.push(`- ❌ **${e.package ? `${e.package}@${e.version}` : 'project'}**: ${e.message}`);
86
+ if (e.detail) {
87
+ lines.push(` - locked: \`${e.detail.locked}\``);
88
+ lines.push(` - registry: \`${e.detail.registry}\``);
89
+ }
90
+ }
91
+ lines.push(``);
92
+ }
93
+
94
+ if (findings.warnings.length > 0) {
95
+ lines.push(`## Warnings`, ``);
96
+ for (const w of findings.warnings) {
97
+ lines.push(`- ⚠️ **${w.package ? `${w.package}@${w.version}` : 'project'}**: ${w.message}`);
98
+ }
99
+ lines.push(``);
100
+ }
101
+
102
+ if (findings.errors.length === 0 && findings.warnings.length === 0) {
103
+ lines.push(`## Result`, ``, `✅ No issues found. All packages verified clean.`, ``);
104
+ }
105
+
106
+ writeFileSync(reportPath, lines.join('\n'), 'utf8');
107
+ }
108
+
109
+ export async function scanCommand(cwd = process.cwd(), opts = {}) {
110
+ const { report, reportFormat = 'json', verbose = false } = opts;
111
+
112
+ console.log(`\n${bold}⚒ Forged Scanner${reset}`);
113
+ console.log(` Scanning: ${cwd}\n`);
114
+
115
+ const findings = { warnings: [], errors: [], info: [], suppressed: [] };
116
+ let packagesVerified = 0;
117
+
118
+ // 1. Parse package.json
119
+ const { deps, scripts, error, name: pkgName, version: pkgVersion } = checkPackageJson(cwd);
120
+ if (error) findings.errors.push({ message: error });
121
+
122
+ // 2. Dangerous scripts
123
+ if (scripts) {
124
+ for (const d of checkDangerousScripts(scripts)) {
125
+ findings.errors.push({ message: `Dangerous script "${d.script}": ${d.command}` });
126
+ }
127
+ }
128
+
129
+ // 3. Typosquat check
130
+ const popularSet = new Set(POPULAR_PACKAGES);
131
+ for (const name of Object.keys(deps)) {
132
+ if (popularSet.has(name)) continue;
133
+ for (const { known, distance } of isSuspiciousName(name, POPULAR_PACKAGES)) {
134
+ findings.warnings.push({
135
+ package: name,
136
+ message: `${distance} character(s) from popular package "${known}" — possible typosquat`,
137
+ });
138
+ }
139
+ }
140
+
141
+ // 4. Tarball integrity verification
142
+ process.stdout.write(' Verifying tarball integrity');
143
+ const { error: integrityError, findings: integrityFindings, total } = await verifyTarballIntegrity(
144
+ cwd,
145
+ (checked, t) => {
146
+ if (checked % 10 === 0 || checked === t) {
147
+ process.stdout.write(`\r Verifying tarball integrity (${checked}/${t})...`);
148
+ }
149
+ }
150
+ );
151
+ process.stdout.write('\n');
152
+
153
+ if (integrityError) {
154
+ findings.warnings.push({ message: integrityError });
155
+ } else {
156
+ packagesVerified = total || 0;
157
+ for (const f of integrityFindings) {
158
+ if (f.type === 'error') findings.errors.push(f);
159
+ else if (f.type === 'info') findings.suppressed.push(f);
160
+ else findings.warnings.push(f);
161
+ }
162
+ if (total) findings.info.push(`Verified ${total} packages against npm registry`);
163
+ }
164
+
165
+ // 5. Binary scan
166
+ for (const b of scanBinaries(cwd)) {
167
+ findings.warnings.push({
168
+ message: `node_modules/.bin/${b.file} is a large non-symlink binary (${(b.size / 1024).toFixed(1)}KB)`,
169
+ });
170
+ }
171
+
172
+ // Print results
173
+ if (findings.errors.length === 0 && findings.warnings.length === 0) {
174
+ console.log(`${green}✔ No issues found. Looks clean.${reset}\n`);
175
+ } else {
176
+ if (findings.errors.length > 0) {
177
+ console.log(`${red}${bold}ERRORS (${findings.errors.length}):${reset}`);
178
+ for (const e of findings.errors) {
179
+ const label = e.package ? `${e.package}@${e.version}: ` : '';
180
+ console.log(` ${red}✖${reset} ${label}${e.message}`);
181
+ if (e.detail) {
182
+ console.log(` locked: ${e.detail.locked}`);
183
+ console.log(` registry: ${e.detail.registry}`);
184
+ }
185
+ }
186
+ console.log();
187
+ }
188
+
189
+ if (findings.warnings.length > 0) {
190
+ console.log(`${yellow}${bold}WARNINGS (${findings.warnings.length}):${reset}`);
191
+ for (const w of findings.warnings) {
192
+ const label = w.package ? `${w.package}@${w.version}: ` : '';
193
+ console.log(` ${yellow}⚠${reset} ${label}${w.message}`);
194
+ }
195
+ console.log();
196
+ }
197
+ }
198
+
199
+ // Verbose: show suppressed trusted rotations
200
+ if (verbose && findings.suppressed.length > 0) {
201
+ console.log(`${bold}SUPPRESSED — trusted publisher rotations:${reset}`);
202
+ for (const s of findings.suppressed) {
203
+ console.log(` ${green}~${reset} ${s.package}@${s.version}: ${s.message}`);
204
+ }
205
+ console.log();
206
+ }
207
+
208
+ if (findings.info.length > 0) {
209
+ for (const i of findings.info) console.log(` ${green}ℹ${reset} ${i}`);
210
+ }
211
+ if (findings.suppressed.length > 0) {
212
+ const note = verbose ? '' : ' (run with --verbose to see them)';
213
+ console.log(` ${green}ℹ${reset} ${findings.suppressed.length} trusted publisher rotation(s) suppressed${note}`);
214
+ }
215
+ console.log();
216
+
217
+ console.log(`${bold}Summary:${reset} ${findings.errors.length} error(s), ${findings.warnings.length} warning(s), ${findings.suppressed.length} suppressed\n`);
218
+
219
+ // Save report if requested
220
+ if (report) {
221
+ const reportData = {
222
+ meta: {
223
+ project: pkgName || basename(cwd),
224
+ version: pkgVersion || 'unknown',
225
+ scannedAt: new Date().toISOString(),
226
+ packagesVerified,
227
+ cwd,
228
+ },
229
+ findings,
230
+ summary: {
231
+ errors: findings.errors.length,
232
+ warnings: findings.warnings.length,
233
+ clean: findings.errors.length === 0 && findings.warnings.length === 0,
234
+ },
235
+ };
236
+
237
+ const ext = reportFormat === 'markdown' ? 'md' : 'json';
238
+ const reportFile = join(cwd, `forged-report.${ext}`);
239
+
240
+ if (reportFormat === 'markdown') {
241
+ saveMarkdownReport(reportFile, reportData);
242
+ } else {
243
+ saveJsonReport(reportFile, reportData);
244
+ }
245
+
246
+ console.log(`${green}✔ Report saved: ${reportFile}${reset}\n`);
247
+ }
248
+ }
@@ -0,0 +1,48 @@
1
+ import { execSync } from 'child_process';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { basename, resolve } from 'path';
4
+
5
+ export function detectProjectInfo() {
6
+ const cwd = process.cwd();
7
+ const info = {
8
+ title: basename(cwd),
9
+ github: '',
10
+ email: '',
11
+ installCommand: 'npm i',
12
+ testCommand: 'npm test',
13
+ };
14
+
15
+ // Pull GitHub username + email from git config
16
+ try {
17
+ const remoteUrl = execSync('git remote get-url origin 2>/dev/null', { stdio: ['pipe', 'pipe', 'ignore'] })
18
+ .toString().trim();
19
+ const match = remoteUrl.match(/github\.com[:/]([^/]+)/);
20
+ if (match) info.github = match[1];
21
+ } catch {}
22
+
23
+ try {
24
+ info.email = execSync('git config user.email', { stdio: ['pipe', 'pipe', 'ignore'] })
25
+ .toString().trim();
26
+ } catch {}
27
+
28
+ // Detect stack and set install/test defaults
29
+ if (existsSync('package.json')) {
30
+ try {
31
+ const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
32
+ if (pkg.scripts?.test && !pkg.scripts.test.includes('no test')) {
33
+ info.testCommand = 'npm test';
34
+ }
35
+ } catch {}
36
+ } else if (existsSync('requirements.txt')) {
37
+ info.installCommand = 'pip install -r requirements.txt';
38
+ info.testCommand = 'pytest';
39
+ } else if (existsSync('Cargo.toml')) {
40
+ info.installCommand = 'cargo build';
41
+ info.testCommand = 'cargo test';
42
+ } else if (existsSync('go.mod')) {
43
+ info.installCommand = 'go mod download';
44
+ info.testCommand = 'go test ./...';
45
+ }
46
+
47
+ return info;
48
+ }
@@ -0,0 +1,44 @@
1
+ import { renderLicenseBadge, renderLicenseLink, renderLicenseSection } from './licenseUtils.js';
2
+
3
+ export function generateMarkdown(data) {
4
+ return `# ${data.title}
5
+ ${renderLicenseBadge(data.license)}
6
+
7
+ ## Description
8
+ ${data.description}
9
+
10
+ ## Table of Contents
11
+
12
+ - [Installation](#installation)
13
+ - [Usage](#usage)
14
+ ${data.toDo ? '- [To Do](#to-do)\n' : ''}- [Test](#test)
15
+ - [Contributing](#contributing)
16
+ ${renderLicenseLink(data.license)}
17
+ - [Questions](#questions)
18
+ ${data.toDo ? `
19
+ ## To Do
20
+ ${data.toDo}
21
+ ` : ''}
22
+ ## Installation
23
+ \`\`\`sh
24
+ ${data.installation}
25
+ \`\`\`
26
+
27
+ ## Usage
28
+ ${data.usage}
29
+
30
+ ## Test
31
+ \`\`\`sh
32
+ ${data.test}
33
+ \`\`\`
34
+
35
+ ## Contributing
36
+ ${data.contributing}
37
+
38
+ ${renderLicenseSection(data.license)}
39
+
40
+ ## Questions
41
+ Questions? Email me at ${data.email}.
42
+ See more of my work at [github.com/${data.github}](https://github.com/${data.github}).
43
+ `;
44
+ }
@@ -0,0 +1,25 @@
1
+ // Levenshtein distance — catches typosquatted package names
2
+ export function levenshtein(a, b) {
3
+ const m = a.length, n = b.length;
4
+ const dp = Array.from({ length: m + 1 }, (_, i) =>
5
+ Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))
6
+ );
7
+ for (let i = 1; i <= m; i++)
8
+ for (let j = 1; j <= n; j++)
9
+ dp[i][j] = a[i-1] === b[j-1]
10
+ ? dp[i-1][j-1]
11
+ : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
12
+ return dp[m][n];
13
+ }
14
+
15
+ export function isSuspiciousName(name, popularPackages, threshold = 2) {
16
+ const suspects = [];
17
+ for (const known of popularPackages) {
18
+ if (name === known) continue;
19
+ const dist = levenshtein(name, known);
20
+ if (dist > 0 && dist <= threshold) {
21
+ suspects.push({ known, distance: dist });
22
+ }
23
+ }
24
+ return suspects;
25
+ }
@@ -0,0 +1,14 @@
1
+ export function renderLicenseBadge(license) {
2
+ if (!license || license === 'None') return '';
3
+ return `![License](https://img.shields.io/badge/license-${encodeURIComponent(license)}-blue.svg)`;
4
+ }
5
+
6
+ export function renderLicenseLink(license) {
7
+ if (!license || license === 'None') return '';
8
+ return '- [License](#license)';
9
+ }
10
+
11
+ export function renderLicenseSection(license) {
12
+ if (!license || license === 'None') return '';
13
+ return `## License\nThis project is licensed under the ${license} license.`;
14
+ }
@@ -0,0 +1,20 @@
1
+ // Top npm packages by download count — used for typosquat detection
2
+ export const POPULAR_PACKAGES = [
3
+ 'lodash', 'chalk', 'react', 'express', 'axios', 'moment', 'typescript',
4
+ 'webpack', 'babel', 'eslint', 'prettier', 'jest', 'mocha', 'nodemon',
5
+ 'dotenv', 'cors', 'body-parser', 'mongoose', 'sequelize', 'prisma',
6
+ 'next', 'nuxt', 'vite', 'rollup', 'esbuild', 'turbo', 'nx',
7
+ 'inquirer', 'commander', 'yargs', 'minimist', 'ora', 'boxen', 'figlet',
8
+ 'uuid', 'nanoid', 'crypto', 'bcrypt', 'jsonwebtoken', 'passport',
9
+ 'socket.io', 'ws', 'fastify', 'koa', 'hapi', 'restify',
10
+ 'sharp', 'jimp', 'multer', 'formidable', 'busboy',
11
+ 'redis', 'ioredis', 'pg', 'mysql2', 'sqlite3', 'knex',
12
+ 'zod', 'yup', 'joi', 'ajv', 'class-validator',
13
+ 'rxjs', 'immer', 'zustand', 'redux', 'mobx', 'recoil',
14
+ 'date-fns', 'dayjs', 'luxon', 'validator', 'sanitize-html',
15
+ 'cheerio', 'puppeteer', 'playwright', 'cypress',
16
+ 'tar', 'archiver', 'adm-zip', 'node-fetch', 'got', 'superagent',
17
+ 'cross-env', 'rimraf', 'glob', 'chokidar', 'fs-extra', 'mkdirp',
18
+ 'semver', 'debug', 'winston', 'pino', 'morgan',
19
+ 'pm2', 'concurrently', 'husky', 'lint-staged', 'commitizen',
20
+ ];
@@ -0,0 +1,27 @@
1
+ // Known npm core team — ownership rotates freely between these accounts
2
+ export const NPM_TEAM = new Set([
3
+ 'isaacs', 'gar', 'lukekarrys', 'nlf', 'zkat', 'ruyadorno',
4
+ 'npm-cli-ops', 'iarna', 'darcyclarke', 'wraithgar', 'fritzy',
5
+ 'GitHub Actions',
6
+ ]);
7
+
8
+ // Trusted community maintainers — well-known, long-standing accounts
9
+ export const TRUSTED_COMMUNITY = new Set([
10
+ 'sindresorhus', 'jbnicolai', 'jonschlinkert', 'doowb',
11
+ 'tjholowaychuk', 'dominicbarnes', 'loganfsmyth', 'eemeli',
12
+ 'styfle', 'leo', 'dsblv', 'ljharb', 'nicolo-ribaudo',
13
+ 'mysticatea', 'feross', 'mafintosh',
14
+ 'phated', 'nodejs-foundation', 'legendecas',
15
+ 'ulisesgascon', 'formidablelabs',
16
+ 'blakeembrey', 'wesleytodd', 'danez', 'domenic', 'panva', 'joshuakgoldberg',
17
+ 'matteo.collina', 'rvagg',
18
+ ]);
19
+
20
+ export function classifyPublisherChange(from, to) {
21
+ const fromTrusted = NPM_TEAM.has(from) || TRUSTED_COMMUNITY.has(from);
22
+ const toTrusted = NPM_TEAM.has(to) || TRUSTED_COMMUNITY.has(to);
23
+
24
+ if (toTrusted) return 'info'; // handed to a trusted publisher — suppress
25
+ if (fromTrusted) return 'warn'; // trusted → unknown — flag it
26
+ return 'warn'; // unknown → unknown — flag it
27
+ }
@@ -0,0 +1,129 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { classifyPublisherChange } from './trustedPublishers.js';
4
+
5
+ // Fetch registry metadata with a simple in-memory cache to avoid duplicate requests
6
+ const registryCache = new Map();
7
+
8
+ async function fetchRegistryMeta(name) {
9
+ if (registryCache.has(name)) return registryCache.get(name);
10
+ try {
11
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`);
12
+ if (!res.ok) return null;
13
+ const data = await res.json();
14
+ registryCache.set(name, data);
15
+ return data;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function parseLockfile(cwd) {
22
+ const lockPath = join(cwd, 'package-lock.json');
23
+ if (!existsSync(lockPath)) return null;
24
+ try {
25
+ return JSON.parse(readFileSync(lockPath, 'utf8'));
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ export async function verifyTarballIntegrity(cwd, onProgress) {
32
+ const lock = parseLockfile(cwd);
33
+ if (!lock) return { error: 'No package-lock.json found — run npm install first', findings: [] };
34
+
35
+ const packages = lock.packages || {};
36
+ const entries = Object.entries(packages).filter(
37
+ ([path, meta]) => path && path !== '' && meta.version && meta.integrity && !meta.link
38
+ );
39
+
40
+ if (entries.length === 0) return { error: null, findings: [] };
41
+
42
+ const findings = [];
43
+ let checked = 0;
44
+
45
+ // Batch requests — 5 at a time to avoid hammering the registry
46
+ const batchSize = 5;
47
+ for (let i = 0; i < entries.length; i += batchSize) {
48
+ const batch = entries.slice(i, i + batchSize);
49
+
50
+ await Promise.all(batch.map(async ([pkgPath, meta]) => {
51
+ // Handle nested node_modules (e.g. "node_modules/pkg/node_modules/dep" → "dep")
52
+ const name = pkgPath.replace(/^.*node_modules\//, '');
53
+
54
+ const registryMeta = await fetchRegistryMeta(name);
55
+ checked++;
56
+ if (onProgress) onProgress(checked, entries.length, name);
57
+
58
+ if (!registryMeta) {
59
+ findings.push({
60
+ type: 'warning',
61
+ package: name,
62
+ version: meta.version,
63
+ message: 'Could not reach registry — unable to verify',
64
+ });
65
+ return;
66
+ }
67
+
68
+ // Package not found on registry at all — highly suspicious
69
+ if (registryMeta.error === 'Not found') {
70
+ findings.push({
71
+ type: 'error',
72
+ package: name,
73
+ version: meta.version,
74
+ message: 'Package not found on npm registry — could be a private or malicious package',
75
+ });
76
+ return;
77
+ }
78
+
79
+ const versionData = registryMeta.versions?.[meta.version];
80
+ if (!versionData) {
81
+ findings.push({
82
+ type: 'warning',
83
+ package: name,
84
+ version: meta.version,
85
+ message: `Version ${meta.version} not found on registry`,
86
+ });
87
+ return;
88
+ }
89
+
90
+ const registryIntegrity = versionData.dist?.integrity;
91
+ if (!registryIntegrity) return;
92
+
93
+ // THE CORE CHECK — lock file hash vs registry hash
94
+ if (meta.integrity !== registryIntegrity) {
95
+ findings.push({
96
+ type: 'error',
97
+ package: name,
98
+ version: meta.version,
99
+ message: 'INTEGRITY MISMATCH — installed package differs from registry',
100
+ detail: {
101
+ locked: meta.integrity.slice(0, 40) + '...',
102
+ registry: registryIntegrity.slice(0, 40) + '...',
103
+ },
104
+ });
105
+ return;
106
+ }
107
+
108
+ // Check if the author/publisher changed in this version vs the previous one
109
+ const versions = Object.keys(registryMeta.versions || {});
110
+ const idx = versions.indexOf(meta.version);
111
+ if (idx > 0) {
112
+ const prevVersion = versions[idx - 1];
113
+ const prevPublisher = registryMeta.versions[prevVersion]?._npmUser?.name;
114
+ const currPublisher = versionData._npmUser?.name;
115
+ if (prevPublisher && currPublisher && prevPublisher !== currPublisher) {
116
+ const severity = classifyPublisherChange(prevPublisher, currPublisher);
117
+ findings.push({
118
+ type: severity,
119
+ package: name,
120
+ version: meta.version,
121
+ message: `Publisher changed from "${prevPublisher}" to "${currPublisher}" in this version`,
122
+ });
123
+ }
124
+ }
125
+ }));
126
+ }
127
+
128
+ return { error: null, findings, total: entries.length };
129
+ }
@@ -1,22 +0,0 @@
1
- name: Publish to npm
2
-
3
- on:
4
- push:
5
- tags:
6
- - 'v*'
7
-
8
- jobs:
9
- publish:
10
- runs-on: ubuntu-latest
11
- steps:
12
- - uses: actions/checkout@v4
13
-
14
- - uses: actions/setup-node@v4
15
- with:
16
- node-version: '20'
17
- registry-url: 'https://registry.npmjs.org'
18
-
19
- - name: Publish
20
- run: npm publish
21
- env:
22
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}