@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.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/README.zh-CN.md +121 -0
- package/dist/cli/args-parser.d.ts +11 -0
- package/dist/cli/args-parser.js +36 -0
- package/dist/cli/check.d.ts +5 -0
- package/dist/cli/check.js +126 -0
- package/dist/cli/proxy.d.ts +1 -0
- package/dist/cli/proxy.js +4 -0
- package/dist/data/popular-packages.d.ts +9 -0
- package/dist/data/popular-packages.js +83 -0
- package/dist/i18n/en.d.ts +43 -0
- package/dist/i18n/en.js +50 -0
- package/dist/i18n/index.d.ts +5 -0
- package/dist/i18n/index.js +11 -0
- package/dist/i18n/zh.d.ts +2 -0
- package/dist/i18n/zh.js +50 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +77 -0
- package/dist/scanner/code-analyzer.d.ts +2 -0
- package/dist/scanner/code-analyzer.js +130 -0
- package/dist/scanner/index.d.ts +3 -0
- package/dist/scanner/index.js +163 -0
- package/dist/scanner/patterns/exfiltration.d.ts +7 -0
- package/dist/scanner/patterns/exfiltration.js +49 -0
- package/dist/scanner/patterns/miner.d.ts +5 -0
- package/dist/scanner/patterns/miner.js +32 -0
- package/dist/scanner/patterns/obfuscation.d.ts +15 -0
- package/dist/scanner/patterns/obfuscation.js +110 -0
- package/dist/scanner/types.d.ts +26 -0
- package/dist/scanner/types.js +1 -0
- package/dist/scanner/typosquatting.d.ts +3 -0
- package/dist/scanner/typosquatting.js +126 -0
- package/dist/scanner/virustotal.d.ts +7 -0
- package/dist/scanner/virustotal.js +249 -0
- package/dist/scanner/vulnerability.d.ts +2 -0
- package/dist/scanner/vulnerability.js +42 -0
- package/dist/tui/App.d.ts +2 -0
- package/dist/tui/App.js +67 -0
- package/dist/tui/index.d.ts +1 -0
- package/dist/tui/index.js +6 -0
- package/dist/tui/screens/CheckScreen.d.ts +7 -0
- package/dist/tui/screens/CheckScreen.js +92 -0
- package/dist/tui/screens/PopularScreen.d.ts +7 -0
- package/dist/tui/screens/PopularScreen.js +39 -0
- package/dist/tui/screens/SettingsScreen.d.ts +6 -0
- package/dist/tui/screens/SettingsScreen.js +64 -0
- package/dist/utils/config.d.ts +16 -0
- package/dist/utils/config.js +69 -0
- package/dist/utils/npm-package.d.ts +38 -0
- package/dist/utils/npm-package.js +191 -0
- package/dist/utils/npm-runner.d.ts +7 -0
- package/dist/utils/npm-runner.js +56 -0
- package/package.json +48 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type RiskLevel = 'fatal' | 'high' | 'warning' | 'safe';
|
|
2
|
+
export interface ScanResult {
|
|
3
|
+
packageName: string;
|
|
4
|
+
version?: string;
|
|
5
|
+
riskLevel: RiskLevel;
|
|
6
|
+
issues: ScanIssue[];
|
|
7
|
+
checks: ScanCheck[];
|
|
8
|
+
canBypass: boolean;
|
|
9
|
+
suggestedPackage?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ScanCheck {
|
|
12
|
+
name: string;
|
|
13
|
+
status: 'pass' | 'fail' | 'skipped' | 'error';
|
|
14
|
+
description?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ScanIssue {
|
|
17
|
+
type: 'virus' | 'typosquat' | 'suspicious_code' | 'cve' | 'miner';
|
|
18
|
+
severity: RiskLevel;
|
|
19
|
+
message: string;
|
|
20
|
+
details?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface ScanOptions {
|
|
23
|
+
offline?: boolean;
|
|
24
|
+
skipVirustotal?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export type ProgressCallback = (message: string, completed: number, total: number) => void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { popularPackages } from '../data/popular-packages.js';
|
|
2
|
+
import { t } from '../i18n/index.js';
|
|
3
|
+
// Calculate Levenshtein distance
|
|
4
|
+
function levenshtein(a, b) {
|
|
5
|
+
const matrix = [];
|
|
6
|
+
for (let i = 0; i <= b.length; i++) {
|
|
7
|
+
matrix[i] = [i];
|
|
8
|
+
}
|
|
9
|
+
for (let j = 0; j <= a.length; j++) {
|
|
10
|
+
matrix[0][j] = j;
|
|
11
|
+
}
|
|
12
|
+
for (let i = 1; i <= b.length; i++) {
|
|
13
|
+
for (let j = 1; j <= a.length; j++) {
|
|
14
|
+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
15
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return matrix[b.length][a.length];
|
|
23
|
+
}
|
|
24
|
+
// Common typosquatting patterns
|
|
25
|
+
function checkCommonPatterns(name, target) {
|
|
26
|
+
// Same name with hyphens/underscores swapped
|
|
27
|
+
if (name.replace(/-/g, '_') === target.replace(/-/g, '_'))
|
|
28
|
+
return false;
|
|
29
|
+
if (name.replace(/_/g, '-') === target.replace(/_/g, '-'))
|
|
30
|
+
return false;
|
|
31
|
+
// Character substitution (0 for o, 1 for l, etc.)
|
|
32
|
+
const normalized = name
|
|
33
|
+
.replace(/0/g, 'o')
|
|
34
|
+
.replace(/1/g, 'l')
|
|
35
|
+
.replace(/3/g, 'e')
|
|
36
|
+
.replace(/4/g, 'a')
|
|
37
|
+
.replace(/5/g, 's');
|
|
38
|
+
if (normalized === target)
|
|
39
|
+
return true;
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
function parsePackageName(name) {
|
|
43
|
+
if (name.startsWith('@')) {
|
|
44
|
+
const parts = name.split('/');
|
|
45
|
+
return { scope: parts[0], name: parts[1] || '' };
|
|
46
|
+
}
|
|
47
|
+
return { scope: null, name: name };
|
|
48
|
+
}
|
|
49
|
+
export function findClosestPopularPackage(packageName) {
|
|
50
|
+
const lowerName = packageName.toLowerCase();
|
|
51
|
+
const targetParsed = parsePackageName(lowerName);
|
|
52
|
+
let bestMatch = null;
|
|
53
|
+
let minDistance = Infinity;
|
|
54
|
+
for (const popular of popularPackages) {
|
|
55
|
+
const distance = levenshtein(lowerName, popular);
|
|
56
|
+
// Exact match is not a suggestion
|
|
57
|
+
if (distance === 0)
|
|
58
|
+
return null;
|
|
59
|
+
if (distance <= 3 && distance < minDistance) { // Slightly looser threshold for "Did you mean?"
|
|
60
|
+
minDistance = distance;
|
|
61
|
+
bestMatch = popular;
|
|
62
|
+
}
|
|
63
|
+
// Check scoped similarity for suggestions
|
|
64
|
+
const popularParsed = parsePackageName(popular);
|
|
65
|
+
if (popularParsed.scope && targetParsed.name) {
|
|
66
|
+
const baseDist = levenshtein(targetParsed.name, popularParsed.name);
|
|
67
|
+
if (baseDist <= 1) {
|
|
68
|
+
return popular; // Strong signal: base name matches scoped package
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return bestMatch;
|
|
73
|
+
}
|
|
74
|
+
export async function scanTyposquatting(packageName) {
|
|
75
|
+
const issues = [];
|
|
76
|
+
const lowerName = packageName.toLowerCase();
|
|
77
|
+
// Skip if it's an exact match of a popular package
|
|
78
|
+
if (popularPackages.includes(lowerName)) {
|
|
79
|
+
return issues;
|
|
80
|
+
}
|
|
81
|
+
const targetParsed = parsePackageName(lowerName);
|
|
82
|
+
for (const popular of popularPackages) {
|
|
83
|
+
// 1. Standard full-name check
|
|
84
|
+
const distance = levenshtein(lowerName, popular);
|
|
85
|
+
if (distance > 0 && distance <= 2) {
|
|
86
|
+
issues.push({
|
|
87
|
+
type: 'typosquat',
|
|
88
|
+
severity: 'fatal',
|
|
89
|
+
message: t('typosquatDetected'),
|
|
90
|
+
details: `Similar to popular package "${popular}" (distance: ${distance})`,
|
|
91
|
+
});
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
if (checkCommonPatterns(lowerName, popular)) {
|
|
95
|
+
issues.push({
|
|
96
|
+
type: 'typosquat',
|
|
97
|
+
severity: 'fatal',
|
|
98
|
+
message: t('typosquatDetected'),
|
|
99
|
+
details: `Suspicious similarity to "${popular}"`,
|
|
100
|
+
});
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
// 2. Scoped package protection logic
|
|
104
|
+
const popularParsed = parsePackageName(popular);
|
|
105
|
+
if (popularParsed.scope) {
|
|
106
|
+
// If the popular package IS scoped (e.g., @anthropic-ai/claude-code)
|
|
107
|
+
// Check A: The user is trying to install the "unscoped" version (e.g. claude-code)
|
|
108
|
+
// or a version in a different scope (e.g. @fake/claude-code)
|
|
109
|
+
const baseNameDistance = levenshtein(targetParsed.name, popularParsed.name);
|
|
110
|
+
// If the base name is identical or extremely similar
|
|
111
|
+
if (baseNameDistance <= 1) { // Very strict for scoped bases
|
|
112
|
+
// And the scopes are different (or target has no scope)
|
|
113
|
+
if (targetParsed.scope !== popularParsed.scope) {
|
|
114
|
+
issues.push({
|
|
115
|
+
type: 'typosquat',
|
|
116
|
+
severity: 'fatal',
|
|
117
|
+
message: t('typosquatDetected'),
|
|
118
|
+
details: `Scope Hijacking Detected: This package "${packageName}" mimics the official package "${popular}". Verify the scope carefully!`,
|
|
119
|
+
});
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return issues;
|
|
126
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { t } from '../i18n/index.js';
|
|
2
|
+
import { getConfig } from '../utils/config.js';
|
|
3
|
+
import { getPackageInfo, downloadPackageAndHash } from '../utils/npm-package.js';
|
|
4
|
+
import axios from 'axios';
|
|
5
|
+
import FormData from 'form-data';
|
|
6
|
+
// Local blacklist cache for offline mode
|
|
7
|
+
// Source: Known malicious npm packages from security advisories
|
|
8
|
+
const localBlacklist = new Set([
|
|
9
|
+
// Real known malicious packages
|
|
10
|
+
'event-stream', // Compromised in 2018, version 3.3.6
|
|
11
|
+
'flatmap-stream', // Malicious dependency of event-stream
|
|
12
|
+
'eslint-scope', // Compromised in 2018
|
|
13
|
+
'eslint-config-eslint', // Typosquat
|
|
14
|
+
'crossenv', // Typosquat of cross-env
|
|
15
|
+
'cross-env.js', // Typosquat of cross-env
|
|
16
|
+
'mongose', // Typosquat of mongoose
|
|
17
|
+
'mariadb', // Malicious package (not the real one)
|
|
18
|
+
'discordi.js', // Typosquat of discord.js
|
|
19
|
+
'discord.js-user', // Malicious
|
|
20
|
+
'colors-js', // Typosquat
|
|
21
|
+
'nodejs-base64', // Malicious
|
|
22
|
+
'nodesass', // Typosquat of node-sass
|
|
23
|
+
'nodefabric', // Malicious
|
|
24
|
+
'node-fabric', // Malicious
|
|
25
|
+
'fabric-js', // Typosquat
|
|
26
|
+
'grpc-js', // Typosquat of @grpc/grpc-js
|
|
27
|
+
'sqlite.js', // Typosquat
|
|
28
|
+
'sqlite-js', // Typosquat
|
|
29
|
+
'mssql.js', // Typosquat
|
|
30
|
+
'mssql-node', // Typosquat
|
|
31
|
+
'lodash-js', // Typosquat
|
|
32
|
+
'loadsh', // Typosquat
|
|
33
|
+
'lodashs', // Typosquat
|
|
34
|
+
'underscore-js', // Typosquat
|
|
35
|
+
'underscores', // Typosquat
|
|
36
|
+
'babel-preset-es2016', // Typosquat
|
|
37
|
+
'babelpreset-es2015', // Typosquat
|
|
38
|
+
'rc-js', // Typosquat
|
|
39
|
+
'rpc-websocket', // Malicious
|
|
40
|
+
'jquey', // Typosquat of jquery
|
|
41
|
+
'jquery.js', // Typosquat
|
|
42
|
+
'jqeury', // Typosquat
|
|
43
|
+
'boostrap', // Typosquat of bootstrap
|
|
44
|
+
'bootstrap-css', // Typosquat
|
|
45
|
+
'angularjs', // Typosquat
|
|
46
|
+
'angular.js', // Typosquat
|
|
47
|
+
'react.js', // Typosquat
|
|
48
|
+
'react-js', // Typosquat
|
|
49
|
+
'vue-js', // Typosquat
|
|
50
|
+
'twilio-npm', // Malicious
|
|
51
|
+
'discord-selfbot-v13', // Malicious
|
|
52
|
+
'discord-lofy', // Malicious
|
|
53
|
+
]);
|
|
54
|
+
// Known malicious package version ranges
|
|
55
|
+
const maliciousVersions = {
|
|
56
|
+
'event-stream': ['3.3.6'],
|
|
57
|
+
'ua-parser-js': ['0.7.29', '0.8.0', '1.0.0'],
|
|
58
|
+
'coa': ['2.0.3', '2.0.4', '2.1.1', '2.1.3', '3.0.1', '3.1.3'],
|
|
59
|
+
'rc': ['1.2.9', '1.3.9', '2.3.9'],
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Get a special upload URL for files larger than 32MB
|
|
63
|
+
*/
|
|
64
|
+
async function getLargeFileUploadUrl(apiKey) {
|
|
65
|
+
try {
|
|
66
|
+
const response = await axios.get('https://www.virustotal.com/api/v3/files/upload_url', {
|
|
67
|
+
headers: { 'x-apikey': apiKey },
|
|
68
|
+
timeout: 30000,
|
|
69
|
+
});
|
|
70
|
+
return response.data?.data || null;
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
console.error('Failed to get VT upload URL', e);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Upload file to VirusTotal for analysis
|
|
79
|
+
*/
|
|
80
|
+
async function uploadFileToVirusTotal(buffer, filename, apiKey) {
|
|
81
|
+
try {
|
|
82
|
+
const formData = new FormData();
|
|
83
|
+
formData.append('file', buffer, { filename });
|
|
84
|
+
let uploadUrl = 'https://www.virustotal.com/api/v3/files';
|
|
85
|
+
// VirusTotal requires a special upload URL for files > 32MB
|
|
86
|
+
// Using 32MB limit to be safe (API doc says 32MB)
|
|
87
|
+
if (buffer.length > 32 * 1024 * 1024) {
|
|
88
|
+
const customUrl = await getLargeFileUploadUrl(apiKey);
|
|
89
|
+
if (customUrl) {
|
|
90
|
+
uploadUrl = customUrl;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const response = await axios.post(uploadUrl, formData, {
|
|
94
|
+
headers: {
|
|
95
|
+
'x-apikey': apiKey,
|
|
96
|
+
...formData.getHeaders(),
|
|
97
|
+
},
|
|
98
|
+
// Increase timeout significantly for uploads (10 minutes)
|
|
99
|
+
timeout: 600000,
|
|
100
|
+
maxBodyLength: Infinity,
|
|
101
|
+
maxContentLength: Infinity
|
|
102
|
+
});
|
|
103
|
+
// Returns the analysis ID (e.g., "ZmY5...")
|
|
104
|
+
return response.data?.data?.id || null;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') {
|
|
108
|
+
console.error('VirusTotal Upload Timeout: File too large or network too slow');
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
console.error('VirusTotal Upload Error:', error?.message || error);
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Query VirusTotal API for file hash
|
|
118
|
+
*/
|
|
119
|
+
async function queryVirusTotal(sha256, apiKey) {
|
|
120
|
+
try {
|
|
121
|
+
const response = await axios.get(`https://www.virustotal.com/api/v3/files/${sha256}`, {
|
|
122
|
+
headers: {
|
|
123
|
+
'x-apikey': apiKey,
|
|
124
|
+
},
|
|
125
|
+
timeout: 30000,
|
|
126
|
+
validateStatus: (status) => status === 200 || status === 404,
|
|
127
|
+
});
|
|
128
|
+
if (response.status === 404) {
|
|
129
|
+
// File not found in VirusTotal - not necessarily bad
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const stats = response.data?.data?.attributes?.last_analysis_stats;
|
|
133
|
+
const results = response.data?.data?.attributes?.last_analysis_results;
|
|
134
|
+
if (!stats) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
// Collect detection names
|
|
138
|
+
const detections = [];
|
|
139
|
+
if (results) {
|
|
140
|
+
for (const [engine, result] of Object.entries(results)) {
|
|
141
|
+
if (result.category === 'malicious' && result.result) {
|
|
142
|
+
detections.push(`${engine}: ${result.result}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
malicious: stats.malicious || 0,
|
|
148
|
+
suspicious: stats.suspicious || 0,
|
|
149
|
+
detections,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
console.error('VirusTotal API error:', error);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export async function scanVirusTotal(packageName, options = {}) {
|
|
158
|
+
const issues = [];
|
|
159
|
+
const config = getConfig();
|
|
160
|
+
// Parse package name without version
|
|
161
|
+
let baseName = packageName;
|
|
162
|
+
if (packageName.includes('@') && !packageName.startsWith('@')) {
|
|
163
|
+
baseName = packageName.split('@')[0];
|
|
164
|
+
}
|
|
165
|
+
else if (packageName.startsWith('@')) {
|
|
166
|
+
const lastAt = packageName.lastIndexOf('@');
|
|
167
|
+
if (lastAt > 0) {
|
|
168
|
+
baseName = packageName.substring(0, lastAt);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Check local blacklist first
|
|
172
|
+
if (localBlacklist.has(baseName)) {
|
|
173
|
+
issues.push({
|
|
174
|
+
type: 'virus',
|
|
175
|
+
severity: 'fatal',
|
|
176
|
+
message: t('virusDetected'),
|
|
177
|
+
details: `Package "${baseName}" is in the known malicious packages blacklist`,
|
|
178
|
+
});
|
|
179
|
+
return { issues, info: 'Blocked by local blacklist' };
|
|
180
|
+
}
|
|
181
|
+
// Get package info to check version
|
|
182
|
+
const packageInfo = await getPackageInfo(packageName);
|
|
183
|
+
if (packageInfo) {
|
|
184
|
+
// Check for known malicious versions
|
|
185
|
+
const badVersions = maliciousVersions[packageInfo.name];
|
|
186
|
+
if (badVersions && badVersions.includes(packageInfo.version)) {
|
|
187
|
+
issues.push({
|
|
188
|
+
type: 'virus',
|
|
189
|
+
severity: 'fatal',
|
|
190
|
+
message: t('virusDetected'),
|
|
191
|
+
details: `Version ${packageInfo.version} of "${packageInfo.name}" is known to be compromised`,
|
|
192
|
+
});
|
|
193
|
+
return { issues, info: 'Blocked by malicious version list' };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// If offline mode or no API key, skip online check
|
|
197
|
+
if (options.offline || config.offline) {
|
|
198
|
+
return { issues, info: 'Skipped (Offline Mode)' };
|
|
199
|
+
}
|
|
200
|
+
if (!config.virustotal.apiKey || !config.virustotal.enabled) {
|
|
201
|
+
// No API key configured, skip VT check but don't warn
|
|
202
|
+
return { issues, info: 'Skipped (No API Key)' };
|
|
203
|
+
}
|
|
204
|
+
// Download package and calculate hash
|
|
205
|
+
if (packageInfo?.tarballUrl) {
|
|
206
|
+
const downloadResult = await downloadPackageAndHash(packageInfo.tarballUrl);
|
|
207
|
+
if (downloadResult) {
|
|
208
|
+
const vtResult = await queryVirusTotal(downloadResult.hash, config.virustotal.apiKey);
|
|
209
|
+
if (vtResult) {
|
|
210
|
+
const totalEngines = (vtResult.malicious || 0) + (vtResult.suspicious || 0) + 70; // Estimate
|
|
211
|
+
const info = `Scanned Hash: ${downloadResult.hash.substring(0, 8)}... | Detections: ${vtResult.malicious}/${totalEngines}`;
|
|
212
|
+
if (vtResult.malicious > 0) {
|
|
213
|
+
issues.push({
|
|
214
|
+
type: 'virus',
|
|
215
|
+
severity: 'fatal',
|
|
216
|
+
message: t('virusDetected'),
|
|
217
|
+
details: `VirusTotal: ${vtResult.malicious} security vendors flagged this package as malicious. Detections: ${vtResult.detections.slice(0, 3).join(', ')}${vtResult.detections.length > 3 ? '...' : ''}`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
else if (vtResult.suspicious > 2) {
|
|
221
|
+
issues.push({
|
|
222
|
+
type: 'virus',
|
|
223
|
+
severity: 'high',
|
|
224
|
+
message: t('virusDetected'),
|
|
225
|
+
details: `VirusTotal: ${vtResult.suspicious} security vendors flagged this package as suspicious`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return { issues, info };
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
// File not found in VT, upload it
|
|
232
|
+
const analysisId = await uploadFileToVirusTotal(downloadResult.buffer, `${packageInfo.name}-${packageInfo.version}.tgz`, config.virustotal.apiKey);
|
|
233
|
+
if (analysisId) {
|
|
234
|
+
return {
|
|
235
|
+
issues,
|
|
236
|
+
info: `File not found in VT. Uploaded for analysis. View: https://www.virustotal.com/gui/file-analysis/${analysisId}`
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
return {
|
|
241
|
+
issues,
|
|
242
|
+
info: `Hash ${downloadResult.hash.substring(0, 8)}... not found in VirusTotal (Upload failed)`
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { issues, info: 'Could not download package for scanning' };
|
|
249
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { t } from '../i18n/index.js';
|
|
2
|
+
import { exec } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
const execAsync = promisify(exec);
|
|
5
|
+
export async function scanVulnerabilities(packageName) {
|
|
6
|
+
const issues = [];
|
|
7
|
+
try {
|
|
8
|
+
// Use npm audit to check for known vulnerabilities
|
|
9
|
+
// Note: This is a simplified implementation
|
|
10
|
+
// In reality, we'd need to create a temp package.json to audit specific packages
|
|
11
|
+
const { stdout } = await execAsync(`npm audit --json --package-lock-only 2>nul || echo {}`, {
|
|
12
|
+
timeout: 30000,
|
|
13
|
+
});
|
|
14
|
+
if (!stdout.trim() || stdout.trim() === '{}') {
|
|
15
|
+
return issues;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const auditResult = JSON.parse(stdout);
|
|
19
|
+
// Check if our package is in the vulnerabilities
|
|
20
|
+
const vulns = auditResult.vulnerabilities || {};
|
|
21
|
+
if (vulns[packageName]) {
|
|
22
|
+
const vuln = vulns[packageName];
|
|
23
|
+
const severity = vuln.severity;
|
|
24
|
+
if (severity === 'critical' || severity === 'high') {
|
|
25
|
+
issues.push({
|
|
26
|
+
type: 'cve',
|
|
27
|
+
severity: 'warning', // CVEs are warnings, not blockers
|
|
28
|
+
message: t('cveFound'),
|
|
29
|
+
details: `${severity.toUpperCase()}: ${vuln.via?.[0]?.title || 'Known vulnerability'}`,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// JSON parse failed, skip
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Audit failed, skip vulnerability check
|
|
40
|
+
}
|
|
41
|
+
return issues;
|
|
42
|
+
}
|
package/dist/tui/App.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput, useApp } from 'ink';
|
|
3
|
+
import { t } from '../i18n/index.js';
|
|
4
|
+
import { PopularScreen } from './screens/PopularScreen.js';
|
|
5
|
+
import { CheckScreen } from './screens/CheckScreen.js';
|
|
6
|
+
import { SettingsScreen } from './screens/SettingsScreen.js';
|
|
7
|
+
export function App() {
|
|
8
|
+
const [screen, setScreen] = useState('menu');
|
|
9
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
10
|
+
const [checkPackage, setCheckPackage] = useState(undefined);
|
|
11
|
+
const { exit } = useApp();
|
|
12
|
+
const menuItems = [
|
|
13
|
+
{ key: 'popular', label: t('tuiPopular') },
|
|
14
|
+
{ key: 'check', label: t('tuiCheck') },
|
|
15
|
+
{ key: 'settings', label: t('tuiSettings') },
|
|
16
|
+
{ key: 'quit', label: t('tuiQuit') },
|
|
17
|
+
];
|
|
18
|
+
useInput((input, key) => {
|
|
19
|
+
if (screen === 'menu') {
|
|
20
|
+
if (key.upArrow) {
|
|
21
|
+
setSelectedIndex((i) => (i > 0 ? i - 1 : menuItems.length - 1));
|
|
22
|
+
}
|
|
23
|
+
if (key.downArrow) {
|
|
24
|
+
setSelectedIndex((i) => (i < menuItems.length - 1 ? i + 1 : 0));
|
|
25
|
+
}
|
|
26
|
+
if (key.return) {
|
|
27
|
+
const item = menuItems[selectedIndex];
|
|
28
|
+
if (item.key === 'quit') {
|
|
29
|
+
exit();
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
setCheckPackage(undefined); // Reset check package when entering normally
|
|
33
|
+
setScreen(item.key);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (input === 'q') {
|
|
37
|
+
exit();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
if (screen === 'menu') {
|
|
42
|
+
return (React.createElement(Box, { flexDirection: "column", padding: 1 },
|
|
43
|
+
React.createElement(Text, { bold: true, color: "cyan" }, t('tuiWelcome')),
|
|
44
|
+
React.createElement(Text, null, " "),
|
|
45
|
+
menuItems.map((item, i) => (React.createElement(Text, { key: item.key, color: i === selectedIndex ? 'green' : undefined },
|
|
46
|
+
i === selectedIndex ? '❯ ' : ' ',
|
|
47
|
+
item.label))),
|
|
48
|
+
React.createElement(Text, null, " "),
|
|
49
|
+
React.createElement(Text, { dimColor: true }, "Use \u2191\u2193 to navigate, Enter to select, q to quit")));
|
|
50
|
+
}
|
|
51
|
+
if (screen === 'popular') {
|
|
52
|
+
return (React.createElement(Box, { padding: 1 },
|
|
53
|
+
React.createElement(PopularScreen, { onSelect: (pkg) => {
|
|
54
|
+
setCheckPackage(pkg);
|
|
55
|
+
setScreen('check');
|
|
56
|
+
}, onBack: () => setScreen('menu') })));
|
|
57
|
+
}
|
|
58
|
+
if (screen === 'check') {
|
|
59
|
+
return (React.createElement(Box, { padding: 1 },
|
|
60
|
+
React.createElement(CheckScreen, { initialPackage: checkPackage, onBack: () => setScreen('menu') })));
|
|
61
|
+
}
|
|
62
|
+
if (screen === 'settings') {
|
|
63
|
+
return (React.createElement(Box, { padding: 1 },
|
|
64
|
+
React.createElement(SettingsScreen, { onBack: () => setScreen('menu') })));
|
|
65
|
+
}
|
|
66
|
+
return React.createElement(Text, null, "Error: Unknown screen");
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startTui(): void;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import { scanPackage } from '../../scanner/index.js';
|
|
5
|
+
import { t } from '../../i18n/index.js';
|
|
6
|
+
export function CheckScreen({ initialPackage, onBack }) {
|
|
7
|
+
const [query, setQuery] = useState(initialPackage || '');
|
|
8
|
+
const [loading, setLoading] = useState(!!initialPackage);
|
|
9
|
+
const [progressMsg, setProgressMsg] = useState('');
|
|
10
|
+
const [result, setResult] = useState(null);
|
|
11
|
+
const [error, setError] = useState(null);
|
|
12
|
+
useInput((input, key) => {
|
|
13
|
+
if (loading)
|
|
14
|
+
return;
|
|
15
|
+
if (key.escape) {
|
|
16
|
+
onBack();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (key.return) {
|
|
20
|
+
if (query.trim()) {
|
|
21
|
+
startScan(query);
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (initialPackage) {
|
|
28
|
+
startScan(initialPackage);
|
|
29
|
+
}
|
|
30
|
+
}, []);
|
|
31
|
+
const startScan = async (pkg) => {
|
|
32
|
+
setLoading(true);
|
|
33
|
+
setProgressMsg('Initializing scan...');
|
|
34
|
+
setError(null);
|
|
35
|
+
setResult(null);
|
|
36
|
+
try {
|
|
37
|
+
const res = await scanPackage(pkg, {}, (msg, completed, total) => {
|
|
38
|
+
setProgressMsg(`${msg} (${completed}/${total})`);
|
|
39
|
+
});
|
|
40
|
+
setResult(res);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
setError(err.message || 'Scan failed');
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
setLoading(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const getRiskColor = (level) => {
|
|
50
|
+
switch (level) {
|
|
51
|
+
case 'fatal': return 'red';
|
|
52
|
+
case 'high': return 'red';
|
|
53
|
+
case 'warning': return 'yellow';
|
|
54
|
+
case 'safe': return 'green';
|
|
55
|
+
default: return 'white';
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
59
|
+
React.createElement(Text, { bold: true, color: "cyan" }, t('tuiCheck')),
|
|
60
|
+
React.createElement(Box, { marginTop: 1 },
|
|
61
|
+
React.createElement(Text, null, "Package: "),
|
|
62
|
+
React.createElement(TextInput, { value: query, onChange: setQuery, onSubmit: () => {
|
|
63
|
+
if (query.trim())
|
|
64
|
+
startScan(query);
|
|
65
|
+
} }),
|
|
66
|
+
React.createElement(Text, { dimColor: true }, loading ? ` ${progressMsg}` : '')),
|
|
67
|
+
error && (React.createElement(Box, { marginTop: 1 },
|
|
68
|
+
React.createElement(Text, { color: "red" },
|
|
69
|
+
"Error: ",
|
|
70
|
+
error))),
|
|
71
|
+
result && (React.createElement(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: getRiskColor(result.riskLevel), padding: 1 },
|
|
72
|
+
React.createElement(Box, null,
|
|
73
|
+
React.createElement(Text, { bold: true }, "Risk Level: "),
|
|
74
|
+
React.createElement(Text, { color: getRiskColor(result.riskLevel), bold: true }, result.riskLevel.toUpperCase())),
|
|
75
|
+
React.createElement(Box, { flexDirection: "column", marginTop: 1 }, result.checks.map((check, i) => (React.createElement(Box, { key: i },
|
|
76
|
+
React.createElement(Text, { color: check.status === 'pass' ? 'green' : check.status === 'fail' ? 'red' : 'gray' }, check.status === 'pass' ? '✓ ' : check.status === 'fail' ? '✗ ' : '○ '),
|
|
77
|
+
React.createElement(Text, null,
|
|
78
|
+
check.name,
|
|
79
|
+
": "),
|
|
80
|
+
React.createElement(Text, { dimColor: true }, check.description))))),
|
|
81
|
+
React.createElement(Box, { flexDirection: "column", marginTop: 1 }, result.issues.length === 0 ? (React.createElement(Text, { color: "green" }, "No issues found. Package appears safe.")) : (result.issues.map((issue, i) => (React.createElement(Box, { key: i, flexDirection: "column", marginBottom: 1 },
|
|
82
|
+
React.createElement(Text, { color: issue.severity === 'high' || issue.severity === 'fatal' ? 'red' : 'yellow' },
|
|
83
|
+
"\u26A0 ",
|
|
84
|
+
issue.message),
|
|
85
|
+
issue.details && React.createElement(Text, { dimColor: true },
|
|
86
|
+
" ",
|
|
87
|
+
issue.details)))))),
|
|
88
|
+
!result.canBypass && (React.createElement(Box, { marginTop: 1 },
|
|
89
|
+
React.createElement(Text, { color: "red", bold: true }, "BLOCK: Package installation blocked by policy."))))),
|
|
90
|
+
React.createElement(Box, { marginTop: 1 },
|
|
91
|
+
React.createElement(Text, { dimColor: true }, "Enter to scan, Esc to back"))));
|
|
92
|
+
}
|