pkgradar 0.1.0 → 0.1.1

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/lib/checks.js CHANGED
@@ -95,14 +95,11 @@ function checkWormFiles(pkg) {
95
95
  out.push({ sev: SEV.CRITICAL, code: 'worm-ioc-file', msg: `known worm artifact file: ${e.name}`, evidence: path.relative(pkg.dir, full) });
96
96
  }
97
97
  if (/(^|[\\/])\.github[\\/]workflows([\\/]|$)/.test(d) && /\.ya?ml$/i.test(low)) {
98
- // Many legit packages publish their whole repo (incl. .github). On its own this
99
- // is barely a signal, so it is INFO. A *malicious* workflow file would also be
100
- // caught by name (worm-ioc-file) or by content checks.
101
- const wf = readSafe(full, 200_000);
102
- const nasty = wf && /\b(curl|wget)\b[^|;&\n]*[|;&]+\s*(ba|z|d)?sh\b|secrets\.[A-Z_]+\b[\s\S]{0,80}(curl|nc |bash -c|http)/.test(wf.text);
103
- out.push(nasty
104
- ? { sev: SEV.CRITICAL, code: 'malicious-gh-workflow', msg: `bundled GitHub Actions workflow runs a piped shell download / exfiltrates secrets`, evidence: path.relative(pkg.dir, full) }
105
- : { sev: SEV.INFO, code: 'embedded-gh-workflow', msg: `package ships a GitHub Actions workflow (${e.name})`, evidence: path.relative(pkg.dir, full) });
98
+ // Lots of legit packages publish their whole repo, .github included, and plenty
99
+ // of normal CI workflows pipe an installer into a shell. On its own this is not a
100
+ // signal, so it is INFO only. A genuinely malicious workflow file with a known
101
+ // name is already caught by worm-ioc-file.
102
+ out.push({ sev: SEV.INFO, code: 'embedded-gh-workflow', msg: `package ships a GitHub Actions workflow (${e.name})`, evidence: path.relative(pkg.dir, full) });
106
103
  }
107
104
  }
108
105
  }
@@ -158,21 +155,28 @@ function checkObfuscation(pkg) {
158
155
  const weak = []; // weak reasons only matter in combination
159
156
  let critical = false;
160
157
 
161
- // --- hard worm markers: any one => critical ---
162
- for (const m of WORM_HARD_MARKERS) if (t.includes(m)) { strong.push(`worm IOC string: ${m}`); critical = true; }
163
- // --- soft worm markers: need >=3 distinct in one file (so a tool's advisory text won't trip it) ---
164
- const softHits = WORM_SOFT_MARKERS.filter((m) => t.includes(m));
165
- if (softHits.length >= 3) { strong.push(`co-occurring worm terms: ${softHits.slice(0, 6).join(', ')}`); }
166
- else if (softHits.length) weak.push(`worm term(s): ${softHits.join(', ')}`);
167
-
168
- // --- javascript-obfuscator signature ---
158
+ // --- "executable payload" context: dynamic code execution over decoded data, or
159
+ // heavy identifier mangling. Worm IOC strings are only damning when they sit
160
+ // alongside one of these; on their own they also appear in scanners, advisories,
161
+ // and writeups (this file included). ---
169
162
  const hexIds = (t.match(/_0x[a-f0-9]{4,6}\b/g) || []).length;
163
+ const evalOverDecoded = /(eval|new Function|Function\()\s*\(?\s*(atob|Buffer\.from\s*\([^)]*base64|unescape|decodeURIComponent)/.test(t);
164
+ const procFromDecoded = /\bchild_process\b[\s\S]{0,400}\b(atob|Buffer\.from\s*\([^)]*base64)/.test(t);
165
+ const execContext = evalOverDecoded || procFromDecoded || hexIds > 80;
166
+ if (evalOverDecoded) strong.push('constructs & executes decoded payload (eval/Function over atob/Buffer.from)');
167
+ if (procFromDecoded) strong.push('spawns a process from decoded data');
170
168
  if (hexIds > 80) strong.push(`${hexIds} _0x… mangled identifiers (javascript-obfuscator)`);
171
169
  else if (hexIds > 20) weak.push(`${hexIds} _0x… identifiers`);
172
170
 
173
- // --- code building & executing decoded data dynamically ---
174
- if (/(eval|new Function|Function\()\s*\(?\s*(atob|Buffer\.from\s*\([^)]*base64|unescape|decodeURIComponent)/.test(t)) strong.push('constructs & executes decoded payload (eval/Function over atob/Buffer.from)');
175
- if (/\bchild_process\b[\s\S]{0,400}\b(atob|Buffer\.from\s*\([^)]*base64)/.test(t)) strong.push('spawns a process from decoded data');
171
+ // --- worm IOC strings ---
172
+ const hardHits = WORM_HARD_MARKERS.filter((m) => t.includes(m));
173
+ if (hardHits.length) {
174
+ if (execContext) { strong.push(`worm IOC string(s) in an executing payload: ${hardHits.join(', ')}`); critical = true; }
175
+ else weak.push(`contains known IOC string(s): ${hardHits.join(', ')} (also found in security tooling / advisories / writeups)`);
176
+ }
177
+ const softHits = WORM_SOFT_MARKERS.filter((m) => t.includes(m));
178
+ if (softHits.length >= 3 && execContext) strong.push(`co-occurring worm terms with an executable payload: ${softHits.slice(0, 6).join(', ')}`);
179
+ else if (softHits.length) weak.push(`worm term(s): ${softHits.slice(0, 6).join(', ')}`);
176
180
 
177
181
  // --- weak structural signals (modern bundlers do these constantly) ---
178
182
  const longest = t.split('\n').reduce((m, l) => Math.max(m, l.length), 0);
@@ -217,7 +221,13 @@ function checkManifestAnomalies(pkg) {
217
221
  return out;
218
222
  }
219
223
 
224
+ let SELF = null;
225
+ try { SELF = require('../package.json').name; } catch { /* ignore */ }
226
+
220
227
  function runAll(pkg) {
228
+ // Don't scan our own package: this file necessarily contains the IOC strings it
229
+ // searches for, so scanning a cached copy of pkgradar would flag pkgradar.
230
+ if (SELF && pkg && pkg.manifest && pkg.manifest.name === SELF) return [];
221
231
  const findings = [];
222
232
  for (const fn of [checkLifecycleHooks, checkWormFiles, checkManifestAnomalies, checkObfuscation]) {
223
233
  try { findings.push(...fn(pkg)); } catch (e) { /* ignore per-check failure */ }
package/lib/scan.js CHANGED
@@ -76,9 +76,22 @@ async function scan(opts = {}) {
76
76
  }
77
77
  }
78
78
 
79
- findings.sort((a, b) => b.sevNum - a.sevNum || a.package.localeCompare(b.package));
79
+ // Collapse identical findings that show up in several stores (e.g. the same package
80
+ // cached under multiple npx hashes) into one, noting how many places it was seen.
81
+ const byKey = new Map();
82
+ for (const f of findings) {
83
+ const k = `${f.package}@${f.version}|${f.code}|${f.evidence || ''}|${f.message}`;
84
+ const prev = byKey.get(k);
85
+ if (prev) { prev._copies = (prev._copies || 1) + 1; if (!prev._stores.includes(f.store)) prev._stores.push(f.store); }
86
+ else { f._copies = 1; f._stores = [f.store]; byKey.set(k, f); }
87
+ }
88
+ const deduped = [...byKey.values()].map((f) => {
89
+ if (f._copies > 1) { f.store = `${f._stores.join(', ')} (${f._copies}x)`; }
90
+ delete f._copies; delete f._stores; return f;
91
+ });
92
+ deduped.sort((a, b) => b.sevNum - a.sevNum || a.package.localeCompare(b.package));
80
93
  const totalPackages = [...new Set(allPkgList.map((p) => `${p.name}@${p.version}`))].length;
81
- return { cwd, stores: storeStats, totalPackages, totalScanned: seen.size, findings, osv };
94
+ return { cwd, stores: storeStats, totalPackages, totalScanned: seen.size, findings: deduped, osv };
82
95
  }
83
96
 
84
97
  function splitNV(nv) { const i = nv.lastIndexOf('@'); return [nv.slice(0, i), nv.slice(i + 1)]; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pkgradar",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Content-based supply-chain scanner for npm/pnpm/yarn/bun: inspects the bytes you actually installed (lifecycle hooks, obfuscated payloads, worm IOCs) instead of just matching package names against an advisory list.",
5
5
  "bin": {
6
6
  "pkgradar": "bin/pkgradar.js"