pkgxray 0.1.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.
@@ -0,0 +1,519 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const fsp = require("node:fs/promises");
6
+ const https = require("node:https");
7
+ const os = require("node:os");
8
+ const path = require("node:path");
9
+ const { spawn } = require("node:child_process");
10
+ const { auditEvidence } = require("./auditor");
11
+
12
+ const DEFAULT_MAX_FILE_BYTES = 256 * 1024;
13
+ const DEFAULT_MAX_FILES = 600;
14
+ const SKIP_DIRS = new Set([
15
+ ".git",
16
+ "node_modules",
17
+ "dist",
18
+ "build",
19
+ "coverage",
20
+ ".next",
21
+ ".turbo",
22
+ "__pycache__"
23
+ ]);
24
+
25
+ const TEXT_FILE_PATTERNS = [
26
+ "package.json",
27
+ "README",
28
+ ".js",
29
+ ".jsx",
30
+ ".ts",
31
+ ".tsx",
32
+ ".mjs",
33
+ ".cjs",
34
+ ".json",
35
+ ".py",
36
+ ".rs",
37
+ ".go",
38
+ ".java",
39
+ ".sh",
40
+ ".ps1",
41
+ ".toml",
42
+ ".yaml",
43
+ ".yml",
44
+ ".md",
45
+ ".txt",
46
+ ".env"
47
+ ];
48
+
49
+ async function guardExtension(reference, options = {}) {
50
+ if (!reference) {
51
+ throw new Error("Missing extension reference");
52
+ }
53
+
54
+ const quarantineRoot = path.resolve(
55
+ options.quarantineRoot || path.join(os.tmpdir(), "supply-chain-auditor")
56
+ );
57
+ await fsp.mkdir(quarantineRoot, { recursive: true, mode: 0o700 });
58
+
59
+ const workspace = await fsp.mkdtemp(path.join(quarantineRoot, "stage-"));
60
+ await fsp.chmod(workspace, 0o700);
61
+ const stagedPath = path.join(workspace, "package");
62
+ const timings = {};
63
+
64
+ const stageStart = now();
65
+ const resolved = await stageReference(reference, stagedPath, options);
66
+ timings.stageMs = elapsed(stageStart);
67
+
68
+ const vulnerabilityStart = now();
69
+ const vulnerabilities =
70
+ options.vulnerabilityCheck === false
71
+ ? []
72
+ : await precheckVulnerabilities(resolved, stagedPath);
73
+ timings.vulnerabilityPrecheckMs = elapsed(vulnerabilityStart);
74
+
75
+ if (vulnerabilities.length === 0 && resolved.needsDownload) {
76
+ const downloadStart = now();
77
+ await downloadResolvedPackage(resolved, stagedPath);
78
+ timings.downloadMs = elapsed(downloadStart);
79
+ } else {
80
+ timings.downloadMs = 0;
81
+ }
82
+
83
+ let sourceFiles = {};
84
+ if (vulnerabilities.length === 0 && !resolved.skipSourceScan && options.sourceScan !== false) {
85
+ const scanStart = now();
86
+ sourceFiles = await collectSourceFiles(stagedPath, options);
87
+ timings.sourceCollectionMs = elapsed(scanStart);
88
+ } else {
89
+ timings.sourceCollectionMs = 0;
90
+ }
91
+
92
+ const evidence = {
93
+ packageName: resolved.packageName || reference,
94
+ npmMetadata: resolved.npmMetadata || null,
95
+ githubMetadata: null,
96
+ webPresence: null,
97
+ knownVulnerabilities: vulnerabilities,
98
+ sourceFiles
99
+ };
100
+ const auditStart = now();
101
+ const report = auditEvidence(evidence);
102
+ timings.auditMs = elapsed(auditStart);
103
+ const decision = decisionForReport(report, options.policy || "safe-only");
104
+
105
+ const result = {
106
+ decision,
107
+ reference,
108
+ resolved,
109
+ sourceFiles,
110
+ vulnerabilityPrecheck: {
111
+ enabled: options.vulnerabilityCheck !== false,
112
+ database: "OSV",
113
+ vulnerabilityCount: vulnerabilities.length,
114
+ vulnerabilities
115
+ },
116
+ timings,
117
+ quarantinePath: workspace,
118
+ stagedPath,
119
+ promotedPath: null,
120
+ report
121
+ };
122
+
123
+ if (options.promoteTo && shouldPromote(decision)) {
124
+ result.promotedPath = await promoteStagedPackage(stagedPath, options.promoteTo, options);
125
+ }
126
+
127
+ return result;
128
+ }
129
+
130
+ async function stageReference(reference, stagedPath, options) {
131
+ const parsed = parseReference(reference);
132
+ if (parsed.type === "local") {
133
+ await copyLocalPath(parsed.path, stagedPath);
134
+ return {
135
+ type: "local",
136
+ source: parsed.path,
137
+ packageName: path.basename(parsed.path)
138
+ };
139
+ }
140
+
141
+ if (parsed.type === "npm") {
142
+ return resolveNpmPackage(parsed.specifier, options);
143
+ }
144
+
145
+ throw new Error(`Unsupported reference type: ${reference}`);
146
+ }
147
+
148
+ function parseReference(reference) {
149
+ if (reference.startsWith("npm:")) {
150
+ return { type: "npm", specifier: reference.slice("npm:".length) };
151
+ }
152
+
153
+ if (reference.startsWith("file:")) {
154
+ return { type: "local", path: path.resolve(reference.slice("file:".length)) };
155
+ }
156
+
157
+ if (
158
+ reference.startsWith(".") ||
159
+ reference.startsWith("/") ||
160
+ reference.startsWith("~")
161
+ ) {
162
+ const expanded = reference.startsWith("~/")
163
+ ? path.join(os.homedir(), reference.slice(2))
164
+ : reference;
165
+ return { type: "local", path: path.resolve(expanded) };
166
+ }
167
+
168
+ return { type: "npm", specifier: reference };
169
+ }
170
+
171
+ async function copyLocalPath(sourcePath, stagedPath) {
172
+ const stat = await fsp.stat(sourcePath);
173
+ if (!stat.isDirectory()) {
174
+ throw new Error("Local extension reference must be a directory");
175
+ }
176
+
177
+ await fsp.cp(sourcePath, stagedPath, {
178
+ recursive: true,
179
+ dereference: false,
180
+ filter: (source) => {
181
+ const base = path.basename(source);
182
+ return !SKIP_DIRS.has(base);
183
+ }
184
+ });
185
+ }
186
+
187
+ async function resolveNpmPackage(specifier, options) {
188
+ const metadata = await fetchNpmMetadata(specifier, options.registry || "https://registry.npmjs.org");
189
+ const tarballUrl = metadata.dist && metadata.dist.tarball;
190
+ if (!tarballUrl) {
191
+ throw new Error(`No npm tarball URL found for ${specifier}`);
192
+ }
193
+
194
+ return {
195
+ type: "npm",
196
+ packageName: metadata.name,
197
+ version: metadata.version,
198
+ needsDownload: true,
199
+ tarballUrl,
200
+ npmMetadata: npmMetadataForEvidence(metadata)
201
+ };
202
+ }
203
+
204
+ async function downloadResolvedPackage(resolved, stagedPath) {
205
+ const archivePath = `${stagedPath}.tgz`;
206
+ await fsp.mkdir(path.dirname(stagedPath), { recursive: true, mode: 0o700 });
207
+ await downloadFile(resolved.tarballUrl, archivePath);
208
+ resolved.sha256 = await hashFile(archivePath);
209
+ await fsp.mkdir(stagedPath, { recursive: true, mode: 0o700 });
210
+ await extractTarball(archivePath, stagedPath);
211
+ }
212
+
213
+ function npmMetadataForEvidence(metadata) {
214
+ return {
215
+ name: metadata.name,
216
+ version: metadata.version,
217
+ repository: metadata.repository || null,
218
+ maintainers: metadata.maintainers || [],
219
+ dist: metadata.dist || null,
220
+ deprecated: metadata.deprecated || null
221
+ };
222
+ }
223
+
224
+ async function fetchNpmMetadata(specifier, registry) {
225
+ const parsed = parseNpmSpecifier(specifier);
226
+ const encodedName = encodeURIComponent(parsed.name);
227
+ const metadataUrl = `${registry.replace(/\/$/, "")}/${encodedName}`;
228
+ const packageMetadata = await fetchJson(metadataUrl);
229
+ const version =
230
+ parsed.version ||
231
+ (packageMetadata["dist-tags"] && packageMetadata["dist-tags"].latest);
232
+ if (!version || !packageMetadata.versions || !packageMetadata.versions[version]) {
233
+ throw new Error(`Version not found for npm package: ${specifier}`);
234
+ }
235
+ return packageMetadata.versions[version];
236
+ }
237
+
238
+ function parseNpmSpecifier(specifier) {
239
+ if (specifier.startsWith("@")) {
240
+ const secondAt = specifier.indexOf("@", 1);
241
+ if (secondAt === -1) {
242
+ return { name: specifier, version: null };
243
+ }
244
+ return {
245
+ name: specifier.slice(0, secondAt),
246
+ version: specifier.slice(secondAt + 1)
247
+ };
248
+ }
249
+
250
+ const at = specifier.lastIndexOf("@");
251
+ if (at > 0) {
252
+ return {
253
+ name: specifier.slice(0, at),
254
+ version: specifier.slice(at + 1)
255
+ };
256
+ }
257
+ return { name: specifier, version: null };
258
+ }
259
+
260
+ function fetchJson(url) {
261
+ return new Promise((resolve, reject) => {
262
+ https
263
+ .get(url, { headers: { "user-agent": "supply-chain-auditor/0.1.0" } }, (response) => {
264
+ if (response.statusCode < 200 || response.statusCode >= 300) {
265
+ reject(new Error(`HTTP ${response.statusCode} from ${url}`));
266
+ response.resume();
267
+ return;
268
+ }
269
+ let body = "";
270
+ response.setEncoding("utf8");
271
+ response.on("data", (chunk) => {
272
+ body += chunk;
273
+ });
274
+ response.on("end", () => {
275
+ try {
276
+ resolve(JSON.parse(body));
277
+ } catch (error) {
278
+ reject(error);
279
+ }
280
+ });
281
+ })
282
+ .on("error", reject);
283
+ });
284
+ }
285
+
286
+ async function precheckVulnerabilities(resolved, stagedPath) {
287
+ if (Array.isArray(resolved.precheckedVulnerabilities)) {
288
+ return resolved.precheckedVulnerabilities;
289
+ }
290
+
291
+ if (resolved.type === "npm" && resolved.packageName && resolved.version) {
292
+ return queryOsvPackage(resolved.packageName, resolved.version, "npm");
293
+ }
294
+
295
+ const identity = await readPackageIdentity(stagedPath);
296
+ if (!identity || !identity.name || !identity.version) {
297
+ return [];
298
+ }
299
+
300
+ return queryOsvPackage(identity.name, identity.version, "npm");
301
+ }
302
+
303
+ async function readPackageIdentity(stagedPath) {
304
+ try {
305
+ const packageJson = JSON.parse(
306
+ await fsp.readFile(path.join(stagedPath, "package.json"), "utf8")
307
+ );
308
+ return {
309
+ name: packageJson.name,
310
+ version: packageJson.version
311
+ };
312
+ } catch {
313
+ return null;
314
+ }
315
+ }
316
+
317
+ async function queryOsvPackage(name, version, ecosystem) {
318
+ const payload = {
319
+ package: {
320
+ name,
321
+ ecosystem
322
+ },
323
+ version
324
+ };
325
+ const response = await postJson("https://api.osv.dev/v1/query", payload);
326
+ return Array.isArray(response.vulns) ? response.vulns : [];
327
+ }
328
+
329
+ function postJson(url, payload) {
330
+ const body = JSON.stringify(payload);
331
+ return new Promise((resolve, reject) => {
332
+ const request = https.request(
333
+ url,
334
+ {
335
+ method: "POST",
336
+ headers: {
337
+ "content-type": "application/json",
338
+ "content-length": Buffer.byteLength(body),
339
+ "user-agent": "supply-chain-auditor/0.1.0"
340
+ }
341
+ },
342
+ (response) => {
343
+ if (response.statusCode < 200 || response.statusCode >= 300) {
344
+ reject(new Error(`HTTP ${response.statusCode} from ${url}`));
345
+ response.resume();
346
+ return;
347
+ }
348
+ let responseBody = "";
349
+ response.setEncoding("utf8");
350
+ response.on("data", (chunk) => {
351
+ responseBody += chunk;
352
+ });
353
+ response.on("end", () => {
354
+ try {
355
+ resolve(JSON.parse(responseBody || "{}"));
356
+ } catch (error) {
357
+ reject(error);
358
+ }
359
+ });
360
+ }
361
+ );
362
+ request.on("error", reject);
363
+ request.write(body);
364
+ request.end();
365
+ });
366
+ }
367
+
368
+ function downloadFile(url, destination) {
369
+ return new Promise((resolve, reject) => {
370
+ const file = fs.createWriteStream(destination, { mode: 0o600 });
371
+ https
372
+ .get(url, { headers: { "user-agent": "supply-chain-auditor/0.1.0" } }, (response) => {
373
+ if (response.statusCode < 200 || response.statusCode >= 300) {
374
+ reject(new Error(`HTTP ${response.statusCode} from ${url}`));
375
+ response.resume();
376
+ return;
377
+ }
378
+ response.pipe(file);
379
+ file.on("finish", () => {
380
+ file.close(resolve);
381
+ });
382
+ })
383
+ .on("error", reject);
384
+ });
385
+ }
386
+
387
+ async function hashFile(filePath) {
388
+ const hash = crypto.createHash("sha256");
389
+ await new Promise((resolve, reject) => {
390
+ fs.createReadStream(filePath)
391
+ .on("data", (chunk) => hash.update(chunk))
392
+ .on("error", reject)
393
+ .on("end", resolve);
394
+ });
395
+ return hash.digest("hex");
396
+ }
397
+
398
+ function extractTarball(archivePath, destination) {
399
+ return run("tar", ["-xzf", archivePath, "-C", destination, "--strip-components", "1"]);
400
+ }
401
+
402
+ function run(command, args) {
403
+ return new Promise((resolve, reject) => {
404
+ const child = spawn(command, args, {
405
+ stdio: ["ignore", "pipe", "pipe"]
406
+ });
407
+ let stderr = "";
408
+ child.stderr.on("data", (chunk) => {
409
+ stderr += chunk;
410
+ });
411
+ child.on("error", reject);
412
+ child.on("close", (code) => {
413
+ if (code === 0) {
414
+ resolve();
415
+ } else {
416
+ reject(new Error(`${command} exited with ${code}: ${stderr.trim()}`));
417
+ }
418
+ });
419
+ });
420
+ }
421
+
422
+ async function collectSourceFiles(root, options = {}) {
423
+ const maxFiles = options.maxFiles || DEFAULT_MAX_FILES;
424
+ const maxFileBytes = options.maxFileBytes || DEFAULT_MAX_FILE_BYTES;
425
+ const sourceFiles = {};
426
+ const queue = [root];
427
+
428
+ while (queue.length && Object.keys(sourceFiles).length < maxFiles) {
429
+ const current = queue.shift();
430
+ const entries = await fsp.readdir(current, { withFileTypes: true });
431
+ for (const entry of entries) {
432
+ const fullPath = path.join(current, entry.name);
433
+ const relativePath = path.relative(root, fullPath);
434
+
435
+ if (entry.isDirectory()) {
436
+ if (!SKIP_DIRS.has(entry.name)) {
437
+ queue.push(fullPath);
438
+ }
439
+ continue;
440
+ }
441
+
442
+ if (!entry.isFile() || !looksTextLike(relativePath)) {
443
+ continue;
444
+ }
445
+
446
+ const stat = await fsp.stat(fullPath);
447
+ if (stat.size > maxFileBytes) {
448
+ sourceFiles[relativePath] = `[omitted: file exceeds ${maxFileBytes} bytes]`;
449
+ continue;
450
+ }
451
+
452
+ sourceFiles[relativePath] = await fsp.readFile(fullPath, "utf8");
453
+ if (Object.keys(sourceFiles).length >= maxFiles) {
454
+ break;
455
+ }
456
+ }
457
+ }
458
+
459
+ return sourceFiles;
460
+ }
461
+
462
+ function looksTextLike(filePath) {
463
+ const normalized = filePath.replace(/\\/g, "/").toLowerCase();
464
+ return TEXT_FILE_PATTERNS.some((pattern) => normalized.endsWith(pattern) || normalized.includes(pattern));
465
+ }
466
+
467
+ function decisionForReport(report, policy) {
468
+ if (report.verdict === "block") {
469
+ return "block";
470
+ }
471
+ if (report.verdict === "review") {
472
+ return policy === "allow-review" ? "allow" : "review";
473
+ }
474
+ return "allow";
475
+ }
476
+
477
+ function shouldPromote(decision) {
478
+ return decision === "allow";
479
+ }
480
+
481
+ async function promoteStagedPackage(stagedPath, promoteTo, options = {}) {
482
+ const destination = path.resolve(promoteTo);
483
+ const exists = await pathExists(destination);
484
+ if (exists && !options.force) {
485
+ throw new Error(`Promotion target already exists: ${destination}`);
486
+ }
487
+ if (exists) {
488
+ await fsp.rm(destination, { recursive: true, force: true });
489
+ }
490
+ await fsp.mkdir(path.dirname(destination), { recursive: true });
491
+ await fsp.cp(stagedPath, destination, { recursive: true, dereference: false });
492
+ return destination;
493
+ }
494
+
495
+ function now() {
496
+ return process.hrtime.bigint();
497
+ }
498
+
499
+ function elapsed(start) {
500
+ return Number((process.hrtime.bigint() - start) / 1000000n);
501
+ }
502
+
503
+ async function pathExists(filePath) {
504
+ try {
505
+ await fsp.access(filePath);
506
+ return true;
507
+ } catch {
508
+ return false;
509
+ }
510
+ }
511
+
512
+ module.exports = {
513
+ guardExtension,
514
+ parseReference,
515
+ parseNpmSpecifier,
516
+ collectSourceFiles,
517
+ queryOsvPackage,
518
+ decisionForReport
519
+ };