muaddib-scanner 1.0.10 → 1.0.11
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 +1 -1
- package/src/scanner/ast.js +45 -14
- package/src/scanner/dataflow.js +39 -11
- package/src/scanner/typosquat.js +60 -37
package/package.json
CHANGED
package/src/scanner/ast.js
CHANGED
|
@@ -11,29 +11,32 @@ const EXCLUDED_FILES = [
|
|
|
11
11
|
'src/response/playbooks.js'
|
|
12
12
|
];
|
|
13
13
|
|
|
14
|
-
const EXCLUDED_DIRS = [
|
|
14
|
+
const EXCLUDED_DIRS = [
|
|
15
|
+
'test', 'tests', 'node_modules', '.git', 'src', 'vscode-extension',
|
|
16
|
+
'scripts', 'bin', 'tools', 'build', 'dist', 'fixtures', 'examples',
|
|
17
|
+
'__tests__', '__mocks__', 'benchmark', 'benchmarks', 'docs', 'doc'
|
|
18
|
+
];
|
|
15
19
|
|
|
16
20
|
const DANGEROUS_CALLS = [
|
|
17
21
|
'eval',
|
|
18
|
-
'Function'
|
|
19
|
-
'exec',
|
|
20
|
-
'execSync',
|
|
21
|
-
'spawn',
|
|
22
|
-
'spawnSync'
|
|
22
|
+
'Function'
|
|
23
23
|
];
|
|
24
24
|
|
|
25
25
|
const SENSITIVE_STRINGS = [
|
|
26
26
|
'.npmrc',
|
|
27
27
|
'.ssh',
|
|
28
|
-
'GITHUB_TOKEN',
|
|
29
|
-
'NPM_TOKEN',
|
|
30
|
-
'AWS_SECRET',
|
|
31
|
-
'api.github.com',
|
|
32
28
|
'Shai-Hulud',
|
|
33
29
|
'The Second Coming',
|
|
34
30
|
'Goldox-T3chs'
|
|
35
31
|
];
|
|
36
32
|
|
|
33
|
+
// Strings qui ne sont PAS suspects
|
|
34
|
+
const SAFE_STRINGS = [
|
|
35
|
+
'api.github.com',
|
|
36
|
+
'registry.npmjs.org',
|
|
37
|
+
'npmjs.com'
|
|
38
|
+
];
|
|
39
|
+
|
|
37
40
|
async function analyzeAST(targetPath) {
|
|
38
41
|
const threats = [];
|
|
39
42
|
const files = findJsFiles(targetPath);
|
|
@@ -45,6 +48,11 @@ async function analyzeAST(targetPath) {
|
|
|
45
48
|
continue;
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
// Ignorer les fichiers dans les dossiers de dev
|
|
52
|
+
if (isDevFile(relativePath)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
const content = fs.readFileSync(file, 'utf8');
|
|
49
57
|
const fileThreats = analyzeFile(content, file, targetPath);
|
|
50
58
|
threats.push(...fileThreats);
|
|
@@ -53,6 +61,27 @@ async function analyzeAST(targetPath) {
|
|
|
53
61
|
return threats;
|
|
54
62
|
}
|
|
55
63
|
|
|
64
|
+
function isDevFile(relativePath) {
|
|
65
|
+
const devPatterns = [
|
|
66
|
+
/^scripts\//,
|
|
67
|
+
/^bin\//,
|
|
68
|
+
/^tools\//,
|
|
69
|
+
/^build\//,
|
|
70
|
+
/^fixtures\//,
|
|
71
|
+
/^examples\//,
|
|
72
|
+
/^__tests__\//,
|
|
73
|
+
/^__mocks__\//,
|
|
74
|
+
/^benchmark/,
|
|
75
|
+
/^docs?\//,
|
|
76
|
+
/\.test\.js$/,
|
|
77
|
+
/\.spec\.js$/,
|
|
78
|
+
/test\.js$/,
|
|
79
|
+
/spec\.js$/
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
return devPatterns.some(pattern => pattern.test(relativePath));
|
|
83
|
+
}
|
|
84
|
+
|
|
56
85
|
function analyzeFile(content, filePath, basePath) {
|
|
57
86
|
const threats = [];
|
|
58
87
|
let ast;
|
|
@@ -64,7 +93,6 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
64
93
|
allowHashBang: true
|
|
65
94
|
});
|
|
66
95
|
} catch (e) {
|
|
67
|
-
// Fichier non parseable, peut etre obfusque
|
|
68
96
|
if (content.length > 1000 && content.split('\n').length < 10) {
|
|
69
97
|
threats.push({
|
|
70
98
|
type: 'possible_obfuscation',
|
|
@@ -76,7 +104,6 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
76
104
|
return threats;
|
|
77
105
|
}
|
|
78
106
|
|
|
79
|
-
// Analyse des appels de fonction
|
|
80
107
|
walk.simple(ast, {
|
|
81
108
|
CallExpression(node) {
|
|
82
109
|
const callName = getCallName(node);
|
|
@@ -84,7 +111,7 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
84
111
|
if (DANGEROUS_CALLS.includes(callName)) {
|
|
85
112
|
threats.push({
|
|
86
113
|
type: 'dangerous_call_' + callName.toLowerCase(),
|
|
87
|
-
severity:
|
|
114
|
+
severity: 'HIGH',
|
|
88
115
|
message: `Appel dangereux "${callName}" detecte.`,
|
|
89
116
|
file: path.relative(basePath, filePath)
|
|
90
117
|
});
|
|
@@ -104,6 +131,11 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
104
131
|
|
|
105
132
|
Literal(node) {
|
|
106
133
|
if (typeof node.value === 'string') {
|
|
134
|
+
// Ignorer les strings safe
|
|
135
|
+
if (SAFE_STRINGS.some(s => node.value.includes(s))) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
107
139
|
for (const sensitive of SENSITIVE_STRINGS) {
|
|
108
140
|
if (node.value.includes(sensitive)) {
|
|
109
141
|
threats.push({
|
|
@@ -118,7 +150,6 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
118
150
|
},
|
|
119
151
|
|
|
120
152
|
MemberExpression(node) {
|
|
121
|
-
// Detecte process.env.XXX
|
|
122
153
|
if (
|
|
123
154
|
node.object?.object?.name === 'process' &&
|
|
124
155
|
node.object?.property?.name === 'env'
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -3,13 +3,24 @@ const path = require('path');
|
|
|
3
3
|
const acorn = require('acorn');
|
|
4
4
|
const walk = require('acorn-walk');
|
|
5
5
|
|
|
6
|
-
const EXCLUDED_DIRS = [
|
|
6
|
+
const EXCLUDED_DIRS = [
|
|
7
|
+
'test', 'tests', 'node_modules', '.git', 'src', 'vscode-extension',
|
|
8
|
+
'scripts', 'bin', 'tools', 'build', 'dist', 'fixtures', 'examples',
|
|
9
|
+
'__tests__', '__mocks__', 'benchmark', 'benchmarks', 'docs', 'doc'
|
|
10
|
+
];
|
|
7
11
|
|
|
8
12
|
async function analyzeDataFlow(targetPath) {
|
|
9
13
|
const threats = [];
|
|
10
14
|
const files = findJsFiles(targetPath);
|
|
11
15
|
|
|
12
16
|
for (const file of files) {
|
|
17
|
+
const relativePath = path.relative(targetPath, file).replace(/\\/g, '/');
|
|
18
|
+
|
|
19
|
+
// Ignorer les fichiers de dev/build/scripts
|
|
20
|
+
if (isDevFile(relativePath)) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
const content = fs.readFileSync(file, 'utf8');
|
|
14
25
|
const fileThreats = analyzeFile(content, file, targetPath);
|
|
15
26
|
threats.push(...fileThreats);
|
|
@@ -18,6 +29,29 @@ async function analyzeDataFlow(targetPath) {
|
|
|
18
29
|
return threats;
|
|
19
30
|
}
|
|
20
31
|
|
|
32
|
+
function isDevFile(relativePath) {
|
|
33
|
+
const devPatterns = [
|
|
34
|
+
/^scripts\//,
|
|
35
|
+
/^bin\//,
|
|
36
|
+
/^tools\//,
|
|
37
|
+
/^build\//,
|
|
38
|
+
/^fixtures\//,
|
|
39
|
+
/^examples\//,
|
|
40
|
+
/^__tests__\//,
|
|
41
|
+
/^__mocks__\//,
|
|
42
|
+
/^benchmark/,
|
|
43
|
+
/^docs?\//,
|
|
44
|
+
/^compiler\//,
|
|
45
|
+
/^packages\/.*\/scripts\//,
|
|
46
|
+
/\.test\.js$/,
|
|
47
|
+
/\.spec\.js$/,
|
|
48
|
+
/test\.js$/,
|
|
49
|
+
/spec\.js$/
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
return devPatterns.some(pattern => pattern.test(relativePath));
|
|
53
|
+
}
|
|
54
|
+
|
|
21
55
|
function analyzeFile(content, filePath, basePath) {
|
|
22
56
|
const threats = [];
|
|
23
57
|
let ast;
|
|
@@ -32,15 +66,13 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
32
66
|
return threats;
|
|
33
67
|
}
|
|
34
68
|
|
|
35
|
-
const sources = [];
|
|
36
|
-
const sinks = [];
|
|
69
|
+
const sources = [];
|
|
70
|
+
const sinks = [];
|
|
37
71
|
|
|
38
72
|
walk.simple(ast, {
|
|
39
|
-
// Detecte les lectures de fichiers sensibles
|
|
40
73
|
CallExpression(node) {
|
|
41
74
|
const callName = getCallName(node);
|
|
42
75
|
|
|
43
|
-
// fs.readFileSync, fs.readFile
|
|
44
76
|
if (callName === 'readFileSync' || callName === 'readFile') {
|
|
45
77
|
const arg = node.arguments[0];
|
|
46
78
|
if (arg && isCredentialPath(arg, content)) {
|
|
@@ -52,7 +84,6 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
52
84
|
}
|
|
53
85
|
}
|
|
54
86
|
|
|
55
|
-
// Detecte les envois reseau
|
|
56
87
|
if (callName === 'request' || callName === 'fetch' || callName === 'post' || callName === 'get') {
|
|
57
88
|
sinks.push({
|
|
58
89
|
type: 'network_send',
|
|
@@ -61,7 +92,6 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
61
92
|
});
|
|
62
93
|
}
|
|
63
94
|
|
|
64
|
-
// exec avec curl/wget
|
|
65
95
|
if (callName === 'exec' || callName === 'execSync') {
|
|
66
96
|
const arg = node.arguments[0];
|
|
67
97
|
if (arg && arg.type === 'Literal' && typeof arg.value === 'string') {
|
|
@@ -76,7 +106,6 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
76
106
|
}
|
|
77
107
|
},
|
|
78
108
|
|
|
79
|
-
// Detecte les acces process.env sensibles
|
|
80
109
|
MemberExpression(node) {
|
|
81
110
|
if (
|
|
82
111
|
node.object?.object?.name === 'process' &&
|
|
@@ -94,7 +123,6 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
94
123
|
}
|
|
95
124
|
});
|
|
96
125
|
|
|
97
|
-
// Si on a des sources ET des sinks = flux suspect
|
|
98
126
|
if (sources.length > 0 && sinks.length > 0) {
|
|
99
127
|
threats.push({
|
|
100
128
|
type: 'suspicious_dataflow',
|
|
@@ -126,7 +154,6 @@ function isCredentialPath(arg, content) {
|
|
|
126
154
|
val.includes('.gitconfig') ||
|
|
127
155
|
val.includes('.env');
|
|
128
156
|
}
|
|
129
|
-
// Verifie aussi les templates strings et concatenations
|
|
130
157
|
if (arg.type === 'TemplateLiteral' || arg.type === 'BinaryExpression') {
|
|
131
158
|
return content.includes('.npmrc') ||
|
|
132
159
|
content.includes('.ssh') ||
|
|
@@ -136,7 +163,8 @@ function isCredentialPath(arg, content) {
|
|
|
136
163
|
}
|
|
137
164
|
|
|
138
165
|
function isSensitiveEnv(name) {
|
|
139
|
-
const sensitive = ['TOKEN', 'SECRET', 'KEY', 'PASSWORD', 'CREDENTIAL', 'AUTH', 'NPM', '
|
|
166
|
+
const sensitive = ['TOKEN', 'SECRET', 'KEY', 'PASSWORD', 'CREDENTIAL', 'AUTH', 'NPM', 'AWS', 'AZURE', 'GCP'];
|
|
167
|
+
// Ignore GITHUB — trop de faux positifs dans les scripts de release
|
|
140
168
|
return sensitive.some(s => name.toUpperCase().includes(s));
|
|
141
169
|
}
|
|
142
170
|
|
package/src/scanner/typosquat.js
CHANGED
|
@@ -7,13 +7,13 @@ const POPULAR_PACKAGES = [
|
|
|
7
7
|
'request', 'async', 'bluebird', 'underscore', 'uuid', 'debug', 'mkdirp',
|
|
8
8
|
'glob', 'minimist', 'webpack', 'babel-core', 'typescript', 'eslint',
|
|
9
9
|
'prettier', 'jest', 'mocha', 'chai', 'sinon', 'mongoose', 'sequelize',
|
|
10
|
-
'
|
|
10
|
+
'redis', 'mongodb', 'socket.io', 'express-session',
|
|
11
11
|
'body-parser', 'cookie-parser', 'cors', 'helmet', 'morgan', 'dotenv',
|
|
12
12
|
'jsonwebtoken', 'bcrypt', 'passport', 'nodemailer', 'aws-sdk', 'stripe',
|
|
13
|
-
'twilio', 'firebase', 'graphql', 'apollo-server', '
|
|
13
|
+
'twilio', 'firebase', 'graphql', 'apollo-server', 'nuxt',
|
|
14
14
|
'gatsby', 'angular', 'svelte', 'electron', 'puppeteer', 'cheerio',
|
|
15
15
|
'sharp', 'jimp', 'canvas', 'pdf-lib', 'exceljs', 'csv-parser', 'xml2js',
|
|
16
|
-
'yaml', 'config', 'yargs', '
|
|
16
|
+
'yaml', 'config', 'yargs', 'colors',
|
|
17
17
|
'winston', 'bunyan', 'pino', 'log4js', 'ramda', 'immutable',
|
|
18
18
|
'mobx', 'redux', 'zustand', 'formik', 'yup', 'ajv', 'validator',
|
|
19
19
|
'date-fns', 'dayjs', 'luxon', 'numeral', 'accounting', 'currency.js',
|
|
@@ -28,7 +28,7 @@ const WHITELIST = [
|
|
|
28
28
|
'co', 'q', 'n', 'i', 'a', 'v', 'x', 'y', 'z',
|
|
29
29
|
'ejs', 'nyc', 'ini', 'joi', 'vue', 'npm', 'got', 'ora',
|
|
30
30
|
'vary', 'mime', 'send', 'etag', 'raw', 'tar', 'uid', 'cjs',
|
|
31
|
-
'rxjs', 'yarn', 'pnpm',
|
|
31
|
+
'rxjs', 'yarn', 'pnpm', 'next',
|
|
32
32
|
|
|
33
33
|
// Packages legitimes avec noms similaires
|
|
34
34
|
'acorn', 'acorn-walk', 'js-yaml', 'cross-env', 'node-fetch', 'node-gyp',
|
|
@@ -42,7 +42,24 @@ const WHITELIST = [
|
|
|
42
42
|
'depd', 'destroy', 'encodeurl', 'escape-html', 'fresh', 'merge-descriptors',
|
|
43
43
|
'methods', 'on-finished', 'parseurl', 'path-to-regexp', 'proxy-addr',
|
|
44
44
|
'range-parser', 'safe-buffer', 'safer-buffer', 'setprototypeof',
|
|
45
|
-
'statuses', 'type-is', 'unpipe', 'utils-merge'
|
|
45
|
+
'statuses', 'type-is', 'unpipe', 'utils-merge',
|
|
46
|
+
|
|
47
|
+
// Packages CLI et outils legitimes
|
|
48
|
+
'jest-cli', 'prettier-2', 'prettier-1', 'eslint-cli',
|
|
49
|
+
'inquirer', 'enquirer', 'prompts',
|
|
50
|
+
'mysql2', 'pg-native', 'sqlite3', 'better-sqlite3',
|
|
51
|
+
'node-sass', 'sass', 'less',
|
|
52
|
+
'esbuild', 'rollup', 'parcel', 'vite',
|
|
53
|
+
'husky', 'lint-staged', 'commitlint',
|
|
54
|
+
'nodemon', 'pm2', 'forever', 'concurrently',
|
|
55
|
+
'lerna', 'turbo', 'nx',
|
|
56
|
+
'chalk', 'colors', 'picocolors', 'colorette',
|
|
57
|
+
'commander', 'yargs', 'meow', 'cac',
|
|
58
|
+
'execa', 'shelljs', 'cross-spawn',
|
|
59
|
+
'rimraf', 'del', 'trash-cli',
|
|
60
|
+
'globby', 'fast-glob', 'tiny-glob',
|
|
61
|
+
'chokidar', 'watchpack', 'nsfw',
|
|
62
|
+
'dotenv', 'dotenv-expand', 'env-cmd'
|
|
46
63
|
];
|
|
47
64
|
|
|
48
65
|
// Seuil minimum de longueur pour eviter faux positifs
|
|
@@ -86,8 +103,10 @@ async function scanTyposquatting(targetPath) {
|
|
|
86
103
|
}
|
|
87
104
|
|
|
88
105
|
function findTyposquatMatch(name) {
|
|
106
|
+
const nameLower = name.toLowerCase();
|
|
107
|
+
|
|
89
108
|
// Ignore les packages whitelistes
|
|
90
|
-
if (WHITELIST.includes(
|
|
109
|
+
if (WHITELIST.includes(nameLower)) return null;
|
|
91
110
|
|
|
92
111
|
// Ignore les packages scoped (@org/package)
|
|
93
112
|
if (name.startsWith('@')) return null;
|
|
@@ -95,14 +114,17 @@ function findTyposquatMatch(name) {
|
|
|
95
114
|
// Ignore les packages tres courts (trop de faux positifs)
|
|
96
115
|
if (name.length < MIN_PACKAGE_LENGTH) return null;
|
|
97
116
|
|
|
117
|
+
// Ignore les packages avec suffixes legitimes courants
|
|
118
|
+
if (isLegitimateVariant(nameLower)) return null;
|
|
119
|
+
|
|
98
120
|
for (const popular of POPULAR_PACKAGES) {
|
|
99
121
|
// Ignore si c'est exactement le meme
|
|
100
|
-
if (
|
|
122
|
+
if (nameLower === popular.toLowerCase()) continue;
|
|
101
123
|
|
|
102
124
|
// Ignore si le package populaire est trop court
|
|
103
125
|
if (popular.length < MIN_PACKAGE_LENGTH) continue;
|
|
104
126
|
|
|
105
|
-
const distance = levenshteinDistance(
|
|
127
|
+
const distance = levenshteinDistance(nameLower, popular.toLowerCase());
|
|
106
128
|
|
|
107
129
|
// Distance de 1 = tres suspect (une seule lettre de difference)
|
|
108
130
|
if (distance === 1) {
|
|
@@ -121,25 +143,45 @@ function findTyposquatMatch(name) {
|
|
|
121
143
|
distance: distance
|
|
122
144
|
};
|
|
123
145
|
}
|
|
124
|
-
|
|
125
|
-
// Verifie les tricks de suffixe
|
|
126
|
-
if (isSuffixTrick(name, popular)) {
|
|
127
|
-
return {
|
|
128
|
-
original: popular,
|
|
129
|
-
type: 'suffix_trick',
|
|
130
|
-
distance: distance
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
146
|
}
|
|
134
147
|
|
|
135
148
|
return null;
|
|
136
149
|
}
|
|
137
150
|
|
|
151
|
+
function isLegitimateVariant(name) {
|
|
152
|
+
// Suffixes legitimes qui ne sont PAS du typosquatting
|
|
153
|
+
const legitimateSuffixes = [
|
|
154
|
+
'-cli', '-core', '-utils', '-plugin', '-loader', '-webpack',
|
|
155
|
+
'-react', '-vue', '-angular', '-node', '-browser',
|
|
156
|
+
'-esm', '-cjs', '-umd',
|
|
157
|
+
'-types', '-typings',
|
|
158
|
+
'2', '3', '4', '5', // versions majeures (mysql2, etc)
|
|
159
|
+
'-v2', '-v3', '-next', '-latest', '-stable', '-lts'
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
for (const suffix of legitimateSuffixes) {
|
|
163
|
+
if (name.endsWith(suffix)) return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Prefixes legitimes
|
|
167
|
+
const legitimatePrefixes = [
|
|
168
|
+
'@types/', '@babel/', '@jest/', '@testing-library/',
|
|
169
|
+
'eslint-plugin-', 'eslint-config-',
|
|
170
|
+
'babel-plugin-', 'babel-preset-',
|
|
171
|
+
'webpack-plugin-', 'rollup-plugin-', 'vite-plugin-'
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
for (const prefix of legitimatePrefixes) {
|
|
175
|
+
if (name.startsWith(prefix)) return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
138
181
|
function detectTyposquatType(typo, original) {
|
|
139
182
|
if (typo.length === original.length - 1) return 'missing_char';
|
|
140
183
|
if (typo.length === original.length + 1) return 'extra_char';
|
|
141
184
|
if (typo.length === original.length) {
|
|
142
|
-
// Verifie si swap
|
|
143
185
|
let diffs = 0;
|
|
144
186
|
for (let i = 0; i < typo.length; i++) {
|
|
145
187
|
if (typo[i] !== original[i]) diffs++;
|
|
@@ -150,25 +192,6 @@ function detectTyposquatType(typo, original) {
|
|
|
150
192
|
return 'unknown';
|
|
151
193
|
}
|
|
152
194
|
|
|
153
|
-
function isSuffixTrick(name, popular) {
|
|
154
|
-
const nameLower = name.toLowerCase();
|
|
155
|
-
const popularLower = popular.toLowerCase();
|
|
156
|
-
|
|
157
|
-
const suffixes = ['-js', '.js', '-node', '-npm', '-cli', '-api', '-lib', '-pkg', '-dev', '-pro'];
|
|
158
|
-
for (const suffix of suffixes) {
|
|
159
|
-
if (nameLower === popularLower + suffix) return true;
|
|
160
|
-
if (nameLower === popularLower.replace('-', '') + suffix) return true;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Verifie aussi les prefixes
|
|
164
|
-
const prefixes = ['node-', 'npm-', 'js-', 'get-', 'the-'];
|
|
165
|
-
for (const prefix of prefixes) {
|
|
166
|
-
if (nameLower === prefix + popularLower) return true;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return false;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
195
|
function levenshteinDistance(a, b) {
|
|
173
196
|
const matrix = [];
|
|
174
197
|
|