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
package/src/ioc/ghsa-poller.js
CHANGED
|
@@ -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")
|
|
88
|
-
//
|
|
89
|
-
|
|
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
|
-
:
|
|
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
|
}]
|
package/src/scanner/typosquat.js
CHANGED
|
@@ -764,4 +764,80 @@ function findPyPITyposquatMatch(name) {
|
|
|
764
764
|
return null;
|
|
765
765
|
}
|
|
766
766
|
|
|
767
|
-
|
|
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 };
|