pkgxray 0.6.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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pkgxray",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
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",
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
@@ -607,16 +606,16 @@ const BULK_ENV_REGEXES = [
607
606
 
608
607
  function inspectCredentialAccess(file, content, lower, findings) {
609
608
  for (const target of SUSPICIOUS_READ_TARGETS) {
610
- const index = lower.indexOf(target.toLowerCase());
611
- if (index === -1) continue;
612
- 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;
613
612
  findings.push({
614
613
  severity: "high",
615
614
  category: "credential-access",
616
615
  file: file.path,
617
- snippet: clipAround(file.content, index),
616
+ snippet: clipAround(file.content, match.index),
618
617
  rationale:
619
- "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.`
620
619
  });
621
620
  return;
622
621
  }
package/src/quarantine.js CHANGED
@@ -158,6 +158,10 @@ async function stageReference(reference, stagedPath, options) {
158
158
  return resolveNpmPackage(parsed.specifier, options);
159
159
  }
160
160
 
161
+ if (parsed.type === "github") {
162
+ return resolveGithubRepo(parsed, options);
163
+ }
164
+
161
165
  throw new Error(`Unsupported reference type: ${reference}`);
162
166
  }
163
167
 
@@ -170,6 +174,21 @@ function parseReference(reference) {
170
174
  return { type: "local", path: path.resolve(reference.slice("file:".length)) };
171
175
  }
172
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
+
173
192
  if (
174
193
  reference.startsWith(".") ||
175
194
  reference.startsWith("/") ||
@@ -184,6 +203,62 @@ function parseReference(reference) {
184
203
  return { type: "npm", specifier: reference };
185
204
  }
186
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
+
187
262
  async function copyLocalPath(sourcePath, stagedPath) {
188
263
  const stat = await fsp.stat(sourcePath);
189
264
  if (!stat.isDirectory()) {