pkgxray 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/audit.js +3 -1
- package/bin/mcp-server.js +5 -0
- package/package.json +2 -2
- package/src/auditor.js +132 -25
- package/src/github.js +166 -0
- package/src/quarantine.js +92 -1
package/bin/audit.js
CHANGED
|
@@ -12,7 +12,7 @@ function printUsage() {
|
|
|
12
12
|
" pkgxray < evidence.json",
|
|
13
13
|
" pkgxray --format json < evidence.json",
|
|
14
14
|
" pkgxray --file evidence.json --format markdown",
|
|
15
|
-
" pkgxray guard <npm-package|npm:name@version|./path> [--promote-to dir] [--no-source-scan]",
|
|
15
|
+
" pkgxray guard <npm-package|npm:name@version|github:owner/repo[#ref]|./path> [--promote-to dir] [--no-source-scan]",
|
|
16
16
|
"",
|
|
17
17
|
"Evidence JSON fields:",
|
|
18
18
|
" packageName, npmMetadata, githubMetadata, webPresence, sourceFiles",
|
|
@@ -49,6 +49,8 @@ function parseArgs(argv) {
|
|
|
49
49
|
options.sourceScan = false;
|
|
50
50
|
} else if (arg === "--no-vulnerability-check") {
|
|
51
51
|
options.vulnerabilityCheck = false;
|
|
52
|
+
} else if (arg === "--no-github") {
|
|
53
|
+
options.githubMetadata = false;
|
|
52
54
|
} else {
|
|
53
55
|
throw new Error(`Unknown argument: ${arg}`);
|
|
54
56
|
}
|
package/bin/mcp-server.js
CHANGED
|
@@ -102,6 +102,11 @@ function guardToolDefinition() {
|
|
|
102
102
|
default: true,
|
|
103
103
|
description: "Set false to skip OSV vulnerability intelligence checks."
|
|
104
104
|
},
|
|
105
|
+
githubMetadata: {
|
|
106
|
+
type: "boolean",
|
|
107
|
+
default: true,
|
|
108
|
+
description: "Set false to skip the GitHub provenance cross-check."
|
|
109
|
+
},
|
|
105
110
|
outputFormat: {
|
|
106
111
|
type: "string",
|
|
107
112
|
enum: ["markdown", "json"],
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pkgxray",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Zero-dep local CLI and MCP server that scans npm packages
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Zero-dep local CLI and MCP server that scans npm packages for supply-chain risk. OSV vuln pre-check, sandboxed quarantine, tarball-integrity verification, calibrated static heuristics, GitHub provenance cross-check.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jack Adams-Lovell",
|
|
7
7
|
"type": "commonjs",
|
package/src/auditor.js
CHANGED
|
@@ -13,25 +13,24 @@ const SEVERITY_ORDER = {
|
|
|
13
13
|
high: 3
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
// Suspicious credential / wallet read targets. Each entry is a regex that
|
|
17
|
+
// requires a path or quote boundary so we don't match identifiers like
|
|
18
|
+
// `process.env` or `someObj.ledger`.
|
|
16
19
|
const SUSPICIOUS_READ_TARGETS = [
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"metamask",
|
|
32
|
-
"electrum",
|
|
33
|
-
"exodus",
|
|
34
|
-
"ledger"
|
|
20
|
+
{ re: /['"`\/\\]\.?ssh\/(?:id_(?:rsa|dsa|ecdsa|ed25519)|authorized_keys)/i, label: "ssh-private-key" },
|
|
21
|
+
{ re: /['"`\/\\]id_(?:rsa|dsa|ecdsa|ed25519)\b/i, label: "ssh-key-file" },
|
|
22
|
+
{ re: /['"`\/\\]\.ssh(?:\/|['"`])/, label: ".ssh-dir" },
|
|
23
|
+
{ re: /['"`\/\\]\.aws\/credentials\b/, label: ".aws/credentials" },
|
|
24
|
+
{ re: /['"`\/\\]\.aws\/(?:config|credentials)\b/, label: ".aws-files" },
|
|
25
|
+
{ re: /['"`\/\\]\.npmrc(?:['"`]|\s|$)/, label: ".npmrc" },
|
|
26
|
+
{ re: /['"`\/\\]\.env(?:\.[a-z]+)?(?:['"`]|\s|$)/i, label: ".env-file" },
|
|
27
|
+
{ re: /['"`]login\.keychain(?:-db)?['"`]/i, label: "macOS keychain" },
|
|
28
|
+
{ re: /\bsecurity\s+find-(?:generic|internet)-password\b/, label: "macOS security CLI" },
|
|
29
|
+
{ re: /['"`]\/?(?:Cookies|Login Data|Web Data|cookies\.sqlite)['"`]/i, label: "browser-creds" },
|
|
30
|
+
{ re: /['"`]Local State['"`]/, label: "browser local-state" },
|
|
31
|
+
{ re: /\bkeytar\.[a-z]+Password\(/i, label: "keytar API" },
|
|
32
|
+
{ re: /\bmetamask['"`\s\/]/i, label: "metamask wallet" },
|
|
33
|
+
{ re: /\b(?:electrum|exodus|ledger live|atomic wallet)\b/i, label: "crypto wallet" }
|
|
35
34
|
];
|
|
36
35
|
|
|
37
36
|
// Persistence destinations. Each pattern requires a quote/slash boundary
|
|
@@ -239,7 +238,12 @@ const BAND_DEFINITIONS = [
|
|
|
239
238
|
{ band: "bulk-env", label: "bulk-env-access", categories: ["environment-access"], rationale: "Reads the entire process environment in bulk; risky paired with network." },
|
|
240
239
|
{ band: "clipboard", label: "clipboard-access", categories: ["data-access"], rationale: "Reads or writes the system clipboard — can expose copied secrets." },
|
|
241
240
|
{ band: "incomplete-evidence", label: "incomplete-evidence", categories: ["missing-evidence", "missing-package-json", "package-metadata"], rationale: "Source or package.json was missing or unparseable — cannot rule the package safe." },
|
|
242
|
-
{ band: "missing-metadata", label: "missing-metadata", categories: ["missing-metadata", "supply-chain-signal"], rationale: "Provenance metadata (npm registry / GitHub) absent or weak; cross-checks skipped." }
|
|
241
|
+
{ band: "missing-metadata", label: "missing-metadata", categories: ["missing-metadata", "supply-chain-signal", "github-fetch"], rationale: "Provenance metadata (npm registry / GitHub) absent or weak; cross-checks skipped." },
|
|
242
|
+
{ band: "github-mismatch", label: "github-mismatch", categories: ["github-mismatch"], rationale: "package.json points at a GitHub repo that doesn't exist or doesn't match — strong typosquat / impersonation signal." },
|
|
243
|
+
{ band: "github-archived", label: "github-archived", categories: ["github-archived"], rationale: "Linked repository is archived or disabled — no maintenance, security issues will not be fixed." },
|
|
244
|
+
{ band: "github-young", label: "github-young", categories: ["github-young"], rationale: "Linked repository was created within the last 30 days — common slopsquat shape." },
|
|
245
|
+
{ band: "github-lonely", label: "github-lonely", categories: ["github-lonely"], rationale: "0 stars + 0 forks + low watcher count on a young repo. Low community signal." },
|
|
246
|
+
{ band: "github-stale", label: "github-stale", categories: ["github-stale"], rationale: "Repository hasn't been pushed to in over two years and isn't formally archived." }
|
|
243
247
|
];
|
|
244
248
|
|
|
245
249
|
const SEVERITY_RANK = { info: 0, low: 1, medium: 2, high: 3 };
|
|
@@ -285,10 +289,113 @@ function auditMetadata(evidence, findings) {
|
|
|
285
289
|
}
|
|
286
290
|
|
|
287
291
|
inspectMetadataObject("NPM_METADATA", evidence.npmMetadata, findings);
|
|
288
|
-
|
|
292
|
+
inspectGithubMetadata(evidence, findings);
|
|
289
293
|
inspectKnownVulnerabilities(evidence.knownVulnerabilities, findings);
|
|
290
294
|
}
|
|
291
295
|
|
|
296
|
+
const YOUNG_REPO_DAYS = 30;
|
|
297
|
+
const STALE_REPO_DAYS = 365 * 2;
|
|
298
|
+
|
|
299
|
+
function daysAgo(iso) {
|
|
300
|
+
if (!iso) return null;
|
|
301
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
302
|
+
return Math.floor(ms / 86400000);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function inspectGithubMetadata(evidence, findings) {
|
|
306
|
+
const meta = evidence.githubMetadata;
|
|
307
|
+
if (!meta || typeof meta !== "object") {
|
|
308
|
+
findings.push({
|
|
309
|
+
severity: "info",
|
|
310
|
+
category: "missing-metadata",
|
|
311
|
+
file: "GITHUB_METADATA",
|
|
312
|
+
snippet: "GITHUB_METADATA was not provided.",
|
|
313
|
+
rationale: "Supply-chain reputation and repository consistency could not be checked."
|
|
314
|
+
});
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (meta.found === false) {
|
|
319
|
+
const where = meta.owner && meta.repo ? `${meta.owner}/${meta.repo}` : "linked URL";
|
|
320
|
+
if (meta.reason === "not-found") {
|
|
321
|
+
findings.push({
|
|
322
|
+
severity: "high",
|
|
323
|
+
category: "github-mismatch",
|
|
324
|
+
file: "GITHUB_METADATA",
|
|
325
|
+
snippet: `Repository ${where} 404s on GitHub`,
|
|
326
|
+
rationale:
|
|
327
|
+
"package.json points at a GitHub repository that does not exist. Strong typosquat / impersonation signal."
|
|
328
|
+
});
|
|
329
|
+
} else if (meta.reason === "not-github") {
|
|
330
|
+
// Not a GitHub URL at all — skip silently.
|
|
331
|
+
} else {
|
|
332
|
+
findings.push({
|
|
333
|
+
severity: "info",
|
|
334
|
+
category: "github-fetch",
|
|
335
|
+
file: "GITHUB_METADATA",
|
|
336
|
+
snippet: meta.message || "Could not reach GitHub API",
|
|
337
|
+
rationale: "Provenance metadata could not be fetched; cross-checks skipped."
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (meta.archived) {
|
|
344
|
+
findings.push({
|
|
345
|
+
severity: "medium",
|
|
346
|
+
category: "github-archived",
|
|
347
|
+
file: "GITHUB_METADATA",
|
|
348
|
+
snippet: `${meta.full_name} is archived (read-only)`,
|
|
349
|
+
rationale: "Archived repos receive no maintenance; security issues will not be fixed."
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (meta.disabled) {
|
|
354
|
+
findings.push({
|
|
355
|
+
severity: "medium",
|
|
356
|
+
category: "github-archived",
|
|
357
|
+
file: "GITHUB_METADATA",
|
|
358
|
+
snippet: `${meta.full_name} is disabled`,
|
|
359
|
+
rationale: "Disabled repos cannot be updated; maintainer access may be revoked."
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const ageDays = daysAgo(meta.created_at);
|
|
364
|
+
if (ageDays !== null && ageDays < YOUNG_REPO_DAYS) {
|
|
365
|
+
findings.push({
|
|
366
|
+
severity: "medium",
|
|
367
|
+
category: "github-young",
|
|
368
|
+
file: "GITHUB_METADATA",
|
|
369
|
+
snippet: `${meta.full_name} created ${ageDays} days ago`,
|
|
370
|
+
rationale:
|
|
371
|
+
"Brand-new repository combined with an npm package using a popular-sounding name is a classic slopsquat / impersonation shape."
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const lonelySignal = (meta.stars || 0) === 0 && (meta.forks || 0) === 0 && (meta.watchers || 0) <= 1;
|
|
376
|
+
if (lonelySignal && (ageDays === null || ageDays < 90)) {
|
|
377
|
+
findings.push({
|
|
378
|
+
severity: "low",
|
|
379
|
+
category: "github-lonely",
|
|
380
|
+
file: "GITHUB_METADATA",
|
|
381
|
+
snippet: `${meta.full_name} has 0 stars, 0 forks, ${ageDays !== null ? `${ageDays} days old` : "unknown age"}`,
|
|
382
|
+
rationale:
|
|
383
|
+
"Very low community signal. Common for new tools, but compounds the slopsquat risk on similarly-named popular packages."
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const pushedDaysAgo = daysAgo(meta.pushed_at);
|
|
388
|
+
if (pushedDaysAgo !== null && pushedDaysAgo > STALE_REPO_DAYS && !meta.archived) {
|
|
389
|
+
findings.push({
|
|
390
|
+
severity: "info",
|
|
391
|
+
category: "github-stale",
|
|
392
|
+
file: "GITHUB_METADATA",
|
|
393
|
+
snippet: `${meta.full_name} last push ${pushedDaysAgo} days ago`,
|
|
394
|
+
rationale: "Repo has not seen a push in over two years; consider whether it's still maintained."
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
292
399
|
function inspectKnownVulnerabilities(vulnerabilities, findings) {
|
|
293
400
|
if (!Array.isArray(vulnerabilities) || vulnerabilities.length === 0) {
|
|
294
401
|
return;
|
|
@@ -499,16 +606,16 @@ const BULK_ENV_REGEXES = [
|
|
|
499
606
|
|
|
500
607
|
function inspectCredentialAccess(file, content, lower, findings) {
|
|
501
608
|
for (const target of SUSPICIOUS_READ_TARGETS) {
|
|
502
|
-
const
|
|
503
|
-
if (
|
|
504
|
-
if (!looksLikeCredentialRead(content, lower, index)) continue;
|
|
609
|
+
const match = target.re.exec(content);
|
|
610
|
+
if (!match) continue;
|
|
611
|
+
if (!looksLikeCredentialRead(content, lower, match.index)) continue;
|
|
505
612
|
findings.push({
|
|
506
613
|
severity: "high",
|
|
507
614
|
category: "credential-access",
|
|
508
615
|
file: file.path,
|
|
509
|
-
snippet: clipAround(file.content, index),
|
|
616
|
+
snippet: clipAround(file.content, match.index),
|
|
510
617
|
rationale:
|
|
511
|
-
|
|
618
|
+
`Reads or references ${target.label} near a filesystem read primitive.`
|
|
512
619
|
});
|
|
513
620
|
return;
|
|
514
621
|
}
|
package/src/github.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fsp = require("node:fs/promises");
|
|
4
|
+
const os = require("node:os");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const https = require("node:https");
|
|
7
|
+
|
|
8
|
+
const USER_AGENT = "pkgxray/0.6.0";
|
|
9
|
+
const CACHE_DIR = path.join(os.homedir(), ".cache", "pkgxray", "github");
|
|
10
|
+
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
11
|
+
const FETCH_TIMEOUT_MS = 3000;
|
|
12
|
+
|
|
13
|
+
async function readCache(key) {
|
|
14
|
+
try {
|
|
15
|
+
const file = path.join(CACHE_DIR, `${encodeURIComponent(key)}.json`);
|
|
16
|
+
const stat = await fsp.stat(file);
|
|
17
|
+
if (Date.now() - stat.mtimeMs > CACHE_TTL_MS) return null;
|
|
18
|
+
return JSON.parse(await fsp.readFile(file, "utf8"));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function writeCache(key, value) {
|
|
25
|
+
try {
|
|
26
|
+
await fsp.mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
27
|
+
const file = path.join(CACHE_DIR, `${encodeURIComponent(key)}.json`);
|
|
28
|
+
await fsp.writeFile(file, JSON.stringify(value), { mode: 0o600 });
|
|
29
|
+
} catch {
|
|
30
|
+
// best-effort cache; never fail the audit because of a cache write
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Pull owner/repo from common repository.url shapes:
|
|
35
|
+
// git+https://github.com/owner/repo.git
|
|
36
|
+
// https://github.com/owner/repo
|
|
37
|
+
// git@github.com:owner/repo.git
|
|
38
|
+
// github:owner/repo
|
|
39
|
+
// git+ssh://git@github.com/owner/repo.git
|
|
40
|
+
function parseGithubRepo(repository) {
|
|
41
|
+
if (!repository) return null;
|
|
42
|
+
const url = typeof repository === "string" ? repository : repository.url;
|
|
43
|
+
if (!url || typeof url !== "string") return null;
|
|
44
|
+
const cleaned = url.replace(/^git\+/, "").replace(/\.git$/, "");
|
|
45
|
+
const patterns = [
|
|
46
|
+
/^github:([^/]+)\/(.+)$/,
|
|
47
|
+
/^(?:https?|git):\/\/github\.com\/([^/]+)\/([^/?#]+)/,
|
|
48
|
+
/^git@github\.com:([^/]+)\/([^/?#]+)/,
|
|
49
|
+
/^ssh:\/\/git@github\.com\/([^/]+)\/([^/?#]+)/
|
|
50
|
+
];
|
|
51
|
+
for (const pattern of patterns) {
|
|
52
|
+
const match = cleaned.match(pattern);
|
|
53
|
+
if (match) {
|
|
54
|
+
return { owner: match[1], repo: match[2].replace(/\.git$/, "") };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Use GITHUB_TOKEN if the user has set it (5000 req/hr). Otherwise fall back
|
|
61
|
+
// to unauthenticated calls (60 req/hr — fine for occasional use). We
|
|
62
|
+
// deliberately do NOT shell out to `gh auth token` — that adds ~150ms on
|
|
63
|
+
// cold runs and speed is a goal.
|
|
64
|
+
function loadToken() {
|
|
65
|
+
return process.env.GITHUB_TOKEN || process.env.PKGXRAY_GITHUB_TOKEN || null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function githubApiGet(urlPath, token, hops = 0) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
if (hops > 3) return reject(new Error("Too many GitHub redirects"));
|
|
71
|
+
const headers = {
|
|
72
|
+
"user-agent": USER_AGENT,
|
|
73
|
+
accept: "application/vnd.github+json",
|
|
74
|
+
"x-github-api-version": "2022-11-28"
|
|
75
|
+
};
|
|
76
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
77
|
+
const request = https.get(
|
|
78
|
+
{ hostname: "api.github.com", path: urlPath, headers, timeout: FETCH_TIMEOUT_MS },
|
|
79
|
+
(response) => {
|
|
80
|
+
// Follow GitHub's 301 redirects (repo transferred / renamed)
|
|
81
|
+
if ([301, 302, 307, 308].includes(response.statusCode) && response.headers.location) {
|
|
82
|
+
response.resume();
|
|
83
|
+
const nextUrl = new URL(response.headers.location, `https://api.github.com${urlPath}`);
|
|
84
|
+
return githubApiGet(nextUrl.pathname + nextUrl.search, token, hops + 1).then(resolve, reject);
|
|
85
|
+
}
|
|
86
|
+
let body = "";
|
|
87
|
+
response.setEncoding("utf8");
|
|
88
|
+
response.on("data", (chunk) => {
|
|
89
|
+
body += chunk;
|
|
90
|
+
});
|
|
91
|
+
response.on("end", () => {
|
|
92
|
+
if (response.statusCode === 404) {
|
|
93
|
+
const error = new Error(`GitHub 404: ${urlPath}`);
|
|
94
|
+
error.statusCode = 404;
|
|
95
|
+
return reject(error);
|
|
96
|
+
}
|
|
97
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
98
|
+
return reject(new Error(`GitHub HTTP ${response.statusCode}: ${body.slice(0, 120)}`));
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
resolve(JSON.parse(body));
|
|
102
|
+
} catch (parseError) {
|
|
103
|
+
reject(parseError);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
request.on("error", reject);
|
|
109
|
+
request.on("timeout", () => {
|
|
110
|
+
request.destroy(new Error("GitHub request timed out"));
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function fetchRepoMetadata(repository, options = {}) {
|
|
116
|
+
const parsed = parseGithubRepo(repository);
|
|
117
|
+
if (!parsed) return { found: false, reason: "not-github" };
|
|
118
|
+
|
|
119
|
+
const cacheKey = `${parsed.owner}/${parsed.repo}`;
|
|
120
|
+
if (options.useCache !== false) {
|
|
121
|
+
const cached = await readCache(cacheKey);
|
|
122
|
+
if (cached) return { ...cached, fromCache: true };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const token = options.token === undefined ? loadToken() : options.token;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const repo = await githubApiGet(`/repos/${parsed.owner}/${parsed.repo}`, token);
|
|
129
|
+
const result = {
|
|
130
|
+
found: true,
|
|
131
|
+
owner: parsed.owner,
|
|
132
|
+
repo: parsed.repo,
|
|
133
|
+
full_name: repo.full_name,
|
|
134
|
+
description: repo.description,
|
|
135
|
+
archived: Boolean(repo.archived),
|
|
136
|
+
disabled: Boolean(repo.disabled),
|
|
137
|
+
fork: Boolean(repo.fork),
|
|
138
|
+
stars: repo.stargazers_count || 0,
|
|
139
|
+
forks: repo.forks_count || 0,
|
|
140
|
+
open_issues: repo.open_issues_count || 0,
|
|
141
|
+
watchers: repo.watchers_count || 0,
|
|
142
|
+
created_at: repo.created_at,
|
|
143
|
+
updated_at: repo.updated_at,
|
|
144
|
+
pushed_at: repo.pushed_at,
|
|
145
|
+
default_branch: repo.default_branch,
|
|
146
|
+
html_url: repo.html_url,
|
|
147
|
+
license: repo.license && repo.license.spdx_id,
|
|
148
|
+
owner_type: repo.owner && repo.owner.type
|
|
149
|
+
};
|
|
150
|
+
await writeCache(cacheKey, result);
|
|
151
|
+
return result;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error.statusCode === 404) {
|
|
154
|
+
const result = { found: false, reason: "not-found", owner: parsed.owner, repo: parsed.repo };
|
|
155
|
+
await writeCache(cacheKey, result);
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
// Don't cache transient errors — next call should retry.
|
|
159
|
+
return { found: false, reason: "fetch-error", message: error.message, owner: parsed.owner, repo: parsed.repo };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
parseGithubRepo,
|
|
165
|
+
fetchRepoMetadata
|
|
166
|
+
};
|
package/src/quarantine.js
CHANGED
|
@@ -8,6 +8,7 @@ const os = require("node:os");
|
|
|
8
8
|
const path = require("node:path");
|
|
9
9
|
const { spawn } = require("node:child_process");
|
|
10
10
|
const { auditEvidence } = require("./auditor");
|
|
11
|
+
const { fetchRepoMetadata } = require("./github");
|
|
11
12
|
|
|
12
13
|
const DEFAULT_MAX_FILE_BYTES = 256 * 1024;
|
|
13
14
|
const DEFAULT_MAX_FILES = 600;
|
|
@@ -65,6 +66,15 @@ async function guardExtension(reference, options = {}) {
|
|
|
65
66
|
const resolved = await stageReference(reference, stagedPath, options);
|
|
66
67
|
timings.stageMs = elapsed(stageStart);
|
|
67
68
|
|
|
69
|
+
// Start the GitHub metadata fetch the moment we have npm metadata. It runs
|
|
70
|
+
// concurrently with vuln-check and tarball download so it only adds latency
|
|
71
|
+
// if it's slower than everything else combined (rare — usually <250ms).
|
|
72
|
+
const githubStart = now();
|
|
73
|
+
const githubMetadataPromise = options.githubMetadata === false
|
|
74
|
+
? Promise.resolve(null)
|
|
75
|
+
: fetchRepoMetadata(resolved.npmMetadata && resolved.npmMetadata.repository)
|
|
76
|
+
.catch(() => null);
|
|
77
|
+
|
|
68
78
|
const vulnerabilityStart = now();
|
|
69
79
|
const vulnerabilities =
|
|
70
80
|
options.vulnerabilityCheck === false
|
|
@@ -89,10 +99,15 @@ async function guardExtension(reference, options = {}) {
|
|
|
89
99
|
timings.sourceCollectionMs = 0;
|
|
90
100
|
}
|
|
91
101
|
|
|
102
|
+
// By now the GitHub fetch is either done or has been running concurrently
|
|
103
|
+
// with everything above; await whatever remains.
|
|
104
|
+
const githubMetadata = await githubMetadataPromise;
|
|
105
|
+
timings.githubMetadataMs = elapsed(githubStart);
|
|
106
|
+
|
|
92
107
|
const evidence = {
|
|
93
108
|
packageName: resolved.packageName || reference,
|
|
94
109
|
npmMetadata: resolved.npmMetadata || null,
|
|
95
|
-
githubMetadata
|
|
110
|
+
githubMetadata,
|
|
96
111
|
webPresence: null,
|
|
97
112
|
knownVulnerabilities: vulnerabilities,
|
|
98
113
|
sourceFiles
|
|
@@ -107,6 +122,7 @@ async function guardExtension(reference, options = {}) {
|
|
|
107
122
|
reference,
|
|
108
123
|
resolved,
|
|
109
124
|
sourceFiles,
|
|
125
|
+
githubMetadata,
|
|
110
126
|
vulnerabilityPrecheck: {
|
|
111
127
|
enabled: options.vulnerabilityCheck !== false,
|
|
112
128
|
database: "OSV",
|
|
@@ -142,6 +158,10 @@ async function stageReference(reference, stagedPath, options) {
|
|
|
142
158
|
return resolveNpmPackage(parsed.specifier, options);
|
|
143
159
|
}
|
|
144
160
|
|
|
161
|
+
if (parsed.type === "github") {
|
|
162
|
+
return resolveGithubRepo(parsed, options);
|
|
163
|
+
}
|
|
164
|
+
|
|
145
165
|
throw new Error(`Unsupported reference type: ${reference}`);
|
|
146
166
|
}
|
|
147
167
|
|
|
@@ -154,6 +174,21 @@ function parseReference(reference) {
|
|
|
154
174
|
return { type: "local", path: path.resolve(reference.slice("file:".length)) };
|
|
155
175
|
}
|
|
156
176
|
|
|
177
|
+
if (reference.startsWith("github:")) {
|
|
178
|
+
return parseGithubReference(reference.slice("github:".length));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// github.com URLs as a convenience shorthand
|
|
182
|
+
const ghMatch = reference.match(/^https?:\/\/github\.com\/([^/]+)\/([^/?#]+?)(?:\.git)?(?:#(.+))?$/);
|
|
183
|
+
if (ghMatch) {
|
|
184
|
+
return {
|
|
185
|
+
type: "github",
|
|
186
|
+
owner: ghMatch[1],
|
|
187
|
+
repo: ghMatch[2],
|
|
188
|
+
ref: ghMatch[3] || null
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
157
192
|
if (
|
|
158
193
|
reference.startsWith(".") ||
|
|
159
194
|
reference.startsWith("/") ||
|
|
@@ -168,6 +203,62 @@ function parseReference(reference) {
|
|
|
168
203
|
return { type: "npm", specifier: reference };
|
|
169
204
|
}
|
|
170
205
|
|
|
206
|
+
function parseGithubReference(spec) {
|
|
207
|
+
// Supports owner/repo[#ref] and owner/repo[@ref]
|
|
208
|
+
const match = spec.match(/^([^/#@]+)\/([^/#@]+?)(?:[#@](.+))?$/);
|
|
209
|
+
if (!match) throw new Error(`Invalid github reference: github:${spec}`);
|
|
210
|
+
return {
|
|
211
|
+
type: "github",
|
|
212
|
+
owner: match[1],
|
|
213
|
+
repo: match[2].replace(/\.git$/, ""),
|
|
214
|
+
ref: match[3] || null
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function resolveGithubRepo(parsed, options) {
|
|
219
|
+
// Resolve default branch if no ref pinned. Uses the existing GitHub metadata
|
|
220
|
+
// helper which is already cached + parallel-safe.
|
|
221
|
+
const { fetchRepoMetadata } = require("./github");
|
|
222
|
+
let ref = parsed.ref;
|
|
223
|
+
let resolvedMeta = null;
|
|
224
|
+
if (!ref) {
|
|
225
|
+
const meta = await fetchRepoMetadata(`https://github.com/${parsed.owner}/${parsed.repo}`).catch(() => null);
|
|
226
|
+
if (meta && meta.found === false && meta.reason === "not-found") {
|
|
227
|
+
throw new Error(`GitHub repository not found: ${parsed.owner}/${parsed.repo}`);
|
|
228
|
+
}
|
|
229
|
+
if (meta && meta.found) {
|
|
230
|
+
ref = meta.default_branch || "HEAD";
|
|
231
|
+
resolvedMeta = meta;
|
|
232
|
+
} else {
|
|
233
|
+
ref = "HEAD";
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// GitHub's "codeload" endpoint returns a .tar.gz of the repo at the given
|
|
238
|
+
// ref. Works for branch names, tags, and commit SHAs.
|
|
239
|
+
const tarballUrl = `https://codeload.github.com/${parsed.owner}/${parsed.repo}/tar.gz/${encodeURIComponent(ref)}`;
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
type: "github",
|
|
243
|
+
owner: parsed.owner,
|
|
244
|
+
repo: parsed.repo,
|
|
245
|
+
ref,
|
|
246
|
+
needsDownload: true,
|
|
247
|
+
tarballUrl,
|
|
248
|
+
packageName: `${parsed.owner}/${parsed.repo}`,
|
|
249
|
+
githubArchive: true,
|
|
250
|
+
npmMetadata: resolvedMeta
|
|
251
|
+
? {
|
|
252
|
+
// Synthetic shape so the downstream auditor still sees a repository
|
|
253
|
+
// URL and the github cross-check finds the same data we already have.
|
|
254
|
+
name: parsed.repo,
|
|
255
|
+
repository: { url: resolvedMeta.html_url, type: "git" },
|
|
256
|
+
maintainers: []
|
|
257
|
+
}
|
|
258
|
+
: null
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
171
262
|
async function copyLocalPath(sourcePath, stagedPath) {
|
|
172
263
|
const stat = await fsp.stat(sourcePath);
|
|
173
264
|
if (!stat.isDirectory()) {
|