install-guard 1.1.1 → 1.1.2
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 +197 -120
- package/bin/cli.js +28 -7
- package/package.json +5 -1
- package/src/analyze.js +16 -10
- package/src/checks/dependencyDiff.js +114 -0
- package/src/checks/deprecation.js +24 -0
- package/src/checks/githubVerify.js +67 -0
- package/src/checks/index.js +8 -0
- package/src/checks/license.js +35 -0
- package/src/checks/maintainers.js +30 -0
- package/src/checks/recentPublish.js +70 -0
- package/src/checks/scripts.js +45 -0
- package/src/checks/typosquat.js +77 -0
- package/src/format.js +143 -87
- package/src/index.js +2 -2
- package/src/install.js +16 -10
- package/src/scan.js +13 -11
- package/src/services/pipeline.js +105 -0
- package/src/services/scorer.js +36 -0
- package/src/utils/cache.js +44 -0
- package/src/utils/github.js +64 -0
- package/src/utils/registry.js +99 -0
package/src/format.js
CHANGED
|
@@ -1,106 +1,127 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const
|
|
3
|
+
const W = 58;
|
|
4
|
+
const SEPARATOR = chalk.gray("━".repeat(W));
|
|
5
|
+
const THIN_SEP = chalk.gray("─".repeat(W));
|
|
5
6
|
|
|
6
7
|
function riskBar(score) {
|
|
7
8
|
const filled = score;
|
|
8
9
|
const empty = 10 - score;
|
|
9
10
|
const color =
|
|
10
|
-
score <=
|
|
11
|
+
score <= 2 ? chalk.green : score <= 5 ? chalk.yellow : score <= 7 ? chalk.red : chalk.redBright;
|
|
11
12
|
return color("█".repeat(filled)) + chalk.gray("░".repeat(empty));
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
function riskLabel(
|
|
15
|
-
switch (
|
|
16
|
-
case "
|
|
17
|
-
return chalk.green.bold("✅
|
|
18
|
-
case "
|
|
19
|
-
return chalk.yellow.bold("⚠
|
|
20
|
-
case "
|
|
21
|
-
return chalk.red.bold("🚨
|
|
15
|
+
function riskLabel(label) {
|
|
16
|
+
switch (label) {
|
|
17
|
+
case "LOW":
|
|
18
|
+
return chalk.green.bold("✅ LOW");
|
|
19
|
+
case "MEDIUM":
|
|
20
|
+
return chalk.yellow.bold("⚠ MEDIUM");
|
|
21
|
+
case "HIGH":
|
|
22
|
+
return chalk.red.bold("🚨 HIGH");
|
|
23
|
+
case "CRITICAL":
|
|
24
|
+
return chalk.redBright.bold("💀 CRITICAL");
|
|
25
|
+
default:
|
|
26
|
+
return label;
|
|
22
27
|
}
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
function
|
|
26
|
-
switch (
|
|
27
|
-
case "
|
|
28
|
-
return chalk.
|
|
29
|
-
case "
|
|
30
|
-
return chalk.yellow("⚠");
|
|
31
|
-
case "fail":
|
|
30
|
+
function severityIcon(severity) {
|
|
31
|
+
switch (severity) {
|
|
32
|
+
case "critical":
|
|
33
|
+
return chalk.redBright("✘");
|
|
34
|
+
case "high":
|
|
32
35
|
return chalk.red("✘");
|
|
36
|
+
case "medium":
|
|
37
|
+
return chalk.yellow("⚠");
|
|
38
|
+
case "info":
|
|
39
|
+
return chalk.green("✔");
|
|
40
|
+
default:
|
|
41
|
+
return chalk.gray("·");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function severityColor(severity) {
|
|
46
|
+
switch (severity) {
|
|
47
|
+
case "critical":
|
|
48
|
+
return chalk.redBright;
|
|
49
|
+
case "high":
|
|
50
|
+
return chalk.red;
|
|
51
|
+
case "medium":
|
|
52
|
+
return chalk.yellow;
|
|
53
|
+
case "info":
|
|
54
|
+
return chalk.green;
|
|
55
|
+
default:
|
|
56
|
+
return chalk.white;
|
|
33
57
|
}
|
|
34
58
|
}
|
|
35
59
|
|
|
36
60
|
function timeAgo(dateStr) {
|
|
37
61
|
if (!dateStr) return "Unknown";
|
|
38
62
|
const diff = Date.now() - new Date(dateStr).getTime();
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
63
|
+
const hours = Math.floor(diff / (1000 * 60 * 60));
|
|
64
|
+
if (hours < 1) return "just now";
|
|
65
|
+
if (hours < 24) return `${hours} hour(s) ago`;
|
|
66
|
+
const days = Math.floor(hours / 24);
|
|
41
67
|
if (days === 1) return "1 day ago";
|
|
42
68
|
if (days < 30) return `${days} days ago`;
|
|
43
69
|
const months = Math.floor(days / 30);
|
|
44
|
-
if (months
|
|
45
|
-
if (months < 12) return `${months} months ago`;
|
|
70
|
+
if (months < 12) return `${months} month(s) ago`;
|
|
46
71
|
const years = Math.floor(days / 365);
|
|
47
|
-
|
|
48
|
-
return `${years} years ago`;
|
|
72
|
+
return `${years} year(s) ago`;
|
|
49
73
|
}
|
|
50
74
|
|
|
51
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Formats the full pipeline result for CLI display.
|
|
77
|
+
*/
|
|
78
|
+
export function formatPipelineResult(result) {
|
|
52
79
|
const lines = [];
|
|
53
80
|
|
|
54
81
|
lines.push("");
|
|
55
82
|
lines.push(SEPARATOR);
|
|
56
83
|
lines.push(
|
|
57
|
-
chalk.bold(` 📦 ${
|
|
84
|
+
chalk.bold(` 📦 ${result.package} `) + chalk.gray(`v${result.version}`)
|
|
58
85
|
);
|
|
59
|
-
if (
|
|
60
|
-
lines.push(chalk.gray(` ${
|
|
86
|
+
if (result.description) {
|
|
87
|
+
lines.push(chalk.gray(` ${result.description.slice(0, W - 4)}`));
|
|
61
88
|
}
|
|
62
89
|
lines.push(SEPARATOR);
|
|
63
90
|
|
|
64
|
-
// Risk
|
|
91
|
+
// ── Risk Score ──────────────────────────────
|
|
65
92
|
lines.push("");
|
|
66
93
|
lines.push(
|
|
67
|
-
` Risk
|
|
94
|
+
` Risk Level: ${riskLabel(result.label)} ${chalk.bold(`(${result.score}/10)`)}`
|
|
68
95
|
);
|
|
69
96
|
lines.push(` ${riskBar(result.score)}`);
|
|
70
97
|
|
|
71
98
|
lines.push("");
|
|
72
99
|
lines.push(THIN_SEP);
|
|
73
100
|
|
|
74
|
-
// Package
|
|
101
|
+
// ── Package Info ────────────────────────────
|
|
75
102
|
lines.push("");
|
|
76
103
|
lines.push(chalk.bold(" 📊 Package Info"));
|
|
77
104
|
lines.push("");
|
|
78
105
|
|
|
79
106
|
const info = [
|
|
80
|
-
["Downloads (weekly)",
|
|
81
|
-
["Maintainers",
|
|
82
|
-
[
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
],
|
|
88
|
-
["Last Published", timeAgo(data.lastPublished)],
|
|
89
|
-
["Dependencies", String(data.dependencies)],
|
|
90
|
-
["Versions", String(data.totalVersions)],
|
|
107
|
+
["Downloads (weekly)", result.downloads.toLocaleString()],
|
|
108
|
+
["Maintainers", result.maintainers.join(", ") || "None"],
|
|
109
|
+
["License", typeof result.license === "string" ? result.license : result.license?.type || "Unknown"],
|
|
110
|
+
["Published", timeAgo(result.publishedAt)],
|
|
111
|
+
["Dependencies", String(result.dependencyCount)],
|
|
112
|
+
["Total Versions", String(result.totalVersions)],
|
|
113
|
+
["Repository", result.repository ? "✔" : "✘ None"],
|
|
91
114
|
];
|
|
92
115
|
|
|
93
116
|
for (const [label, value] of info) {
|
|
94
|
-
lines.push(
|
|
95
|
-
` ${chalk.gray(label.padEnd(22))} ${chalk.white(value)}`
|
|
96
|
-
);
|
|
117
|
+
lines.push(` ${chalk.gray(label.padEnd(22))} ${chalk.white(value)}`);
|
|
97
118
|
}
|
|
98
119
|
|
|
99
|
-
if (
|
|
120
|
+
if (result.deprecated) {
|
|
100
121
|
lines.push("");
|
|
101
122
|
lines.push(
|
|
102
123
|
chalk.red.bold(
|
|
103
|
-
` ⚠ DEPRECATED: ${typeof
|
|
124
|
+
` ⚠ DEPRECATED: ${typeof result.deprecated === "string" ? result.deprecated : "This package is deprecated"}`
|
|
104
125
|
)
|
|
105
126
|
);
|
|
106
127
|
}
|
|
@@ -108,20 +129,51 @@ export function formatAnalysis(data, result) {
|
|
|
108
129
|
lines.push("");
|
|
109
130
|
lines.push(THIN_SEP);
|
|
110
131
|
|
|
111
|
-
//
|
|
132
|
+
// ── Findings ────────────────────────────────
|
|
112
133
|
lines.push("");
|
|
113
|
-
lines.push(chalk.bold(" 🔍
|
|
134
|
+
lines.push(chalk.bold(" 🔍 Findings"));
|
|
114
135
|
lines.push("");
|
|
115
136
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
137
|
+
// Group by severity
|
|
138
|
+
const critical = result.findings.filter((f) => f.severity === "critical");
|
|
139
|
+
const high = result.findings.filter((f) => f.severity === "high");
|
|
140
|
+
const medium = result.findings.filter((f) => f.severity === "medium");
|
|
141
|
+
const info2 = result.findings.filter((f) => f.severity === "info");
|
|
142
|
+
|
|
143
|
+
for (const group of [critical, high, medium, info2]) {
|
|
144
|
+
for (const f of group) {
|
|
145
|
+
const icon = severityIcon(f.severity);
|
|
146
|
+
const color = severityColor(f.severity);
|
|
147
|
+
lines.push(` ${icon} ${color(f.message)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Recommendation ──────────────────────────
|
|
152
|
+
if (result.recommendation) {
|
|
153
|
+
lines.push("");
|
|
154
|
+
lines.push(THIN_SEP);
|
|
155
|
+
lines.push("");
|
|
156
|
+
lines.push(chalk.bold(" 💡 Recommendation"));
|
|
157
|
+
lines.push("");
|
|
158
|
+
if (result.label === "CRITICAL" || result.label === "HIGH") {
|
|
159
|
+
lines.push(chalk.red(` ⛔ Avoid installing ${result.package}@${result.version}`));
|
|
160
|
+
}
|
|
161
|
+
lines.push(
|
|
162
|
+
chalk.green(
|
|
163
|
+
` ✔ Safe alternative: ${result.package}@${chalk.bold(result.recommendation.version)}`
|
|
164
|
+
)
|
|
165
|
+
);
|
|
166
|
+
lines.push(
|
|
167
|
+
chalk.gray(` ${result.recommendation.reason}`)
|
|
168
|
+
);
|
|
169
|
+
} else if (result.label === "CRITICAL" || result.label === "HIGH") {
|
|
170
|
+
lines.push("");
|
|
171
|
+
lines.push(THIN_SEP);
|
|
172
|
+
lines.push("");
|
|
173
|
+
lines.push(chalk.bold(" 💡 Recommendation"));
|
|
174
|
+
lines.push("");
|
|
175
|
+
lines.push(chalk.red(` ⛔ Avoid installing this version`));
|
|
176
|
+
lines.push(chalk.gray(" No safe alternative version found"));
|
|
125
177
|
}
|
|
126
178
|
|
|
127
179
|
lines.push("");
|
|
@@ -131,10 +183,13 @@ export function formatAnalysis(data, result) {
|
|
|
131
183
|
return lines.join("\n");
|
|
132
184
|
}
|
|
133
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Formats a summary table for scan results.
|
|
188
|
+
*/
|
|
134
189
|
export function formatScanSummary(results) {
|
|
135
190
|
const lines = [];
|
|
136
191
|
|
|
137
|
-
results.sort((a, b) => b.
|
|
192
|
+
results.sort((a, b) => b.score - a.score);
|
|
138
193
|
|
|
139
194
|
lines.push("");
|
|
140
195
|
lines.push(SEPARATOR);
|
|
@@ -142,57 +197,58 @@ export function formatScanSummary(results) {
|
|
|
142
197
|
lines.push(SEPARATOR);
|
|
143
198
|
lines.push("");
|
|
144
199
|
|
|
145
|
-
const nameWidth = Math.max(
|
|
146
|
-
20,
|
|
147
|
-
...results.map((r) => r.data.name.length + 2)
|
|
148
|
-
);
|
|
200
|
+
const nameWidth = Math.max(20, ...results.map((r) => r.package.length + 2));
|
|
149
201
|
|
|
150
202
|
lines.push(
|
|
151
203
|
chalk.gray(
|
|
152
204
|
" " +
|
|
153
205
|
"Package".padEnd(nameWidth) +
|
|
154
|
-
"
|
|
155
|
-
"
|
|
206
|
+
"Score".padEnd(10) +
|
|
207
|
+
"Level"
|
|
156
208
|
)
|
|
157
209
|
);
|
|
158
|
-
lines.push(chalk.gray(" " + "─".repeat(nameWidth +
|
|
159
|
-
|
|
160
|
-
for (const
|
|
161
|
-
const name =
|
|
162
|
-
const
|
|
163
|
-
let
|
|
164
|
-
switch (
|
|
165
|
-
case "
|
|
166
|
-
|
|
210
|
+
lines.push(chalk.gray(" " + "─".repeat(nameWidth + 24)));
|
|
211
|
+
|
|
212
|
+
for (const r of results) {
|
|
213
|
+
const name = r.package.padEnd(nameWidth);
|
|
214
|
+
const score = `${r.score}/10`.padEnd(10);
|
|
215
|
+
let level;
|
|
216
|
+
switch (r.label) {
|
|
217
|
+
case "LOW":
|
|
218
|
+
level = chalk.green("✅ Low");
|
|
219
|
+
break;
|
|
220
|
+
case "MEDIUM":
|
|
221
|
+
level = chalk.yellow("⚠ Medium");
|
|
167
222
|
break;
|
|
168
|
-
case "
|
|
169
|
-
|
|
223
|
+
case "HIGH":
|
|
224
|
+
level = chalk.red("🚨 High");
|
|
170
225
|
break;
|
|
171
|
-
case "
|
|
172
|
-
|
|
226
|
+
case "CRITICAL":
|
|
227
|
+
level = chalk.redBright("💀 Critical");
|
|
173
228
|
break;
|
|
174
229
|
}
|
|
175
230
|
|
|
176
231
|
const colorFn =
|
|
177
|
-
|
|
232
|
+
r.label === "CRITICAL" || r.label === "HIGH"
|
|
178
233
|
? chalk.red
|
|
179
|
-
:
|
|
234
|
+
: r.label === "MEDIUM"
|
|
180
235
|
? chalk.yellow
|
|
181
236
|
: chalk.white;
|
|
182
|
-
lines.push(` ${colorFn(name)}${
|
|
237
|
+
lines.push(` ${colorFn(name)}${score}${level}`);
|
|
183
238
|
}
|
|
184
239
|
|
|
185
240
|
lines.push("");
|
|
186
241
|
lines.push(THIN_SEP);
|
|
187
242
|
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
).length;
|
|
192
|
-
const high = results.filter((r) => r.result.level === "high").length;
|
|
243
|
+
const low = results.filter((r) => r.label === "LOW").length;
|
|
244
|
+
const med = results.filter((r) => r.label === "MEDIUM").length;
|
|
245
|
+
const high = results.filter((r) => r.label === "HIGH").length;
|
|
246
|
+
const crit = results.filter((r) => r.label === "CRITICAL").length;
|
|
193
247
|
|
|
194
248
|
lines.push(
|
|
195
|
-
` ${chalk.bold("
|
|
249
|
+
` ${chalk.bold("Total:")} ${results.length} packages ` +
|
|
250
|
+
`${chalk.green(`${low} low`)} · ${chalk.yellow(`${med} medium`)} · ` +
|
|
251
|
+
`${chalk.red(`${high} high`)} · ${chalk.redBright(`${crit} critical`)}`
|
|
196
252
|
);
|
|
197
253
|
lines.push("");
|
|
198
254
|
lines.push(SEPARATOR);
|
package/src/index.js
CHANGED
package/src/install.js
CHANGED
|
@@ -2,9 +2,8 @@ import { execFileSync } from "child_process";
|
|
|
2
2
|
import readline from "readline";
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import ora from "ora";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { formatAnalysis } from "./format.js";
|
|
5
|
+
import { runPipeline } from "./services/pipeline.js";
|
|
6
|
+
import { formatPipelineResult } from "./format.js";
|
|
8
7
|
|
|
9
8
|
const VALID_PKG_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(@[a-z0-9._^~>=<|-]+)?$/i;
|
|
10
9
|
|
|
@@ -30,27 +29,34 @@ export async function analyzeAndPrompt(pkgName) {
|
|
|
30
29
|
|
|
31
30
|
const spinner = ora(`Analyzing ${pkgName}...`).start();
|
|
32
31
|
|
|
33
|
-
let
|
|
32
|
+
let result;
|
|
34
33
|
try {
|
|
35
|
-
|
|
36
|
-
result = calculateRisk(data);
|
|
34
|
+
result = await runPipeline(pkgName);
|
|
37
35
|
spinner.stop();
|
|
38
36
|
} catch (err) {
|
|
39
37
|
spinner.fail(`Failed to analyze "${pkgName}": ${err.message}`);
|
|
40
38
|
return;
|
|
41
39
|
}
|
|
42
40
|
|
|
43
|
-
console.log(
|
|
41
|
+
console.log(formatPipelineResult(result));
|
|
44
42
|
|
|
45
|
-
if (result.
|
|
43
|
+
if (result.label === "CRITICAL") {
|
|
46
44
|
const ans = await askQuestion(
|
|
47
|
-
chalk.
|
|
45
|
+
chalk.redBright.bold(" 💀 CRITICAL risk. Are you absolutely sure? (y/n): ")
|
|
48
46
|
);
|
|
49
47
|
if (ans.toLowerCase() !== "y") {
|
|
50
48
|
console.log(chalk.yellow("\n Installation aborted.\n"));
|
|
51
49
|
return;
|
|
52
50
|
}
|
|
53
|
-
} else if (result.
|
|
51
|
+
} else if (result.label === "HIGH") {
|
|
52
|
+
const ans = await askQuestion(
|
|
53
|
+
chalk.red.bold(" 🚨 HIGH risk package. Continue install? (y/n): ")
|
|
54
|
+
);
|
|
55
|
+
if (ans.toLowerCase() !== "y") {
|
|
56
|
+
console.log(chalk.yellow("\n Installation aborted.\n"));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
} else if (result.label === "MEDIUM") {
|
|
54
60
|
const ans = await askQuestion(
|
|
55
61
|
chalk.yellow(" ⚠ Medium risk. Continue install? (y/n): ")
|
|
56
62
|
);
|
package/src/scan.js
CHANGED
|
@@ -2,11 +2,10 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import ora from "ora";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { formatAnalysis, formatScanSummary } from "./format.js";
|
|
5
|
+
import { runPipeline } from "./services/pipeline.js";
|
|
6
|
+
import { formatPipelineResult, formatScanSummary } from "./format.js";
|
|
8
7
|
|
|
9
|
-
export async function scanProject({ verbose } = {}) {
|
|
8
|
+
export async function scanProject({ verbose, json, skipGithub } = {}) {
|
|
10
9
|
const pkgPath = path.resolve("package.json");
|
|
11
10
|
|
|
12
11
|
if (!fs.existsSync(pkgPath)) {
|
|
@@ -36,18 +35,17 @@ export async function scanProject({ verbose } = {}) {
|
|
|
36
35
|
for (const dep of depNames) {
|
|
37
36
|
spinner.start(`Analyzing ${dep}...`);
|
|
38
37
|
try {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
results.push({ data, result });
|
|
38
|
+
const result = await runPipeline(dep, undefined, { skipGithub: skipGithub !== false });
|
|
39
|
+
results.push(result);
|
|
42
40
|
|
|
43
41
|
if (verbose) {
|
|
44
42
|
spinner.stop();
|
|
45
|
-
console.log(
|
|
43
|
+
console.log(formatPipelineResult(result));
|
|
46
44
|
} else {
|
|
47
45
|
const icon =
|
|
48
|
-
result.
|
|
46
|
+
result.label === "CRITICAL" || result.label === "HIGH"
|
|
49
47
|
? "🚨"
|
|
50
|
-
: result.
|
|
48
|
+
: result.label === "MEDIUM"
|
|
51
49
|
? "⚠"
|
|
52
50
|
: "✅";
|
|
53
51
|
spinner.succeed(
|
|
@@ -59,5 +57,9 @@ export async function scanProject({ verbose } = {}) {
|
|
|
59
57
|
}
|
|
60
58
|
}
|
|
61
59
|
|
|
62
|
-
|
|
60
|
+
if (json) {
|
|
61
|
+
console.log(JSON.stringify(results, null, 2));
|
|
62
|
+
} else {
|
|
63
|
+
console.log(formatScanSummary(results));
|
|
64
|
+
}
|
|
63
65
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { buildContext } from "../utils/registry.js";
|
|
2
|
+
import {
|
|
3
|
+
checkRecentPublish,
|
|
4
|
+
checkDependencyDiff,
|
|
5
|
+
checkScripts,
|
|
6
|
+
checkTyposquat,
|
|
7
|
+
checkMaintainers,
|
|
8
|
+
checkLicense,
|
|
9
|
+
checkGithubVerify,
|
|
10
|
+
checkDeprecation,
|
|
11
|
+
} from "../checks/index.js";
|
|
12
|
+
import { computeScore } from "./scorer.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Runs the full detection pipeline on a package+version.
|
|
16
|
+
* Returns structured results consumable by CLI or JSON output.
|
|
17
|
+
*/
|
|
18
|
+
export async function runPipeline(packageName, version, opts = {}) {
|
|
19
|
+
const ctx = await buildContext(packageName, version);
|
|
20
|
+
|
|
21
|
+
// Run sync checks immediately
|
|
22
|
+
const syncResults = [
|
|
23
|
+
checkRecentPublish(ctx),
|
|
24
|
+
checkScripts(ctx),
|
|
25
|
+
checkTyposquat(ctx),
|
|
26
|
+
checkMaintainers(ctx),
|
|
27
|
+
checkLicense(ctx),
|
|
28
|
+
checkDeprecation(ctx),
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// Run async checks in parallel
|
|
32
|
+
const asyncChecks = [checkDependencyDiff(ctx)];
|
|
33
|
+
|
|
34
|
+
if (!opts.skipGithub) {
|
|
35
|
+
asyncChecks.push(checkGithubVerify(ctx));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const asyncResults = await Promise.all(asyncChecks);
|
|
39
|
+
const allChecks = [...syncResults, ...asyncResults];
|
|
40
|
+
|
|
41
|
+
const { score, label, findings } = computeScore(allChecks);
|
|
42
|
+
|
|
43
|
+
// Find a safe version recommendation
|
|
44
|
+
const safeVersion = findSafeVersion(ctx, score);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
package: ctx.name,
|
|
48
|
+
version: ctx.version,
|
|
49
|
+
previousVersion: ctx.previousVersion,
|
|
50
|
+
description: ctx.description,
|
|
51
|
+
downloads: ctx.downloads,
|
|
52
|
+
maintainers: ctx.currentMaintainers.map((m) => m.name || m.email),
|
|
53
|
+
license: ctx.license,
|
|
54
|
+
publishedAt: ctx.publishedAt,
|
|
55
|
+
repository: ctx.repository,
|
|
56
|
+
deprecated: ctx.deprecated,
|
|
57
|
+
totalVersions: ctx.totalVersions,
|
|
58
|
+
dependencyCount: Object.keys(ctx.dependencies).length,
|
|
59
|
+
|
|
60
|
+
score,
|
|
61
|
+
label,
|
|
62
|
+
findings,
|
|
63
|
+
checks: allChecks,
|
|
64
|
+
|
|
65
|
+
recommendation: safeVersion,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Suggests the latest safe version — one that is older, with no install scripts,
|
|
71
|
+
* and published more than 7 days ago.
|
|
72
|
+
*/
|
|
73
|
+
function findSafeVersion(ctx, currentScore) {
|
|
74
|
+
if (currentScore <= 3) return null; // current version is fine
|
|
75
|
+
|
|
76
|
+
const registry = ctx._registry;
|
|
77
|
+
const timeData = registry.time || {};
|
|
78
|
+
const versions = ctx.allVersions.slice().reverse(); // newest first
|
|
79
|
+
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
80
|
+
|
|
81
|
+
for (const v of versions) {
|
|
82
|
+
if (v === ctx.version) continue;
|
|
83
|
+
|
|
84
|
+
const vData = registry.versions[v];
|
|
85
|
+
if (!vData) continue;
|
|
86
|
+
if (vData.deprecated) continue;
|
|
87
|
+
if (
|
|
88
|
+
vData.scripts?.postinstall ||
|
|
89
|
+
vData.scripts?.preinstall ||
|
|
90
|
+
vData.scripts?.install
|
|
91
|
+
)
|
|
92
|
+
continue;
|
|
93
|
+
|
|
94
|
+
const publishTime = new Date(timeData[v] || 0).getTime();
|
|
95
|
+
if (publishTime > sevenDaysAgo) continue;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
version: v,
|
|
99
|
+
publishedAt: timeData[v],
|
|
100
|
+
reason: "No install scripts, published > 7 days ago",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weighted risk scorer.
|
|
3
|
+
* Takes raw check results and computes a normalized 0-10 score with label.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Max raw score (theoretical) — used to normalize
|
|
7
|
+
const MAX_RAW = 50;
|
|
8
|
+
|
|
9
|
+
export function computeScore(checkResults) {
|
|
10
|
+
let rawScore = 0;
|
|
11
|
+
const allFindings = [];
|
|
12
|
+
let hasCritical = false;
|
|
13
|
+
|
|
14
|
+
for (const check of checkResults) {
|
|
15
|
+
for (const f of check.findings) {
|
|
16
|
+
allFindings.push({ ...f, checkId: check.id });
|
|
17
|
+
rawScore += f.score;
|
|
18
|
+
if (f.severity === "critical") hasCritical = true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Normalize to 0-10
|
|
23
|
+
let score = Math.round((rawScore / MAX_RAW) * 10);
|
|
24
|
+
score = Math.max(0, Math.min(10, score));
|
|
25
|
+
|
|
26
|
+
// Critical findings floor at 7
|
|
27
|
+
if (hasCritical && score < 7) score = 7;
|
|
28
|
+
|
|
29
|
+
let label;
|
|
30
|
+
if (score <= 2) label = "LOW";
|
|
31
|
+
else if (score <= 5) label = "MEDIUM";
|
|
32
|
+
else if (score <= 7) label = "HIGH";
|
|
33
|
+
else label = "CRITICAL";
|
|
34
|
+
|
|
35
|
+
return { score, label, rawScore, findings: allFindings };
|
|
36
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { createHash } from "crypto";
|
|
4
|
+
|
|
5
|
+
const CACHE_DIR = path.join(
|
|
6
|
+
process.env.HOME || process.env.USERPROFILE || "/tmp",
|
|
7
|
+
".install-guard-cache"
|
|
8
|
+
);
|
|
9
|
+
const TTL_MS = 15 * 60 * 1000; // 15 minutes
|
|
10
|
+
|
|
11
|
+
function ensureDir() {
|
|
12
|
+
if (!fs.existsSync(CACHE_DIR)) {
|
|
13
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function keyToFile(key) {
|
|
18
|
+
const hash = createHash("sha256").update(key).digest("hex").slice(0, 16);
|
|
19
|
+
return path.join(CACHE_DIR, `${hash}.json`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getCached(key) {
|
|
23
|
+
try {
|
|
24
|
+
const file = keyToFile(key);
|
|
25
|
+
if (!fs.existsSync(file)) return null;
|
|
26
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
27
|
+
if (Date.now() - raw.ts > TTL_MS) {
|
|
28
|
+
fs.unlinkSync(file);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return raw.data;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function setCache(key, data) {
|
|
38
|
+
try {
|
|
39
|
+
ensureDir();
|
|
40
|
+
fs.writeFileSync(keyToFile(key), JSON.stringify({ ts: Date.now(), data }));
|
|
41
|
+
} catch {
|
|
42
|
+
// cache write failures are non-fatal
|
|
43
|
+
}
|
|
44
|
+
}
|