@deepv-code/safe-npm 0.1.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/README.zh-CN.md +121 -0
  4. package/dist/cli/args-parser.d.ts +11 -0
  5. package/dist/cli/args-parser.js +36 -0
  6. package/dist/cli/check.d.ts +5 -0
  7. package/dist/cli/check.js +126 -0
  8. package/dist/cli/proxy.d.ts +1 -0
  9. package/dist/cli/proxy.js +4 -0
  10. package/dist/data/popular-packages.d.ts +9 -0
  11. package/dist/data/popular-packages.js +83 -0
  12. package/dist/i18n/en.d.ts +43 -0
  13. package/dist/i18n/en.js +50 -0
  14. package/dist/i18n/index.d.ts +5 -0
  15. package/dist/i18n/index.js +11 -0
  16. package/dist/i18n/zh.d.ts +2 -0
  17. package/dist/i18n/zh.js +50 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.js +77 -0
  20. package/dist/scanner/code-analyzer.d.ts +2 -0
  21. package/dist/scanner/code-analyzer.js +130 -0
  22. package/dist/scanner/index.d.ts +3 -0
  23. package/dist/scanner/index.js +163 -0
  24. package/dist/scanner/patterns/exfiltration.d.ts +7 -0
  25. package/dist/scanner/patterns/exfiltration.js +49 -0
  26. package/dist/scanner/patterns/miner.d.ts +5 -0
  27. package/dist/scanner/patterns/miner.js +32 -0
  28. package/dist/scanner/patterns/obfuscation.d.ts +15 -0
  29. package/dist/scanner/patterns/obfuscation.js +110 -0
  30. package/dist/scanner/types.d.ts +26 -0
  31. package/dist/scanner/types.js +1 -0
  32. package/dist/scanner/typosquatting.d.ts +3 -0
  33. package/dist/scanner/typosquatting.js +126 -0
  34. package/dist/scanner/virustotal.d.ts +7 -0
  35. package/dist/scanner/virustotal.js +249 -0
  36. package/dist/scanner/vulnerability.d.ts +2 -0
  37. package/dist/scanner/vulnerability.js +42 -0
  38. package/dist/tui/App.d.ts +2 -0
  39. package/dist/tui/App.js +67 -0
  40. package/dist/tui/index.d.ts +1 -0
  41. package/dist/tui/index.js +6 -0
  42. package/dist/tui/screens/CheckScreen.d.ts +7 -0
  43. package/dist/tui/screens/CheckScreen.js +92 -0
  44. package/dist/tui/screens/PopularScreen.d.ts +7 -0
  45. package/dist/tui/screens/PopularScreen.js +39 -0
  46. package/dist/tui/screens/SettingsScreen.d.ts +6 -0
  47. package/dist/tui/screens/SettingsScreen.js +64 -0
  48. package/dist/utils/config.d.ts +16 -0
  49. package/dist/utils/config.js +69 -0
  50. package/dist/utils/npm-package.d.ts +38 -0
  51. package/dist/utils/npm-package.js +191 -0
  52. package/dist/utils/npm-runner.d.ts +7 -0
  53. package/dist/utils/npm-runner.js +56 -0
  54. package/package.json +48 -0
@@ -0,0 +1,39 @@
1
+ import React, { useState } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { popularPackages, popularGlobalTools, popularAgents } from '../../data/popular-packages.js';
4
+ export function PopularScreen({ onSelect, onBack }) {
5
+ const [selectedIndex, setSelectedIndex] = useState(0);
6
+ // Combine lists for display
7
+ const items = [
8
+ ...popularAgents.map(a => ({ name: a.name, desc: a.desc })),
9
+ ...popularGlobalTools.map(t => ({ name: t.name, desc: t.desc })),
10
+ ...popularPackages.map(p => ({ name: p, desc: '' }))
11
+ ];
12
+ const visibleItems = items.slice(0, 20); // Show only top 20 for now to avoid scrolling issues initially
13
+ useInput((input, key) => {
14
+ if (key.upArrow) {
15
+ setSelectedIndex(i => Math.max(0, i - 1));
16
+ }
17
+ if (key.downArrow) {
18
+ setSelectedIndex(i => Math.min(visibleItems.length - 1, i + 1));
19
+ }
20
+ if (key.return) {
21
+ onSelect(visibleItems[selectedIndex].name);
22
+ }
23
+ if (input === 'q' || key.escape) {
24
+ onBack();
25
+ }
26
+ });
27
+ return (React.createElement(Box, { flexDirection: "column" },
28
+ React.createElement(Text, { bold: true, color: "cyan" }, "Popular Packages & Tools"),
29
+ React.createElement(Text, { dimColor: true }, "Select a package to scan it"),
30
+ React.createElement(Box, { flexDirection: "column", marginTop: 1 }, visibleItems.map((item, i) => (React.createElement(Box, { key: item.name },
31
+ React.createElement(Text, { color: i === selectedIndex ? 'green' : undefined },
32
+ i === selectedIndex ? '❯ ' : ' ',
33
+ item.name),
34
+ item.desc && (React.createElement(Text, { dimColor: true },
35
+ " - ",
36
+ item.desc)))))),
37
+ React.createElement(Box, { marginTop: 1 },
38
+ React.createElement(Text, { dimColor: true }, "Press Enter to scan, q to back"))));
39
+ }
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ interface Props {
3
+ onBack: () => void;
4
+ }
5
+ export declare function SettingsScreen({ onBack }: Props): React.ReactElement;
6
+ export {};
@@ -0,0 +1,64 @@
1
+ import React, { useState } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { getConfig, saveConfig } from '../../utils/config.js';
4
+ export function SettingsScreen({ onBack }) {
5
+ const [config, setConfig] = useState(getConfig());
6
+ const [selectedIndex, setSelectedIndex] = useState(0);
7
+ const settings = [
8
+ {
9
+ key: 'language',
10
+ label: 'Language',
11
+ value: config.language,
12
+ toggle: () => {
13
+ const newLang = config.language === 'en' ? 'zh' : 'en';
14
+ updateConfig({ language: newLang });
15
+ }
16
+ },
17
+ {
18
+ key: 'vt_enabled',
19
+ label: 'VirusTotal Scan',
20
+ value: config.virustotal.enabled ? 'Enabled' : 'Disabled',
21
+ toggle: () => {
22
+ updateConfig({
23
+ virustotal: { ...config.virustotal, enabled: !config.virustotal.enabled }
24
+ });
25
+ }
26
+ },
27
+ {
28
+ key: 'offline',
29
+ label: 'Offline Mode',
30
+ value: config.offline ? 'On' : 'Off',
31
+ toggle: () => {
32
+ updateConfig({ offline: !config.offline });
33
+ }
34
+ }
35
+ ];
36
+ const updateConfig = (newPart) => {
37
+ saveConfig(newPart);
38
+ setConfig(getConfig()); // Reload to be sure
39
+ };
40
+ useInput((input, key) => {
41
+ if (key.upArrow) {
42
+ setSelectedIndex(i => Math.max(0, i - 1));
43
+ }
44
+ if (key.downArrow) {
45
+ setSelectedIndex(i => Math.min(settings.length - 1, i + 1));
46
+ }
47
+ if (key.return || input === ' ') {
48
+ settings[selectedIndex].toggle();
49
+ }
50
+ if (key.escape || input === 'q') {
51
+ onBack();
52
+ }
53
+ });
54
+ return (React.createElement(Box, { flexDirection: "column" },
55
+ React.createElement(Text, { bold: true, color: "cyan" }, "Settings"),
56
+ React.createElement(Box, { flexDirection: "column", marginTop: 1 }, settings.map((item, i) => (React.createElement(Box, { key: item.key },
57
+ React.createElement(Text, { color: i === selectedIndex ? 'green' : undefined },
58
+ i === selectedIndex ? '❯ ' : ' ',
59
+ item.label,
60
+ ": ",
61
+ React.createElement(Text, { bold: true }, String(item.value))))))),
62
+ React.createElement(Box, { marginTop: 1 },
63
+ React.createElement(Text, { dimColor: true }, "Space/Enter to toggle, q to back"))));
64
+ }
@@ -0,0 +1,16 @@
1
+ import type { Language } from '../i18n/index.js';
2
+ export interface Config {
3
+ language: Language;
4
+ virustotal: {
5
+ apiKey: string;
6
+ enabled: boolean;
7
+ };
8
+ offline: boolean;
9
+ cache: {
10
+ ttl: number;
11
+ };
12
+ }
13
+ export declare function hasConfigFile(): boolean;
14
+ export declare function getConfig(): Config;
15
+ export declare function saveConfig(config: Partial<Config>): void;
16
+ export declare function resetConfigCache(): void;
@@ -0,0 +1,69 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ const DEFAULT_VT_KEY = '3886ca2a3f7f9f42cb161f314cd1446fb7fd88e4097c5e004e1b64435d0726a9';
5
+ const defaultConfig = {
6
+ language: 'en',
7
+ virustotal: {
8
+ apiKey: DEFAULT_VT_KEY,
9
+ enabled: true,
10
+ },
11
+ offline: false,
12
+ cache: {
13
+ ttl: 86400,
14
+ },
15
+ };
16
+ function getConfigDir() {
17
+ return join(homedir(), '.safe-npm');
18
+ }
19
+ function getConfigPath() {
20
+ return join(getConfigDir(), 'config.json');
21
+ }
22
+ export function hasConfigFile() {
23
+ return existsSync(getConfigPath());
24
+ }
25
+ let cachedConfig = null;
26
+ export function getConfig() {
27
+ if (cachedConfig)
28
+ return cachedConfig;
29
+ const configPath = getConfigPath();
30
+ if (existsSync(configPath)) {
31
+ try {
32
+ const content = readFileSync(configPath, 'utf-8');
33
+ const loaded = JSON.parse(content);
34
+ cachedConfig = {
35
+ ...defaultConfig,
36
+ ...loaded,
37
+ virustotal: {
38
+ ...defaultConfig.virustotal,
39
+ ...(loaded.virustotal || {})
40
+ }
41
+ };
42
+ // Ensure API key is set (fallback to default if empty)
43
+ if (!cachedConfig?.virustotal.apiKey) {
44
+ // @ts-ignore
45
+ cachedConfig.virustotal.apiKey = DEFAULT_VT_KEY;
46
+ }
47
+ return cachedConfig;
48
+ }
49
+ catch {
50
+ cachedConfig = defaultConfig;
51
+ return cachedConfig;
52
+ }
53
+ }
54
+ cachedConfig = defaultConfig;
55
+ return cachedConfig;
56
+ }
57
+ export function saveConfig(config) {
58
+ const configDir = getConfigDir();
59
+ const configPath = getConfigPath();
60
+ if (!existsSync(configDir)) {
61
+ mkdirSync(configDir, { recursive: true });
62
+ }
63
+ const newConfig = { ...getConfig(), ...config };
64
+ writeFileSync(configPath, JSON.stringify(newConfig, null, 2));
65
+ cachedConfig = newConfig;
66
+ }
67
+ export function resetConfigCache() {
68
+ cachedConfig = null;
69
+ }
@@ -0,0 +1,38 @@
1
+ export interface PackageInfo {
2
+ name: string;
3
+ version: string;
4
+ tarballUrl: string;
5
+ tarballSha: string;
6
+ scripts: Record<string, string>;
7
+ main?: string;
8
+ dependencies: Record<string, string>;
9
+ }
10
+ export interface PackageFiles {
11
+ packageJson: Record<string, unknown>;
12
+ scripts: Record<string, string>;
13
+ entryFile?: string;
14
+ entryContent?: string;
15
+ allJsFiles: {
16
+ path: string;
17
+ content: string;
18
+ }[];
19
+ }
20
+ /**
21
+ * Get package info from npm registry
22
+ */
23
+ export declare function getPackageInfo(packageName: string): Promise<PackageInfo | null>;
24
+ /**
25
+ * Check if a package exists in the registry
26
+ */
27
+ export declare function checkPackageExists(packageName: string): Promise<boolean>;
28
+ /**
29
+ * Download package tarball and return SHA256 hash
30
+ */
31
+ export declare function downloadPackageAndHash(tarballUrl: string): Promise<{
32
+ hash: string;
33
+ buffer: Buffer;
34
+ } | null>;
35
+ /**
36
+ * Extract tarball and scan files
37
+ */
38
+ export declare function extractAndScanPackage(packageName: string): Promise<PackageFiles | null>;
@@ -0,0 +1,191 @@
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { createHash } from 'crypto';
4
+ import { tmpdir } from 'os';
5
+ import { join } from 'path';
6
+ import { mkdirSync, rmSync, existsSync, readFileSync } from 'fs';
7
+ import axios from 'axios';
8
+ const execAsync = promisify(exec);
9
+ /**
10
+ * Get package info from npm registry
11
+ */
12
+ export async function getPackageInfo(packageName) {
13
+ try {
14
+ // Parse package name and version
15
+ let name = packageName;
16
+ let version = 'latest';
17
+ if (packageName.includes('@') && !packageName.startsWith('@')) {
18
+ const parts = packageName.split('@');
19
+ name = parts[0];
20
+ version = parts[1] || 'latest';
21
+ }
22
+ else if (packageName.startsWith('@')) {
23
+ // Scoped package like @types/node@1.0.0
24
+ const lastAt = packageName.lastIndexOf('@');
25
+ if (lastAt > 0) {
26
+ name = packageName.substring(0, lastAt);
27
+ version = packageName.substring(lastAt + 1) || 'latest';
28
+ }
29
+ }
30
+ const { stdout } = await execAsync(`npm view ${name}@${version} --json`, {
31
+ timeout: 30000,
32
+ });
33
+ const info = JSON.parse(stdout);
34
+ return {
35
+ name: info.name,
36
+ version: info.version,
37
+ tarballUrl: info.dist?.tarball || '',
38
+ tarballSha: info.dist?.shasum || '',
39
+ scripts: info.scripts || {},
40
+ main: info.main,
41
+ dependencies: info.dependencies || {},
42
+ };
43
+ }
44
+ catch (error) {
45
+ if (error.stdout && error.stdout.includes('E404')) {
46
+ return null; // Clearly not found
47
+ }
48
+ // For other errors, we also return null currently but log it
49
+ // Suppress logging if it's just a 404 (npm view output usually contains the error)
50
+ if (!error.message.includes('code E404')) {
51
+ console.error(`Failed to get package info for ${packageName}:`, error.message);
52
+ }
53
+ return null;
54
+ }
55
+ }
56
+ /**
57
+ * Check if a package exists in the registry
58
+ */
59
+ export async function checkPackageExists(packageName) {
60
+ try {
61
+ await execAsync(`npm view ${packageName} name`, { timeout: 10000 });
62
+ return true;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ /**
69
+ * Download package tarball and return SHA256 hash
70
+ */
71
+ export async function downloadPackageAndHash(tarballUrl) {
72
+ try {
73
+ const response = await axios.get(tarballUrl, {
74
+ responseType: 'arraybuffer',
75
+ timeout: 60000,
76
+ });
77
+ const buffer = Buffer.from(response.data);
78
+ const sha256 = createHash('sha256').update(buffer).digest('hex');
79
+ return { hash: sha256, buffer };
80
+ }
81
+ catch (error) {
82
+ console.error('Failed to download tarball:', error);
83
+ return null;
84
+ }
85
+ }
86
+ /**
87
+ * Extract tarball and scan files
88
+ */
89
+ export async function extractAndScanPackage(packageName) {
90
+ const tempDir = join(tmpdir(), `safe-npm-scan-${Date.now()}`);
91
+ try {
92
+ mkdirSync(tempDir, { recursive: true });
93
+ // Use npm pack to download the package
94
+ const { stdout } = await execAsync(`npm pack ${packageName} --pack-destination="${tempDir}"`, {
95
+ timeout: 60000,
96
+ cwd: tempDir,
97
+ });
98
+ const tgzFile = stdout.trim();
99
+ const tgzPath = join(tempDir, tgzFile);
100
+ // Extract the tarball
101
+ await execAsync(`tar -xzf "${tgzPath}" -C "${tempDir}"`, {
102
+ timeout: 30000,
103
+ });
104
+ const packageDir = join(tempDir, 'package');
105
+ if (!existsSync(packageDir)) {
106
+ return null;
107
+ }
108
+ // Read package.json
109
+ const packageJsonPath = join(packageDir, 'package.json');
110
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
111
+ // Collect all JS files (limit to entry point and install scripts area)
112
+ const allJsFiles = [];
113
+ const filesToScan = [];
114
+ // Add main entry file
115
+ const mainFile = packageJson.main || 'index.js';
116
+ const mainPath = join(packageDir, mainFile);
117
+ if (existsSync(mainPath)) {
118
+ filesToScan.push(mainPath);
119
+ }
120
+ // Add bin files
121
+ if (packageJson.bin) {
122
+ const bins = typeof packageJson.bin === 'string'
123
+ ? [packageJson.bin]
124
+ : Object.values(packageJson.bin);
125
+ for (const bin of bins) {
126
+ const binPath = join(packageDir, bin);
127
+ if (existsSync(binPath)) {
128
+ filesToScan.push(binPath);
129
+ }
130
+ }
131
+ }
132
+ // Scan preinstall/postinstall scripts if they reference files
133
+ const scripts = packageJson.scripts || {};
134
+ for (const [scriptName, scriptCmd] of Object.entries(scripts)) {
135
+ if (['preinstall', 'install', 'postinstall'].includes(scriptName)) {
136
+ // Check if script references a JS file
137
+ const cmd = scriptCmd;
138
+ const jsMatch = cmd.match(/node\s+([^\s]+\.js)/);
139
+ if (jsMatch) {
140
+ const scriptPath = join(packageDir, jsMatch[1]);
141
+ if (existsSync(scriptPath)) {
142
+ filesToScan.push(scriptPath);
143
+ }
144
+ }
145
+ }
146
+ }
147
+ // Read files to scan
148
+ for (const filePath of filesToScan) {
149
+ try {
150
+ const content = readFileSync(filePath, 'utf-8');
151
+ allJsFiles.push({
152
+ path: filePath.replace(packageDir, ''),
153
+ content,
154
+ });
155
+ }
156
+ catch {
157
+ // Skip unreadable files
158
+ }
159
+ }
160
+ // Get entry content
161
+ let entryContent;
162
+ if (existsSync(mainPath)) {
163
+ try {
164
+ entryContent = readFileSync(mainPath, 'utf-8');
165
+ }
166
+ catch {
167
+ // Ignore
168
+ }
169
+ }
170
+ return {
171
+ packageJson,
172
+ scripts: packageJson.scripts || {},
173
+ entryFile: mainFile,
174
+ entryContent,
175
+ allJsFiles,
176
+ };
177
+ }
178
+ catch (error) {
179
+ console.error('Failed to extract package:', error);
180
+ return null;
181
+ }
182
+ finally {
183
+ // Cleanup temp directory
184
+ try {
185
+ rmSync(tempDir, { recursive: true, force: true });
186
+ }
187
+ catch {
188
+ // Ignore cleanup errors
189
+ }
190
+ }
191
+ }
@@ -0,0 +1,7 @@
1
+ export interface NpmResult {
2
+ exitCode: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ }
6
+ export declare function runNpm(args: string[]): Promise<NpmResult>;
7
+ export declare function runNpmPassthrough(args: string[]): Promise<number>;
@@ -0,0 +1,56 @@
1
+ import { spawn } from 'child_process';
2
+ export function runNpm(args) {
3
+ return new Promise((resolve) => {
4
+ const isWin = process.platform === 'win32';
5
+ const command = isWin ? 'cmd.exe' : 'npm';
6
+ // On Windows, use /d /s /c to run the command safely
7
+ // npm.cmd is usually in PATH, but we can specify it directly if needed.
8
+ // Usually 'npm' works inside cmd.
9
+ const finalArgs = isWin ? ['/d', '/s', '/c', 'npm', ...args] : args;
10
+ const child = spawn(command, finalArgs, {
11
+ stdio: ['inherit', 'pipe', 'pipe'],
12
+ shell: false,
13
+ });
14
+ let stdout = '';
15
+ let stderr = '';
16
+ child.stdout?.on('data', (data) => {
17
+ stdout += data.toString();
18
+ process.stdout.write(data);
19
+ });
20
+ child.stderr?.on('data', (data) => {
21
+ stderr += data.toString();
22
+ process.stderr.write(data);
23
+ });
24
+ child.on('close', (code) => {
25
+ resolve({
26
+ exitCode: code ?? 1,
27
+ stdout,
28
+ stderr,
29
+ });
30
+ });
31
+ child.on('error', () => {
32
+ resolve({
33
+ exitCode: 1,
34
+ stdout,
35
+ stderr: stderr || 'Failed to run npm',
36
+ });
37
+ });
38
+ });
39
+ }
40
+ export function runNpmPassthrough(args) {
41
+ return new Promise((resolve) => {
42
+ const isWin = process.platform === 'win32';
43
+ const command = isWin ? 'cmd.exe' : 'npm';
44
+ const finalArgs = isWin ? ['/d', '/s', '/c', 'npm', ...args] : args;
45
+ const child = spawn(command, finalArgs, {
46
+ stdio: 'inherit',
47
+ shell: false,
48
+ });
49
+ child.on('close', (code) => {
50
+ resolve(code ?? 1);
51
+ });
52
+ child.on('error', () => {
53
+ resolve(1);
54
+ });
55
+ });
56
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@deepv-code/safe-npm",
3
+ "version": "0.1.0",
4
+ "description": "A security-focused npm wrapper that scans packages before installation",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "safe-npm": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node dist/index.js",
12
+ "build": "tsc",
13
+ "dev": "tsc -w",
14
+ "test": "vitest"
15
+ },
16
+ "keywords": [
17
+ "npm",
18
+ "security",
19
+ "malware",
20
+ "scanner"
21
+ ],
22
+ "author": "DeepV Code",
23
+ "license": "MIT",
24
+ "files": [
25
+ "dist",
26
+ "README.md",
27
+ "README.zh-CN.md",
28
+ "LICENSE"
29
+ ],
30
+ "dependencies": {
31
+ "axios": "^1.13.4",
32
+ "chalk": "^5.6.2",
33
+ "commander": "^14.0.2",
34
+ "form-data": "^4.0.5",
35
+ "ink": "^6.6.0",
36
+ "ink-text-input": "^6.0.0",
37
+ "ora": "^9.1.0",
38
+ "react": "^19.2.4"
39
+ },
40
+ "devDependencies": {
41
+ "@types/form-data": "^2.2.1",
42
+ "@types/ink-text-input": "^2.0.5",
43
+ "@types/node": "^25.0.10",
44
+ "@types/react": "^19.2.10",
45
+ "typescript": "^5.9.3",
46
+ "vitest": "^4.0.18"
47
+ }
48
+ }