install-guard 1.0.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/LICENSE +21 -0
- package/README.md +229 -52
- package/bin/cli.js +40 -14
- package/package.json +13 -4
- package/src/analyze.js +15 -22
- 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 +258 -0
- package/src/index.js +2 -2
- package/src/install.js +43 -15
- package/src/npm.js +31 -16
- package/src/scan.js +55 -5
- package/src/score.js +107 -26
- package/src/services/pipeline.js +105 -0
- package/src/services/scorer.js +36 -0
- package/src/typosquat.js +50 -0
- package/src/utils/cache.js +44 -0
- package/src/utils/github.js +64 -0
- package/src/utils/registry.js +99 -0
- package/install-guard-1.0.0.tgz +0 -0
package/src/install.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
2
|
import readline from "readline";
|
|
3
|
-
import
|
|
4
|
-
import
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import ora from "ora";
|
|
5
|
+
import { runPipeline } from "./services/pipeline.js";
|
|
6
|
+
import { formatPipelineResult } from "./format.js";
|
|
7
|
+
|
|
8
|
+
const VALID_PKG_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(@[a-z0-9._^~>=<|-]+)?$/i;
|
|
5
9
|
|
|
6
10
|
function askQuestion(query) {
|
|
7
11
|
const rl = readline.createInterface({
|
|
@@ -18,27 +22,51 @@ function askQuestion(query) {
|
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export async function analyzeAndPrompt(pkgName) {
|
|
21
|
-
|
|
25
|
+
if (!VALID_PKG_NAME.test(pkgName)) {
|
|
26
|
+
console.log(chalk.red("\n ✘ Invalid package name.\n"));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
22
29
|
|
|
23
|
-
const
|
|
24
|
-
const { score, warnings } = calculateRisk(data);
|
|
30
|
+
const spinner = ora(`Analyzing ${pkgName}...`).start();
|
|
25
31
|
|
|
26
|
-
|
|
32
|
+
let result;
|
|
33
|
+
try {
|
|
34
|
+
result = await runPipeline(pkgName);
|
|
35
|
+
spinner.stop();
|
|
36
|
+
} catch (err) {
|
|
37
|
+
spinner.fail(`Failed to analyze "${pkgName}": ${err.message}`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
27
40
|
|
|
28
|
-
|
|
41
|
+
console.log(formatPipelineResult(result));
|
|
29
42
|
|
|
30
|
-
if (
|
|
43
|
+
if (result.label === "CRITICAL") {
|
|
31
44
|
const ans = await askQuestion(
|
|
32
|
-
"
|
|
45
|
+
chalk.redBright.bold(" 💀 CRITICAL risk. Are you absolutely sure? (y/n): ")
|
|
33
46
|
);
|
|
34
|
-
|
|
35
47
|
if (ans.toLowerCase() !== "y") {
|
|
36
|
-
console.log("Installation aborted
|
|
48
|
+
console.log(chalk.yellow("\n Installation aborted.\n"));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
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") {
|
|
60
|
+
const ans = await askQuestion(
|
|
61
|
+
chalk.yellow(" ⚠ Medium risk. Continue install? (y/n): ")
|
|
62
|
+
);
|
|
63
|
+
if (ans.toLowerCase() !== "y") {
|
|
64
|
+
console.log(chalk.yellow("\n Installation aborted.\n"));
|
|
37
65
|
return;
|
|
38
66
|
}
|
|
39
67
|
}
|
|
40
68
|
|
|
41
|
-
console.log("\
|
|
42
|
-
|
|
43
|
-
|
|
69
|
+
console.log(chalk.green("\n Installing...\n"));
|
|
70
|
+
execFileSync("npm", ["install", pkgName], { stdio: "inherit" });
|
|
71
|
+
console.log(chalk.green(`\n ✔ ${pkgName} installed successfully.\n`));
|
|
44
72
|
}
|
package/src/npm.js
CHANGED
|
@@ -1,27 +1,42 @@
|
|
|
1
|
-
import axios from "axios";
|
|
2
|
-
|
|
3
1
|
export async function getPackageData(pkg) {
|
|
4
|
-
const
|
|
5
|
-
const
|
|
2
|
+
const encodedPkg = encodeURIComponent(pkg).replace("%40", "@");
|
|
3
|
+
const registryUrl = `https://registry.npmjs.org/${encodedPkg}`;
|
|
4
|
+
const downloadUrl = `https://api.npmjs.org/downloads/point/last-week/${encodedPkg}`;
|
|
6
5
|
|
|
7
6
|
const [registryRes, downloadRes] = await Promise.all([
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
fetch(registryUrl).then((r) => {
|
|
8
|
+
if (!r.ok) throw new Error(`Package "${pkg}" not found on npm`);
|
|
9
|
+
return r.json();
|
|
10
|
+
}),
|
|
11
|
+
fetch(downloadUrl)
|
|
12
|
+
.then((r) => r.json())
|
|
13
|
+
.catch(() => ({ downloads: 0 })),
|
|
10
14
|
]);
|
|
11
15
|
|
|
12
|
-
const data = registryRes
|
|
13
|
-
const latest = data["dist-tags"]
|
|
14
|
-
|
|
16
|
+
const data = registryRes;
|
|
17
|
+
const latest = data["dist-tags"]?.latest;
|
|
18
|
+
if (!latest) throw new Error(`No published version found for "${pkg}"`);
|
|
19
|
+
|
|
20
|
+
const versionData = data.versions?.[latest] || {};
|
|
21
|
+
const timeData = data.time || {};
|
|
15
22
|
|
|
16
23
|
return {
|
|
17
24
|
name: data.name,
|
|
18
|
-
maintainers: data.maintainers?.length || 0,
|
|
19
|
-
lastPublished: data.time?.[latest],
|
|
20
|
-
hasInstallScript:
|
|
21
|
-
versionData.scripts?.install ||
|
|
22
|
-
versionData.scripts?.postinstall ||
|
|
23
|
-
false,
|
|
24
25
|
version: latest,
|
|
25
|
-
|
|
26
|
+
description: data.description || "",
|
|
27
|
+
downloads: downloadRes.downloads || 0,
|
|
28
|
+
maintainers: data.maintainers || [],
|
|
29
|
+
license: versionData.license || data.license || "Unknown",
|
|
30
|
+
lastPublished: timeData[latest],
|
|
31
|
+
firstPublished: timeData.created,
|
|
32
|
+
hasInstallScript: !!(
|
|
33
|
+
versionData.scripts?.install ||
|
|
34
|
+
versionData.scripts?.preinstall ||
|
|
35
|
+
versionData.scripts?.postinstall
|
|
36
|
+
),
|
|
37
|
+
deprecated: versionData.deprecated || false,
|
|
38
|
+
repository: data.repository?.url || versionData.repository?.url || null,
|
|
39
|
+
dependencies: Object.keys(versionData.dependencies || {}).length,
|
|
40
|
+
totalVersions: Object.keys(data.versions || {}).length,
|
|
26
41
|
};
|
|
27
42
|
}
|
package/src/scan.js
CHANGED
|
@@ -1,15 +1,65 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
|
-
import
|
|
2
|
+
import path from "path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import ora from "ora";
|
|
5
|
+
import { runPipeline } from "./services/pipeline.js";
|
|
6
|
+
import { formatPipelineResult, formatScanSummary } from "./format.js";
|
|
3
7
|
|
|
4
|
-
export async function scanProject() {
|
|
5
|
-
const
|
|
8
|
+
export async function scanProject({ verbose, json, skipGithub } = {}) {
|
|
9
|
+
const pkgPath = path.resolve("package.json");
|
|
10
|
+
|
|
11
|
+
if (!fs.existsSync(pkgPath)) {
|
|
12
|
+
console.log(chalk.red("\n ✘ No package.json found in current directory\n"));
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
6
17
|
|
|
7
18
|
const deps = {
|
|
8
19
|
...pkg.dependencies,
|
|
9
20
|
...pkg.devDependencies,
|
|
10
21
|
};
|
|
11
22
|
|
|
12
|
-
|
|
13
|
-
|
|
23
|
+
const depNames = Object.keys(deps);
|
|
24
|
+
|
|
25
|
+
if (depNames.length === 0) {
|
|
26
|
+
console.log(chalk.yellow("\n No dependencies found.\n"));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log(chalk.bold(`\n Scanning ${depNames.length} dependencies...\n`));
|
|
31
|
+
|
|
32
|
+
const results = [];
|
|
33
|
+
const spinner = ora();
|
|
34
|
+
|
|
35
|
+
for (const dep of depNames) {
|
|
36
|
+
spinner.start(`Analyzing ${dep}...`);
|
|
37
|
+
try {
|
|
38
|
+
const result = await runPipeline(dep, undefined, { skipGithub: skipGithub !== false });
|
|
39
|
+
results.push(result);
|
|
40
|
+
|
|
41
|
+
if (verbose) {
|
|
42
|
+
spinner.stop();
|
|
43
|
+
console.log(formatPipelineResult(result));
|
|
44
|
+
} else {
|
|
45
|
+
const icon =
|
|
46
|
+
result.label === "CRITICAL" || result.label === "HIGH"
|
|
47
|
+
? "🚨"
|
|
48
|
+
: result.label === "MEDIUM"
|
|
49
|
+
? "⚠"
|
|
50
|
+
: "✅";
|
|
51
|
+
spinner.succeed(
|
|
52
|
+
`${dep} ${chalk.gray(`(${result.score}/10)`)} ${icon}`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
spinner.fail(`${dep} - failed to fetch`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (json) {
|
|
61
|
+
console.log(JSON.stringify(results, null, 2));
|
|
62
|
+
} else {
|
|
63
|
+
console.log(formatScanSummary(results));
|
|
14
64
|
}
|
|
15
65
|
}
|
package/src/score.js
CHANGED
|
@@ -1,47 +1,128 @@
|
|
|
1
|
+
import { checkTyposquat } from "./typosquat.js";
|
|
2
|
+
|
|
1
3
|
export function calculateRisk(pkg) {
|
|
2
4
|
let score = 0;
|
|
3
|
-
const
|
|
5
|
+
const checks = [];
|
|
6
|
+
const now = new Date();
|
|
4
7
|
|
|
5
|
-
//
|
|
6
|
-
if (pkg.downloads <
|
|
8
|
+
// ── Downloads ──────────────────────────────────
|
|
9
|
+
if (pkg.downloads < 100) {
|
|
7
10
|
score += 3;
|
|
8
|
-
|
|
9
|
-
} else if (pkg.downloads <
|
|
11
|
+
checks.push({ label: "Downloads", status: "fail", detail: `Very low (${pkg.downloads.toLocaleString()}/week)` });
|
|
12
|
+
} else if (pkg.downloads < 1_000) {
|
|
10
13
|
score += 2;
|
|
11
|
-
|
|
14
|
+
checks.push({ label: "Downloads", status: "warn", detail: `Low (${pkg.downloads.toLocaleString()}/week)` });
|
|
15
|
+
} else if (pkg.downloads < 10_000) {
|
|
16
|
+
score += 1;
|
|
17
|
+
checks.push({ label: "Downloads", status: "warn", detail: `Moderate (${pkg.downloads.toLocaleString()}/week)` });
|
|
18
|
+
} else {
|
|
19
|
+
checks.push({ label: "Downloads", status: "pass", detail: `${pkg.downloads.toLocaleString()}/week` });
|
|
12
20
|
}
|
|
13
21
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const now = new Date();
|
|
17
|
-
const diffMonths = (now - lastUpdate) / (1000 * 60 * 60 * 24 * 30);
|
|
22
|
+
if (pkg.downloads > 1_000_000) score -= 2;
|
|
23
|
+
if (pkg.downloads > 5_000_000) score -= 1;
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
// ── Update recency ────────────────────────────
|
|
26
|
+
if (pkg.lastPublished) {
|
|
27
|
+
const diffMonths = (now - new Date(pkg.lastPublished)) / (1000 * 60 * 60 * 24 * 30);
|
|
28
|
+
if (diffMonths > 24) {
|
|
29
|
+
score += 3;
|
|
30
|
+
checks.push({ label: "Last Updated", status: "fail", detail: "Not updated in over 2 years" });
|
|
31
|
+
} else if (diffMonths > 12) {
|
|
32
|
+
score += 2;
|
|
33
|
+
checks.push({ label: "Last Updated", status: "warn", detail: "Not updated in over a year" });
|
|
34
|
+
} else if (diffMonths > 6) {
|
|
35
|
+
score += 1;
|
|
36
|
+
checks.push({ label: "Last Updated", status: "warn", detail: "Not updated in 6+ months" });
|
|
37
|
+
} else {
|
|
38
|
+
checks.push({ label: "Last Updated", status: "pass", detail: "Recently updated" });
|
|
39
|
+
}
|
|
25
40
|
}
|
|
26
41
|
|
|
27
|
-
//
|
|
42
|
+
// ── Install scripts ───────────────────────────
|
|
28
43
|
if (pkg.hasInstallScript) {
|
|
29
44
|
score += 3;
|
|
30
|
-
|
|
45
|
+
checks.push({ label: "Install Scripts", status: "fail", detail: "Has install/postinstall scripts" });
|
|
46
|
+
} else {
|
|
47
|
+
checks.push({ label: "Install Scripts", status: "pass", detail: "No install scripts" });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Maintainers ───────────────────────────────
|
|
51
|
+
const maintainerCount = pkg.maintainers?.length || 0;
|
|
52
|
+
if (maintainerCount === 0) {
|
|
53
|
+
score += 2;
|
|
54
|
+
checks.push({ label: "Maintainers", status: "fail", detail: "No maintainers listed" });
|
|
55
|
+
} else if (maintainerCount === 1) {
|
|
56
|
+
score += 1;
|
|
57
|
+
checks.push({ label: "Maintainers", status: "warn", detail: "Single maintainer" });
|
|
58
|
+
} else {
|
|
59
|
+
checks.push({ label: "Maintainers", status: "pass", detail: `${maintainerCount} maintainers` });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── License ───────────────────────────────────
|
|
63
|
+
const license = (typeof pkg.license === "string" ? pkg.license : pkg.license?.type) || "Unknown";
|
|
64
|
+
const permissive = ["MIT", "ISC", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "0BSD", "Unlicense"];
|
|
65
|
+
if (license === "Unknown" || license === "UNLICENSED") {
|
|
66
|
+
score += 2;
|
|
67
|
+
checks.push({ label: "License", status: "fail", detail: "No license specified" });
|
|
68
|
+
} else if (permissive.includes(license)) {
|
|
69
|
+
checks.push({ label: "License", status: "pass", detail: license });
|
|
70
|
+
} else {
|
|
71
|
+
score += 1;
|
|
72
|
+
checks.push({ label: "License", status: "warn", detail: `${license} (review recommended)` });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Repository ────────────────────────────────
|
|
76
|
+
if (!pkg.repository) {
|
|
77
|
+
score += 1;
|
|
78
|
+
checks.push({ label: "Repository", status: "warn", detail: "No repository URL" });
|
|
79
|
+
} else {
|
|
80
|
+
checks.push({ label: "Repository", status: "pass", detail: "Has repository link" });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Package age ───────────────────────────────
|
|
84
|
+
if (pkg.firstPublished) {
|
|
85
|
+
const ageDays = (now - new Date(pkg.firstPublished)) / (1000 * 60 * 60 * 24);
|
|
86
|
+
if (ageDays < 30) {
|
|
87
|
+
score += 2;
|
|
88
|
+
checks.push({ label: "Package Age", status: "fail", detail: "Published less than 30 days ago" });
|
|
89
|
+
} else if (ageDays < 180) {
|
|
90
|
+
score += 1;
|
|
91
|
+
checks.push({ label: "Package Age", status: "warn", detail: "Published less than 6 months ago" });
|
|
92
|
+
} else {
|
|
93
|
+
const years = Math.floor(ageDays / 365);
|
|
94
|
+
checks.push({ label: "Package Age", status: "pass", detail: years > 0 ? `${years}+ year(s) old` : "6+ months old" });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Dependency count ──────────────────────────
|
|
99
|
+
if (pkg.dependencies > 20) {
|
|
100
|
+
score += 1;
|
|
101
|
+
checks.push({ label: "Dependencies", status: "warn", detail: `${pkg.dependencies} direct dependencies (high)` });
|
|
102
|
+
} else {
|
|
103
|
+
checks.push({ label: "Dependencies", status: "pass", detail: `${pkg.dependencies} direct dependencies` });
|
|
31
104
|
}
|
|
32
105
|
|
|
33
|
-
//
|
|
34
|
-
if (pkg.
|
|
35
|
-
score
|
|
106
|
+
// ── Deprecated ────────────────────────────────
|
|
107
|
+
if (pkg.deprecated) {
|
|
108
|
+
score += 3;
|
|
109
|
+
checks.push({
|
|
110
|
+
label: "Deprecated",
|
|
111
|
+
status: "fail",
|
|
112
|
+
detail: typeof pkg.deprecated === "string" ? pkg.deprecated : "Package is deprecated",
|
|
113
|
+
});
|
|
36
114
|
}
|
|
37
115
|
|
|
38
|
-
|
|
39
|
-
|
|
116
|
+
// ── Typosquatting ─────────────────────────────
|
|
117
|
+
const typosquatMatch = checkTyposquat(pkg.name);
|
|
118
|
+
if (typosquatMatch) {
|
|
119
|
+
score += 3;
|
|
120
|
+
checks.push({ label: "Typosquatting", status: "fail", detail: `Name is suspiciously similar to "${typosquatMatch}"` });
|
|
40
121
|
}
|
|
41
122
|
|
|
42
123
|
// Normalize
|
|
43
|
-
|
|
44
|
-
|
|
124
|
+
score = Math.max(0, Math.min(10, score));
|
|
125
|
+
const level = score <= 3 ? "low" : score <= 6 ? "medium" : "high";
|
|
45
126
|
|
|
46
|
-
return { score,
|
|
127
|
+
return { score, checks, level };
|
|
47
128
|
}
|
|
@@ -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
|
+
}
|
package/src/typosquat.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const POPULAR_PACKAGES = [
|
|
2
|
+
"express", "react", "vue", "angular", "lodash", "axios", "moment",
|
|
3
|
+
"webpack", "babel", "typescript", "eslint", "prettier", "jest",
|
|
4
|
+
"mocha", "chai", "next", "nuxt", "gatsby", "svelte", "jquery",
|
|
5
|
+
"underscore", "async", "chalk", "commander", "inquirer", "ora",
|
|
6
|
+
"nodemon", "dotenv", "cors", "mongoose", "sequelize", "prisma",
|
|
7
|
+
"socket.io", "passport", "bcrypt", "jsonwebtoken", "uuid",
|
|
8
|
+
"mysql", "pg", "redis", "mongodb", "fastify", "koa", "hapi",
|
|
9
|
+
"request", "node-fetch", "got", "cheerio", "puppeteer",
|
|
10
|
+
"sharp", "multer", "helmet", "morgan", "winston", "debug",
|
|
11
|
+
"bluebird", "rxjs", "ramda", "date-fns", "dayjs", "zod",
|
|
12
|
+
"yup", "ajv", "joi", "class-validator", "formik",
|
|
13
|
+
"tailwindcss", "bootstrap", "sass", "less", "styled-components",
|
|
14
|
+
"vite", "esbuild", "rollup", "parcel", "turbo",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function levenshtein(a, b) {
|
|
18
|
+
const m = a.length;
|
|
19
|
+
const n = b.length;
|
|
20
|
+
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
23
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
24
|
+
|
|
25
|
+
for (let i = 1; i <= m; i++) {
|
|
26
|
+
for (let j = 1; j <= n; j++) {
|
|
27
|
+
dp[i][j] =
|
|
28
|
+
a[i - 1] === b[j - 1]
|
|
29
|
+
? dp[i - 1][j - 1]
|
|
30
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return dp[m][n];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function checkTyposquat(name) {
|
|
38
|
+
const lower = name.toLowerCase();
|
|
39
|
+
|
|
40
|
+
if (POPULAR_PACKAGES.includes(lower)) return null;
|
|
41
|
+
|
|
42
|
+
for (const popular of POPULAR_PACKAGES) {
|
|
43
|
+
const dist = levenshtein(lower, popular);
|
|
44
|
+
if (dist > 0 && dist <= 2 && Math.abs(lower.length - popular.length) <= 2) {
|
|
45
|
+
return popular;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { getCached, setCache } from "./cache.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extracts owner/repo from a repository URL.
|
|
5
|
+
*/
|
|
6
|
+
function parseRepoUrl(url) {
|
|
7
|
+
if (!url) return null;
|
|
8
|
+
// Handles: git+https://github.com/owner/repo.git, https://github.com/owner/repo, etc.
|
|
9
|
+
const match = url.match(
|
|
10
|
+
/github\.com[/:]([^/]+)\/([^/.#]+)/
|
|
11
|
+
);
|
|
12
|
+
if (!match) return null;
|
|
13
|
+
return { owner: match[1], repo: match[2] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function ghFetch(path) {
|
|
17
|
+
const headers = { "User-Agent": "install-guard-cli" };
|
|
18
|
+
// Use token if available to avoid rate limits
|
|
19
|
+
if (process.env.GITHUB_TOKEN) {
|
|
20
|
+
headers.Authorization = `token ${process.env.GITHUB_TOKEN}`;
|
|
21
|
+
}
|
|
22
|
+
const res = await fetch(`https://api.github.com${path}`, { headers });
|
|
23
|
+
if (!res.ok) return null;
|
|
24
|
+
return res.json();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Checks if a given version tag exists on GitHub.
|
|
29
|
+
* Tries both `v1.2.3` and `1.2.3` tag formats.
|
|
30
|
+
*/
|
|
31
|
+
export async function checkGitHubTag(repoUrl, version) {
|
|
32
|
+
const repo = parseRepoUrl(repoUrl);
|
|
33
|
+
if (!repo) return { hasRepo: false, tagFound: false, recentCommits: false };
|
|
34
|
+
|
|
35
|
+
const key = `gh-tag:${repo.owner}/${repo.repo}:${version}`;
|
|
36
|
+
const cached = getCached(key);
|
|
37
|
+
if (cached) return cached;
|
|
38
|
+
|
|
39
|
+
// Try v-prefixed and plain tag
|
|
40
|
+
const tags = await ghFetch(`/repos/${repo.owner}/${repo.repo}/tags?per_page=100`);
|
|
41
|
+
if (!tags) {
|
|
42
|
+
const result = { hasRepo: true, tagFound: false, recentCommits: false, error: "rate-limited or private" };
|
|
43
|
+
setCache(key, result);
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const tagNames = tags.map((t) => t.name);
|
|
48
|
+
const tagFound = tagNames.includes(`v${version}`) || tagNames.includes(version);
|
|
49
|
+
|
|
50
|
+
// Check recent commits
|
|
51
|
+
const commits = await ghFetch(
|
|
52
|
+
`/repos/${repo.owner}/${repo.repo}/commits?per_page=1`
|
|
53
|
+
);
|
|
54
|
+
let recentCommits = false;
|
|
55
|
+
if (commits && commits.length > 0) {
|
|
56
|
+
const lastCommitDate = new Date(commits[0].commit?.committer?.date || 0);
|
|
57
|
+
const daysSinceCommit = (Date.now() - lastCommitDate.getTime()) / (1000 * 60 * 60 * 24);
|
|
58
|
+
recentCommits = daysSinceCommit < 90;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const result = { hasRepo: true, tagFound, recentCommits };
|
|
62
|
+
setCache(key, result);
|
|
63
|
+
return result;
|
|
64
|
+
}
|