muaddib-scanner 1.0.0 → 1.0.2
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 +2 -2
- package/src/index.js +5 -0
- package/src/response/playbooks.js +3 -0
- package/src/rules/index.js +14 -1
- package/src/scanner/ast.js +1 -1
- package/src/scanner/dataflow.js +1 -1
- package/src/scanner/obfuscation.js +1 -6
- package/src/scanner/shell.js +1 -1
- package/src/scanner/typosquat.js +235 -0
- package/tests/run-tests.js +26 -0
- package/tests/samples/typosquat/package.json +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muaddib-scanner",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Supply-chain threat detection & response for npm",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -40,4 +40,4 @@
|
|
|
40
40
|
"acorn-walk": "^8.3.4",
|
|
41
41
|
"js-yaml": "^4.1.0"
|
|
42
42
|
}
|
|
43
|
-
}
|
|
43
|
+
}
|
package/src/index.js
CHANGED
|
@@ -9,6 +9,7 @@ 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');
|
|
12
13
|
|
|
13
14
|
async function run(targetPath, options = {}) {
|
|
14
15
|
const threats = [];
|
|
@@ -34,6 +35,10 @@ async function run(targetPath, options = {}) {
|
|
|
34
35
|
const dataflowThreats = await analyzeDataFlow(targetPath);
|
|
35
36
|
threats.push(...dataflowThreats);
|
|
36
37
|
|
|
38
|
+
// Scan typosquatting
|
|
39
|
+
const typosquatThreats = await scanTyposquatting(targetPath);
|
|
40
|
+
threats.push(...typosquatThreats);
|
|
41
|
+
|
|
37
42
|
// Enrichir chaque menace avec les regles
|
|
38
43
|
const enrichedThreats = threats.map(t => {
|
|
39
44
|
const rule = getRule(t.type);
|
|
@@ -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) {
|
package/src/scanner/ast.js
CHANGED
package/src/scanner/dataflow.js
CHANGED
|
@@ -3,7 +3,7 @@ const path = require('path');
|
|
|
3
3
|
const acorn = require('acorn');
|
|
4
4
|
const walk = require('acorn-walk');
|
|
5
5
|
|
|
6
|
-
const EXCLUDED_DIRS = ['test', 'node_modules', '.git', 'src'];
|
|
6
|
+
const EXCLUDED_DIRS = ['test', 'tests', 'node_modules', '.git', 'src'];
|
|
7
7
|
|
|
8
8
|
async function analyzeDataFlow(targetPath) {
|
|
9
9
|
const threats = [];
|
|
@@ -66,12 +66,7 @@ function detectObfuscation(targetPath) {
|
|
|
66
66
|
return threats;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
const EXCLUDED_DIRS = [
|
|
70
|
-
'test',
|
|
71
|
-
'node_modules',
|
|
72
|
-
'.git',
|
|
73
|
-
'src'
|
|
74
|
-
];
|
|
69
|
+
const EXCLUDED_DIRS = ['test', 'tests', 'node_modules', '.git', 'src'];
|
|
75
70
|
|
|
76
71
|
function findJsFiles(dir) {
|
|
77
72
|
const results = [];
|
package/src/scanner/shell.js
CHANGED
|
@@ -45,7 +45,7 @@ function findFiles(dir, extension) {
|
|
|
45
45
|
const items = fs.readdirSync(dir);
|
|
46
46
|
|
|
47
47
|
for (const item of items) {
|
|
48
|
-
if (item === 'node_modules' || item === '.git') continue;
|
|
48
|
+
if (item === 'node_modules' || item === '.git' || item === 'test' || item === 'tests' || item === 'src') continue;
|
|
49
49
|
|
|
50
50
|
const fullPath = path.join(dir, item);
|
|
51
51
|
const stat = fs.statSync(fullPath);
|
|
@@ -0,0 +1,235 @@
|
|
|
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
|
+
// Techniques de typosquatting connues
|
|
25
|
+
const TYPOSQUAT_PATTERNS = [
|
|
26
|
+
{ type: 'missing_char', fn: (name) => generateMissingChar(name) },
|
|
27
|
+
{ type: 'extra_char', fn: (name) => generateExtraChar(name) },
|
|
28
|
+
{ type: 'swapped_chars', fn: (name) => generateSwappedChars(name) },
|
|
29
|
+
{ type: 'wrong_char', fn: (name) => generateWrongChar(name) },
|
|
30
|
+
{ type: 'hyphen_tricks', fn: (name) => generateHyphenTricks(name) },
|
|
31
|
+
{ type: 'suffix_tricks', fn: (name) => generateSuffixTricks(name) }
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
async function scanTyposquatting(targetPath) {
|
|
35
|
+
const threats = [];
|
|
36
|
+
const packageJsonPath = path.join(targetPath, 'package.json');
|
|
37
|
+
|
|
38
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
39
|
+
return threats;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
43
|
+
const dependencies = {
|
|
44
|
+
...packageJson.dependencies,
|
|
45
|
+
...packageJson.devDependencies,
|
|
46
|
+
...packageJson.peerDependencies,
|
|
47
|
+
...packageJson.optionalDependencies
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
for (const depName of Object.keys(dependencies)) {
|
|
51
|
+
const match = findTyposquatMatch(depName);
|
|
52
|
+
if (match) {
|
|
53
|
+
threats.push({
|
|
54
|
+
type: 'typosquat_detected',
|
|
55
|
+
severity: 'HIGH',
|
|
56
|
+
message: `Package "${depName}" ressemble a "${match.original}" (${match.type}). Possible typosquatting.`,
|
|
57
|
+
file: 'package.json',
|
|
58
|
+
details: {
|
|
59
|
+
suspicious: depName,
|
|
60
|
+
legitimate: match.original,
|
|
61
|
+
technique: match.type,
|
|
62
|
+
distance: match.distance
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return threats;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function findTyposquatMatch(name) {
|
|
72
|
+
// Ignore les packages scoped (@org/package)
|
|
73
|
+
if (name.startsWith('@')) return null;
|
|
74
|
+
|
|
75
|
+
for (const popular of POPULAR_PACKAGES) {
|
|
76
|
+
// Ignore si c'est exactement le meme
|
|
77
|
+
if (name === popular) continue;
|
|
78
|
+
|
|
79
|
+
const distance = levenshteinDistance(name, popular);
|
|
80
|
+
|
|
81
|
+
// Distance de 1 ou 2 = tres suspect
|
|
82
|
+
if (distance === 1) {
|
|
83
|
+
return {
|
|
84
|
+
original: popular,
|
|
85
|
+
type: detectTyposquatType(name, popular),
|
|
86
|
+
distance: distance
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Distance de 2 avec nom court = suspect
|
|
91
|
+
if (distance === 2 && popular.length <= 6) {
|
|
92
|
+
return {
|
|
93
|
+
original: popular,
|
|
94
|
+
type: detectTyposquatType(name, popular),
|
|
95
|
+
distance: distance
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Verifie les tricks de suffixe
|
|
100
|
+
if (isSuffixTrick(name, popular)) {
|
|
101
|
+
return {
|
|
102
|
+
original: popular,
|
|
103
|
+
type: 'suffix_trick',
|
|
104
|
+
distance: distance
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function detectTyposquatType(typo, original) {
|
|
113
|
+
if (typo.length === original.length - 1) return 'missing_char';
|
|
114
|
+
if (typo.length === original.length + 1) return 'extra_char';
|
|
115
|
+
if (typo.length === original.length) {
|
|
116
|
+
// Verifie si swap
|
|
117
|
+
let diffs = 0;
|
|
118
|
+
for (let i = 0; i < typo.length; i++) {
|
|
119
|
+
if (typo[i] !== original[i]) diffs++;
|
|
120
|
+
}
|
|
121
|
+
if (diffs === 2) return 'swapped_chars';
|
|
122
|
+
return 'wrong_char';
|
|
123
|
+
}
|
|
124
|
+
return 'unknown';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isSuffixTrick(name, popular) {
|
|
128
|
+
const suffixes = ['-js', '.js', '-node', '-npm', '-cli', '-api', '-lib', '-pkg', '-dev', '-pro'];
|
|
129
|
+
for (const suffix of suffixes) {
|
|
130
|
+
if (name === popular + suffix) return true;
|
|
131
|
+
if (name === popular.replace('-', '') + suffix) return true;
|
|
132
|
+
}
|
|
133
|
+
// Verifie aussi les prefixes
|
|
134
|
+
const prefixes = ['node-', 'npm-', 'js-', 'get-', 'the-'];
|
|
135
|
+
for (const prefix of prefixes) {
|
|
136
|
+
if (name === prefix + popular) return true;
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function levenshteinDistance(a, b) {
|
|
142
|
+
const matrix = [];
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i <= b.length; i++) {
|
|
145
|
+
matrix[i] = [i];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (let j = 0; j <= a.length; j++) {
|
|
149
|
+
matrix[0][j] = j;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (let i = 1; i <= b.length; i++) {
|
|
153
|
+
for (let j = 1; j <= a.length; j++) {
|
|
154
|
+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
155
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
156
|
+
} else {
|
|
157
|
+
matrix[i][j] = Math.min(
|
|
158
|
+
matrix[i - 1][j - 1] + 1, // substitution
|
|
159
|
+
matrix[i][j - 1] + 1, // insertion
|
|
160
|
+
matrix[i - 1][j] + 1 // deletion
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return matrix[b.length][a.length];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Generateurs pour tests (pas utilises dans le scan, mais utiles pour enrichir les IOCs)
|
|
170
|
+
function generateMissingChar(name) {
|
|
171
|
+
const results = [];
|
|
172
|
+
for (let i = 0; i < name.length; i++) {
|
|
173
|
+
results.push(name.slice(0, i) + name.slice(i + 1));
|
|
174
|
+
}
|
|
175
|
+
return results;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function generateExtraChar(name) {
|
|
179
|
+
const results = [];
|
|
180
|
+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789-';
|
|
181
|
+
for (let i = 0; i <= name.length; i++) {
|
|
182
|
+
for (const char of chars) {
|
|
183
|
+
results.push(name.slice(0, i) + char + name.slice(i));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return results;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function generateSwappedChars(name) {
|
|
190
|
+
const results = [];
|
|
191
|
+
for (let i = 0; i < name.length - 1; i++) {
|
|
192
|
+
const arr = name.split('');
|
|
193
|
+
[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]];
|
|
194
|
+
results.push(arr.join(''));
|
|
195
|
+
}
|
|
196
|
+
return results;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function generateWrongChar(name) {
|
|
200
|
+
const results = [];
|
|
201
|
+
const keyboard = {
|
|
202
|
+
'a': 'sqwz', 'b': 'vghn', 'c': 'xdfv', 'd': 'serfcx', 'e': 'wsdfr',
|
|
203
|
+
'f': 'drtgvc', 'g': 'ftyhbv', 'h': 'gyujnb', 'i': 'ujklo', 'j': 'huikmn',
|
|
204
|
+
'k': 'jiolm', 'l': 'kop', 'm': 'njk', 'n': 'bhjm', 'o': 'iklp',
|
|
205
|
+
'p': 'ol', 'q': 'wa', 'r': 'edft', 's': 'awedxz', 't': 'rfgy',
|
|
206
|
+
'u': 'yhji', 'v': 'cfgb', 'w': 'qase', 'x': 'zsdc', 'y': 'tghu', 'z': 'asx'
|
|
207
|
+
};
|
|
208
|
+
for (let i = 0; i < name.length; i++) {
|
|
209
|
+
const char = name[i].toLowerCase();
|
|
210
|
+
if (keyboard[char]) {
|
|
211
|
+
for (const replacement of keyboard[char]) {
|
|
212
|
+
results.push(name.slice(0, i) + replacement + name.slice(i + 1));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return results;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function generateHyphenTricks(name) {
|
|
220
|
+
const results = [];
|
|
221
|
+
// Ajouter des hyphens
|
|
222
|
+
for (let i = 1; i < name.length; i++) {
|
|
223
|
+
results.push(name.slice(0, i) + '-' + name.slice(i));
|
|
224
|
+
}
|
|
225
|
+
// Retirer des hyphens
|
|
226
|
+
results.push(name.replace(/-/g, ''));
|
|
227
|
+
return results;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function generateSuffixTricks(name) {
|
|
231
|
+
const suffixes = ['-js', '.js', '-node', '-npm', '-cli'];
|
|
232
|
+
return suffixes.map(s => name + s);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
module.exports = { scanTyposquatting, levenshteinDistance };
|
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
|
// ============================================
|