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 +1 -1
- package/package.json +1 -1
- package/src/auditor.js +22 -23
- package/src/quarantine.js +75 -0
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.
|
|
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
|
-
"
|
|
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
|
|
@@ -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
|
|
611
|
-
if (
|
|
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
|
-
|
|
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()) {
|