muaddib-scanner 2.11.74 → 2.11.75

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.74",
3
+ "version": "2.11.75",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "node_modules",
3
- "timestamp": "2026-06-07T18:51:19.187Z",
3
+ "timestamp": "2026-06-07T19:18:50.434Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -31,7 +31,7 @@ const path = require('path');
31
31
  const https = require('https');
32
32
 
33
33
  const GHSA_API_HOST = 'api.github.com';
34
- const GHSA_ECOSYSTEMS = ['npm', 'pypi'];
34
+ const GHSA_ECOSYSTEMS = ['npm', 'pypi', 'crates'];
35
35
  const GHSA_CURSOR_FILE = process.env.MUADDIB_GHSA_CURSOR_FILE ||
36
36
  path.join(__dirname, '..', '..', 'data', 'ghsa-cursor.json');
37
37
  const GHSA_MALWARE_FILE = process.env.MUADDIB_GHSA_MALWARE_FILE ||
@@ -84,9 +84,10 @@ function _httpGetJson(pathName, { token, httpImpl = https, timeoutMs = 20_000 }
84
84
  */
85
85
  async function _defaultFetch(ecosystem, opts = {}) {
86
86
  const token = opts.token || process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null;
87
- // GHSA names the Python ecosystem "pip" (not "pypi") in BOTH the query and the response;
88
- // querying ecosystem=pypi returns HTTP 422. Map our internal name to GHSA's for the query.
89
- const apiEco = ecosystem === 'pypi' ? 'pip' : ecosystem;
87
+ // GHSA names the Python ecosystem "pip" (not "pypi") and Rust "rust" (we call it
88
+ // "crates") in BOTH the query and the response; querying ecosystem=pypi returns HTTP
89
+ // 422. Map our internal name to GHSA's for the query.
90
+ const apiEco = ecosystem === 'pypi' ? 'pip' : ecosystem === 'crates' ? 'rust' : ecosystem;
90
91
  const p = `/advisories?type=malware&ecosystem=${encodeURIComponent(apiEco)}&per_page=100&sort=updated&direction=desc`;
91
92
  const { status, json } = await _httpGetJson(p, { token, httpImpl: opts.httpImpl });
92
93
  if (status !== 200 || !Array.isArray(json)) {
@@ -112,7 +113,7 @@ function _nextLink(linkHeader) {
112
113
  async function fetchAllGhsaMalware(ecosystem, opts = {}) {
113
114
  const token = opts.token || process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null;
114
115
  const maxPages = Number.isFinite(opts.maxPages) ? opts.maxPages : 30;
115
- const apiEco = ecosystem === 'pypi' ? 'pip' : ecosystem;
116
+ const apiEco = ecosystem === 'pypi' ? 'pip' : ecosystem === 'crates' ? 'rust' : ecosystem;
116
117
  let pathName = `/advisories?type=malware&ecosystem=${encodeURIComponent(apiEco)}&per_page=100&sort=published&direction=desc`;
117
118
  const rows = [];
118
119
  for (let page = 0; page < maxPages && pathName; page++) {
@@ -141,6 +142,7 @@ function parseAdvisory(adv, ecosystems = GHSA_ECOSYSTEMS) {
141
142
  if (!pkg || !pkg.name || !pkg.ecosystem) continue;
142
143
  let eco = String(pkg.ecosystem).toLowerCase();
143
144
  if (eco === 'pip') eco = 'pypi'; // normalize GHSA's "pip" to our internal "pypi"
145
+ else if (eco === 'rust') eco = 'crates'; // normalize GHSA's "rust" to our internal "crates"
144
146
  if (ecosystems && !ecosystems.includes(eco)) continue;
145
147
  out.push({
146
148
  ghsa_id: adv.ghsa_id,
@@ -210,17 +212,29 @@ function _maybeCompactMalware(file) {
210
212
  function buildGhsaPreAlertEmbed(row) {
211
213
  const link = row.ecosystem === 'pypi'
212
214
  ? `https://pypi.org/project/${encodeURIComponent(row.name)}/`
213
- : `https://www.npmjs.com/package/${encodeURIComponent(row.name)}`;
215
+ : row.ecosystem === 'crates'
216
+ ? `https://crates.io/crates/${encodeURIComponent(row.name)}`
217
+ : `https://www.npmjs.com/package/${encodeURIComponent(row.name)}`;
218
+ const fields = [
219
+ { name: 'Package', value: `[${row.ecosystem}/${row.name}](${link})`, inline: true },
220
+ { name: 'Range', value: String(row.versionRange || '*'), inline: true },
221
+ { name: 'Advisory', value: `[${row.ghsa_id}](https://github.com/advisories/${row.ghsa_id})`, inline: true },
222
+ { name: 'Source', value: 'GitHub Advisory DB (type=malware) — active poller', inline: false }
223
+ ];
224
+ // crates enrichment: flag if the malicious crate name typosquats a popular crate.
225
+ // Lazy require keeps the poller light; findCratesTyposquatMatch is pure.
226
+ if (row.ecosystem === 'crates') {
227
+ try {
228
+ const { findCratesTyposquatMatch } = require('../scanner/typosquat.js');
229
+ const m = findCratesTyposquatMatch(row.name);
230
+ if (m) fields.push({ name: 'Typosquat', value: `looks like \`${m.original}\` (distance ${m.distance})`, inline: true });
231
+ } catch { /* enrichment is best-effort */ }
232
+ }
214
233
  return {
215
234
  embeds: [{
216
235
  title: '⚠️ GHSA PRE-ALERT — Fresh Malware Advisory',
217
236
  color: 0xe74c3c,
218
- fields: [
219
- { name: 'Package', value: `[${row.ecosystem}/${row.name}](${link})`, inline: true },
220
- { name: 'Range', value: String(row.versionRange || '*'), inline: true },
221
- { name: 'Advisory', value: `[${row.ghsa_id}](https://github.com/advisories/${row.ghsa_id})`, inline: true },
222
- { name: 'Source', value: 'GitHub Advisory DB (type=malware) — active poller', inline: false }
223
- ],
237
+ fields,
224
238
  footer: { text: `MUAD'DIB GHSA Pre-Alert | ${new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}` },
225
239
  timestamp: new Date().toISOString()
226
240
  }]
@@ -764,4 +764,80 @@ function findPyPITyposquatMatch(name) {
764
764
  return null;
765
765
  }
766
766
 
767
- module.exports = { scanTyposquatting, levenshteinDistance, clearMetadataCache, findPyPITyposquatMatch, findTyposquatMatch };
767
+ // ============================================
768
+ // crates.io (Rust) TYPOSQUATTING — Phase 4
769
+ // ============================================
770
+ // Pre-alert enrichment ONLY: flags when an incoming crate name (from the GHSA rust
771
+ // malware feed) typosquats a popular crate. No crates ingestion / build.rs / scan-time
772
+ // Cargo parsing (non-goal). Mirrors the PyPI block above.
773
+
774
+ // Top crates.io packages by downloads (typosquat targets). Hardcoded snapshot.
775
+ const POPULAR_CRATES = [
776
+ 'serde', 'serde_json', 'serde_derive', 'serde_yaml', 'syn', 'quote', 'proc-macro2',
777
+ 'libc', 'rand', 'rand_core', 'log', 'cfg-if', 'bitflags', 'itertools', 'once_cell',
778
+ 'lazy_static', 'regex', 'regex-syntax', 'aho-corasick', 'base64', 'num-traits',
779
+ 'unicode-ident', 'tokio', 'tokio-util', 'futures', 'futures-util', 'bytes',
780
+ 'hashbrown', 'smallvec', 'parking_lot', 'anyhow', 'thiserror', 'indexmap', 'memchr',
781
+ 'chrono', 'semver', 'getrandom', 'clap', 'time', 'uuid', 'hyper', 'reqwest',
782
+ 'async-trait', 'tracing', 'tracing-core', 'tracing-subscriber', 'url',
783
+ 'percent-encoding', 'idna', 'socket2', 'httparse', 'tower', 'rayon', 'num_cpus',
784
+ 'either', 'toml', 'winapi', 'windows-sys', 'env_logger', 'generic-array', 'digest',
785
+ 'sha2', 'typenum', 'subtle', 'rustls', 'ring', 'openssl', 'flate2', 'miniz_oxide',
786
+ 'crc32fast', 'walkdir', 'tempfile', 'dirs', 'nix', 'backtrace', 'scopeguard',
787
+ 'pin-project', 'pin-project-lite', 'slab', 'lock_api', 'crossbeam-utils',
788
+ 'crossbeam-channel', 'crossbeam-epoch', 'ahash', 'fnv', 'mio', 'h2', 'http'
789
+ ];
790
+
791
+ // crates.io treats '-' and '_' as equivalent and is case-insensitive for name
792
+ // uniqueness; normalize the same way for typosquat comparison.
793
+ function normalizeCrate(name) {
794
+ return name.toLowerCase().replace(/[-_]+/g, '-');
795
+ }
796
+
797
+ const POPULAR_CRATES_NORMALIZED = POPULAR_CRATES.map(normalizeCrate);
798
+ const POPULAR_CRATES_SET = new Set(POPULAR_CRATES_NORMALIZED);
799
+
800
+ // Legitimate crates within edit-distance of a popular crate but not squats.
801
+ const CRATES_WHITELIST = new Set([
802
+ 'mime', // distance 1 from 'time' — both real & popular
803
+ 'rand-chacha', // rand ecosystem sibling (normalized)
804
+ 'serde-with', // serde ecosystem sibling
805
+ 'futures-core',
806
+ ]);
807
+
808
+ const MIN_CRATE_LENGTH = 4;
809
+
810
+ /**
811
+ * Find a crates.io typosquat match (Levenshtein over the popular-crate list).
812
+ * Pure + IOC-independent. Used by the GHSA rust pre-alert to enrich the embed.
813
+ *
814
+ * @param {string} name - crate name
815
+ * @returns {{original: string, type: string, distance: number}|null}
816
+ */
817
+ function findCratesTyposquatMatch(name) {
818
+ if (typeof name !== 'string' || !name) return null;
819
+ const normalized = normalizeCrate(name);
820
+
821
+ if (POPULAR_CRATES_SET.has(normalized)) return null; // it IS a popular crate
822
+ if (CRATES_WHITELIST.has(normalized)) return null;
823
+ if (normalized.length < MIN_CRATE_LENGTH) return null;
824
+
825
+ for (let i = 0; i < POPULAR_CRATES.length; i++) {
826
+ const popularNorm = POPULAR_CRATES_NORMALIZED[i];
827
+ const popular = POPULAR_CRATES[i];
828
+ if (normalized === popularNorm) continue;
829
+ if (popularNorm.length < MIN_CRATE_LENGTH) continue;
830
+ if (Math.abs(normalized.length - popularNorm.length) > 2) continue;
831
+
832
+ const distance = levenshteinDistance(normalized, popularNorm);
833
+ if (distance === 1) {
834
+ return { original: popular, type: detectTyposquatType(normalized, popularNorm), distance };
835
+ }
836
+ if (distance === 2 && popularNorm.length >= 5) {
837
+ return { original: popular, type: detectTyposquatType(normalized, popularNorm), distance };
838
+ }
839
+ }
840
+ return null;
841
+ }
842
+
843
+ module.exports = { scanTyposquatting, levenshteinDistance, clearMetadataCache, findPyPITyposquatMatch, findCratesTyposquatMatch, findTyposquatMatch };