muaddib-scanner 1.0.1 → 1.0.3
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/muaddib.js +14 -4
- package/package.json +3 -2
- package/src/daemon.js +142 -0
- package/src/index.js +44 -5
- package/src/response/playbooks.js +3 -0
- package/src/rules/index.js +14 -1
- package/src/scanner/typosquat.js +266 -0
- package/src/webhook.js +176 -0
- package/tests/run-tests.js +26 -0
- package/tests/samples/typosquat/package.json +11 -0
package/bin/muaddib.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { run } = require('../src/index.js');
|
|
4
4
|
const { updateIOCs } = require('../src/ioc/updater.js');
|
|
5
5
|
const { watch } = require('../src/watch.js');
|
|
6
|
+
const { startDaemon } = require('../src/daemon.js');
|
|
6
7
|
|
|
7
8
|
const args = process.argv.slice(2);
|
|
8
9
|
const command = args[0];
|
|
@@ -13,7 +14,8 @@ let jsonOutput = false;
|
|
|
13
14
|
let htmlOutput = null;
|
|
14
15
|
let sarifOutput = null;
|
|
15
16
|
let explainMode = false;
|
|
16
|
-
let failLevel = 'high';
|
|
17
|
+
let failLevel = 'high';
|
|
18
|
+
let webhookUrl = null;
|
|
17
19
|
|
|
18
20
|
for (let i = 0; i < options.length; i++) {
|
|
19
21
|
if (options[i] === '--json') {
|
|
@@ -29,6 +31,9 @@ for (let i = 0; i < options.length; i++) {
|
|
|
29
31
|
} else if (options[i] === '--fail-on') {
|
|
30
32
|
failLevel = options[i + 1] || 'high';
|
|
31
33
|
i++;
|
|
34
|
+
} else if (options[i] === '--webhook') {
|
|
35
|
+
webhookUrl = options[i + 1];
|
|
36
|
+
i++;
|
|
32
37
|
} else if (!options[i].startsWith('-')) {
|
|
33
38
|
target = options[i];
|
|
34
39
|
}
|
|
@@ -51,6 +56,8 @@ if (!command) {
|
|
|
51
56
|
--explain Affiche les details de chaque detection
|
|
52
57
|
--fail-on [level] Niveau de severite pour exit code (critical|high|medium|low)
|
|
53
58
|
Defaut: high (fail sur HIGH et CRITICAL)
|
|
59
|
+
--webhook [url] Envoie une alerte Discord/Slack
|
|
60
|
+
muaddib daemon [options] Lance le daemon de surveillance
|
|
54
61
|
`);
|
|
55
62
|
process.exit(0);
|
|
56
63
|
}
|
|
@@ -61,7 +68,8 @@ if (command === 'scan') {
|
|
|
61
68
|
html: htmlOutput,
|
|
62
69
|
sarif: sarifOutput,
|
|
63
70
|
explain: explainMode,
|
|
64
|
-
failLevel: failLevel
|
|
71
|
+
failLevel: failLevel,
|
|
72
|
+
webhook: webhookUrl
|
|
65
73
|
}).then(exitCode => {
|
|
66
74
|
process.exit(exitCode);
|
|
67
75
|
});
|
|
@@ -75,10 +83,12 @@ if (command === 'scan') {
|
|
|
75
83
|
process.exit(1);
|
|
76
84
|
});
|
|
77
85
|
} else if (command === 'help') {
|
|
78
|
-
console.log('muaddib scan [path] [--json] [--html file] [--sarif file] [--explain] [--fail-on level]');
|
|
86
|
+
console.log('muaddib scan [path] [--json] [--html file] [--sarif file] [--explain] [--fail-on level] [--webhook url]');
|
|
79
87
|
console.log('muaddib watch [path] - Surveille un projet en temps reel');
|
|
80
88
|
console.log('muaddib update - Met a jour les IOCs');
|
|
89
|
+
} else if (command === 'daemon') {
|
|
90
|
+
startDaemon({ webhook: webhookUrl });
|
|
81
91
|
} else {
|
|
82
92
|
console.log(`Commande inconnue: ${command}`);
|
|
83
93
|
process.exit(1);
|
|
84
|
-
}
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muaddib-scanner",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Supply-chain threat detection & response for npm",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"acorn": "^8.14.0",
|
|
40
40
|
"acorn-walk": "^8.3.4",
|
|
41
|
-
"js-yaml": "^4.1.0"
|
|
41
|
+
"js-yaml": "^4.1.0",
|
|
42
|
+
"lodash": "^4.17.21"
|
|
42
43
|
}
|
|
43
44
|
}
|
package/src/daemon.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const { run } = require('./index.js');
|
|
5
|
+
|
|
6
|
+
let webhookUrl = null;
|
|
7
|
+
let watchedDirs = [];
|
|
8
|
+
let isRunning = false;
|
|
9
|
+
|
|
10
|
+
async function startDaemon(options = {}) {
|
|
11
|
+
webhookUrl = options.webhook || null;
|
|
12
|
+
isRunning = true;
|
|
13
|
+
|
|
14
|
+
console.log(`
|
|
15
|
+
╔════════════════════════════════════════════╗
|
|
16
|
+
║ MUAD'DIB Security Daemon ║
|
|
17
|
+
║ Surveillance npm install active ║
|
|
18
|
+
╚════════════════════════════════════════════╝
|
|
19
|
+
`);
|
|
20
|
+
|
|
21
|
+
console.log('[DAEMON] Demarrage...');
|
|
22
|
+
console.log(`[DAEMON] Webhook: ${webhookUrl ? 'Configure' : 'Non configure'}`);
|
|
23
|
+
console.log('[DAEMON] Ctrl+C pour arreter\n');
|
|
24
|
+
|
|
25
|
+
// Surveille le dossier courant
|
|
26
|
+
const cwd = process.cwd();
|
|
27
|
+
watchDirectory(cwd);
|
|
28
|
+
|
|
29
|
+
// Garde le processus actif
|
|
30
|
+
process.on('SIGINT', () => {
|
|
31
|
+
console.log('\n[DAEMON] Arret...');
|
|
32
|
+
isRunning = false;
|
|
33
|
+
process.exit(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Boucle infinie
|
|
37
|
+
while (isRunning) {
|
|
38
|
+
await sleep(1000);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function watchDirectory(dir) {
|
|
43
|
+
const nodeModulesPath = path.join(dir, 'node_modules');
|
|
44
|
+
const packageLockPath = path.join(dir, 'package-lock.json');
|
|
45
|
+
const yarnLockPath = path.join(dir, 'yarn.lock');
|
|
46
|
+
|
|
47
|
+
console.log(`[DAEMON] Surveillance de ${dir}`);
|
|
48
|
+
|
|
49
|
+
// Surveille package-lock.json
|
|
50
|
+
if (fs.existsSync(packageLockPath)) {
|
|
51
|
+
watchFile(packageLockPath, dir);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Surveille yarn.lock
|
|
55
|
+
if (fs.existsSync(yarnLockPath)) {
|
|
56
|
+
watchFile(yarnLockPath, dir);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Surveille node_modules
|
|
60
|
+
if (fs.existsSync(nodeModulesPath)) {
|
|
61
|
+
watchNodeModules(nodeModulesPath, dir);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Surveille la creation de node_modules
|
|
65
|
+
fs.watch(dir, (eventType, filename) => {
|
|
66
|
+
if (filename === 'node_modules' && eventType === 'rename') {
|
|
67
|
+
const nmPath = path.join(dir, 'node_modules');
|
|
68
|
+
if (fs.existsSync(nmPath)) {
|
|
69
|
+
console.log('[DAEMON] node_modules detecte, scan en cours...');
|
|
70
|
+
triggerScan(dir);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (filename === 'package-lock.json' || filename === 'yarn.lock') {
|
|
74
|
+
console.log(`[DAEMON] ${filename} modifie, scan en cours...`);
|
|
75
|
+
triggerScan(dir);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function watchFile(filePath, projectDir) {
|
|
81
|
+
let lastMtime = fs.statSync(filePath).mtime.getTime();
|
|
82
|
+
|
|
83
|
+
fs.watch(filePath, (eventType) => {
|
|
84
|
+
if (eventType === 'change') {
|
|
85
|
+
const currentMtime = fs.statSync(filePath).mtime.getTime();
|
|
86
|
+
if (currentMtime !== lastMtime) {
|
|
87
|
+
lastMtime = currentMtime;
|
|
88
|
+
console.log(`[DAEMON] ${path.basename(filePath)} modifie`);
|
|
89
|
+
triggerScan(projectDir);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function watchNodeModules(nodeModulesPath, projectDir) {
|
|
96
|
+
fs.watch(nodeModulesPath, { recursive: true }, (eventType, filename) => {
|
|
97
|
+
if (filename && filename.includes('package.json')) {
|
|
98
|
+
console.log(`[DAEMON] Nouveau package detecte: ${filename}`);
|
|
99
|
+
triggerScan(projectDir);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let scanTimeout = null;
|
|
105
|
+
let lastScanTime = 0;
|
|
106
|
+
|
|
107
|
+
function triggerScan(dir) {
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
|
|
110
|
+
// Debounce: attend 3 secondes avant de scanner
|
|
111
|
+
if (scanTimeout) {
|
|
112
|
+
clearTimeout(scanTimeout);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Evite les scans trop frequents (minimum 10 secondes entre chaque)
|
|
116
|
+
if (now - lastScanTime < 10000) {
|
|
117
|
+
scanTimeout = setTimeout(() => triggerScan(dir), 10000 - (now - lastScanTime));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
scanTimeout = setTimeout(async () => {
|
|
122
|
+
lastScanTime = Date.now();
|
|
123
|
+
console.log(`\n[DAEMON] ========== SCAN AUTOMATIQUE ==========`);
|
|
124
|
+
console.log(`[DAEMON] Cible: ${dir}`);
|
|
125
|
+
console.log(`[DAEMON] Heure: ${new Date().toLocaleTimeString()}\n`);
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await run(dir, { webhook: webhookUrl });
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.log(`[DAEMON] Erreur scan: ${err.message}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
console.log(`\n[DAEMON] ======================================\n`);
|
|
134
|
+
console.log('[DAEMON] En attente de modifications...');
|
|
135
|
+
}, 3000);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sleep(ms) {
|
|
139
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { startDaemon };
|
package/src/index.js
CHANGED
|
@@ -9,6 +9,8 @@ const { getPlaybook } = require('./response/playbooks.js');
|
|
|
9
9
|
const { getRule } = require('./rules/index.js');
|
|
10
10
|
const { saveReport } = require('./report.js');
|
|
11
11
|
const { saveSARIF } = require('./sarif.js');
|
|
12
|
+
const { scanTyposquatting } = require('./scanner/typosquat.js');
|
|
13
|
+
const { sendWebhook } = require('./webhook.js');
|
|
12
14
|
|
|
13
15
|
async function run(targetPath, options = {}) {
|
|
14
16
|
const threats = [];
|
|
@@ -34,6 +36,10 @@ async function run(targetPath, options = {}) {
|
|
|
34
36
|
const dataflowThreats = await analyzeDataFlow(targetPath);
|
|
35
37
|
threats.push(...dataflowThreats);
|
|
36
38
|
|
|
39
|
+
// Scan typosquatting
|
|
40
|
+
const typosquatThreats = await scanTyposquatting(targetPath);
|
|
41
|
+
threats.push(...typosquatThreats);
|
|
42
|
+
|
|
37
43
|
// Enrichir chaque menace avec les regles
|
|
38
44
|
const enrichedThreats = threats.map(t => {
|
|
39
45
|
const rule = getRule(t.type);
|
|
@@ -48,15 +54,34 @@ async function run(targetPath, options = {}) {
|
|
|
48
54
|
};
|
|
49
55
|
});
|
|
50
56
|
|
|
57
|
+
// Calculer le score de risque (0-100)
|
|
58
|
+
const criticalCount = threats.filter(t => t.severity === 'CRITICAL').length;
|
|
59
|
+
const highCount = threats.filter(t => t.severity === 'HIGH').length;
|
|
60
|
+
const mediumCount = threats.filter(t => t.severity === 'MEDIUM').length;
|
|
61
|
+
|
|
62
|
+
let riskScore = 0;
|
|
63
|
+
riskScore += criticalCount * 25; // CRITICAL = 25 points
|
|
64
|
+
riskScore += highCount * 10; // HIGH = 10 points
|
|
65
|
+
riskScore += mediumCount * 3; // MEDIUM = 3 points
|
|
66
|
+
riskScore = Math.min(100, riskScore); // Cap a 100
|
|
67
|
+
|
|
68
|
+
const riskLevel = riskScore >= 75 ? 'CRITICAL'
|
|
69
|
+
: riskScore >= 50 ? 'HIGH'
|
|
70
|
+
: riskScore >= 25 ? 'MEDIUM'
|
|
71
|
+
: riskScore > 0 ? 'LOW'
|
|
72
|
+
: 'SAFE';
|
|
73
|
+
|
|
51
74
|
const result = {
|
|
52
75
|
target: targetPath,
|
|
53
76
|
timestamp: new Date().toISOString(),
|
|
54
77
|
threats: enrichedThreats,
|
|
55
78
|
summary: {
|
|
56
79
|
total: threats.length,
|
|
57
|
-
critical:
|
|
58
|
-
high:
|
|
59
|
-
medium:
|
|
80
|
+
critical: criticalCount,
|
|
81
|
+
high: highCount,
|
|
82
|
+
medium: mediumCount,
|
|
83
|
+
riskScore: riskScore,
|
|
84
|
+
riskLevel: riskLevel
|
|
60
85
|
}
|
|
61
86
|
};
|
|
62
87
|
|
|
@@ -100,10 +125,14 @@ async function run(targetPath, options = {}) {
|
|
|
100
125
|
});
|
|
101
126
|
}
|
|
102
127
|
}
|
|
103
|
-
|
|
128
|
+
// Sortie normale
|
|
104
129
|
else {
|
|
105
130
|
console.log(`\n[MUADDIB] Scan de ${targetPath}\n`);
|
|
106
131
|
|
|
132
|
+
// Afficher le score de risque
|
|
133
|
+
const scoreBar = '█'.repeat(Math.floor(result.summary.riskScore / 5)) + '░'.repeat(20 - Math.floor(result.summary.riskScore / 5));
|
|
134
|
+
console.log(`[SCORE] ${result.summary.riskScore}/100 [${scoreBar}] ${result.summary.riskLevel}\n`);
|
|
135
|
+
|
|
107
136
|
if (threats.length === 0) {
|
|
108
137
|
console.log('[OK] Aucune menace detectee.\n');
|
|
109
138
|
} else {
|
|
@@ -124,7 +153,17 @@ async function run(targetPath, options = {}) {
|
|
|
124
153
|
}
|
|
125
154
|
}
|
|
126
155
|
|
|
127
|
-
//
|
|
156
|
+
// Envoyer webhook si configure
|
|
157
|
+
if (options.webhook) {
|
|
158
|
+
try {
|
|
159
|
+
await sendWebhook(options.webhook, result);
|
|
160
|
+
console.log(`[OK] Alerte envoyee au webhook`);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.log(`[WARN] Echec envoi webhook: ${err.message}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Calculer exit code selon le niveau de fail
|
|
128
167
|
const failLevel = options.failLevel || 'high';
|
|
129
168
|
const severityLevels = {
|
|
130
169
|
critical: ['CRITICAL'],
|
|
@@ -106,6 +106,9 @@ const PLAYBOOKS = {
|
|
|
106
106
|
|
|
107
107
|
suspicious_dataflow:
|
|
108
108
|
'CRITIQUE: Code lit des credentials et les envoie sur le reseau. Exfiltration probable. Isoler la machine, regenerer tous les secrets.',
|
|
109
|
+
|
|
110
|
+
typosquat_detected:
|
|
111
|
+
'ATTENTION: Ce package a un nom tres similaire a un package populaire. Verifier que c\'est bien le bon package. Si erreur de frappe, corriger immediatement.',
|
|
109
112
|
};
|
|
110
113
|
|
|
111
114
|
function getPlaybook(threatType) {
|
package/src/rules/index.js
CHANGED
|
@@ -179,7 +179,20 @@ const RULES = {
|
|
|
179
179
|
'https://blog.phylum.io/shai-hulud-npm-worm'
|
|
180
180
|
],
|
|
181
181
|
mitre: 'T1041'
|
|
182
|
-
}
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
typosquat_detected: {
|
|
185
|
+
id: 'MUADDIB-TYPO-001',
|
|
186
|
+
name: 'Typosquatting Detected',
|
|
187
|
+
severity: 'HIGH',
|
|
188
|
+
confidence: 'high',
|
|
189
|
+
description: 'Package avec un nom tres similaire a un package populaire. Possible typosquatting.',
|
|
190
|
+
references: [
|
|
191
|
+
'https://blog.npmjs.org/post/163723642530/crossenv-malware-on-the-npm-registry',
|
|
192
|
+
'https://snyk.io/blog/typosquatting-attacks/'
|
|
193
|
+
],
|
|
194
|
+
mitre: 'T1195.002'
|
|
195
|
+
},
|
|
183
196
|
};
|
|
184
197
|
|
|
185
198
|
function getRule(type) {
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
// Top 100 packages npm les plus populaires (cibles de typosquatting)
|
|
5
|
+
const POPULAR_PACKAGES = [
|
|
6
|
+
'lodash', 'express', 'react', 'axios', 'chalk', 'commander', 'moment',
|
|
7
|
+
'request', 'async', 'bluebird', 'underscore', 'uuid', 'debug', 'mkdirp',
|
|
8
|
+
'glob', 'minimist', 'webpack', 'babel-core', 'typescript', 'eslint',
|
|
9
|
+
'prettier', 'jest', 'mocha', 'chai', 'sinon', 'mongoose', 'sequelize',
|
|
10
|
+
'mysql', 'pg', 'redis', 'mongodb', 'socket.io', 'express-session',
|
|
11
|
+
'body-parser', 'cookie-parser', 'cors', 'helmet', 'morgan', 'dotenv',
|
|
12
|
+
'jsonwebtoken', 'bcrypt', 'passport', 'nodemailer', 'aws-sdk', 'stripe',
|
|
13
|
+
'twilio', 'firebase', 'graphql', 'apollo-server', 'next', 'nuxt',
|
|
14
|
+
'gatsby', 'vue', 'angular', 'svelte', 'electron', 'puppeteer', 'cheerio',
|
|
15
|
+
'sharp', 'jimp', 'canvas', 'pdf-lib', 'exceljs', 'csv-parser', 'xml2js',
|
|
16
|
+
'yaml', 'ini', 'config', 'yargs', 'inquirer', 'ora', 'chalk', 'colors',
|
|
17
|
+
'winston', 'bunyan', 'pino', 'log4js', 'ramda', 'rxjs', 'immutable',
|
|
18
|
+
'mobx', 'redux', 'zustand', 'formik', 'yup', 'joi', 'ajv', 'validator',
|
|
19
|
+
'date-fns', 'dayjs', 'luxon', 'numeral', 'accounting', 'currency.js',
|
|
20
|
+
'lodash-es', 'core-js', 'regenerator-runtime', 'tslib', 'classnames',
|
|
21
|
+
'prop-types', 'cross-env', 'npm', 'yarn', 'pnpm', 'node-fetch', 'got'
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Packages legitimes qui ressemblent a des populaires mais sont OK
|
|
25
|
+
const WHITELIST = [
|
|
26
|
+
'acorn',
|
|
27
|
+
'acorn-walk',
|
|
28
|
+
'js-yaml',
|
|
29
|
+
'cross-env',
|
|
30
|
+
'node-fetch',
|
|
31
|
+
'node-gyp',
|
|
32
|
+
'core-js',
|
|
33
|
+
'lodash-es',
|
|
34
|
+
'date-fns',
|
|
35
|
+
'ts-node',
|
|
36
|
+
'ts-jest',
|
|
37
|
+
'css-loader',
|
|
38
|
+
'style-loader',
|
|
39
|
+
'file-loader',
|
|
40
|
+
'url-loader',
|
|
41
|
+
'babel-loader',
|
|
42
|
+
'vue-loader',
|
|
43
|
+
'react-dom',
|
|
44
|
+
'react-router',
|
|
45
|
+
'react-redux',
|
|
46
|
+
'vue-router',
|
|
47
|
+
'express-session',
|
|
48
|
+
'body-parser',
|
|
49
|
+
'cookie-parser'
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
// Techniques de typosquatting connues
|
|
53
|
+
const TYPOSQUAT_PATTERNS = [
|
|
54
|
+
{ type: 'missing_char', fn: (name) => generateMissingChar(name) },
|
|
55
|
+
{ type: 'extra_char', fn: (name) => generateExtraChar(name) },
|
|
56
|
+
{ type: 'swapped_chars', fn: (name) => generateSwappedChars(name) },
|
|
57
|
+
{ type: 'wrong_char', fn: (name) => generateWrongChar(name) },
|
|
58
|
+
{ type: 'hyphen_tricks', fn: (name) => generateHyphenTricks(name) },
|
|
59
|
+
{ type: 'suffix_tricks', fn: (name) => generateSuffixTricks(name) }
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
async function scanTyposquatting(targetPath) {
|
|
63
|
+
const threats = [];
|
|
64
|
+
const packageJsonPath = path.join(targetPath, 'package.json');
|
|
65
|
+
|
|
66
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
67
|
+
return threats;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
71
|
+
const dependencies = {
|
|
72
|
+
...packageJson.dependencies,
|
|
73
|
+
...packageJson.devDependencies,
|
|
74
|
+
...packageJson.peerDependencies,
|
|
75
|
+
...packageJson.optionalDependencies
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
for (const depName of Object.keys(dependencies)) {
|
|
79
|
+
const match = findTyposquatMatch(depName);
|
|
80
|
+
if (match) {
|
|
81
|
+
threats.push({
|
|
82
|
+
type: 'typosquat_detected',
|
|
83
|
+
severity: 'HIGH',
|
|
84
|
+
message: `Package "${depName}" ressemble a "${match.original}" (${match.type}). Possible typosquatting.`,
|
|
85
|
+
file: 'package.json',
|
|
86
|
+
details: {
|
|
87
|
+
suspicious: depName,
|
|
88
|
+
legitimate: match.original,
|
|
89
|
+
technique: match.type,
|
|
90
|
+
distance: match.distance
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return threats;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function findTyposquatMatch(name) {
|
|
100
|
+
// Ignore les packages whitelistes
|
|
101
|
+
if (WHITELIST.includes(name)) return null;
|
|
102
|
+
|
|
103
|
+
// Ignore les packages scoped (@org/package)
|
|
104
|
+
if (name.startsWith('@')) return null;
|
|
105
|
+
|
|
106
|
+
for (const popular of POPULAR_PACKAGES) {
|
|
107
|
+
// Ignore si c'est exactement le meme
|
|
108
|
+
if (name === popular) continue;
|
|
109
|
+
|
|
110
|
+
const distance = levenshteinDistance(name, popular);
|
|
111
|
+
|
|
112
|
+
// Distance de 1 ou 2 = tres suspect
|
|
113
|
+
if (distance === 1) {
|
|
114
|
+
return {
|
|
115
|
+
original: popular,
|
|
116
|
+
type: detectTyposquatType(name, popular),
|
|
117
|
+
distance: distance
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Distance de 2 avec nom court = suspect
|
|
122
|
+
if (distance === 2 && popular.length <= 6) {
|
|
123
|
+
return {
|
|
124
|
+
original: popular,
|
|
125
|
+
type: detectTyposquatType(name, popular),
|
|
126
|
+
distance: distance
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Verifie les tricks de suffixe
|
|
131
|
+
if (isSuffixTrick(name, popular)) {
|
|
132
|
+
return {
|
|
133
|
+
original: popular,
|
|
134
|
+
type: 'suffix_trick',
|
|
135
|
+
distance: distance
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function detectTyposquatType(typo, original) {
|
|
144
|
+
if (typo.length === original.length - 1) return 'missing_char';
|
|
145
|
+
if (typo.length === original.length + 1) return 'extra_char';
|
|
146
|
+
if (typo.length === original.length) {
|
|
147
|
+
// Verifie si swap
|
|
148
|
+
let diffs = 0;
|
|
149
|
+
for (let i = 0; i < typo.length; i++) {
|
|
150
|
+
if (typo[i] !== original[i]) diffs++;
|
|
151
|
+
}
|
|
152
|
+
if (diffs === 2) return 'swapped_chars';
|
|
153
|
+
return 'wrong_char';
|
|
154
|
+
}
|
|
155
|
+
return 'unknown';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isSuffixTrick(name, popular) {
|
|
159
|
+
const suffixes = ['-js', '.js', '-node', '-npm', '-cli', '-api', '-lib', '-pkg', '-dev', '-pro'];
|
|
160
|
+
for (const suffix of suffixes) {
|
|
161
|
+
if (name === popular + suffix) return true;
|
|
162
|
+
if (name === popular.replace('-', '') + suffix) return true;
|
|
163
|
+
}
|
|
164
|
+
// Verifie aussi les prefixes
|
|
165
|
+
const prefixes = ['node-', 'npm-', 'js-', 'get-', 'the-'];
|
|
166
|
+
for (const prefix of prefixes) {
|
|
167
|
+
if (name === prefix + popular) return true;
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function levenshteinDistance(a, b) {
|
|
173
|
+
const matrix = [];
|
|
174
|
+
|
|
175
|
+
for (let i = 0; i <= b.length; i++) {
|
|
176
|
+
matrix[i] = [i];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (let j = 0; j <= a.length; j++) {
|
|
180
|
+
matrix[0][j] = j;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (let i = 1; i <= b.length; i++) {
|
|
184
|
+
for (let j = 1; j <= a.length; j++) {
|
|
185
|
+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
186
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
187
|
+
} else {
|
|
188
|
+
matrix[i][j] = Math.min(
|
|
189
|
+
matrix[i - 1][j - 1] + 1, // substitution
|
|
190
|
+
matrix[i][j - 1] + 1, // insertion
|
|
191
|
+
matrix[i - 1][j] + 1 // deletion
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return matrix[b.length][a.length];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Generateurs pour tests (pas utilises dans le scan, mais utiles pour enrichir les IOCs)
|
|
201
|
+
function generateMissingChar(name) {
|
|
202
|
+
const results = [];
|
|
203
|
+
for (let i = 0; i < name.length; i++) {
|
|
204
|
+
results.push(name.slice(0, i) + name.slice(i + 1));
|
|
205
|
+
}
|
|
206
|
+
return results;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function generateExtraChar(name) {
|
|
210
|
+
const results = [];
|
|
211
|
+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789-';
|
|
212
|
+
for (let i = 0; i <= name.length; i++) {
|
|
213
|
+
for (const char of chars) {
|
|
214
|
+
results.push(name.slice(0, i) + char + name.slice(i));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return results;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function generateSwappedChars(name) {
|
|
221
|
+
const results = [];
|
|
222
|
+
for (let i = 0; i < name.length - 1; i++) {
|
|
223
|
+
const arr = name.split('');
|
|
224
|
+
[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]];
|
|
225
|
+
results.push(arr.join(''));
|
|
226
|
+
}
|
|
227
|
+
return results;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function generateWrongChar(name) {
|
|
231
|
+
const results = [];
|
|
232
|
+
const keyboard = {
|
|
233
|
+
'a': 'sqwz', 'b': 'vghn', 'c': 'xdfv', 'd': 'serfcx', 'e': 'wsdfr',
|
|
234
|
+
'f': 'drtgvc', 'g': 'ftyhbv', 'h': 'gyujnb', 'i': 'ujklo', 'j': 'huikmn',
|
|
235
|
+
'k': 'jiolm', 'l': 'kop', 'm': 'njk', 'n': 'bhjm', 'o': 'iklp',
|
|
236
|
+
'p': 'ol', 'q': 'wa', 'r': 'edft', 's': 'awedxz', 't': 'rfgy',
|
|
237
|
+
'u': 'yhji', 'v': 'cfgb', 'w': 'qase', 'x': 'zsdc', 'y': 'tghu', 'z': 'asx'
|
|
238
|
+
};
|
|
239
|
+
for (let i = 0; i < name.length; i++) {
|
|
240
|
+
const char = name[i].toLowerCase();
|
|
241
|
+
if (keyboard[char]) {
|
|
242
|
+
for (const replacement of keyboard[char]) {
|
|
243
|
+
results.push(name.slice(0, i) + replacement + name.slice(i + 1));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return results;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function generateHyphenTricks(name) {
|
|
251
|
+
const results = [];
|
|
252
|
+
// Ajouter des hyphens
|
|
253
|
+
for (let i = 1; i < name.length; i++) {
|
|
254
|
+
results.push(name.slice(0, i) + '-' + name.slice(i));
|
|
255
|
+
}
|
|
256
|
+
// Retirer des hyphens
|
|
257
|
+
results.push(name.replace(/-/g, ''));
|
|
258
|
+
return results;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function generateSuffixTricks(name) {
|
|
262
|
+
const suffixes = ['-js', '.js', '-node', '-npm', '-cli'];
|
|
263
|
+
return suffixes.map(s => name + s);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = { scanTyposquatting, levenshteinDistance };
|
package/src/webhook.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
const http = require('http');
|
|
3
|
+
|
|
4
|
+
async function sendWebhook(url, results) {
|
|
5
|
+
const isDiscord = url.includes('discord.com');
|
|
6
|
+
const isSlack = url.includes('hooks.slack.com');
|
|
7
|
+
|
|
8
|
+
let payload;
|
|
9
|
+
|
|
10
|
+
if (isDiscord) {
|
|
11
|
+
payload = formatDiscord(results);
|
|
12
|
+
} else if (isSlack) {
|
|
13
|
+
payload = formatSlack(results);
|
|
14
|
+
} else {
|
|
15
|
+
payload = formatGeneric(results);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return send(url, payload);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatDiscord(results) {
|
|
22
|
+
const { summary, threats, target } = results;
|
|
23
|
+
|
|
24
|
+
const color = summary.riskLevel === 'CRITICAL' ? 0xe74c3c
|
|
25
|
+
: summary.riskLevel === 'HIGH' ? 0xe67e22
|
|
26
|
+
: summary.riskLevel === 'MEDIUM' ? 0xf1c40f
|
|
27
|
+
: summary.riskLevel === 'LOW' ? 0x3498db
|
|
28
|
+
: 0x2ecc71;
|
|
29
|
+
|
|
30
|
+
const criticalThreats = threats
|
|
31
|
+
.filter(t => t.severity === 'CRITICAL')
|
|
32
|
+
.slice(0, 5)
|
|
33
|
+
.map(t => `- ${t.message}`)
|
|
34
|
+
.join('\n');
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
embeds: [{
|
|
38
|
+
title: 'MUAD\'DIB Security Scan',
|
|
39
|
+
description: `Scan de **${target}**`,
|
|
40
|
+
color: color,
|
|
41
|
+
fields: [
|
|
42
|
+
{
|
|
43
|
+
name: 'Score de risque',
|
|
44
|
+
value: `**${summary.riskScore}/100** (${summary.riskLevel})`,
|
|
45
|
+
inline: true
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'Menaces',
|
|
49
|
+
value: `${summary.critical} CRITICAL\n${summary.high} HIGH\n${summary.medium} MEDIUM`,
|
|
50
|
+
inline: true
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'Total',
|
|
54
|
+
value: `**${summary.total}** menace(s)`,
|
|
55
|
+
inline: true
|
|
56
|
+
}
|
|
57
|
+
],
|
|
58
|
+
footer: {
|
|
59
|
+
text: 'MUAD\'DIB - Supply-chain threat detection'
|
|
60
|
+
},
|
|
61
|
+
timestamp: results.timestamp
|
|
62
|
+
}]
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatSlack(results) {
|
|
67
|
+
const { summary, threats, target } = results;
|
|
68
|
+
|
|
69
|
+
const emoji = summary.riskLevel === 'CRITICAL' ? ':rotating_light:'
|
|
70
|
+
: summary.riskLevel === 'HIGH' ? ':warning:'
|
|
71
|
+
: summary.riskLevel === 'MEDIUM' ? ':large_yellow_circle:'
|
|
72
|
+
: summary.riskLevel === 'LOW' ? ':information_source:'
|
|
73
|
+
: ':white_check_mark:';
|
|
74
|
+
|
|
75
|
+
const criticalList = threats
|
|
76
|
+
.filter(t => t.severity === 'CRITICAL')
|
|
77
|
+
.slice(0, 5)
|
|
78
|
+
.map(t => `• ${t.message}`)
|
|
79
|
+
.join('\n');
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
blocks: [
|
|
83
|
+
{
|
|
84
|
+
type: 'header',
|
|
85
|
+
text: {
|
|
86
|
+
type: 'plain_text',
|
|
87
|
+
text: `${emoji} MUAD'DIB Security Scan`
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
type: 'section',
|
|
92
|
+
fields: [
|
|
93
|
+
{
|
|
94
|
+
type: 'mrkdwn',
|
|
95
|
+
text: `*Cible:*\n${target}`
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
type: 'mrkdwn',
|
|
99
|
+
text: `*Score:*\n${summary.riskScore}/100 (${summary.riskLevel})`
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
type: 'section',
|
|
105
|
+
fields: [
|
|
106
|
+
{
|
|
107
|
+
type: 'mrkdwn',
|
|
108
|
+
text: `*CRITICAL:* ${summary.critical}`
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
type: 'mrkdwn',
|
|
112
|
+
text: `*HIGH:* ${summary.high}`
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
type: 'mrkdwn',
|
|
116
|
+
text: `*MEDIUM:* ${summary.medium}`
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
type: 'mrkdwn',
|
|
120
|
+
text: `*Total:* ${summary.total}`
|
|
121
|
+
}
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function formatGeneric(results) {
|
|
129
|
+
return {
|
|
130
|
+
tool: 'MUADDIB',
|
|
131
|
+
target: results.target,
|
|
132
|
+
timestamp: results.timestamp,
|
|
133
|
+
summary: results.summary,
|
|
134
|
+
threats: results.threats.map(t => ({
|
|
135
|
+
type: t.type,
|
|
136
|
+
severity: t.severity,
|
|
137
|
+
message: t.message,
|
|
138
|
+
file: t.file
|
|
139
|
+
}))
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function send(url, payload) {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const urlObj = new URL(url);
|
|
146
|
+
const protocol = urlObj.protocol === 'https:' ? https : http;
|
|
147
|
+
|
|
148
|
+
const options = {
|
|
149
|
+
hostname: urlObj.hostname,
|
|
150
|
+
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
|
151
|
+
path: urlObj.pathname + urlObj.search,
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: {
|
|
154
|
+
'Content-Type': 'application/json'
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const req = protocol.request(options, (res) => {
|
|
159
|
+
let data = '';
|
|
160
|
+
res.on('data', chunk => data += chunk);
|
|
161
|
+
res.on('end', () => {
|
|
162
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
163
|
+
resolve({ success: true, status: res.statusCode });
|
|
164
|
+
} else {
|
|
165
|
+
reject(new Error(`Webhook failed: HTTP ${res.statusCode}`));
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
req.on('error', reject);
|
|
171
|
+
req.write(JSON.stringify(payload));
|
|
172
|
+
req.end();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { sendWebhook };
|
package/tests/run-tests.js
CHANGED
|
@@ -198,6 +198,32 @@ test('MARQUEURS: Detecte The Second Coming', () => {
|
|
|
198
198
|
assertIncludes(output, 'Second Coming', 'Devrait detecter marqueur The Second Coming');
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
+
// ============================================
|
|
202
|
+
// TESTS UNITAIRES - DETECTION TYPOSQUATTING
|
|
203
|
+
// ============================================
|
|
204
|
+
|
|
205
|
+
console.log('\n=== TESTS TYPOSQUATTING ===\n');
|
|
206
|
+
|
|
207
|
+
test('TYPOSQUAT: Detecte lodahs (lodash)', () => {
|
|
208
|
+
const output = runScan(path.join(TESTS_DIR, 'typosquat'));
|
|
209
|
+
assertIncludes(output, 'lodahs', 'Devrait detecter lodahs');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('TYPOSQUAT: Detecte axois (axios)', () => {
|
|
213
|
+
const output = runScan(path.join(TESTS_DIR, 'typosquat'));
|
|
214
|
+
assertIncludes(output, 'axois', 'Devrait detecter axois');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('TYPOSQUAT: Detecte expres (express)', () => {
|
|
218
|
+
const output = runScan(path.join(TESTS_DIR, 'typosquat'));
|
|
219
|
+
assertIncludes(output, 'expres', 'Devrait detecter expres');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('TYPOSQUAT: Severity HIGH', () => {
|
|
223
|
+
const output = runScan(path.join(TESTS_DIR, 'typosquat'));
|
|
224
|
+
assertIncludes(output, 'HIGH', 'Devrait etre HIGH');
|
|
225
|
+
});
|
|
226
|
+
|
|
201
227
|
// ============================================
|
|
202
228
|
// TESTS INTEGRATION - CLI
|
|
203
229
|
// ============================================
|