icarus-cmd 0.3.1 → 1.0.1
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 +12 -3
- package/scripts/postinstall.mjs +0 -2
- package/src/core/codescan.js +3 -15
- package/src/core/detect.js +3 -12
- package/src/core/registry.js +3 -9
- package/src/core/resolvers.js +3 -14
- package/src/core/runner.js +0 -12
- package/src/core/setup.js +1 -7
- package/src/core/severity.js +0 -5
- package/src/core/store.js +0 -6
- package/src/core/target.js +0 -2
- package/src/index.js +15 -27
- package/src/report/html.js +0 -3
- package/src/tools/dalfox.js +2 -2
- package/src/tools/gobuster.js +1 -3
- package/src/tools/httpx.js +0 -1
- package/src/tools/metasploit.js +0 -1
- package/src/tools/naabu.js +0 -3
- package/src/tools/nuclei.js +1 -1
- package/src/tools/sqlmap.js +1 -2
- package/src/tools/zap.js +1 -6
- package/src/ui/anim.js +1 -7
- package/src/ui/banner.js +2 -4
- package/src/ui/logo.js +0 -1
- package/src/ui/results.js +0 -2
- package/src/web/server.js +12 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "icarus-cmd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Icarus — plateforme de pentest tout-en-un : nmap, nuclei, sqlmap, ZAP, ffuf, katana & co regroupés. CLI + TUI + interface web locale, analyse de code (SAST), base de résultats commune et rapports.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,8 +24,17 @@
|
|
|
24
24
|
"node": ">=20"
|
|
25
25
|
},
|
|
26
26
|
"keywords": [
|
|
27
|
-
"pentest",
|
|
28
|
-
"
|
|
27
|
+
"pentest",
|
|
28
|
+
"security",
|
|
29
|
+
"sast",
|
|
30
|
+
"nuclei",
|
|
31
|
+
"nmap",
|
|
32
|
+
"sqlmap",
|
|
33
|
+
"ffuf",
|
|
34
|
+
"recon",
|
|
35
|
+
"vulnerability-scanner",
|
|
36
|
+
"cli",
|
|
37
|
+
"infosec"
|
|
29
38
|
],
|
|
30
39
|
"author": "Icarus",
|
|
31
40
|
"license": "MIT",
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// Message d'après-installation. AUCUN téléchargement ici (mauvaise pratique +
|
|
2
|
-
// certains outils demandent l'admin) : on guide juste l'utilisateur.
|
|
3
1
|
const c = (s, n) => `\x1b[${n}m${s}\x1b[0m`;
|
|
4
2
|
console.log('');
|
|
5
3
|
console.log(' ' + c('Icarus installé.', '1') + ' Étape suivante :');
|
package/src/core/codescan.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// Analyse statique (SAST) légère : parcourt un fichier ou un dossier et applique
|
|
2
|
-
// des règles regex par langage pour repérer des motifs de vulnérabilités
|
|
3
|
-
// classiques (injection, secrets en dur, crypto faible, désérialisation…).
|
|
4
|
-
// Zéro dépendance : chaque règle = { id, severity, exts, re, title }.
|
|
5
1
|
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
6
2
|
import { join, extname, basename, relative } from 'node:path';
|
|
7
3
|
|
|
@@ -14,20 +10,18 @@ const TEXT_EXTS = new Set([
|
|
|
14
10
|
'.go', '.cs', '.c', '.cpp', '.h', '.sh', '.bash', '.ps1', '.sql', '.html',
|
|
15
11
|
'.env', '.yml', '.yaml', '.json', '.xml', '.ini', '.conf', '.cfg', '.txt',
|
|
16
12
|
]);
|
|
17
|
-
const MAX_FILE = 1.5 * 1024 * 1024;
|
|
13
|
+
const MAX_FILE = 1.5 * 1024 * 1024;
|
|
18
14
|
|
|
19
|
-
const ANY = null;
|
|
15
|
+
const ANY = null;
|
|
20
16
|
|
|
21
|
-
// exts = liste d'extensions ('.js') ou ANY ; re testée ligne par ligne.
|
|
22
17
|
const RULES = [
|
|
23
|
-
|
|
18
|
+
|
|
24
19
|
{ id: 'secret-aws-key', severity: 'critical', exts: ANY, title: 'Cle AWS Access Key en dur', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
25
20
|
{ id: 'secret-private-key', severity: 'critical', exts: ANY, title: 'Cle privee en dur', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ },
|
|
26
21
|
{ id: 'secret-generic', severity: 'high', exts: ANY, title: 'Secret/mot de passe en dur', re: /\b(?:api[_-]?key|secret|passwd|password|token|access[_-]?key|client[_-]?secret)\b\s*[:=]\s*['"][^'"\s]{8,}['"]/i },
|
|
27
22
|
{ id: 'secret-bearer', severity: 'high', exts: ANY, title: 'Token Bearer en dur', re: /bearer\s+[a-z0-9._\-]{20,}/i },
|
|
28
23
|
{ id: 'secret-slack', severity: 'high', exts: ANY, title: 'Token Slack en dur', re: /xox[baprs]-[0-9A-Za-z-]{10,}/ },
|
|
29
24
|
|
|
30
|
-
// ---- Injection de commande / code ----
|
|
31
25
|
{ id: 'js-eval', severity: 'high', exts: ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'], title: 'eval() — execution de code', re: /\beval\s*\(/ },
|
|
32
26
|
{ id: 'js-new-function', severity: 'high', exts: ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'], title: 'new Function() — execution dynamique', re: /\bnew\s+Function\s*\(/ },
|
|
33
27
|
{ id: 'js-child-exec', severity: 'high', exts: ['.js', '.mjs', '.cjs', '.ts'], title: 'child_process.exec avec variable — injection cmd', re: /\bexec(?:Sync)?\s*\(\s*[`'"][^`'"]*\$\{|\bexec(?:Sync)?\s*\([^)]*\+/ },
|
|
@@ -36,29 +30,24 @@ const RULES = [
|
|
|
36
30
|
{ id: 'py-subprocess-shell', severity: 'high', exts: ['.py'], title: 'subprocess shell=True — injection commande', re: /subprocess\.[a-z]+\([^)]*shell\s*=\s*True/ },
|
|
37
31
|
{ id: 'php-cmd', severity: 'high', exts: ['.php'], title: 'system/exec/shell_exec/passthru — injection commande', re: /\b(?:system|exec|shell_exec|passthru|popen|proc_open)\s*\(/ },
|
|
38
32
|
|
|
39
|
-
// ---- Injection SQL ----
|
|
40
33
|
{ id: 'sql-concat', severity: 'high', exts: ['.js', '.mjs', '.cjs', '.ts', '.py', '.php', '.java', '.rb', '.cs'], title: 'Requete SQL concatenee — risque SQLi', re: /(?:select|insert|update|delete)\b[^;'"]*['"`]\s*(?:\+|\.|%|\$\{|f")/i },
|
|
41
34
|
{ id: 'php-sql-super', severity: 'high', exts: ['.php'], title: 'Variable $_GET/$_POST dans requete SQL', re: /(?:query|mysqli?_query|->query)\s*\([^)]*\$_(?:GET|POST|REQUEST|COOKIE)/i },
|
|
42
35
|
|
|
43
|
-
// ---- XSS ----
|
|
44
36
|
{ id: 'js-innerhtml', severity: 'medium', exts: ['.js', '.ts', '.tsx', '.jsx', '.html'], title: 'innerHTML/outerHTML — risque XSS', re: /\.(?:inner|outer)HTML\s*=/ },
|
|
45
37
|
{ id: 'react-dangerously', severity: 'medium', exts: ['.jsx', '.tsx', '.js', '.ts'], title: 'dangerouslySetInnerHTML — risque XSS', re: /dangerouslySetInnerHTML/ },
|
|
46
38
|
{ id: 'js-doc-write', severity: 'medium', exts: ['.js', '.ts', '.html'], title: 'document.write — risque XSS', re: /document\.write\s*\(/ },
|
|
47
39
|
{ id: 'php-echo-super', severity: 'medium', exts: ['.php'], title: 'echo direct de $_GET/$_POST — XSS', re: /echo\s+[^;]*\$_(?:GET|POST|REQUEST)/i },
|
|
48
40
|
|
|
49
|
-
// ---- Deserialisation / parsing dangereux ----
|
|
50
41
|
{ id: 'py-pickle', severity: 'high', exts: ['.py'], title: 'pickle.loads — deserialisation non sure', re: /pickle\.loads?\s*\(/ },
|
|
51
42
|
{ id: 'py-yaml-load', severity: 'high', exts: ['.py'], title: 'yaml.load sans SafeLoader', re: /yaml\.load\s*\((?![^)]*Safe)/ },
|
|
52
43
|
{ id: 'php-unserialize', severity: 'high', exts: ['.php'], title: 'unserialize() — deserialisation non sure', re: /\bunserialize\s*\(/ },
|
|
53
44
|
|
|
54
|
-
// ---- TLS / crypto ----
|
|
55
45
|
{ id: 'tls-node-reject', severity: 'high', exts: ['.js', '.mjs', '.cjs', '.ts'], title: 'rejectUnauthorized:false — TLS non verifie', re: /rejectUnauthorized\s*:\s*false/ },
|
|
56
46
|
{ id: 'tls-py-verify', severity: 'high', exts: ['.py'], title: 'verify=False — TLS non verifie', re: /verify\s*=\s*False/ },
|
|
57
47
|
{ id: 'tls-curl-insecure', severity: 'medium', exts: ANY, title: 'curl -k / --insecure — TLS non verifie', re: /curl\s+[^\n]*(?:\s-k\b|--insecure)/ },
|
|
58
48
|
{ id: 'crypto-weak', severity: 'medium', exts: ANY, title: 'Hash faible (MD5/SHA1)', re: /\b(?:md5|sha1)\s*\(/i },
|
|
59
49
|
{ id: 'js-math-random', severity: 'low', exts: ['.js', '.ts'], title: 'Math.random() — non cryptographique', re: /Math\.random\s*\(\)/ },
|
|
60
50
|
|
|
61
|
-
// ---- Divers ----
|
|
62
51
|
{ id: 'flask-debug', severity: 'medium', exts: ['.py'], title: 'Flask debug=True — execution de code', re: /\.run\s*\([^)]*debug\s*=\s*True/ },
|
|
63
52
|
{ id: 'cors-wildcard', severity: 'low', exts: ANY, title: 'CORS Access-Control-Allow-Origin: *', re: /Access-Control-Allow-Origin['"\s:]+\*/ },
|
|
64
53
|
];
|
|
@@ -109,7 +98,6 @@ function scanFile(file, root) {
|
|
|
109
98
|
return findings;
|
|
110
99
|
}
|
|
111
100
|
|
|
112
|
-
// Analyse un fichier ou un dossier. Renvoie { findings, files, root }.
|
|
113
101
|
export function analyzeCode(target) {
|
|
114
102
|
if (!existsSync(target)) return { error: `Chemin introuvable : ${target}`, findings: [], files: 0 };
|
|
115
103
|
const st = statSync(target);
|
package/src/core/detect.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// Détection d'un outil : on le cherche dans le PATH (via `where`/`which`),
|
|
2
|
-
// puis dans une liste de chemins d'installation connus (utile sous Windows où
|
|
3
|
-
// nmap/ZAP n'ajoutent pas toujours leur dossier au PATH).
|
|
4
|
-
|
|
5
1
|
import { existsSync } from 'node:fs';
|
|
6
2
|
import { execFileSync } from 'node:child_process';
|
|
7
3
|
import { join, dirname } from 'node:path';
|
|
@@ -9,7 +5,6 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
5
|
|
|
10
6
|
const cache = new Map();
|
|
11
7
|
|
|
12
|
-
// Dossier où Icarus installe les binaires précompilés (install-tools.ps1).
|
|
13
8
|
const BUNDLED_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'tools', 'bin');
|
|
14
9
|
|
|
15
10
|
function whichBin(name) {
|
|
@@ -25,29 +20,25 @@ function whichBin(name) {
|
|
|
25
20
|
}
|
|
26
21
|
}
|
|
27
22
|
|
|
28
|
-
// Renvoie le chemin résolu du binaire d'un outil, ou null si introuvable.
|
|
29
23
|
export function resolveBin(tool, { fresh = false } = {}) {
|
|
30
24
|
if (!fresh && cache.has(tool.id)) return cache.get(tool.id);
|
|
31
25
|
|
|
32
26
|
const candidates = [];
|
|
33
|
-
|
|
27
|
+
|
|
34
28
|
for (const b of tool.bins || []) {
|
|
35
29
|
const local = join(BUNDLED_DIR, b.endsWith('.exe') ? b : `${b}.exe`);
|
|
36
30
|
if (existsSync(local)) candidates.push(local);
|
|
37
31
|
}
|
|
38
|
-
|
|
32
|
+
|
|
39
33
|
for (const p of tool.winPaths || []) {
|
|
40
34
|
if (existsSync(p)) candidates.push(p);
|
|
41
35
|
}
|
|
42
|
-
|
|
36
|
+
|
|
43
37
|
for (const b of tool.bins || []) {
|
|
44
38
|
const r = whichBin(b);
|
|
45
39
|
if (r) candidates.push(r);
|
|
46
40
|
}
|
|
47
41
|
|
|
48
|
-
// Certains outils partagent un nom de binaire avec un autre logiciel
|
|
49
|
-
// (ex: le scanner httpx de ProjectDiscovery vs la lib Python httpx).
|
|
50
|
-
// tool.verify(bin) permet d'écarter le mauvais.
|
|
51
42
|
let found = null;
|
|
52
43
|
for (const c of candidates) {
|
|
53
44
|
if (typeof tool.verify === 'function' && !tool.verify(c)) continue;
|
package/src/core/registry.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// Registre central des outils. Pour ajouter un outil : créer src/tools/xxx.js,
|
|
2
|
-
// l'importer ici et l'ajouter à TOOLS. Tout le reste (TUI, scans, rapports,
|
|
3
|
-
// profils) le prend en compte automatiquement.
|
|
4
|
-
|
|
5
1
|
import { resolveBin } from './detect.js';
|
|
6
2
|
|
|
7
3
|
import nmap from '../tools/nmap.js';
|
|
@@ -22,13 +18,11 @@ import zap from '../tools/zap.js';
|
|
|
22
18
|
import metasploit from '../tools/metasploit.js';
|
|
23
19
|
|
|
24
20
|
export const TOOLS = [
|
|
25
|
-
nmap, naabu, subfinder, httpx, katana, whatweb, wafw00f,
|
|
26
|
-
nuclei, metasploit,
|
|
27
|
-
ffuf, gobuster, nikto, sqlmap, dalfox, wpscan, zap,
|
|
21
|
+
nmap, naabu, subfinder, httpx, katana, whatweb, wafw00f,
|
|
22
|
+
nuclei, metasploit,
|
|
23
|
+
ffuf, gobuster, nikto, sqlmap, dalfox, wpscan, zap,
|
|
28
24
|
];
|
|
29
25
|
|
|
30
|
-
// Profils = enchaînements d'outils par objectif. Le profil ne lance que les
|
|
31
|
-
// outils réellement installés (les autres sont ignorés silencieusement).
|
|
32
26
|
export const PROFILES = {
|
|
33
27
|
recon: { label: 'Reconnaissance', tools: ['nmap', 'naabu', 'subfinder', 'httpx', 'katana', 'whatweb', 'wafw00f'] },
|
|
34
28
|
web: { label: 'Application web', tools: ['httpx', 'whatweb', 'katana', 'ffuf', 'gobuster', 'nikto', 'sqlmap', 'dalfox', 'wpscan', 'zap'] },
|
package/src/core/resolvers.js
CHANGED
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
// Résolveurs DNS imposés aux outils ProjectDiscovery (nuclei, httpx, katana,
|
|
2
|
-
// subfinder). Leur résolveur Go interne échoue souvent ("no address found for
|
|
3
|
-
// host"), surtout sous VPN (Mullvad force son propre DNS et bloque les autres) :
|
|
4
|
-
// du coup l'appli trouve 0 vuln alors que tout marche par ailleurs.
|
|
5
|
-
//
|
|
6
|
-
// On génère donc un fichier -resolvers à partir du DNS SYSTÈME ACTIF
|
|
7
|
-
// (dns.getServers() — le primaire en tête, c'est celui que l'OS utilise et qui
|
|
8
|
-
// marche). Deux pièges Windows gérés ici :
|
|
9
|
-
// 1) nuclie ne lit le fichier qu'en fins de ligne CRLF ;
|
|
10
|
-
// 2) son chemin ne doit pas contenir d'espaces (le projet vit dans
|
|
11
|
-
// "…/pentest tool/…") -> on écrit dans tmp/home.
|
|
12
1
|
import { dirname, join } from 'node:path';
|
|
13
2
|
import { fileURLToPath } from 'node:url';
|
|
14
3
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -33,9 +22,9 @@ function buildList() {
|
|
|
33
22
|
}
|
|
34
23
|
} catch { /* ignore */ }
|
|
35
24
|
// Le primaire SEUL : c'est le DNS que l'OS utilise (donc joignable même sous
|
|
36
|
-
|
|
25
|
+
|
|
37
26
|
if (out.length) return out.slice(0, 1);
|
|
38
|
-
|
|
27
|
+
|
|
39
28
|
try { return readFileSync(BUNDLED, 'utf8').split(/\r?\n/).map((l) => l.trim()).filter(Boolean); }
|
|
40
29
|
catch { return []; }
|
|
41
30
|
}
|
|
@@ -45,7 +34,7 @@ try {
|
|
|
45
34
|
const dst = pickPath();
|
|
46
35
|
const list = buildList();
|
|
47
36
|
if (dst && list.length) {
|
|
48
|
-
writeFileSync(dst, list.join('\r\n') + '\r\n');
|
|
37
|
+
writeFileSync(dst, list.join('\r\n') + '\r\n');
|
|
49
38
|
resolved = dst;
|
|
50
39
|
}
|
|
51
40
|
} catch { resolved = ''; }
|
package/src/core/runner.js
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// Lance un outil, streame sa sortie en direct dans le terminal, capture tout,
|
|
2
|
-
// puis délègue au parser de l'outil pour produire des findings normalisés.
|
|
3
|
-
|
|
4
1
|
import { spawn } from 'node:child_process';
|
|
5
2
|
import { mkdtempSync } from 'node:fs';
|
|
6
3
|
import { tmpdir } from 'node:os';
|
|
@@ -8,8 +5,6 @@ import { join } from 'node:path';
|
|
|
8
5
|
import { resolveBin } from './detect.js';
|
|
9
6
|
import { recordScan, addFindings } from './store.js';
|
|
10
7
|
|
|
11
|
-
// Exécute un binaire et renvoie { code, stdout, stderr, timedOut }.
|
|
12
|
-
// timeoutMs > 0 : tue le process s'il dépasse le délai.
|
|
13
8
|
export function runProcess(cmd, args, { onData, cwd, env, timeoutMs = 0, shell = false } = {}) {
|
|
14
9
|
return new Promise((resolve) => {
|
|
15
10
|
let stdout = '';
|
|
@@ -37,8 +32,6 @@ export function runProcess(cmd, args, { onData, cwd, env, timeoutMs = 0, shell =
|
|
|
37
32
|
});
|
|
38
33
|
}
|
|
39
34
|
|
|
40
|
-
// Orchestration d'un outil de bout en bout : détection -> commande -> run ->
|
|
41
|
-
// parse -> stockage. Renvoie { ok, findings, code, error }.
|
|
42
35
|
export async function runTool(tool, target, { opts = {}, onData, onLog } = {}) {
|
|
43
36
|
const log = onLog || (() => {});
|
|
44
37
|
const bin = resolveBin(tool);
|
|
@@ -54,11 +47,6 @@ export async function runTool(tool, target, { opts = {}, onData, onLog } = {}) {
|
|
|
54
47
|
let args = built.args || [];
|
|
55
48
|
let shell = false;
|
|
56
49
|
|
|
57
|
-
// Sous Windows, Node ≥20 refuse de spawn directement un .bat/.cmd
|
|
58
|
-
// (EINVAL, correctif CVE-2024-27980). On reconstruit une ligne de commande
|
|
59
|
-
// correctement quotée et on laisse cmd.exe l'exécuter via shell:true.
|
|
60
|
-
// Les outils qui définissent déjà built.cmd (ex. metasploit via `cmd /c`)
|
|
61
|
-
// gèrent leur propre invocation et ne sont pas touchés.
|
|
62
50
|
if (process.platform === 'win32' && !built.cmd && /\.(bat|cmd)$/i.test(cmd)) {
|
|
63
51
|
const q = (s) => (/[\s"&()[\]{}^=;!'+,`~%<>|]/.test(String(s))
|
|
64
52
|
? `"${String(s).replace(/"/g, '""')}"`
|
package/src/core/setup.js
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
// `icarus setup` — télécharge les binaires précompilés (releases GitHub) des
|
|
2
|
-
// outils Go et les place dans tools/bin. Cross-platform (Windows/Linux/macOS) :
|
|
3
|
-
// détecte OS+arch, choisit le bon asset, extrait via `tar` (présent par défaut
|
|
4
|
-
// sur Win10+, Linux, macOS). Les outils à runtime (nmap, ZAP, Metasploit,
|
|
5
|
-
// nikto, wpscan…) ne sont pas gérés ici : `icarus doctor` indique comment.
|
|
6
1
|
import { mkdtempSync, mkdirSync, existsSync, copyFileSync, readdirSync, createWriteStream, rmSync } from 'node:fs';
|
|
7
2
|
import { tmpdir } from 'node:os';
|
|
8
3
|
import { join, dirname } from 'node:path';
|
|
@@ -45,7 +40,6 @@ async function download(url, dest) {
|
|
|
45
40
|
await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
|
|
46
41
|
}
|
|
47
42
|
|
|
48
|
-
// Installe un outil. Renvoie true/false. log(msg) pour le suivi.
|
|
49
43
|
async function installTool(t, keys, tmp, log) {
|
|
50
44
|
const rel = await ghJson(`https://api.github.com/repos/${t.repo}/releases/latest`);
|
|
51
45
|
const asset = (rel.assets || []).find((x) =>
|
|
@@ -92,7 +86,7 @@ export async function setupTools({ log = console.log } = {}) {
|
|
|
92
86
|
fail.push(t.id);
|
|
93
87
|
}
|
|
94
88
|
}
|
|
95
|
-
try { rmSync(tmp, { recursive: true, force: true }); } catch {
|
|
89
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch { }
|
|
96
90
|
|
|
97
91
|
log(`\n Installés (${ok.length}) : ${ok.join(', ') || '-'}`);
|
|
98
92
|
if (fail.length) log(` Échecs (${fail.length}) : ${fail.join(', ')}`);
|
package/src/core/severity.js
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
|
-
// Modèle de sévérité partagé par tous les outils, pour normaliser des sorties
|
|
2
|
-
// très hétérogènes (nuclei dit "high", ZAP dit "3", etc.) en une seule échelle.
|
|
3
|
-
|
|
4
1
|
export const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info', 'unknown'];
|
|
5
2
|
|
|
6
|
-
// Plus le rang est petit, plus c'est grave (utile pour trier).
|
|
7
3
|
export function rank(sev) {
|
|
8
4
|
const i = SEVERITIES.indexOf(String(sev || 'unknown').toLowerCase());
|
|
9
5
|
return i < 0 ? SEVERITIES.length : i;
|
|
10
6
|
}
|
|
11
7
|
|
|
12
|
-
// Normalise n'importe quelle étiquette de sévérité vers notre échelle.
|
|
13
8
|
export function normalize(sev) {
|
|
14
9
|
const s = String(sev || '').toLowerCase().trim();
|
|
15
10
|
if (['critical', 'crit', '4'].includes(s)) return 'critical';
|
package/src/core/store.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// Stockage des résultats : un simple fichier JSON (pas de dépendance native à
|
|
2
|
-
// compiler sous Windows). Tous les outils écrivent leurs "findings" normalisés
|
|
3
|
-
// au même endroit -> c'est ça, "tout regrouper".
|
|
4
|
-
|
|
5
1
|
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
6
2
|
import { dirname, join } from 'node:path';
|
|
7
3
|
import { fileURLToPath } from 'node:url';
|
|
@@ -29,7 +25,6 @@ function save(db) {
|
|
|
29
25
|
writeFileSync(DB_FILE, JSON.stringify(db, null, 2), 'utf8');
|
|
30
26
|
}
|
|
31
27
|
|
|
32
|
-
// Enregistre une exécution d'outil et renvoie son id.
|
|
33
28
|
export function recordScan({ tool, target, code, command }) {
|
|
34
29
|
const db = load();
|
|
35
30
|
const scan = {
|
|
@@ -45,7 +40,6 @@ export function recordScan({ tool, target, code, command }) {
|
|
|
45
40
|
return scan.id;
|
|
46
41
|
}
|
|
47
42
|
|
|
48
|
-
// Ajoute des findings normalisés (le runner les enrichit avec scanId/tool/target).
|
|
49
43
|
export function addFindings(findings) {
|
|
50
44
|
if (!findings?.length) return [];
|
|
51
45
|
const db = load();
|
package/src/core/target.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// Helpers de cible partagés (TUI + Web).
|
|
2
|
-
|
|
3
1
|
export const toHost = (t) => String(t).replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
4
2
|
export const toUrl = (t) => (/^https?:\/\//.test(t) ? t : `http://${t}`);
|
|
5
3
|
export const effectiveTarget = (tool, target) => (tool.needs === 'url' ? toUrl(target) : toHost(target));
|
package/src/index.js
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Icarus — point d'entrée.
|
|
3
|
-
// (sans argument) -> TUI interactive
|
|
4
|
-
// doctor -> état des outils (installé / manquant)
|
|
5
|
-
// scan <cible> [options] -> scan non interactif (scriptable)
|
|
6
|
-
// report -> rapport HTML + JSON depuis les résultats
|
|
7
|
-
// help -> aide
|
|
8
|
-
|
|
9
2
|
import chalk from 'chalk';
|
|
10
3
|
import Table from 'cli-table3';
|
|
11
4
|
import { select, input, checkbox, confirm } from '@inquirer/prompts';
|
|
@@ -31,11 +24,9 @@ const effectiveTarget = (tool, target) => (tool.needs === 'url' ? toUrl(target)
|
|
|
31
24
|
function openBrowser(url) {
|
|
32
25
|
const cmd = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
33
26
|
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
34
|
-
try { spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref(); } catch {
|
|
27
|
+
try { spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref(); } catch { }
|
|
35
28
|
}
|
|
36
29
|
|
|
37
|
-
// ---------- Affichages partagés ----------
|
|
38
|
-
|
|
39
30
|
function statusTable({ fresh = false } = {}) {
|
|
40
31
|
const table = new Table({
|
|
41
32
|
head: ['Outil', 'Catégorie', 'État', 'Chemin / installation'].map((h) => chalk.bold(h)),
|
|
@@ -64,7 +55,6 @@ function summarize(findings) {
|
|
|
64
55
|
.join(' ');
|
|
65
56
|
}
|
|
66
57
|
|
|
67
|
-
// Résout une liste d'ids d'outils en gardant ceux installés, et signale les autres.
|
|
68
58
|
function resolveTools(ids) {
|
|
69
59
|
const avail = new Set(availableIds());
|
|
70
60
|
const run = ids.filter((id) => getTool(id) && avail.has(id));
|
|
@@ -72,8 +62,6 @@ function resolveTools(ids) {
|
|
|
72
62
|
return { run, skipped };
|
|
73
63
|
}
|
|
74
64
|
|
|
75
|
-
// ---------- Exécution d'un scan ----------
|
|
76
|
-
|
|
77
65
|
const FAST_OPTS = { fast: true, topPorts: '100', depth: 1, severity: 'critical,high', level: 1, risk: 1 };
|
|
78
66
|
|
|
79
67
|
async function runScan(target, toolIds, { opts = {}, quiet = false, parallel = false } = {}) {
|
|
@@ -116,8 +104,6 @@ async function runScan(target, toolIds, { opts = {}, quiet = false, parallel = f
|
|
|
116
104
|
return all;
|
|
117
105
|
}
|
|
118
106
|
|
|
119
|
-
// ---------- TUI interactive ----------
|
|
120
|
-
|
|
121
107
|
async function chooseTools(target) {
|
|
122
108
|
const mode = await select({
|
|
123
109
|
message: 'Comment choisir les outils ?',
|
|
@@ -242,8 +228,6 @@ async function tui() {
|
|
|
242
228
|
}
|
|
243
229
|
}
|
|
244
230
|
|
|
245
|
-
// ---------- CLI non interactive ----------
|
|
246
|
-
|
|
247
231
|
function parseFlags(args) {
|
|
248
232
|
const flags = {};
|
|
249
233
|
const positional = [];
|
|
@@ -307,11 +291,17 @@ async function main() {
|
|
|
307
291
|
const { flags } = parseFlags(rest);
|
|
308
292
|
const port = Number(flags.port) || 7373;
|
|
309
293
|
console.log(banner());
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
294
|
+
try {
|
|
295
|
+
const { url } = await startWeb({ port });
|
|
296
|
+
console.log(chalk.green('✓ Interface web Icarus lancée :') + ' ' + chalk.hex('#fb923c').bold(url));
|
|
297
|
+
console.log(chalk.gray(' (Ctrl+C pour arrêter)'));
|
|
298
|
+
if (!flags['no-open']) openBrowser(url);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
console.log(chalk.red(`✗ Serveur non démarré : ${err.message}`));
|
|
301
|
+
console.log(chalk.gray(' Essaie un autre port : icarus web --port 8080'));
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
315
305
|
}
|
|
316
306
|
|
|
317
307
|
if (cmd === 'report') {
|
|
@@ -338,9 +328,8 @@ async function main() {
|
|
|
338
328
|
return doScanCli(positional[0], flags);
|
|
339
329
|
}
|
|
340
330
|
|
|
341
|
-
// Cible "nue" : `icarus example.com`, ou `icarus ./fichier.js` -> analyse code
|
|
342
331
|
if (!cmd.startsWith('-')) {
|
|
343
|
-
if (existsSync(cmd)) return doCodeScan(cmd);
|
|
332
|
+
if (existsSync(cmd)) return doCodeScan(cmd);
|
|
344
333
|
const { flags } = parseFlags(rest);
|
|
345
334
|
return doScanCli(cmd, flags);
|
|
346
335
|
}
|
|
@@ -354,12 +343,11 @@ const SEV_COLOR = { critical: '#dc2626', high: '#f97316', medium: '#eab308', low
|
|
|
354
343
|
const SEV_ICON = { critical: ICON.critical, high: ICON.high, medium: ICON.medium, low: ICON.low, info: ICON.info, unknown: ICON.info };
|
|
355
344
|
const sevTag = (s) => chalk.hex(SEV_COLOR[s] || '#9ca3af').bold(`${SEV_ICON[s] || '•'} ${String(s || '?').toUpperCase()}`);
|
|
356
345
|
|
|
357
|
-
// Analyse statique du code (SAST) d'un fichier ou dossier local.
|
|
358
346
|
async function doCodeScan(target) {
|
|
359
347
|
console.log('\n' + rule(`ANALYSE CODE — ${target}`));
|
|
360
348
|
const spin = startSpinner(chalk.gray('analyse du code…'));
|
|
361
349
|
const res = analyzeCode(target);
|
|
362
|
-
await sleep(180);
|
|
350
|
+
await sleep(180);
|
|
363
351
|
spin.stop();
|
|
364
352
|
if (res.error) { console.log(chalk.red(` ${ICON.warn} ` + res.error)); process.exit(1); }
|
|
365
353
|
|
|
@@ -375,7 +363,7 @@ async function doCodeScan(target) {
|
|
|
375
363
|
for (const f of stored) {
|
|
376
364
|
console.log(' ' + sevTag(f.severity) + ' ' + chalk.bold(f.title));
|
|
377
365
|
console.log(' ' + chalk.gray(f.detail));
|
|
378
|
-
if (stagger) await sleep(35);
|
|
366
|
+
if (stagger) await sleep(35);
|
|
379
367
|
}
|
|
380
368
|
const counts = {};
|
|
381
369
|
for (const f of stored) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
package/src/report/html.js
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// Génère un rapport HTML autonome (un seul fichier, interactif) + un export JSON,
|
|
2
|
-
// à partir des findings et scans stockés.
|
|
3
|
-
|
|
4
1
|
import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
5
2
|
import { join } from 'node:path';
|
|
6
3
|
import { DATA_DIR } from '../core/store.js';
|
package/src/tools/dalfox.js
CHANGED
|
@@ -26,9 +26,9 @@ export default {
|
|
|
26
26
|
|
|
27
27
|
let items = [];
|
|
28
28
|
try {
|
|
29
|
-
items = JSON.parse(raw);
|
|
29
|
+
items = JSON.parse(raw);
|
|
30
30
|
} catch {
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
items = raw.split(/\r?\n/).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
33
33
|
}
|
|
34
34
|
return [].concat(items).map((p) => ({
|
package/src/tools/gobuster.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// gobuster : brute-force de dossiers/fichiers web. Parsing de la sortie texte.
|
|
2
|
-
|
|
3
1
|
import { existsSync } from 'node:fs';
|
|
4
2
|
import { join, dirname } from 'node:path';
|
|
5
3
|
import { fileURLToPath } from 'node:url';
|
|
@@ -27,7 +25,7 @@ export default {
|
|
|
27
25
|
|
|
28
26
|
parse({ stdout }) {
|
|
29
27
|
const findings = [];
|
|
30
|
-
|
|
28
|
+
|
|
31
29
|
const re = /^(\/\S*)\s+\(Status:\s*(\d+)\)(?:\s*\[Size:\s*(\d+)\])?/gim;
|
|
32
30
|
let m;
|
|
33
31
|
while ((m = re.exec(stdout)) !== null) {
|
package/src/tools/httpx.js
CHANGED
package/src/tools/metasploit.js
CHANGED
package/src/tools/naabu.js
CHANGED
|
@@ -10,9 +10,6 @@ export default {
|
|
|
10
10
|
winPaths: [],
|
|
11
11
|
install: 'go install github.com/projectdiscovery/naabu/v2/cmd/naabu@latest',
|
|
12
12
|
|
|
13
|
-
// Sous Windows, naabu dépend de Npcap (wpcap.dll). Sans elle, le binaire
|
|
14
|
-
// plante au lancement (spawn UNKNOWN). On le considère alors indisponible
|
|
15
|
-
// pour qu'Icarus l'ignore proprement (nmap couvre déjà le scan de ports).
|
|
16
13
|
verify() {
|
|
17
14
|
if (process.platform !== 'win32') return true;
|
|
18
15
|
return [
|
package/src/tools/nuclei.js
CHANGED
|
@@ -23,7 +23,7 @@ export default {
|
|
|
23
23
|
|
|
24
24
|
command(target, opts = {}) {
|
|
25
25
|
const args = ['-u', target, '-jsonl', '-silent', '-no-color'];
|
|
26
|
-
|
|
26
|
+
|
|
27
27
|
if (RESOLVERS_FILE) args.push('-resolvers', RESOLVERS_FILE);
|
|
28
28
|
if (opts.severity) args.push('-severity', opts.severity);
|
|
29
29
|
if (opts.tags) args.push('-tags', opts.tags);
|
package/src/tools/sqlmap.js
CHANGED
|
@@ -3,7 +3,7 @@ export default {
|
|
|
3
3
|
name: 'sqlmap',
|
|
4
4
|
category: 'web',
|
|
5
5
|
description: "Détection d'injections SQL",
|
|
6
|
-
needs: 'url',
|
|
6
|
+
needs: 'url',
|
|
7
7
|
bins: ['sqlmap', 'sqlmap.exe'],
|
|
8
8
|
winPaths: [],
|
|
9
9
|
install: 'pip install sqlmap (ou https://sqlmap.org)',
|
|
@@ -53,7 +53,6 @@ export default {
|
|
|
53
53
|
findings.push({ type: 'sqli', severity: 'info', title: 'Aucune injection SQL trouvée', detail: target, ref: 'sqlmap' });
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
// Bases de données dumpées avec --dbs
|
|
57
56
|
const dbBlock = stdout.match(/available databases \[\d+\]:([\s\S]*?)(?:\n\n|\n\[)/i);
|
|
58
57
|
if (dbBlock) {
|
|
59
58
|
const dbs = dbBlock[1].split(/\r?\n/).map((l) => l.replace(/^\s*\[\*\]\s*/, '').trim()).filter(Boolean);
|
package/src/tools/zap.js
CHANGED
|
@@ -4,8 +4,6 @@ import { normalize } from '../core/severity.js';
|
|
|
4
4
|
|
|
5
5
|
const RISK = { 3: 'high', 2: 'medium', 1: 'low', 0: 'info' };
|
|
6
6
|
|
|
7
|
-
// java.exe en chemin absolu : spawn(shell:false) avec un cwd custom ne résout
|
|
8
|
-
// pas un nom court ("java") -> ENOENT. On le localise via JAVA_HOME puis PATH.
|
|
9
7
|
function findJava() {
|
|
10
8
|
const exe = process.platform === 'win32' ? 'java.exe' : 'java';
|
|
11
9
|
const cands = [];
|
|
@@ -34,10 +32,7 @@ export default {
|
|
|
34
32
|
|
|
35
33
|
command(target, opts = {}) {
|
|
36
34
|
const report = join(opts.workdir, 'zap.json');
|
|
37
|
-
|
|
38
|
-
// un dossier temp, il ne trouve pas son .jar (échec instantané). On appelle
|
|
39
|
-
// donc java directement avec le .jar en chemin absolu, en exécutant depuis
|
|
40
|
-
// le dossier d'installation de ZAP (pour ses ressources).
|
|
35
|
+
|
|
41
36
|
const dir = opts.bin ? dirname(opts.bin) : '';
|
|
42
37
|
let jar = '';
|
|
43
38
|
try { jar = readdirSync(dir).find((f) => /^zap.*\.jar$/i.test(f)) || ''; } catch { /* ignore */ }
|
package/src/ui/anim.js
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
|
-
// Petites animations terminal : spinner live + helpers. Tout se désactive
|
|
2
|
-
// proprement hors TTY (sortie pipée / CI) pour ne pas polluer les logs.
|
|
3
1
|
import chalk from 'chalk';
|
|
4
2
|
|
|
5
3
|
const ESC = '\x1b';
|
|
6
4
|
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
7
5
|
|
|
8
|
-
// Jeu d'icônes — style "épuré" : chevron doré en préfixe de menu, points
|
|
9
|
-
// colorés pour les sévérités. Cohérent, rend partout (pas d'emoji).
|
|
10
6
|
export const GOLD = '#d4a843';
|
|
11
7
|
export const chevron = chalk.hex(GOLD)('❯');
|
|
12
8
|
export const ICON = {
|
|
13
9
|
chevron: '❯', dot: '●',
|
|
14
10
|
ok: '✓', miss: '✗', warn: '!', star: '✦', report: '❯',
|
|
15
|
-
|
|
11
|
+
|
|
16
12
|
critical: '●', high: '●', medium: '●', low: '●', info: '●',
|
|
17
13
|
};
|
|
18
14
|
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -22,7 +18,6 @@ const HIDE = `${ESC}[?25l`;
|
|
|
22
18
|
const SHOW = `${ESC}[?25h`;
|
|
23
19
|
const CLEARLINE = `\r${ESC}[2K`;
|
|
24
20
|
|
|
25
|
-
// Spinner avec lignes de résultat qui défilent au-dessus (spin.log).
|
|
26
21
|
export function startSpinner(text) {
|
|
27
22
|
if (!isTTY()) {
|
|
28
23
|
return { update() {}, log: (l) => console.log(l), stop: (l) => l && console.log(l) };
|
|
@@ -55,5 +50,4 @@ export async function revealLines(lines, { delay = 26, indent = '' } = {}) {
|
|
|
55
50
|
}
|
|
56
51
|
}
|
|
57
52
|
|
|
58
|
-
// Efface la ligne courante (utilisé pour nettoyer après un spinner inline).
|
|
59
53
|
export const clearLine = () => { if (isTTY()) process.stdout.write(CLEARLINE); };
|
package/src/ui/banner.js
CHANGED
|
@@ -5,8 +5,8 @@ import { isTTY, sleep, revealLines } from './anim.js';
|
|
|
5
5
|
const GOLD = '#d4a843';
|
|
6
6
|
const GOLD_HI = '#fff3cf';
|
|
7
7
|
const INDENT = ' ';
|
|
8
|
-
const CLR = '\r\x1b[2K';
|
|
9
|
-
|
|
8
|
+
const CLR = '\r\x1b[2K';
|
|
9
|
+
|
|
10
10
|
const PAD = ' '.repeat(Math.max(0, Math.round((LOGO.length ? 44 : 20) / 2) - 6));
|
|
11
11
|
|
|
12
12
|
function wordmark(highlight = -1) {
|
|
@@ -27,14 +27,12 @@ export function banner() {
|
|
|
27
27
|
);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
// Bannière animée : déploiement du logo + balayage lumineux sur ICARUS.
|
|
31
30
|
export async function intro() {
|
|
32
31
|
if (!isTTY()) { console.log(banner()); return; }
|
|
33
32
|
process.stdout.write('\n');
|
|
34
33
|
await revealLines(LOGO, { delay: 22, indent: INDENT });
|
|
35
34
|
process.stdout.write('\n');
|
|
36
35
|
|
|
37
|
-
// balayage lumineux lettre par lettre
|
|
38
36
|
for (let h = 0; h <= 6; h++) {
|
|
39
37
|
process.stdout.write(CLR + PAD + wordmark(h));
|
|
40
38
|
await sleep(55);
|
package/src/ui/logo.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// Généré par scripts/gen-logo.mjs depuis logo.png — ne pas éditer à la main.
|
|
2
1
|
export const LOGO = [
|
|
3
2
|
" \u001b[38;2;229;206;164m\u001b[48;2;238;209;162m▀\u001b[0m\u001b[38;2;222;196;153m▄\u001b[0m \u001b[38;2;210;181;134m▄\u001b[0m\u001b[38;2;204;176;130m\u001b[48;2;222;187;138m▀\u001b[0m ",
|
|
4
3
|
" \u001b[38;2;220;189;140m\u001b[48;2;213;181;135m▀\u001b[0m\u001b[38;2;235;202;153m\u001b[48;2;229;195;144m▀\u001b[0m\u001b[38;2;222;194;150m\u001b[48;2;231;197;147m▀\u001b[0m\u001b[38;2;222;191;145m▄\u001b[0m \u001b[38;2;208;177;130m▄\u001b[0m\u001b[38;2;210;179;132m\u001b[48;2;216;179;128m▀\u001b[0m\u001b[38;2;218;182;131m\u001b[48;2;213;177;126m▀\u001b[0m\u001b[38;2;205;171;124m\u001b[48;2;196;163;116m▀\u001b[0m ",
|
package/src/ui/results.js
CHANGED
package/src/web/server.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// Serveur web local d'Icarus : sert l'UI, expose une API REST et streame la
|
|
2
|
-
// sortie des scans en direct (NDJSON sur la réponse POST). Aucune dépendance
|
|
3
|
-
// externe : http natif uniquement.
|
|
4
|
-
|
|
5
1
|
import { createServer } from 'node:http';
|
|
6
2
|
import { readFileSync, existsSync, createReadStream } from 'node:fs';
|
|
7
3
|
import { join, dirname, extname, basename } from 'node:path';
|
|
@@ -30,7 +26,6 @@ function readBody(req) {
|
|
|
30
26
|
});
|
|
31
27
|
}
|
|
32
28
|
|
|
33
|
-
// Schéma "public" des outils (avec options) pour que l'UI génère ses contrôles.
|
|
34
29
|
function toolsState() {
|
|
35
30
|
return toolStatus({ fresh: true }).map(({ tool, bin, available }) => ({
|
|
36
31
|
id: tool.id,
|
|
@@ -127,7 +122,6 @@ export function startWeb({ port = 7373 } = {}) {
|
|
|
127
122
|
return serveStatic(res, f);
|
|
128
123
|
}
|
|
129
124
|
|
|
130
|
-
// Fichiers statiques de l'UI.
|
|
131
125
|
const rel = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\//, '');
|
|
132
126
|
return serveStatic(res, join(PUBLIC, basename(rel)));
|
|
133
127
|
} catch (err) {
|
|
@@ -135,7 +129,17 @@ export function startWeb({ port = 7373 } = {}) {
|
|
|
135
129
|
}
|
|
136
130
|
});
|
|
137
131
|
|
|
138
|
-
return new Promise((resolve) => {
|
|
139
|
-
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
let p = port;
|
|
134
|
+
let tries = 0;
|
|
135
|
+
server.on('error', (err) => {
|
|
136
|
+
if (err.code === 'EADDRINUSE' && tries++ < 15) {
|
|
137
|
+
server.listen(++p);
|
|
138
|
+
} else {
|
|
139
|
+
reject(err);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
server.once('listening', () => resolve({ server, url: `http://localhost:${p}`, port: p }));
|
|
143
|
+
server.listen(p);
|
|
140
144
|
});
|
|
141
145
|
}
|