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 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.5.0",
4
- "description": "Zero-dep local CLI and MCP server that scans npm packages and AI-agent extensions for supply-chain risk. OSV vuln pre-check, sandboxed quarantine, tarball-integrity verification, calibrated static heuristics.",
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
- "~/.ssh",
18
- ".ssh/",
19
- "id_rsa",
20
- "id_dsa",
21
- "id_ecdsa",
22
- "id_ed25519",
23
- "~/.aws",
24
- ".aws/credentials",
25
- ".npmrc",
26
- ".env",
27
- "keychain",
28
- "login.keychain",
29
- "cookies.sqlite",
30
- "local state",
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
- inspectMetadataObject("GITHUB_METADATA", evidence.githubMetadata, findings);
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 index = lower.indexOf(target.toLowerCase());
503
- if (index === -1) continue;
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
- "Package reads (or constructs a path to) a credential / wallet / key store in proximity to a filesystem read primitive."
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: null,
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()) {