@saulwade/swl-ses 1.4.1 → 1.4.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/CLAUDE.md +1 -1
- package/README.md +1 -1
- package/agentes/nemesis-auditor-swl.md +161 -161
- package/bin/swl-mcp-server.js +187 -187
- package/comandos/swl/.evolved.json +22 -22
- package/comandos/swl/contribuir.md +233 -233
- package/comandos/swl/nemesis.md +122 -122
- package/gateway/lib/event-channel.js +191 -191
- package/habilidades/backend-production-resilience/SKILL.md +288 -288
- package/habilidades/benchmark-memoria/SKILL.md +186 -186
- package/habilidades/diagrama-arquitectura/assets/template.html +276 -276
- package/habilidades/doubt-driven-review/SKILL.md +171 -171
- package/habilidades/doubt-driven-review/recursos/EXAMPLES.md +130 -130
- package/habilidades/eval-framework/SKILL.md +212 -212
- package/habilidades/feynman-auditor-swl/SKILL.md +123 -123
- package/habilidades/feynman-auditor-swl/recursos/preguntas-language-agnostic.md +108 -108
- package/habilidades/harness-claude-code/SKILL.md +299 -299
- package/habilidades/infra-github-actions/SKILL.md +166 -166
- package/habilidades/legacy-code-rescue/SKILL.md +267 -267
- package/habilidades/manejo-errores/.evolved.json +8 -8
- package/habilidades/meta-skills-estandar/recursos/convencion-examples.md +93 -93
- package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +163 -163
- package/habilidades/patrones-python/SKILL.md +229 -229
- package/habilidades/patrones-python/recursos/patrones-avanzados.md +469 -469
- package/habilidades/planear-fase/SKILL.md +319 -319
- package/habilidades/release-semver/.evolved.json +8 -8
- package/habilidades/state-inconsistency-auditor-swl/SKILL.md +166 -166
- package/habilidades/state-inconsistency-auditor-swl/recursos/coupled-state-patterns.md +147 -147
- package/habilidades/testing-python/SKILL.md +340 -340
- package/habilidades/web-fetcher-routing/SKILL.md +75 -75
- package/hooks/claudemd-bloat-detector.js +161 -161
- package/hooks/lib/agent-routing.js +107 -107
- package/hooks/lib/auto-consolidator.js +335 -335
- package/hooks/lib/error-classifier.js +308 -308
- package/hooks/lib/merkle-audit.js +96 -96
- package/hooks/lib/provenance-tracker.js +191 -191
- package/hooks/lib/rate-limit-tracker.js +253 -253
- package/hooks/lib/resource-quota.js +122 -122
- package/hooks/lib/retry-jitter.js +165 -165
- package/hooks/lib/security-net.js +201 -201
- package/hooks/lib/skill-auditor.js +588 -588
- package/hooks/lib/sync-status.js +228 -228
- package/hooks/lib/taint-tracker.js +107 -107
- package/hooks/lib/text-similarity.js +241 -241
- package/hooks/lib/toon-compressor.js +245 -245
- package/hooks/registro-turnos.js +209 -209
- package/hooks/sugerir-regenerar-inventario.js +170 -170
- package/hooks/validar-formato-post-subagente.js +140 -140
- package/hooks/validar-memoria-hook.js +218 -218
- package/instintos/prompt-appendices.yaml +57 -57
- package/manifiestos/agent-output-schemas.json +57 -57
- package/manifiestos/modulos.json +11 -6
- package/manifiestos/perfiles.json +2 -1
- package/manifiestos/skills-lock.json +1114 -1114
- package/package.json +1 -1
- package/plantillas/auditor-veto-template.md +105 -105
- package/plantillas/github-workflows/README.md +47 -47
- package/plantillas/github-workflows/release-please.yml +44 -44
- package/plantillas/github-workflows/swl-ci.yml +107 -107
- package/plantillas/github-workflows/swl-security.yml +51 -51
- package/plugin.json +9 -1
- package/reglas/analisis-previo-tareas-grandes.md +172 -172
- package/reglas/arreglar-al-detectar.md +147 -147
- package/reglas/fragmentos-compartidos.md +152 -152
- package/reglas/harness-claude-code.md +213 -213
- package/reglas/usar-context7.md +226 -226
- package/schemas/diary-entry.schema.json +80 -80
- package/scripts/audit-tools/audit-history.js +330 -330
- package/scripts/audit-tools/bundle-tracker.js +290 -290
- package/scripts/audit-tools/canary-monitor.js +352 -352
- package/scripts/audit-tools/code-profiler.js +605 -605
- package/scripts/audit-tools/dep-doctor.js +320 -320
- package/scripts/audit-tools/env-validator.js +206 -206
- package/scripts/audit-tools/lib/fs-walk.js +48 -48
- package/scripts/audit-tools/lib/output.js +23 -23
- package/scripts/audit-tools/migration-checker.js +392 -392
- package/scripts/audit-tools/pentest-scanner.js +1436 -1436
- package/scripts/benchmark-memoria.js +167 -167
- package/scripts/configurar-branch-protection.js +418 -418
- package/scripts/detectar-aprendizajes-duplicados.js +151 -151
- package/scripts/field-report.js +199 -199
- package/scripts/generar-checklists-consolidados.js +273 -273
- package/scripts/generar-inventario.js +420 -420
- package/scripts/generar-matriz-lenguajes.js +271 -271
- package/scripts/lib/artefactos-python.js +43 -43
- package/scripts/lib/benchmark-metrics.js +160 -160
- package/scripts/lib/budget-enforcer.js +252 -252
- package/scripts/lib/configurar-ci.js +380 -380
- package/scripts/lib/contadores-inventario.js +217 -217
- package/scripts/lib/detectar-stack-detallado.js +307 -307
- package/scripts/lib/diary-entry.js +234 -234
- package/scripts/lib/eval-metrics-store.js +218 -218
- package/scripts/lib/eval-quality.js +171 -171
- package/scripts/lib/eval-schemas.js +144 -144
- package/scripts/lib/eval-self-correct.js +106 -106
- package/scripts/lib/eval-validator.js +185 -185
- package/scripts/lib/jaccard-similarity.js +98 -98
- package/scripts/lib/longmemeval-runner.js +125 -125
- package/scripts/lib/manifiestos.js +42 -1
- package/scripts/lib/npm-version.js +261 -261
- package/scripts/lib/paquetes-conocidos.js +50 -50
- package/scripts/lib/prompt-builder.js +264 -264
- package/scripts/lib/rrf-fusion.js +175 -175
- package/scripts/lib/scoring-instintos.js +277 -277
- package/scripts/lib/semantic-search.js +252 -252
- package/scripts/limpiar-artefactos-python.js +131 -131
- package/scripts/mcp-server/README.md +128 -128
- package/scripts/mcp-server/handlers.js +206 -206
- package/scripts/migrar-csv-a-array.js +168 -168
- package/scripts/migrar-fase-dominio.js +201 -201
- package/scripts/publicar.js +511 -511
- package/scripts/run-eval.js +141 -141
- package/scripts/validar-manifest.js +231 -195
- package/scripts/validar-userland-vacio.js +110 -110
|
@@ -1,1436 +1,1436 @@
|
|
|
1
|
-
// Adaptado de temp/ultraship-main/tools/pentest-scanner.mjs bajo MIT License
|
|
2
|
-
// Fuente: Houseofmvps/ultraship (https://github.com/Houseofmvps/ultraship)
|
|
3
|
-
// Puerto ESM→CJS: Node.js >=22 CommonJS, zero-deps adicionales, SSRF protegido
|
|
4
|
-
'use strict';
|
|
5
|
-
|
|
6
|
-
const http = require('http');
|
|
7
|
-
const https = require('https');
|
|
8
|
-
const tls = require('tls');
|
|
9
|
-
const { validateUrl, createResponseAccumulator } = require('../../hooks/lib/security-net.js');
|
|
10
|
-
|
|
11
|
-
// ── Constantes ────────────────────────────────────────────────────────────────
|
|
12
|
-
|
|
13
|
-
const SCAN_ID = Math.random().toString(36).slice(2, 8);
|
|
14
|
-
const CANARY = `xss${SCAN_ID}`;
|
|
15
|
-
const UA = 'Mozilla/5.0 (compatible; SecurityScanner/1.0)';
|
|
16
|
-
const DEFAULT_TIMEOUT = 10000;
|
|
17
|
-
const RATE_LIMIT_MS = 50;
|
|
18
|
-
const MAX_ENDPOINTS = 100;
|
|
19
|
-
const MAX_PARAMS_PER_ENDPOINT = 10;
|
|
20
|
-
|
|
21
|
-
function output(data) {
|
|
22
|
-
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function sleep(ms) {
|
|
26
|
-
return new Promise(r => setTimeout(r, ms));
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function simpleHash(str) {
|
|
30
|
-
let h = 0;
|
|
31
|
-
for (let i = 0; i < str.length; i++) {
|
|
32
|
-
h = ((h << 5) - h + str.charCodeAt(i)) | 0;
|
|
33
|
-
}
|
|
34
|
-
return (h >>> 0).toString(16);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// ── Payloads ──────────────────────────────────────────────────────────────────
|
|
38
|
-
|
|
39
|
-
const XSS_PAYLOADS = [
|
|
40
|
-
`<script>document.title='${CANARY}'</script>`,
|
|
41
|
-
`"><img src=x onerror="document.title='${CANARY}'">`,
|
|
42
|
-
`'><svg onload="document.title='${CANARY}'">`,
|
|
43
|
-
`javascript:document.title='${CANARY}'`,
|
|
44
|
-
`<details open ontoggle="document.title='${CANARY}'">`,
|
|
45
|
-
`${CANARY}<script>`,
|
|
46
|
-
];
|
|
47
|
-
|
|
48
|
-
const SQLI_ERROR_PAYLOADS = ["'", "\"", "' OR '1'='1", "' OR 1=1--", "\" OR \"1\"=\"1", "; SELECT 1--", "' WAITFOR DELAY '0:0:3'--", "' AND SLEEP(3)--", "1; DROP TABLE users--", "' UNION SELECT NULL--", "\\", "' OR 'x'='x"];
|
|
49
|
-
const SQLI_ERROR_PATTERNS = [
|
|
50
|
-
/sql syntax/i, /mysql_fetch/i, /ora-\d{5}/i, /unclosed quotation/i,
|
|
51
|
-
/syntax error.*sql/i, /sqlite.*error/i, /pg.*error/i, /mssql/i,
|
|
52
|
-
/sqlexception/i, /jdbc/i, /odbc/i, /invalid query/i,
|
|
53
|
-
];
|
|
54
|
-
const SQLI_TIME_PAYLOADS = [
|
|
55
|
-
{ payload: "' AND SLEEP(3)--", delay: 3000, label: 'MySQL SLEEP' },
|
|
56
|
-
{ payload: "'; WAITFOR DELAY '0:0:3'--", delay: 3000, label: 'MSSQL WAITFOR' },
|
|
57
|
-
{ payload: "' AND pg_sleep(3)--", delay: 3000, label: 'PostgreSQL pg_sleep' },
|
|
58
|
-
{ payload: "' AND (SELECT * FROM (SELECT(SLEEP(3)))a)--", delay: 3000, label: 'MySQL subquery' },
|
|
59
|
-
];
|
|
60
|
-
const SSTI_PAYLOADS = [
|
|
61
|
-
{ payload: '{{7*7}}', verify: '49', label: 'Jinja2/Twig' },
|
|
62
|
-
{ payload: '${7*7}', verify: '49', label: 'Freemarker/EL' },
|
|
63
|
-
{ payload: '<%= 7*7 %>', verify: '49', label: 'Ruby ERB' },
|
|
64
|
-
{ payload: '#{7*7}', verify: '49', label: 'ERB/EJS' },
|
|
65
|
-
{ payload: '{7*7}', verify: '49', label: 'Smarty' },
|
|
66
|
-
];
|
|
67
|
-
const CMD_PAYLOADS = [
|
|
68
|
-
{ payload: `; echo ${CANARY}`, label: 'Unix semicolon' },
|
|
69
|
-
{ payload: `| echo ${CANARY}`, label: 'Unix pipe' },
|
|
70
|
-
{ payload: `\`echo ${CANARY}\``, label: 'Backtick' },
|
|
71
|
-
{ payload: `$(echo ${CANARY})`, label: 'Command substitution' },
|
|
72
|
-
];
|
|
73
|
-
const TRAVERSAL_PAYLOADS = [
|
|
74
|
-
{ payload: '../../../etc/passwd', verify: /root:.*:0:0/i },
|
|
75
|
-
{ payload: '..%2F..%2F..%2Fetc%2Fpasswd', verify: /root:.*:0:0/i },
|
|
76
|
-
{ payload: '....//....//etc/passwd', verify: /root:.*:0:0/i },
|
|
77
|
-
{ payload: '%2e%2e%2f%2e%2e%2fetc%2fpasswd', verify: /root:.*:0:0/i },
|
|
78
|
-
{ payload: '../../../windows/win.ini', verify: /\[fonts\]/i },
|
|
79
|
-
];
|
|
80
|
-
const SENSITIVE_FILES = [
|
|
81
|
-
{ path: '/.env', verify: /^[A-Z_]+=.+/m, severity: 'critical', title: 'Environment file exposed', cwe: 'CWE-538' },
|
|
82
|
-
{ path: '/.env.local', verify: /^[A-Z_]+=.+/m, severity: 'critical', title: '.env.local exposed', cwe: 'CWE-538' },
|
|
83
|
-
{ path: '/.git/config', verify: /\[core\]/i, severity: 'high', title: 'Git config exposed', cwe: 'CWE-538' },
|
|
84
|
-
{ path: '/config.php', verify: /\$db|password|host/i, severity: 'high', title: 'PHP config exposed', cwe: 'CWE-538' },
|
|
85
|
-
{ path: '/wp-config.php', verify: /DB_PASSWORD|DB_HOST/i, severity: 'critical', title: 'WordPress config exposed', cwe: 'CWE-538' },
|
|
86
|
-
{ path: '/phpinfo.php', verify: /phpinfo|PHP Version/i, severity: 'medium', title: 'phpinfo exposed', cwe: 'CWE-200' },
|
|
87
|
-
{ path: '/server-status', verify: /Apache Server Status|Server Version/i, severity: 'medium', title: 'Apache server-status exposed', cwe: 'CWE-200' },
|
|
88
|
-
{ path: '/.DS_Store', verify: /Bud1|\x00/i, severity: 'low', title: '.DS_Store exposed', cwe: 'CWE-538' },
|
|
89
|
-
{ path: '/robots.txt', verify: /disallow/i, severity: 'info', title: 'robots.txt (may reveal paths)', cwe: 'CWE-200' },
|
|
90
|
-
{ path: '/sitemap.xml', verify: /<url>|<loc>/i, severity: 'info', title: 'Sitemap exposed', cwe: 'CWE-200' },
|
|
91
|
-
{ path: '/backup.zip', verify: /PK\x03\x04/, severity: 'critical', title: 'Backup archive exposed', cwe: 'CWE-530' },
|
|
92
|
-
{ path: '/backup.sql', verify: /CREATE TABLE|INSERT INTO/i, severity: 'critical', title: 'SQL backup exposed', cwe: 'CWE-530' },
|
|
93
|
-
{ path: '/api-docs', verify: /swagger|openapi/i, severity: 'info', title: 'API docs exposed', cwe: 'CWE-200' },
|
|
94
|
-
{ path: '/swagger.json', verify: /swagger|openapi/i, severity: 'info', title: 'Swagger JSON exposed', cwe: 'CWE-200' },
|
|
95
|
-
{ path: '/openapi.json', verify: /openapi/i, severity: 'info', title: 'OpenAPI spec exposed', cwe: 'CWE-200' },
|
|
96
|
-
{ path: '/.htaccess', verify: /RewriteRule|Options|AuthType/i, severity: 'medium', title: '.htaccess exposed', cwe: 'CWE-538' },
|
|
97
|
-
{ path: '/web.config', verify: /<configuration>|<system.web>/i, severity: 'high', title: 'web.config exposed', cwe: 'CWE-538' },
|
|
98
|
-
{ path: '/package.json', verify: /"name"|"version"/i, severity: 'low', title: 'package.json exposed', cwe: 'CWE-200' },
|
|
99
|
-
{ path: '/composer.json', verify: /"name"|"require"/i, severity: 'low', title: 'composer.json exposed', cwe: 'CWE-200' },
|
|
100
|
-
{ path: '/Gemfile', verify: /gem |source /i, severity: 'low', title: 'Gemfile exposed', cwe: 'CWE-200' },
|
|
101
|
-
{ path: '/requirements.txt', verify: /==|>=|<=/, severity: 'low', title: 'requirements.txt exposed', cwe: 'CWE-200' },
|
|
102
|
-
{ path: '/docker-compose.yml', verify: /version:|services:/i, severity: 'medium', title: 'Docker Compose exposed', cwe: 'CWE-538' },
|
|
103
|
-
{ path: '/Dockerfile', verify: /FROM |RUN |CMD /i, severity: 'medium', title: 'Dockerfile exposed', cwe: 'CWE-538' },
|
|
104
|
-
{ path: '/.travis.yml', verify: /language:|script:/i, severity: 'low', title: '.travis.yml exposed', cwe: 'CWE-200' },
|
|
105
|
-
{ path: '/.github/workflows', verify: /on:|jobs:/i, severity: 'low', title: 'GitHub Actions workflow exposed', cwe: 'CWE-200' },
|
|
106
|
-
{ path: '/config/database.yml', verify: /adapter:|database:/i, severity: 'critical', title: 'Rails DB config exposed', cwe: 'CWE-538' },
|
|
107
|
-
{ path: '/application.properties', verify: /spring\.|server\.port/i, severity: 'high', title: 'Spring config exposed', cwe: 'CWE-538' },
|
|
108
|
-
{ path: '/application.yml', verify: /spring:|server:/i, severity: 'high', title: 'Spring YAML config exposed', cwe: 'CWE-538' },
|
|
109
|
-
{ path: '/appsettings.json', verify: /"ConnectionStrings"|"AppSettings"/i, severity: 'high', title: 'ASP.NET config exposed', cwe: 'CWE-538' },
|
|
110
|
-
{ path: '/crossdomain.xml', verify: /<cross-domain-policy>/i, severity: 'medium', title: 'crossdomain.xml found', cwe: 'CWE-942' },
|
|
111
|
-
{ path: '/clientaccesspolicy.xml', verify: /<access-policy>/i, severity: 'medium', title: 'clientaccesspolicy.xml found', cwe: 'CWE-942' },
|
|
112
|
-
{ path: '/admin', verify: /admin|dashboard|login/i, severity: 'info', title: 'Admin panel discovered', cwe: 'CWE-284' },
|
|
113
|
-
{ path: '/login', verify: /login|sign.?in|password/i, severity: 'info', title: 'Login page discovered', cwe: 'CWE-200' },
|
|
114
|
-
{ path: '/phpMyAdmin/', verify: /phpMyAdmin|pma_|PMA_/i, severity: 'high', title: 'phpMyAdmin exposed', cwe: 'CWE-284' },
|
|
115
|
-
];
|
|
116
|
-
const SECURITY_HEADERS = [
|
|
117
|
-
{ name: 'strict-transport-security', severity: 'medium', title: 'Missing HSTS', cwe: 'CWE-319' },
|
|
118
|
-
{ name: 'x-frame-options', severity: 'medium', title: 'Missing X-Frame-Options', cwe: 'CWE-1021' },
|
|
119
|
-
{ name: 'x-content-type-options', severity: 'low', title: 'Missing X-Content-Type-Options', cwe: 'CWE-693' },
|
|
120
|
-
{ name: 'content-security-policy', severity: 'medium', title: 'Missing Content-Security-Policy', cwe: 'CWE-1021' },
|
|
121
|
-
{ name: 'referrer-policy', severity: 'low', title: 'Missing Referrer-Policy', cwe: 'CWE-116' },
|
|
122
|
-
{ name: 'permissions-policy', severity: 'info', title: 'Missing Permissions-Policy', cwe: 'CWE-693' },
|
|
123
|
-
{ name: 'cross-origin-opener-policy', severity: 'info', title: 'Missing Cross-Origin-Opener-Policy', cwe: 'CWE-346' },
|
|
124
|
-
];
|
|
125
|
-
|
|
126
|
-
// ── HTTP Utilities ────────────────────────────────────────────────────────────
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Realiza un request HTTP/HTTPS seguro con timeout, acumulador de respuesta y
|
|
130
|
-
* validación SSRF via validateUrl().
|
|
131
|
-
*
|
|
132
|
-
* @param {string} urlString
|
|
133
|
-
* @param {object} opts
|
|
134
|
-
* @returns {Promise<{success,statusCode,headers,body,timing,error?}>}
|
|
135
|
-
*/
|
|
136
|
-
function fetchWithTiming(urlString, opts = {}) {
|
|
137
|
-
return new Promise((resolve) => {
|
|
138
|
-
// Validación SSRF obligatoria antes de cualquier request
|
|
139
|
-
const validation = validateUrl(urlString);
|
|
140
|
-
if (!validation.valid) {
|
|
141
|
-
resolve({
|
|
142
|
-
success: false,
|
|
143
|
-
blocked: true,
|
|
144
|
-
blockReason: validation.reason,
|
|
145
|
-
statusCode: 0,
|
|
146
|
-
headers: {},
|
|
147
|
-
body: '',
|
|
148
|
-
timing: 0,
|
|
149
|
-
});
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const parsed = validation.url;
|
|
154
|
-
const isHttps = parsed.protocol === 'https:';
|
|
155
|
-
const lib = isHttps ? https : http;
|
|
156
|
-
const timeout = opts.timeout || DEFAULT_TIMEOUT;
|
|
157
|
-
const start = Date.now();
|
|
158
|
-
|
|
159
|
-
const reqOpts = {
|
|
160
|
-
hostname: parsed.hostname,
|
|
161
|
-
port: parsed.port || (isHttps ? 443 : 80),
|
|
162
|
-
path: parsed.pathname + (parsed.search || ''),
|
|
163
|
-
method: opts.method || 'GET',
|
|
164
|
-
headers: {
|
|
165
|
-
'User-Agent': UA,
|
|
166
|
-
'Accept': 'text/html,application/json,*/*',
|
|
167
|
-
'Accept-Language': 'en-US,en;q=0.5',
|
|
168
|
-
...opts.headers,
|
|
169
|
-
},
|
|
170
|
-
rejectUnauthorized: false, // pentest scanner examina TLS por separado
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
if (opts.cookie) reqOpts.headers['Cookie'] = opts.cookie;
|
|
174
|
-
|
|
175
|
-
const body = opts.body;
|
|
176
|
-
if (body) {
|
|
177
|
-
const ct = opts.contentType || 'application/x-www-form-urlencoded';
|
|
178
|
-
reqOpts.headers['Content-Type'] = ct;
|
|
179
|
-
reqOpts.headers['Content-Length'] = Buffer.byteLength(body, 'utf8').toString();
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const accumulator = createResponseAccumulator();
|
|
183
|
-
let timedOut = false;
|
|
184
|
-
|
|
185
|
-
const req = lib.request(reqOpts, (res) => {
|
|
186
|
-
res.on('data', (chunk) => accumulator.onData(chunk.toString('binary')));
|
|
187
|
-
res.on('end', () => {
|
|
188
|
-
if (timedOut) return;
|
|
189
|
-
resolve({
|
|
190
|
-
success: true,
|
|
191
|
-
statusCode: res.statusCode,
|
|
192
|
-
headers: res.headers || {},
|
|
193
|
-
body: accumulator.getBody(),
|
|
194
|
-
timing: Date.now() - start,
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
res.on('error', () => {
|
|
198
|
-
if (!timedOut) resolve({ success: false, statusCode: 0, headers: {}, body: '', timing: Date.now() - start, error: 'response error' });
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
const timer = setTimeout(() => {
|
|
203
|
-
timedOut = true;
|
|
204
|
-
req.destroy();
|
|
205
|
-
resolve({ success: false, statusCode: 0, headers: {}, body: '', timing: Date.now() - start, error: 'timeout' });
|
|
206
|
-
}, timeout);
|
|
207
|
-
|
|
208
|
-
req.on('error', (err) => {
|
|
209
|
-
clearTimeout(timer);
|
|
210
|
-
if (!timedOut) resolve({ success: false, statusCode: 0, headers: {}, body: '', timing: Date.now() - start, error: err.message });
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
req.on('close', () => clearTimeout(timer));
|
|
214
|
-
|
|
215
|
-
if (body) req.write(body);
|
|
216
|
-
req.end();
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// ── Reconocimiento ────────────────────────────────────────────────────────────
|
|
221
|
-
|
|
222
|
-
async function get404Baseline(baseUrl, opts) {
|
|
223
|
-
const randomPath = `/${simpleHash(SCAN_ID + Date.now())}-notfound`;
|
|
224
|
-
const res = await fetchWithTiming(baseUrl + randomPath, opts);
|
|
225
|
-
return {
|
|
226
|
-
statusCode: res.statusCode,
|
|
227
|
-
bodyLength: (res.body || '').length,
|
|
228
|
-
bodyHash: simpleHash(res.body || ''),
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function isSoft404(response, baseline) {
|
|
233
|
-
if (!baseline || !response.success) return false;
|
|
234
|
-
if (response.statusCode === baseline.statusCode) {
|
|
235
|
-
const lengthDiff = Math.abs((response.body || '').length - baseline.bodyLength);
|
|
236
|
-
const percentDiff = baseline.bodyLength > 0 ? lengthDiff / baseline.bodyLength : 0;
|
|
237
|
-
if (percentDiff < 0.1) return true;
|
|
238
|
-
if (simpleHash(response.body || '') === baseline.bodyHash) return true;
|
|
239
|
-
}
|
|
240
|
-
return false;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function extractFromHTML(body, baseUrl) {
|
|
244
|
-
const links = new Set();
|
|
245
|
-
const forms = [];
|
|
246
|
-
const scripts = [];
|
|
247
|
-
const params = new Set();
|
|
248
|
-
|
|
249
|
-
// Extraer hrefs
|
|
250
|
-
const hrefRe = /href\s*=\s*["']([^"'#?]+(?:\?[^"']*)?)/gi;
|
|
251
|
-
let m;
|
|
252
|
-
while ((m = hrefRe.exec(body)) !== null) {
|
|
253
|
-
try {
|
|
254
|
-
const u = new URL(m[1], baseUrl);
|
|
255
|
-
if (u.origin === new URL(baseUrl).origin) {
|
|
256
|
-
links.add(u.href);
|
|
257
|
-
for (const [k] of u.searchParams) params.add(k);
|
|
258
|
-
}
|
|
259
|
-
} catch {}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Extraer scripts
|
|
263
|
-
const scriptRe = /src\s*=\s*["']([^"']+\.js[^"']*)/gi;
|
|
264
|
-
while ((m = scriptRe.exec(body)) !== null) {
|
|
265
|
-
try { scripts.push(new URL(m[1], baseUrl).href); } catch {}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Extraer forms
|
|
269
|
-
const formRe = /<form([^>]*)>([\s\S]*?)<\/form>/gi;
|
|
270
|
-
while ((m = formRe.exec(body)) !== null) {
|
|
271
|
-
const attrs = m[1];
|
|
272
|
-
const inner = m[2];
|
|
273
|
-
const actionM = /action\s*=\s*["']([^"']*)/i.exec(attrs);
|
|
274
|
-
const methodM = /method\s*=\s*["']([^"']*)/i.exec(attrs);
|
|
275
|
-
const inputs = [];
|
|
276
|
-
const inputRe = /<input[^>]+name\s*=\s*["']([^"']*)/gi;
|
|
277
|
-
let im;
|
|
278
|
-
while ((im = inputRe.exec(inner)) !== null) inputs.push(im[1]);
|
|
279
|
-
const hasCSRF = inputs.some(n => /csrf|token|_token|authenticity/i.test(n));
|
|
280
|
-
forms.push({
|
|
281
|
-
action: actionM ? actionM[1] : null,
|
|
282
|
-
method: (methodM ? methodM[1] : 'GET').toUpperCase(),
|
|
283
|
-
inputs,
|
|
284
|
-
hasCSRF,
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Extraer query params desde links del body
|
|
289
|
-
const qpRe = /[?&]([a-zA-Z0-9_\-]+)=/g;
|
|
290
|
-
while ((m = qpRe.exec(body)) !== null) params.add(m[1]);
|
|
291
|
-
|
|
292
|
-
return {
|
|
293
|
-
links: [...links].slice(0, MAX_ENDPOINTS),
|
|
294
|
-
forms,
|
|
295
|
-
scripts: [...new Set(scripts)],
|
|
296
|
-
params: [...params].slice(0, MAX_PARAMS_PER_ENDPOINT),
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
function detectTechStack(headers, body) {
|
|
301
|
-
const stack = [];
|
|
302
|
-
const h = JSON.stringify(headers).toLowerCase();
|
|
303
|
-
if (h.includes('php')) stack.push('PHP');
|
|
304
|
-
if (h.includes('express')) stack.push('Express.js');
|
|
305
|
-
if (h.includes('nginx')) stack.push('Nginx');
|
|
306
|
-
if (h.includes('apache')) stack.push('Apache');
|
|
307
|
-
if (h.includes('wordpress') || body.toLowerCase().includes('wp-content')) stack.push('WordPress');
|
|
308
|
-
if (h.includes('drupal') || body.toLowerCase().includes('drupal')) stack.push('Drupal');
|
|
309
|
-
if (body.toLowerCase().includes('react') || body.includes('__next')) stack.push('React/Next.js');
|
|
310
|
-
if (body.toLowerCase().includes('angular')) stack.push('Angular');
|
|
311
|
-
if (body.toLowerCase().includes('vue')) stack.push('Vue.js');
|
|
312
|
-
return stack;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// ── Hallazgos ─────────────────────────────────────────────────────────────────
|
|
316
|
-
|
|
317
|
-
let findingCounter = 0;
|
|
318
|
-
|
|
319
|
-
function createFinding(category, subcategory, severity, title, opts = {}) {
|
|
320
|
-
return {
|
|
321
|
-
id: ++findingCounter,
|
|
322
|
-
category,
|
|
323
|
-
subcategory,
|
|
324
|
-
severity,
|
|
325
|
-
title,
|
|
326
|
-
url: opts.url || null,
|
|
327
|
-
cwe: opts.cwe || null,
|
|
328
|
-
owasp: opts.owasp || null,
|
|
329
|
-
proof: opts.proof || null,
|
|
330
|
-
fix: opts.fix || null,
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// ── Archivos sensibles ────────────────────────────────────────────────────────
|
|
335
|
-
|
|
336
|
-
async function scanSensitiveFiles(baseUrl, baseline, opts) {
|
|
337
|
-
const findings = [];
|
|
338
|
-
for (const entry of SENSITIVE_FILES) {
|
|
339
|
-
const url = baseUrl + entry.path;
|
|
340
|
-
const res = await fetchWithTiming(url, opts);
|
|
341
|
-
await sleep(RATE_LIMIT_MS);
|
|
342
|
-
if (!res.success) continue;
|
|
343
|
-
if (res.statusCode !== 200 && res.statusCode !== 206) continue;
|
|
344
|
-
if (isSoft404(res, baseline)) continue;
|
|
345
|
-
const body = res.body || '';
|
|
346
|
-
if (!entry.verify.test(body)) continue;
|
|
347
|
-
findings.push(createFinding('exposure', 'sensitive_file', entry.severity, entry.title, {
|
|
348
|
-
url,
|
|
349
|
-
cwe: entry.cwe,
|
|
350
|
-
owasp: 'A05:2021 Security Misconfiguration',
|
|
351
|
-
proof: {
|
|
352
|
-
request: `GET ${entry.path}`,
|
|
353
|
-
status: res.statusCode,
|
|
354
|
-
response_excerpt: body.slice(0, 200),
|
|
355
|
-
verification: `Pattern matched: ${entry.verify}`,
|
|
356
|
-
},
|
|
357
|
-
fix: `Restrict access to ${entry.path} via server configuration. Never expose sensitive config files.`,
|
|
358
|
-
}));
|
|
359
|
-
}
|
|
360
|
-
return findings;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// ── Cabeceras de seguridad ────────────────────────────────────────────────────
|
|
364
|
-
|
|
365
|
-
function analyzeHeaders(response) {
|
|
366
|
-
const findings = [];
|
|
367
|
-
const headers = response.headers || {};
|
|
368
|
-
const body = response.body || '';
|
|
369
|
-
|
|
370
|
-
for (const h of SECURITY_HEADERS) {
|
|
371
|
-
if (!headers[h.name]) {
|
|
372
|
-
findings.push(createFinding('headers', h.name.replace(/-/g, '_'), h.severity, h.title, {
|
|
373
|
-
cwe: h.cwe,
|
|
374
|
-
owasp: 'A05:2021 Security Misconfiguration',
|
|
375
|
-
proof: { missing_header: h.name, verification: `Header "${h.name}" absent from response` },
|
|
376
|
-
fix: `Add the "${h.name}" header to all HTTP responses.`,
|
|
377
|
-
}));
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// CSP débil
|
|
382
|
-
const csp = headers['content-security-policy'] || '';
|
|
383
|
-
if (csp && (csp.includes("'unsafe-inline'") || csp.includes("'unsafe-eval'") || csp.includes('*'))) {
|
|
384
|
-
findings.push(createFinding('headers', 'weak_csp', 'medium', 'Content-Security-Policy is weak', {
|
|
385
|
-
cwe: 'CWE-1021', owasp: 'A05:2021',
|
|
386
|
-
proof: { csp_value: csp, verification: "CSP contains 'unsafe-inline', 'unsafe-eval', or wildcard" },
|
|
387
|
-
fix: "Remove 'unsafe-inline', 'unsafe-eval', and wildcard sources from CSP.",
|
|
388
|
-
}));
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Cookies sin Secure/HttpOnly
|
|
392
|
-
const cookies = [].concat(headers['set-cookie'] || []);
|
|
393
|
-
for (const cookie of cookies) {
|
|
394
|
-
const lc = cookie.toLowerCase();
|
|
395
|
-
if (!lc.includes('httponly')) {
|
|
396
|
-
findings.push(createFinding('headers', 'cookie_no_httponly', 'medium', 'Cookie missing HttpOnly flag', {
|
|
397
|
-
cwe: 'CWE-1004', owasp: 'A05:2021',
|
|
398
|
-
proof: { cookie: cookie.slice(0, 80), verification: 'HttpOnly flag absent in Set-Cookie' },
|
|
399
|
-
fix: 'Add HttpOnly flag to all session cookies.',
|
|
400
|
-
}));
|
|
401
|
-
}
|
|
402
|
-
if (response.statusCode && !lc.includes('secure') && !lc.includes('samesite=none')) {
|
|
403
|
-
findings.push(createFinding('headers', 'cookie_no_secure', 'low', 'Cookie missing Secure flag', {
|
|
404
|
-
cwe: 'CWE-614', owasp: 'A05:2021',
|
|
405
|
-
proof: { cookie: cookie.slice(0, 80), verification: 'Secure flag absent in Set-Cookie' },
|
|
406
|
-
fix: 'Add Secure flag to all session cookies to prevent transmission over HTTP.',
|
|
407
|
-
}));
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Server header con versión
|
|
412
|
-
const serverHeader = headers['server'] || '';
|
|
413
|
-
if (serverHeader && /\d\.\d/.test(serverHeader)) {
|
|
414
|
-
findings.push(createFinding('exposure', 'server_version', 'low', `Server version disclosed: ${serverHeader}`, {
|
|
415
|
-
cwe: 'CWE-200', owasp: 'A05:2021',
|
|
416
|
-
proof: { server_header: serverHeader, verification: 'Version number found in Server header' },
|
|
417
|
-
fix: 'Configure server to omit version from the Server header.',
|
|
418
|
-
}));
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// X-Powered-By
|
|
422
|
-
const xPoweredBy = headers['x-powered-by'];
|
|
423
|
-
if (xPoweredBy) {
|
|
424
|
-
findings.push(createFinding('exposure', 'technology_disclosure', 'low', `Technology disclosed via X-Powered-By: ${xPoweredBy}`, {
|
|
425
|
-
cwe: 'CWE-200', owasp: 'A05:2021',
|
|
426
|
-
proof: { header: xPoweredBy, verification: 'X-Powered-By header reveals technology stack' },
|
|
427
|
-
fix: 'Remove or obfuscate the X-Powered-By header.',
|
|
428
|
-
}));
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
return findings;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// ── TLS ───────────────────────────────────────────────────────────────────────
|
|
435
|
-
|
|
436
|
-
function analyzeTLS(hostname, port) {
|
|
437
|
-
port = port || 443;
|
|
438
|
-
return new Promise((resolve) => {
|
|
439
|
-
const findings = [];
|
|
440
|
-
const socket = tls.connect({ host: hostname, port, rejectUnauthorized: false, timeout: 5000 }, () => {
|
|
441
|
-
const cert = socket.getPeerCertificate();
|
|
442
|
-
const proto = socket.getProtocol ? socket.getProtocol() : null;
|
|
443
|
-
|
|
444
|
-
if (proto && (proto === 'TLSv1' || proto === 'TLSv1.1')) {
|
|
445
|
-
findings.push(createFinding('tls', 'weak_protocol', 'high', `Deprecated TLS version in use: ${proto}`, {
|
|
446
|
-
cwe: 'CWE-326', owasp: 'A02:2021',
|
|
447
|
-
proof: { protocol: proto, verification: 'TLS 1.0/1.1 is deprecated and vulnerable to POODLE/BEAST attacks' },
|
|
448
|
-
fix: 'Disable TLS 1.0 and 1.1. Use TLS 1.2+ only.',
|
|
449
|
-
}));
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
if (cert && cert.valid_to) {
|
|
453
|
-
const expiry = new Date(cert.valid_to);
|
|
454
|
-
const daysLeft = (expiry - Date.now()) / 86400000;
|
|
455
|
-
if (daysLeft < 30) {
|
|
456
|
-
findings.push(createFinding('tls', 'cert_expiry', daysLeft < 0 ? 'critical' : 'high',
|
|
457
|
-
daysLeft < 0 ? 'TLS certificate has expired' : `TLS certificate expires in ${Math.floor(daysLeft)} days`, {
|
|
458
|
-
cwe: 'CWE-298', owasp: 'A02:2021',
|
|
459
|
-
proof: { valid_to: cert.valid_to, days_remaining: Math.floor(daysLeft), verification: 'Certificate expiry within 30 days or already expired' },
|
|
460
|
-
fix: 'Renew the TLS certificate immediately.',
|
|
461
|
-
}));
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
if (cert && !cert.subject) {
|
|
466
|
-
findings.push(createFinding('tls', 'self_signed', 'medium', 'Self-signed TLS certificate detected', {
|
|
467
|
-
cwe: 'CWE-297', owasp: 'A02:2021',
|
|
468
|
-
proof: { verification: 'Certificate subject missing or self-signed' },
|
|
469
|
-
fix: 'Use a certificate issued by a trusted Certificate Authority.',
|
|
470
|
-
}));
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
socket.destroy();
|
|
474
|
-
resolve(findings);
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
socket.on('error', () => resolve(findings));
|
|
478
|
-
socket.setTimeout(5000, () => { socket.destroy(); resolve(findings); });
|
|
479
|
-
});
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// ── JWT ───────────────────────────────────────────────────────────────────────
|
|
483
|
-
|
|
484
|
-
function analyzeJWTs(response) {
|
|
485
|
-
const findings = [];
|
|
486
|
-
const body = response.body || '';
|
|
487
|
-
const headers = response.headers || {};
|
|
488
|
-
|
|
489
|
-
const allText = body + JSON.stringify(headers);
|
|
490
|
-
const jwtRe = /eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]*/g;
|
|
491
|
-
const tokens = allText.match(jwtRe) || [];
|
|
492
|
-
|
|
493
|
-
for (const token of tokens.slice(0, 5)) {
|
|
494
|
-
try {
|
|
495
|
-
const parts = token.split('.');
|
|
496
|
-
if (parts.length < 3) continue;
|
|
497
|
-
const header = JSON.parse(Buffer.from(parts[0], 'base64').toString());
|
|
498
|
-
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
|
499
|
-
|
|
500
|
-
if (header.alg === 'none' || header.alg === 'NONE') {
|
|
501
|
-
findings.push(createFinding('auth', 'jwt_none_algorithm', 'critical', 'JWT using "none" algorithm', {
|
|
502
|
-
cwe: 'CWE-347', owasp: 'A02:2021 Cryptographic Failures',
|
|
503
|
-
proof: { alg: header.alg, token_preview: token.slice(0, 60) + '...', verification: 'JWT header declares alg:none — no signature verification' },
|
|
504
|
-
fix: 'Reject JWTs with alg:none. Whitelist only accepted algorithms (RS256, ES256).',
|
|
505
|
-
}));
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
if (header.alg === 'HS256' || header.alg === 'HS384' || header.alg === 'HS512') {
|
|
509
|
-
findings.push(createFinding('auth', 'jwt_weak_algorithm', 'medium', `JWT uses symmetric algorithm: ${header.alg}`, {
|
|
510
|
-
cwe: 'CWE-327', owasp: 'A02:2021',
|
|
511
|
-
proof: { alg: header.alg, verification: 'HMAC algorithms are vulnerable to secret brute-force' },
|
|
512
|
-
fix: 'Prefer asymmetric algorithms (RS256, ES256) for JWTs.',
|
|
513
|
-
}));
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
if (payload.exp) {
|
|
517
|
-
const expDate = new Date(payload.exp * 1000);
|
|
518
|
-
if (expDate < new Date()) {
|
|
519
|
-
findings.push(createFinding('auth', 'jwt_expired', 'high', 'Expired JWT accepted', {
|
|
520
|
-
cwe: 'CWE-613', owasp: 'A07:2021',
|
|
521
|
-
proof: { exp: expDate.toISOString(), verification: 'JWT expiry is in the past' },
|
|
522
|
-
fix: 'Validate JWT expiry (exp claim) on every request.',
|
|
523
|
-
}));
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
// Datos sensibles en payload
|
|
528
|
-
const payloadStr = JSON.stringify(payload);
|
|
529
|
-
if (/password|secret|key|token|credential/i.test(payloadStr)) {
|
|
530
|
-
findings.push(createFinding('exposure', 'jwt_sensitive_data', 'high', 'JWT payload contains sensitive data', {
|
|
531
|
-
cwe: 'CWE-312', owasp: 'A02:2021',
|
|
532
|
-
proof: { fields: Object.keys(payload), verification: 'Sensitive field name detected in JWT payload' },
|
|
533
|
-
fix: 'Never store sensitive data in JWT payload. JWT payload is base64-encoded, not encrypted.',
|
|
534
|
-
}));
|
|
535
|
-
}
|
|
536
|
-
} catch {}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
return findings;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// ── GraphQL ───────────────────────────────────────────────────────────────────
|
|
543
|
-
|
|
544
|
-
async function testGraphQL(targetUrl, opts) {
|
|
545
|
-
const findings = [];
|
|
546
|
-
const endpoints = ['/graphql', '/api/graphql', '/query', '/gql', '/v1/graphql'];
|
|
547
|
-
|
|
548
|
-
for (const ep of endpoints) {
|
|
549
|
-
const url = targetUrl + ep;
|
|
550
|
-
const introspect = '{"query":"{ __schema { types { name } } }"}';
|
|
551
|
-
const res = await fetchWithTiming(url, { ...opts, method: 'POST', body: introspect, contentType: 'application/json' });
|
|
552
|
-
await sleep(RATE_LIMIT_MS);
|
|
553
|
-
|
|
554
|
-
if (!res.success) continue;
|
|
555
|
-
if (res.statusCode !== 200) continue;
|
|
556
|
-
|
|
557
|
-
try {
|
|
558
|
-
const json = JSON.parse(res.body || '');
|
|
559
|
-
if (json.data && json.data.__schema) {
|
|
560
|
-
findings.push(createFinding('exposure', 'graphql_introspection', 'medium', `GraphQL introspection enabled at ${ep}`, {
|
|
561
|
-
url,
|
|
562
|
-
cwe: 'CWE-200', owasp: 'A05:2021',
|
|
563
|
-
proof: {
|
|
564
|
-
endpoint: ep,
|
|
565
|
-
types_count: (json.data.__schema.types || []).length,
|
|
566
|
-
verification: 'Introspection query returned __schema data',
|
|
567
|
-
},
|
|
568
|
-
fix: 'Disable GraphQL introspection in production environments.',
|
|
569
|
-
}));
|
|
570
|
-
break;
|
|
571
|
-
}
|
|
572
|
-
} catch {}
|
|
573
|
-
}
|
|
574
|
-
return findings;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// ── Source Maps ───────────────────────────────────────────────────────────────
|
|
578
|
-
|
|
579
|
-
async function testSourceMaps(targetUrl, scripts, opts) {
|
|
580
|
-
const findings = [];
|
|
581
|
-
const checked = new Set();
|
|
582
|
-
|
|
583
|
-
for (const scriptUrl of scripts.slice(0, 10)) {
|
|
584
|
-
const mapUrl = scriptUrl + '.map';
|
|
585
|
-
if (checked.has(mapUrl)) continue;
|
|
586
|
-
checked.add(mapUrl);
|
|
587
|
-
|
|
588
|
-
const res = await fetchWithTiming(mapUrl, opts);
|
|
589
|
-
await sleep(RATE_LIMIT_MS);
|
|
590
|
-
if (!res.success || res.statusCode !== 200) continue;
|
|
591
|
-
|
|
592
|
-
try {
|
|
593
|
-
const json = JSON.parse(res.body || '');
|
|
594
|
-
if (json.sources || json.mappings) {
|
|
595
|
-
findings.push(createFinding('exposure', 'source_map_exposed', 'medium', `Source map exposed: ${mapUrl}`, {
|
|
596
|
-
url: mapUrl,
|
|
597
|
-
cwe: 'CWE-540', owasp: 'A05:2021',
|
|
598
|
-
proof: {
|
|
599
|
-
sources_count: (json.sources || []).length,
|
|
600
|
-
verification: 'Source map file accessible and contains valid mappings',
|
|
601
|
-
},
|
|
602
|
-
fix: 'Remove .map files from production deployments or block access via server config.',
|
|
603
|
-
}));
|
|
604
|
-
}
|
|
605
|
-
} catch {}
|
|
606
|
-
}
|
|
607
|
-
return findings;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
// ── Error Disclosure ──────────────────────────────────────────────────────────
|
|
611
|
-
|
|
612
|
-
async function testErrorDisclosure(targetUrl, opts) {
|
|
613
|
-
const findings = [];
|
|
614
|
-
const probes = [
|
|
615
|
-
targetUrl + "/'",
|
|
616
|
-
targetUrl + '/undefined/undefined',
|
|
617
|
-
targetUrl + '?id=../../../../etc/passwd',
|
|
618
|
-
targetUrl + '/api?debug=true&verbose=1',
|
|
619
|
-
];
|
|
620
|
-
|
|
621
|
-
for (const url of probes) {
|
|
622
|
-
const res = await fetchWithTiming(url, opts);
|
|
623
|
-
await sleep(RATE_LIMIT_MS);
|
|
624
|
-
if (!res.success) continue;
|
|
625
|
-
const body = res.body || '';
|
|
626
|
-
|
|
627
|
-
const patterns = [
|
|
628
|
-
{ re: /stack trace|stacktrace|at\s+\w+\s+\(/i, label: 'Stack trace in response' },
|
|
629
|
-
{ re: /exception in thread|unhandled exception|java\.lang\./i, label: 'Java exception disclosed' },
|
|
630
|
-
{ re: /Microsoft\.CSharp|System\.Web|ASP\.NET|\.aspx/i, label: 'ASP.NET error disclosed' },
|
|
631
|
-
{ re: /Parse error:|Fatal error:|Warning:|Notice:/i, label: 'PHP error disclosed' },
|
|
632
|
-
{ re: /OperationalError|IntegrityError|ProgrammingError/i, label: 'Python DB error' },
|
|
633
|
-
{ re: /SyntaxError:|ReferenceError:|TypeError:/i, label: 'JS error disclosed' },
|
|
634
|
-
{ re: /server error|internal server error/i, label: 'Generic server error' },
|
|
635
|
-
];
|
|
636
|
-
|
|
637
|
-
for (const { re, label } of patterns) {
|
|
638
|
-
if (re.test(body)) {
|
|
639
|
-
findings.push(createFinding('exposure', 'error_disclosure', 'medium', label, {
|
|
640
|
-
url,
|
|
641
|
-
cwe: 'CWE-209', owasp: 'A05:2021',
|
|
642
|
-
proof: {
|
|
643
|
-
request: `GET ${new URL(url).pathname}`,
|
|
644
|
-
response_excerpt: body.slice(0, 300),
|
|
645
|
-
verification: `Pattern matched: ${re}`,
|
|
646
|
-
},
|
|
647
|
-
fix: 'Configure generic error pages. Never expose stack traces in production.',
|
|
648
|
-
}));
|
|
649
|
-
break;
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
return findings;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// ── CORS ──────────────────────────────────────────────────────────────────────
|
|
657
|
-
|
|
658
|
-
async function testCORS(targetUrl, endpoints, opts) {
|
|
659
|
-
const findings = [];
|
|
660
|
-
const urls = [targetUrl, ...endpoints.slice(0, 5)];
|
|
661
|
-
|
|
662
|
-
for (const url of urls) {
|
|
663
|
-
const origins = [
|
|
664
|
-
'https://evil.example.com',
|
|
665
|
-
'null',
|
|
666
|
-
targetUrl.replace('://', '://evil.'),
|
|
667
|
-
];
|
|
668
|
-
|
|
669
|
-
for (const origin of origins) {
|
|
670
|
-
const res = await fetchWithTiming(url, {
|
|
671
|
-
...opts,
|
|
672
|
-
headers: { ...opts.headers, 'Origin': origin },
|
|
673
|
-
});
|
|
674
|
-
await sleep(RATE_LIMIT_MS);
|
|
675
|
-
if (!res.success) continue;
|
|
676
|
-
|
|
677
|
-
const acao = res.headers['access-control-allow-origin'] || '';
|
|
678
|
-
const acac = (res.headers['access-control-allow-credentials'] || '').toLowerCase();
|
|
679
|
-
|
|
680
|
-
if ((acao === '*' && acac === 'true') || acao === origin) {
|
|
681
|
-
const severity = (acac === 'true') ? 'high' : 'medium';
|
|
682
|
-
findings.push(createFinding('config', 'cors_misconfiguration', severity, `CORS misconfiguration — allows origin: ${origin}`, {
|
|
683
|
-
url,
|
|
684
|
-
cwe: 'CWE-942', owasp: 'A05:2021',
|
|
685
|
-
proof: {
|
|
686
|
-
origin_sent: origin,
|
|
687
|
-
acao_header: acao,
|
|
688
|
-
acac_header: acac || '(not present)',
|
|
689
|
-
verification: 'Server reflects or wildcard-allows the malicious origin',
|
|
690
|
-
},
|
|
691
|
-
fix: 'Restrict Access-Control-Allow-Origin to specific trusted origins. Never combine wildcard with credentials.',
|
|
692
|
-
}));
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
return findings;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
// ── XSS ───────────────────────────────────────────────────────────────────────
|
|
700
|
-
|
|
701
|
-
function extractContext(body, needle, radius) {
|
|
702
|
-
radius = radius || 100;
|
|
703
|
-
const idx = body.indexOf(needle);
|
|
704
|
-
if (idx === -1) return null;
|
|
705
|
-
return body.slice(Math.max(0, idx - radius), idx + needle.length + radius);
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
async function testXSS(targetUrl, recon, opts) {
|
|
709
|
-
const findings = [];
|
|
710
|
-
const tested = new Set();
|
|
711
|
-
const targets = collectTargets(targetUrl, recon, tested).slice(0, MAX_ENDPOINTS);
|
|
712
|
-
|
|
713
|
-
for (const target of targets) {
|
|
714
|
-
for (const xssPayload of XSS_PAYLOADS.slice(0, 3)) {
|
|
715
|
-
const u = new URL(target.url);
|
|
716
|
-
u.searchParams.set(target.param, xssPayload);
|
|
717
|
-
const res = await fetchWithTiming(u.toString(), opts);
|
|
718
|
-
await sleep(RATE_LIMIT_MS);
|
|
719
|
-
if (!res.success || !res.body) continue;
|
|
720
|
-
|
|
721
|
-
const body = res.body;
|
|
722
|
-
// Verificar reflexión del canary
|
|
723
|
-
if (body.includes(CANARY) && body.includes(xssPayload)) {
|
|
724
|
-
const context = extractContext(body, xssPayload);
|
|
725
|
-
findings.push(createFinding('injection', 'xss_reflected', 'high', `Reflected XSS on parameter "${target.param}"`, {
|
|
726
|
-
url: u.toString(),
|
|
727
|
-
cwe: 'CWE-79', owasp: 'A03:2021',
|
|
728
|
-
proof: {
|
|
729
|
-
parameter: target.param,
|
|
730
|
-
payload: xssPayload,
|
|
731
|
-
context: context ? context.slice(0, 200) : '(see response)',
|
|
732
|
-
verification: `Canary "${CANARY}" and payload reflected unescaped in response body`,
|
|
733
|
-
},
|
|
734
|
-
fix: 'Encode all user-controlled output. Use Content-Security-Policy. Avoid innerHTML with user data.',
|
|
735
|
-
}));
|
|
736
|
-
break; // un hallazgo por parámetro es suficiente
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
return findings;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
// ── SQLi ──────────────────────────────────────────────────────────────────────
|
|
744
|
-
|
|
745
|
-
async function testSQLi(targetUrl, recon, opts) {
|
|
746
|
-
const findings = [];
|
|
747
|
-
const tested = new Set();
|
|
748
|
-
const targets = collectTargets(targetUrl, recon, tested).slice(0, MAX_ENDPOINTS);
|
|
749
|
-
|
|
750
|
-
for (const target of targets) {
|
|
751
|
-
// Prueba basada en errores
|
|
752
|
-
for (const payload of SQLI_ERROR_PAYLOADS) {
|
|
753
|
-
const u = new URL(target.url);
|
|
754
|
-
u.searchParams.set(target.param, payload);
|
|
755
|
-
const res = await fetchWithTiming(u.toString(), opts);
|
|
756
|
-
await sleep(RATE_LIMIT_MS);
|
|
757
|
-
if (!res.success || !res.body) continue;
|
|
758
|
-
|
|
759
|
-
const body = res.body;
|
|
760
|
-
const matched = SQLI_ERROR_PATTERNS.find(p => p.test(body));
|
|
761
|
-
if (matched) {
|
|
762
|
-
findings.push(createFinding('injection', 'sqli_error_based', 'critical', `SQL Injection (error-based) on "${target.param}"`, {
|
|
763
|
-
url: u.toString(),
|
|
764
|
-
cwe: 'CWE-89', owasp: 'A03:2021',
|
|
765
|
-
proof: {
|
|
766
|
-
parameter: target.param,
|
|
767
|
-
payload,
|
|
768
|
-
error_pattern: matched.toString(),
|
|
769
|
-
response_excerpt: body.slice(0, 300),
|
|
770
|
-
verification: 'Database error pattern matched in response',
|
|
771
|
-
},
|
|
772
|
-
fix: 'Use parameterized queries or prepared statements. Never concatenate user input into SQL.',
|
|
773
|
-
}));
|
|
774
|
-
break;
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
// Prueba basada en tiempo (solo primer parámetro)
|
|
779
|
-
if (targets.indexOf(target) > 2) continue; // limitar pruebas de tiempo
|
|
780
|
-
for (const { payload, delay, label } of SQLI_TIME_PAYLOADS) {
|
|
781
|
-
const u = new URL(target.url);
|
|
782
|
-
u.searchParams.set(target.param, payload);
|
|
783
|
-
const res = await fetchWithTiming(u.toString(), { ...opts, timeout: (opts.timeout || DEFAULT_TIMEOUT) + delay });
|
|
784
|
-
await sleep(RATE_LIMIT_MS);
|
|
785
|
-
if (!res.success) continue;
|
|
786
|
-
|
|
787
|
-
if (res.timing >= delay * 0.9) {
|
|
788
|
-
findings.push(createFinding('injection', 'sqli_time_based', 'critical', `SQL Injection (time-based, ${label}) on "${target.param}"`, {
|
|
789
|
-
url: u.toString(),
|
|
790
|
-
cwe: 'CWE-89', owasp: 'A03:2021',
|
|
791
|
-
proof: {
|
|
792
|
-
parameter: target.param,
|
|
793
|
-
payload,
|
|
794
|
-
timing_ms: res.timing,
|
|
795
|
-
expected_delay_ms: delay,
|
|
796
|
-
verification: `Response delayed ${res.timing}ms (≥ ${delay * 0.9}ms threshold for ${label})`,
|
|
797
|
-
},
|
|
798
|
-
fix: 'Use parameterized queries. Disable verbose timing in DB queries.',
|
|
799
|
-
}));
|
|
800
|
-
break;
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
return findings;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
// ── SSTI ──────────────────────────────────────────────────────────────────────
|
|
808
|
-
|
|
809
|
-
async function testSSTI(targetUrl, recon, opts) {
|
|
810
|
-
const findings = [];
|
|
811
|
-
const tested = new Set();
|
|
812
|
-
const targets = collectTargets(targetUrl, recon, tested).slice(0, MAX_ENDPOINTS);
|
|
813
|
-
|
|
814
|
-
for (const target of targets) {
|
|
815
|
-
for (const { payload, verify, label } of SSTI_PAYLOADS) {
|
|
816
|
-
const u = new URL(target.url);
|
|
817
|
-
u.searchParams.set(target.param, payload);
|
|
818
|
-
const res = await fetchWithTiming(u.toString(), opts);
|
|
819
|
-
await sleep(RATE_LIMIT_MS);
|
|
820
|
-
if (!res.success || !res.body) continue;
|
|
821
|
-
|
|
822
|
-
if ((res.body || '').includes(verify)) {
|
|
823
|
-
findings.push(createFinding('injection', 'ssti', 'critical', `Server-Side Template Injection (${label}) on "${target.param}"`, {
|
|
824
|
-
url: u.toString(),
|
|
825
|
-
cwe: 'CWE-94', owasp: 'A03:2021',
|
|
826
|
-
proof: {
|
|
827
|
-
parameter: target.param,
|
|
828
|
-
payload,
|
|
829
|
-
expected_output: verify,
|
|
830
|
-
verification: `Template expression ${payload} evaluated to ${verify} in response`,
|
|
831
|
-
},
|
|
832
|
-
fix: 'Use logic-less templates. Never render user input as template code.',
|
|
833
|
-
}));
|
|
834
|
-
break;
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
return findings;
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
// ── Inyección de comandos ─────────────────────────────────────────────────────
|
|
842
|
-
|
|
843
|
-
async function testCmdInjection(targetUrl, recon, opts) {
|
|
844
|
-
const findings = [];
|
|
845
|
-
const tested = new Set();
|
|
846
|
-
const targets = collectTargets(targetUrl, recon, tested).slice(0, MAX_ENDPOINTS);
|
|
847
|
-
|
|
848
|
-
for (const target of targets) {
|
|
849
|
-
for (const { payload, label } of CMD_PAYLOADS) {
|
|
850
|
-
const u = new URL(target.url);
|
|
851
|
-
u.searchParams.set(target.param, payload);
|
|
852
|
-
const res = await fetchWithTiming(u.toString(), opts);
|
|
853
|
-
await sleep(RATE_LIMIT_MS);
|
|
854
|
-
if (!res.success || !res.body) continue;
|
|
855
|
-
|
|
856
|
-
if ((res.body || '').includes(CANARY)) {
|
|
857
|
-
findings.push(createFinding('injection', 'command_injection', 'critical', `Command Injection (${label}) on "${target.param}"`, {
|
|
858
|
-
url: u.toString(),
|
|
859
|
-
cwe: 'CWE-78', owasp: 'A03:2021',
|
|
860
|
-
proof: {
|
|
861
|
-
parameter: target.param,
|
|
862
|
-
payload,
|
|
863
|
-
canary: CANARY,
|
|
864
|
-
verification: `Canary "${CANARY}" echoed back in response — command executed on server`,
|
|
865
|
-
},
|
|
866
|
-
fix: 'Never pass user input to shell commands. Use APIs instead of shell calls.',
|
|
867
|
-
}));
|
|
868
|
-
break;
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
return findings;
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// ── Path Traversal ────────────────────────────────────────────────────────────
|
|
876
|
-
|
|
877
|
-
async function testTraversal(targetUrl, recon, opts) {
|
|
878
|
-
const findings = [];
|
|
879
|
-
const tested = new Set();
|
|
880
|
-
const targets = collectTargets(targetUrl, recon, tested).slice(0, MAX_ENDPOINTS);
|
|
881
|
-
|
|
882
|
-
for (const target of targets) {
|
|
883
|
-
for (const { payload, verify } of TRAVERSAL_PAYLOADS) {
|
|
884
|
-
const u = new URL(target.url);
|
|
885
|
-
u.searchParams.set(target.param, payload);
|
|
886
|
-
const res = await fetchWithTiming(u.toString(), opts);
|
|
887
|
-
await sleep(RATE_LIMIT_MS);
|
|
888
|
-
if (!res.success || !res.body) continue;
|
|
889
|
-
|
|
890
|
-
if (verify.test(res.body || '')) {
|
|
891
|
-
findings.push(createFinding('injection', 'path_traversal', 'high', `Path Traversal on "${target.param}"`, {
|
|
892
|
-
url: u.toString(),
|
|
893
|
-
cwe: 'CWE-22', owasp: 'A01:2021',
|
|
894
|
-
proof: {
|
|
895
|
-
parameter: target.param,
|
|
896
|
-
payload,
|
|
897
|
-
verification: `Pattern ${verify} matched in response (likely /etc/passwd or win.ini content)`,
|
|
898
|
-
},
|
|
899
|
-
fix: 'Canonicalize file paths. Use allowlists for file access. Never pass user input directly to file operations.',
|
|
900
|
-
}));
|
|
901
|
-
break;
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
return findings;
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
// ── Open Redirect ─────────────────────────────────────────────────────────────
|
|
909
|
-
|
|
910
|
-
async function testOpenRedirect(targetUrl, recon, opts) {
|
|
911
|
-
const findings = [];
|
|
912
|
-
const tested = new Set();
|
|
913
|
-
const targets = collectTargets(targetUrl, recon, tested).slice(0, MAX_ENDPOINTS);
|
|
914
|
-
const redirectPayloads = ['https://evil.example.com', '//evil.example.com', 'https://evil.example.com%2F@' + new URL(targetUrl).hostname];
|
|
915
|
-
|
|
916
|
-
for (const target of targets) {
|
|
917
|
-
const paramLower = target.param.toLowerCase();
|
|
918
|
-
if (!/url|redirect|next|return|to|location|target|goto/i.test(paramLower)) continue;
|
|
919
|
-
|
|
920
|
-
for (const payload of redirectPayloads) {
|
|
921
|
-
const u = new URL(target.url);
|
|
922
|
-
u.searchParams.set(target.param, payload);
|
|
923
|
-
const res = await fetchWithTiming(u.toString(), opts);
|
|
924
|
-
await sleep(RATE_LIMIT_MS);
|
|
925
|
-
if (!res.success) continue;
|
|
926
|
-
|
|
927
|
-
const loc = res.headers['location'] || '';
|
|
928
|
-
if ((res.statusCode >= 300 && res.statusCode < 400) && loc.includes('evil.example.com')) {
|
|
929
|
-
findings.push(createFinding('logic', 'open_redirect', 'medium', `Open Redirect on "${target.param}"`, {
|
|
930
|
-
url: u.toString(),
|
|
931
|
-
cwe: 'CWE-601', owasp: 'A01:2021',
|
|
932
|
-
proof: {
|
|
933
|
-
parameter: target.param,
|
|
934
|
-
payload,
|
|
935
|
-
redirect_to: loc,
|
|
936
|
-
status_code: res.statusCode,
|
|
937
|
-
verification: 'Server redirected to attacker-controlled domain',
|
|
938
|
-
},
|
|
939
|
-
fix: 'Validate redirect targets against an allowlist. Never use user input as redirect URLs.',
|
|
940
|
-
}));
|
|
941
|
-
break;
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
return findings;
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
// ── Host Header Injection ─────────────────────────────────────────────────────
|
|
949
|
-
|
|
950
|
-
async function testHostHeader(targetUrl, opts) {
|
|
951
|
-
const findings = [];
|
|
952
|
-
const poisonedHost = 'evil.example.com';
|
|
953
|
-
const res = await fetchWithTiming(targetUrl, {
|
|
954
|
-
...opts,
|
|
955
|
-
headers: { ...opts.headers, 'Host': poisonedHost },
|
|
956
|
-
});
|
|
957
|
-
await sleep(RATE_LIMIT_MS);
|
|
958
|
-
|
|
959
|
-
if (!res.success || !res.body) return findings;
|
|
960
|
-
if ((res.body || '').includes(poisonedHost) || (res.headers['location'] || '').includes(poisonedHost)) {
|
|
961
|
-
findings.push(createFinding('config', 'host_header_injection', 'medium', 'Host Header Injection', {
|
|
962
|
-
url: targetUrl,
|
|
963
|
-
cwe: 'CWE-113', owasp: 'A03:2021',
|
|
964
|
-
proof: {
|
|
965
|
-
poisoned_host: poisonedHost,
|
|
966
|
-
verification: 'Server reflected poisoned Host header in response body or Location header',
|
|
967
|
-
},
|
|
968
|
-
fix: 'Validate the Host header against a whitelist of allowed hosts. Use absolute URLs in redirects.',
|
|
969
|
-
}));
|
|
970
|
-
}
|
|
971
|
-
return findings;
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
// ── Method Tampering ──────────────────────────────────────────────────────────
|
|
975
|
-
|
|
976
|
-
async function testMethodTampering(targetUrl, endpoints, opts) {
|
|
977
|
-
const findings = [];
|
|
978
|
-
const urls = [targetUrl, ...endpoints.slice(0, 5)];
|
|
979
|
-
const methods = ['PUT', 'DELETE', 'PATCH', 'TRACE', 'OPTIONS', 'CONNECT'];
|
|
980
|
-
|
|
981
|
-
for (const url of urls) {
|
|
982
|
-
for (const method of methods) {
|
|
983
|
-
const res = await fetchWithTiming(url, { ...opts, method });
|
|
984
|
-
await sleep(RATE_LIMIT_MS);
|
|
985
|
-
if (!res.success) continue;
|
|
986
|
-
|
|
987
|
-
if (res.statusCode === 200 && method !== 'OPTIONS') {
|
|
988
|
-
findings.push(createFinding('config', 'method_tampering', 'medium', `Unexpected HTTP method allowed: ${method}`, {
|
|
989
|
-
url,
|
|
990
|
-
cwe: 'CWE-650', owasp: 'A05:2021',
|
|
991
|
-
proof: {
|
|
992
|
-
method,
|
|
993
|
-
status_code: res.statusCode,
|
|
994
|
-
verification: `Server returned 200 for ${method} request`,
|
|
995
|
-
},
|
|
996
|
-
fix: `Restrict allowed HTTP methods. Only expose GET/POST (and others only where explicitly required).`,
|
|
997
|
-
}));
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
if (method === 'TRACE' && res.statusCode === 200) {
|
|
1001
|
-
findings.push(createFinding('config', 'trace_enabled', 'low', 'HTTP TRACE method enabled (XST risk)', {
|
|
1002
|
-
url,
|
|
1003
|
-
cwe: 'CWE-16', owasp: 'A05:2021',
|
|
1004
|
-
proof: { method: 'TRACE', status_code: 200, verification: 'TRACE method returns 200 — XST attack vector' },
|
|
1005
|
-
fix: 'Disable TRACE method on the web server.',
|
|
1006
|
-
}));
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
return findings;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
// ── Race Condition ────────────────────────────────────────────────────────────
|
|
1014
|
-
|
|
1015
|
-
async function testRaceCondition(targetUrl, recon, opts) {
|
|
1016
|
-
const findings = [];
|
|
1017
|
-
const endpoint = recon.forms.find(f => f.method === 'POST') ? targetUrl : null;
|
|
1018
|
-
if (!endpoint) return findings;
|
|
1019
|
-
|
|
1020
|
-
const concurrency = 20;
|
|
1021
|
-
const start = Date.now();
|
|
1022
|
-
const results = await Promise.all(
|
|
1023
|
-
Array.from({ length: concurrency }, () =>
|
|
1024
|
-
fetchWithTiming(endpoint, { ...opts, method: 'POST', body: 'amount=1', contentType: 'application/x-www-form-urlencoded' })
|
|
1025
|
-
)
|
|
1026
|
-
);
|
|
1027
|
-
const elapsed = Date.now() - start;
|
|
1028
|
-
|
|
1029
|
-
const successes = results.filter(r => r.success && r.statusCode === 200);
|
|
1030
|
-
if (successes.length > 1 && elapsed < 2000) {
|
|
1031
|
-
findings.push(createFinding('logic', 'race_condition', 'high', `Potential Race Condition on POST endpoint`, {
|
|
1032
|
-
url: endpoint,
|
|
1033
|
-
cwe: 'CWE-362', owasp: 'A04:2021',
|
|
1034
|
-
proof: {
|
|
1035
|
-
concurrent_requests: concurrency,
|
|
1036
|
-
successful_responses: successes.length,
|
|
1037
|
-
elapsed_ms: elapsed,
|
|
1038
|
-
verification: `${successes.length} concurrent POST requests succeeded within ${elapsed}ms`,
|
|
1039
|
-
},
|
|
1040
|
-
fix: 'Use database-level locks or atomic operations for state-changing requests.',
|
|
1041
|
-
}));
|
|
1042
|
-
}
|
|
1043
|
-
return findings;
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
// ── Prototype Pollution ───────────────────────────────────────────────────────
|
|
1047
|
-
|
|
1048
|
-
async function testPrototypePollution(targetUrl, recon, opts) {
|
|
1049
|
-
const findings = [];
|
|
1050
|
-
const tested = new Set();
|
|
1051
|
-
const targets = collectTargets(targetUrl, recon, tested).slice(0, MAX_ENDPOINTS);
|
|
1052
|
-
|
|
1053
|
-
for (const target of targets) {
|
|
1054
|
-
const pollutionParams = ['__proto__[polluted]', 'constructor[prototype][polluted]'];
|
|
1055
|
-
for (const pp of pollutionParams) {
|
|
1056
|
-
const u = new URL(target.url);
|
|
1057
|
-
u.searchParams.set(pp, 'true');
|
|
1058
|
-
const url = u.toString();
|
|
1059
|
-
const res = await fetchWithTiming(url, opts);
|
|
1060
|
-
await sleep(RATE_LIMIT_MS);
|
|
1061
|
-
if (!res.success || !res.body) continue;
|
|
1062
|
-
try {
|
|
1063
|
-
const json = JSON.parse(res.body);
|
|
1064
|
-
if (json.polluted === 'true' || json.polluted === true) {
|
|
1065
|
-
findings.push(createFinding('injection', 'prototype_pollution', 'critical', `Prototype Pollution via query parameter on ${target.url}`, {
|
|
1066
|
-
url,
|
|
1067
|
-
cwe: 'CWE-1321', owasp: 'A03:2021',
|
|
1068
|
-
proof: {
|
|
1069
|
-
request: `GET ${url}`,
|
|
1070
|
-
response_excerpt: JSON.stringify(json).slice(0, 200),
|
|
1071
|
-
verification: 'Polluted property appeared in response JSON object',
|
|
1072
|
-
},
|
|
1073
|
-
fix: 'Sanitize object keys. Use Object.create(null) for user-controlled objects. Block __proto__ and constructor keys.',
|
|
1074
|
-
}));
|
|
1075
|
-
return findings;
|
|
1076
|
-
}
|
|
1077
|
-
} catch {}
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
// Body pollution
|
|
1081
|
-
const jsonPayloads = [
|
|
1082
|
-
'{"__proto__":{"polluted":true}}',
|
|
1083
|
-
'{"constructor":{"prototype":{"polluted":true}}}',
|
|
1084
|
-
];
|
|
1085
|
-
for (const body of jsonPayloads) {
|
|
1086
|
-
const res = await fetchWithTiming(target.url, { ...opts, method: 'POST', body, contentType: 'application/json' });
|
|
1087
|
-
await sleep(RATE_LIMIT_MS);
|
|
1088
|
-
if (!res.success || !res.body) continue;
|
|
1089
|
-
try {
|
|
1090
|
-
const json = JSON.parse(res.body);
|
|
1091
|
-
if (json.polluted === true) {
|
|
1092
|
-
findings.push(createFinding('injection', 'prototype_pollution', 'critical', `Prototype Pollution via JSON body on ${target.url}`, {
|
|
1093
|
-
url: target.url,
|
|
1094
|
-
cwe: 'CWE-1321', owasp: 'A03:2021',
|
|
1095
|
-
proof: {
|
|
1096
|
-
request: `POST ${target.url} with ${body}`,
|
|
1097
|
-
verification: 'Polluted property appeared in response after __proto__ injection',
|
|
1098
|
-
},
|
|
1099
|
-
fix: 'Use a JSON schema validator. Strip __proto__ and constructor from parsed objects.',
|
|
1100
|
-
}));
|
|
1101
|
-
return findings;
|
|
1102
|
-
}
|
|
1103
|
-
} catch {}
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
return findings;
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
// ── HTTP Request Smuggling ────────────────────────────────────────────────────
|
|
1110
|
-
|
|
1111
|
-
async function testRequestSmuggling(targetUrl, opts) {
|
|
1112
|
-
const findings = [];
|
|
1113
|
-
const res = await fetchWithTiming(targetUrl, {
|
|
1114
|
-
...opts, method: 'POST',
|
|
1115
|
-
headers: { ...opts.headers, 'Transfer-Encoding': 'chunked' },
|
|
1116
|
-
body: '0\r\n\r\n',
|
|
1117
|
-
});
|
|
1118
|
-
if (!res.success) return findings;
|
|
1119
|
-
|
|
1120
|
-
const smuggleRes = await fetchWithTiming(targetUrl, {
|
|
1121
|
-
...opts, method: 'POST', timeout: 15000,
|
|
1122
|
-
headers: {
|
|
1123
|
-
...opts.headers,
|
|
1124
|
-
'Content-Length': '4',
|
|
1125
|
-
'Transfer-Encoding': 'chunked',
|
|
1126
|
-
},
|
|
1127
|
-
body: '1\r\nZ\r\nQ\r\n\r\n',
|
|
1128
|
-
});
|
|
1129
|
-
|
|
1130
|
-
if (smuggleRes.success && res.success && smuggleRes.statusCode === 400 && res.statusCode < 400) {
|
|
1131
|
-
findings.push(createFinding('network', 'request_smuggling_potential', 'medium', 'Server may be vulnerable to HTTP Request Smuggling (CL/TE desync)', {
|
|
1132
|
-
url: targetUrl,
|
|
1133
|
-
cwe: 'CWE-444', owasp: 'A05:2021',
|
|
1134
|
-
proof: {
|
|
1135
|
-
normal_status: res.statusCode,
|
|
1136
|
-
smuggle_status: smuggleRes.statusCode,
|
|
1137
|
-
verification: 'Server returned different status for conflicting CL/TE headers',
|
|
1138
|
-
},
|
|
1139
|
-
fix: 'Normalize CL/TE handling. Use HTTP/2 where possible.',
|
|
1140
|
-
}));
|
|
1141
|
-
}
|
|
1142
|
-
return findings;
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
// ── CSRF ──────────────────────────────────────────────────────────────────────
|
|
1146
|
-
|
|
1147
|
-
function testCSRF(recon) {
|
|
1148
|
-
const findings = [];
|
|
1149
|
-
for (const form of recon.forms) {
|
|
1150
|
-
if (form.method !== 'POST') continue;
|
|
1151
|
-
if (!form.hasCSRF) {
|
|
1152
|
-
findings.push(createFinding('session', 'csrf_missing', 'medium', `POST form at "${form.action || '/'}" has no CSRF token`, {
|
|
1153
|
-
cwe: 'CWE-352', owasp: 'A01:2021',
|
|
1154
|
-
proof: {
|
|
1155
|
-
form_action: form.action || '/',
|
|
1156
|
-
form_method: form.method,
|
|
1157
|
-
input_fields: form.inputs,
|
|
1158
|
-
verification: 'No field matching csrf/token/_token/authenticity_token found in form',
|
|
1159
|
-
},
|
|
1160
|
-
fix: 'Add a CSRF token to all state-changing forms. Verify the token server-side on submission.',
|
|
1161
|
-
}));
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
return findings;
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
// ── Parameter Pollution ───────────────────────────────────────────────────────
|
|
1168
|
-
|
|
1169
|
-
async function testParamPollution(targetUrl, recon, opts) {
|
|
1170
|
-
const findings = [];
|
|
1171
|
-
const tested = new Set();
|
|
1172
|
-
const targets = collectTargets(targetUrl, recon, tested).slice(0, 5);
|
|
1173
|
-
|
|
1174
|
-
for (const target of targets) {
|
|
1175
|
-
const u = new URL(target.url);
|
|
1176
|
-
u.searchParams.append(target.param, 'val1');
|
|
1177
|
-
u.searchParams.append(target.param, 'val2');
|
|
1178
|
-
const res = await fetchWithTiming(u.toString(), opts);
|
|
1179
|
-
await sleep(RATE_LIMIT_MS);
|
|
1180
|
-
if (!res.success || !res.body) continue;
|
|
1181
|
-
|
|
1182
|
-
if (res.body.includes('val1') && res.body.includes('val2')) {
|
|
1183
|
-
findings.push(createFinding('logic', 'param_pollution', 'low', `HTTP Parameter Pollution on "${target.param}"`, {
|
|
1184
|
-
url: u.toString(),
|
|
1185
|
-
cwe: 'CWE-235',
|
|
1186
|
-
proof: {
|
|
1187
|
-
request: `GET ${u.pathname}?${target.param}=val1&${target.param}=val2`,
|
|
1188
|
-
verification: 'Both parameter values reflected in response',
|
|
1189
|
-
},
|
|
1190
|
-
fix: 'Reject or deduplicate duplicate query parameters.',
|
|
1191
|
-
}));
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
return findings;
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
// ── Utilidad ──────────────────────────────────────────────────────────────────
|
|
1198
|
-
|
|
1199
|
-
function collectTargets(targetUrl, recon, tested) {
|
|
1200
|
-
const targets = [];
|
|
1201
|
-
for (const link of recon.links) {
|
|
1202
|
-
try {
|
|
1203
|
-
const u = new URL(link, targetUrl);
|
|
1204
|
-
for (const [key] of u.searchParams) {
|
|
1205
|
-
const sig = `${u.pathname}:${key}`;
|
|
1206
|
-
if (tested.has(sig)) continue;
|
|
1207
|
-
tested.add(sig);
|
|
1208
|
-
targets.push({ url: `${u.origin}${u.pathname}`, param: key });
|
|
1209
|
-
}
|
|
1210
|
-
} catch {}
|
|
1211
|
-
}
|
|
1212
|
-
for (const param of recon.params) {
|
|
1213
|
-
const sig = `/:${param}`;
|
|
1214
|
-
if (!tested.has(sig)) { tested.add(sig); targets.push({ url: targetUrl, param }); }
|
|
1215
|
-
}
|
|
1216
|
-
return targets;
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
1220
|
-
|
|
1221
|
-
async function main() {
|
|
1222
|
-
const args = process.argv.slice(2);
|
|
1223
|
-
const positional = args.filter(a => !a.startsWith('--'));
|
|
1224
|
-
const flags = args.filter(a => a.startsWith('--'));
|
|
1225
|
-
const deep = flags.includes('--deep');
|
|
1226
|
-
|
|
1227
|
-
let cookie = null, customHeaders = {}, scope = null, timeout = DEFAULT_TIMEOUT;
|
|
1228
|
-
for (let i = 0; i < args.length; i++) {
|
|
1229
|
-
if (args[i] === '--cookie' && args[i + 1]) { cookie = args[++i]; }
|
|
1230
|
-
if (args[i] === '--header' && args[i + 1]) {
|
|
1231
|
-
const h = args[++i]; const idx = h.indexOf(':');
|
|
1232
|
-
if (idx > 0) customHeaders[h.slice(0, idx).trim().toLowerCase()] = h.slice(idx + 1).trim();
|
|
1233
|
-
}
|
|
1234
|
-
if (args[i] === '--scope' && args[i + 1]) { scope = args[++i]; }
|
|
1235
|
-
if (args[i] === '--timeout' && args[i + 1]) { timeout = parseInt(args[++i]) || DEFAULT_TIMEOUT; }
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
if (positional.length < 1) {
|
|
1239
|
-
output({ error: 'Usage: node pentest-scanner.js <target-url> [--deep] [--cookie "name=value"] [--header "Key: Value"] [--scope /path]', success: false });
|
|
1240
|
-
process.exit(0);
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
let targetUrl = positional[0];
|
|
1244
|
-
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = 'https://' + targetUrl;
|
|
1245
|
-
targetUrl = targetUrl.replace(/\/+$/, '');
|
|
1246
|
-
|
|
1247
|
-
// Validación SSRF del target principal antes de iniciar
|
|
1248
|
-
const targetValidation = validateUrl(targetUrl);
|
|
1249
|
-
if (!targetValidation.valid) {
|
|
1250
|
-
output({
|
|
1251
|
-
success: false,
|
|
1252
|
-
target: targetUrl,
|
|
1253
|
-
scanned_at: new Date().toISOString(),
|
|
1254
|
-
findings: [{ type: 'url_bloqueada', razon: targetValidation.reason }],
|
|
1255
|
-
summary: { high: 0, medium: 0, low: 0, false_positives_filtered: 0 },
|
|
1256
|
-
});
|
|
1257
|
-
process.exit(0);
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
const fetchOpts = { timeout, cookie, headers: customHeaders };
|
|
1261
|
-
const startTime = Date.now();
|
|
1262
|
-
const allFindings = [];
|
|
1263
|
-
|
|
1264
|
-
// Fase 1: Reconocimiento
|
|
1265
|
-
const [mainPage, baseline] = await Promise.all([
|
|
1266
|
-
fetchWithTiming(targetUrl, { ...fetchOpts, followRedirects: true }),
|
|
1267
|
-
get404Baseline(targetUrl, fetchOpts),
|
|
1268
|
-
]);
|
|
1269
|
-
|
|
1270
|
-
if (!mainPage.success) {
|
|
1271
|
-
output({
|
|
1272
|
-
success: false,
|
|
1273
|
-
target: targetUrl,
|
|
1274
|
-
scanned_at: new Date().toISOString(),
|
|
1275
|
-
error: `No se pudo alcanzar el objetivo: ${mainPage.error || mainPage.blockReason || 'desconocido'}`,
|
|
1276
|
-
findings: [],
|
|
1277
|
-
summary: { high: 0, medium: 0, low: 0, false_positives_filtered: 0 },
|
|
1278
|
-
});
|
|
1279
|
-
process.exit(0);
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
const recon = extractFromHTML(mainPage.body || '', targetUrl);
|
|
1283
|
-
const techStack = detectTechStack(mainPage.headers || {}, mainPage.body || '');
|
|
1284
|
-
|
|
1285
|
-
if (scope) recon.links = recon.links.filter(l => l.startsWith(scope));
|
|
1286
|
-
|
|
1287
|
-
// Fase 2: Análisis pasivo
|
|
1288
|
-
const [headerFindings, tlsFindings, jwtFindings, csrfFindings] = await Promise.all([
|
|
1289
|
-
Promise.resolve(analyzeHeaders(mainPage)),
|
|
1290
|
-
targetUrl.startsWith('https://') ? analyzeTLS(new URL(targetUrl).hostname) : Promise.resolve([]),
|
|
1291
|
-
Promise.resolve(analyzeJWTs(mainPage)),
|
|
1292
|
-
Promise.resolve(testCSRF(recon)),
|
|
1293
|
-
]);
|
|
1294
|
-
allFindings.push(...headerFindings, ...tlsFindings, ...jwtFindings, ...csrfFindings);
|
|
1295
|
-
|
|
1296
|
-
// Fase 3: Escaneo activo en paralelo
|
|
1297
|
-
const [sensitiveFileFindings, corsFindings, errorFindings, hostHeaderFindings, graphqlFindings, sourceMapFindings] = await Promise.all([
|
|
1298
|
-
scanSensitiveFiles(targetUrl, baseline, fetchOpts),
|
|
1299
|
-
testCORS(targetUrl, recon.links, fetchOpts),
|
|
1300
|
-
testErrorDisclosure(targetUrl, fetchOpts),
|
|
1301
|
-
testHostHeader(targetUrl, fetchOpts),
|
|
1302
|
-
testGraphQL(targetUrl, fetchOpts),
|
|
1303
|
-
testSourceMaps(targetUrl, recon.scripts, fetchOpts),
|
|
1304
|
-
]);
|
|
1305
|
-
allFindings.push(...sensitiveFileFindings, ...corsFindings, ...errorFindings, ...hostHeaderFindings, ...graphqlFindings, ...sourceMapFindings);
|
|
1306
|
-
|
|
1307
|
-
// Pruebas de inyección secuenciales
|
|
1308
|
-
allFindings.push(...await testXSS(targetUrl, recon, fetchOpts));
|
|
1309
|
-
allFindings.push(...await testSQLi(targetUrl, recon, fetchOpts));
|
|
1310
|
-
allFindings.push(...await testSSTI(targetUrl, recon, fetchOpts));
|
|
1311
|
-
allFindings.push(...await testCmdInjection(targetUrl, recon, fetchOpts));
|
|
1312
|
-
allFindings.push(...await testTraversal(targetUrl, recon, fetchOpts));
|
|
1313
|
-
allFindings.push(...await testOpenRedirect(targetUrl, recon, fetchOpts));
|
|
1314
|
-
allFindings.push(...await testMethodTampering(targetUrl, recon.links, fetchOpts));
|
|
1315
|
-
|
|
1316
|
-
// Fase 4: Modo deep
|
|
1317
|
-
if (deep) {
|
|
1318
|
-
allFindings.push(...await testRaceCondition(targetUrl, recon, fetchOpts));
|
|
1319
|
-
allFindings.push(...await testPrototypePollution(targetUrl, recon, fetchOpts));
|
|
1320
|
-
allFindings.push(...await testParamPollution(targetUrl, recon, fetchOpts));
|
|
1321
|
-
allFindings.push(...await testRequestSmuggling(targetUrl, fetchOpts));
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
// Compilar resultados
|
|
1325
|
-
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
1326
|
-
allFindings.sort((a, b) => (severityOrder[a.severity] || 5) - (severityOrder[b.severity] || 5));
|
|
1327
|
-
|
|
1328
|
-
const summary = { critical: 0, high: 0, medium: 0, low: 0, info: 0, false_positives_filtered: 0, total: allFindings.length };
|
|
1329
|
-
for (const f of allFindings) {
|
|
1330
|
-
if (f.severity) summary[f.severity] = (summary[f.severity] || 0) + 1;
|
|
1331
|
-
}
|
|
1332
|
-
|
|
1333
|
-
output({
|
|
1334
|
-
success: true,
|
|
1335
|
-
target: targetUrl,
|
|
1336
|
-
scanned_at: new Date().toISOString(),
|
|
1337
|
-
scan_mode: deep ? 'deep' : 'standard',
|
|
1338
|
-
scan_duration_ms: Date.now() - startTime,
|
|
1339
|
-
tech_stack: techStack,
|
|
1340
|
-
recon: {
|
|
1341
|
-
endpoints_discovered: recon.links.length,
|
|
1342
|
-
parameters_found: recon.params.length,
|
|
1343
|
-
forms_found: recon.forms.length,
|
|
1344
|
-
scripts_found: recon.scripts.length,
|
|
1345
|
-
},
|
|
1346
|
-
summary,
|
|
1347
|
-
findings: allFindings,
|
|
1348
|
-
});
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
// Exportar funciones para tests
|
|
1352
|
-
module.exports = {
|
|
1353
|
-
fetchWithTiming,
|
|
1354
|
-
validateUrl,
|
|
1355
|
-
extractFromHTML,
|
|
1356
|
-
collectTargets,
|
|
1357
|
-
analyzeHeaders,
|
|
1358
|
-
analyzeJWTs,
|
|
1359
|
-
testCSRF,
|
|
1360
|
-
testCORS,
|
|
1361
|
-
testXSS,
|
|
1362
|
-
testSQLi,
|
|
1363
|
-
testSSTI,
|
|
1364
|
-
testCmdInjection,
|
|
1365
|
-
testTraversal,
|
|
1366
|
-
testOpenRedirect,
|
|
1367
|
-
testHostHeader,
|
|
1368
|
-
testMethodTampering,
|
|
1369
|
-
testRaceCondition,
|
|
1370
|
-
testPrototypePollution,
|
|
1371
|
-
testParamPollution,
|
|
1372
|
-
testRequestSmuggling,
|
|
1373
|
-
scanSensitiveFiles,
|
|
1374
|
-
testGraphQL,
|
|
1375
|
-
testSourceMaps,
|
|
1376
|
-
testErrorDisclosure,
|
|
1377
|
-
analyzeTLS,
|
|
1378
|
-
get404Baseline,
|
|
1379
|
-
isSoft404,
|
|
1380
|
-
detectTechStack,
|
|
1381
|
-
generateReportCard: () => '', // placeholder — solo CLI
|
|
1382
|
-
CANARY,
|
|
1383
|
-
SCAN_ID,
|
|
1384
|
-
RATE_LIMIT_MS,
|
|
1385
|
-
MAX_ENDPOINTS,
|
|
1386
|
-
// internal para acceso en tests
|
|
1387
|
-
_internal: { createFinding },
|
|
1388
|
-
};
|
|
1389
|
-
|
|
1390
|
-
// Generar report card ASCII (accesible externamente)
|
|
1391
|
-
function generateReportCard(target, summary, duration, topFindings) {
|
|
1392
|
-
const w = 64;
|
|
1393
|
-
const pad = (s, n) => (s + ' '.repeat(n)).slice(0, n);
|
|
1394
|
-
const ctr = (s, n) => { const p = Math.max(0, n - s.length); const l = Math.floor(p / 2); return ' '.repeat(l) + s + ' '.repeat(p - l); };
|
|
1395
|
-
let score = 100;
|
|
1396
|
-
score -= (summary.critical || 0) * 20;
|
|
1397
|
-
score -= (summary.high || 0) * 10;
|
|
1398
|
-
score -= (summary.medium || 0) * 5;
|
|
1399
|
-
score -= (summary.low || 0) * 2;
|
|
1400
|
-
score = Math.max(0, Math.min(100, score));
|
|
1401
|
-
const rating = score >= 90 ? 'SECURE' : score >= 70 ? 'MODERATE RISK' : score >= 40 ? 'HIGH RISK' : 'CRITICAL RISK';
|
|
1402
|
-
const barLen = Math.round(score / 100 * 20);
|
|
1403
|
-
const bar = '#'.repeat(barLen) + '-'.repeat(20 - barLen);
|
|
1404
|
-
const lines = [];
|
|
1405
|
-
lines.push('+' + '='.repeat(w) + '+');
|
|
1406
|
-
lines.push('|' + ctr('ULTRASHIP PENETRATION TEST REPORT', w) + '|');
|
|
1407
|
-
lines.push('+' + '='.repeat(w) + '+');
|
|
1408
|
-
lines.push('|' + pad(` Target: ${new URL(target).host}`, w) + '|');
|
|
1409
|
-
lines.push('|' + pad(` Duration: ${(duration / 1000).toFixed(1)}s | Findings: ${summary.total}`, w) + '|');
|
|
1410
|
-
lines.push('+' + '='.repeat(w) + '+');
|
|
1411
|
-
lines.push('|' + pad(` ${bar} SCORE: ${score}/100 (${rating})`, w) + '|');
|
|
1412
|
-
lines.push('+' + '='.repeat(w) + '+');
|
|
1413
|
-
lines.push('|' + pad(` [!!] CRITICAL: ${summary.critical} [!] HIGH: ${summary.high} [~] MEDIUM: ${summary.medium} [-] LOW: ${summary.low}`, w) + '|');
|
|
1414
|
-
lines.push('+' + '-'.repeat(w) + '+');
|
|
1415
|
-
if (topFindings && topFindings.length > 0) {
|
|
1416
|
-
lines.push('|' + pad(' TOP FINDINGS:', w) + '|');
|
|
1417
|
-
for (const f of topFindings.slice(0, 8)) {
|
|
1418
|
-
const sev = f.severity === 'critical' ? '[!!]' : f.severity === 'high' ? ' [!]' : f.severity === 'medium' ? ' [~]' : ' [-]';
|
|
1419
|
-
lines.push('|' + pad(` ${sev} ${f.title}`.slice(0, w), w) + '|');
|
|
1420
|
-
}
|
|
1421
|
-
} else {
|
|
1422
|
-
lines.push('|' + ctr('No vulnerabilities found', w) + '|');
|
|
1423
|
-
}
|
|
1424
|
-
lines.push('+' + '='.repeat(w) + '+');
|
|
1425
|
-
return lines.join('\n');
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
// Actualizar export con la versión real
|
|
1429
|
-
module.exports.generateReportCard = generateReportCard;
|
|
1430
|
-
|
|
1431
|
-
if (require.main === module) {
|
|
1432
|
-
main().catch((err) => {
|
|
1433
|
-
process.stderr.write('[pentest-scanner] Error fatal: ' + (err.message || String(err)) + '\n');
|
|
1434
|
-
process.exit(0); // exit 0 — nunca crash Claude Code
|
|
1435
|
-
});
|
|
1436
|
-
}
|
|
1
|
+
// Adaptado de temp/ultraship-main/tools/pentest-scanner.mjs bajo MIT License
|
|
2
|
+
// Fuente: Houseofmvps/ultraship (https://github.com/Houseofmvps/ultraship)
|
|
3
|
+
// Puerto ESM→CJS: Node.js >=22 CommonJS, zero-deps adicionales, SSRF protegido
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const http = require('http');
|
|
7
|
+
const https = require('https');
|
|
8
|
+
const tls = require('tls');
|
|
9
|
+
const { validateUrl, createResponseAccumulator } = require('../../hooks/lib/security-net.js');
|
|
10
|
+
|
|
11
|
+
// ── Constantes ────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const SCAN_ID = Math.random().toString(36).slice(2, 8);
|
|
14
|
+
const CANARY = `xss${SCAN_ID}`;
|
|
15
|
+
const UA = 'Mozilla/5.0 (compatible; SecurityScanner/1.0)';
|
|
16
|
+
const DEFAULT_TIMEOUT = 10000;
|
|
17
|
+
const RATE_LIMIT_MS = 50;
|
|
18
|
+
const MAX_ENDPOINTS = 100;
|
|
19
|
+
const MAX_PARAMS_PER_ENDPOINT = 10;
|
|
20
|
+
|
|
21
|
+
function output(data) {
|
|
22
|
+
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sleep(ms) {
|
|
26
|
+
return new Promise(r => setTimeout(r, ms));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function simpleHash(str) {
|
|
30
|
+
let h = 0;
|
|
31
|
+
for (let i = 0; i < str.length; i++) {
|
|
32
|
+
h = ((h << 5) - h + str.charCodeAt(i)) | 0;
|
|
33
|
+
}
|
|
34
|
+
return (h >>> 0).toString(16);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Payloads ──────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const XSS_PAYLOADS = [
|
|
40
|
+
`<script>document.title='${CANARY}'</script>`,
|
|
41
|
+
`"><img src=x onerror="document.title='${CANARY}'">`,
|
|
42
|
+
`'><svg onload="document.title='${CANARY}'">`,
|
|
43
|
+
`javascript:document.title='${CANARY}'`,
|
|
44
|
+
`<details open ontoggle="document.title='${CANARY}'">`,
|
|
45
|
+
`${CANARY}<script>`,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const SQLI_ERROR_PAYLOADS = ["'", "\"", "' OR '1'='1", "' OR 1=1--", "\" OR \"1\"=\"1", "; SELECT 1--", "' WAITFOR DELAY '0:0:3'--", "' AND SLEEP(3)--", "1; DROP TABLE users--", "' UNION SELECT NULL--", "\\", "' OR 'x'='x"];
|
|
49
|
+
const SQLI_ERROR_PATTERNS = [
|
|
50
|
+
/sql syntax/i, /mysql_fetch/i, /ora-\d{5}/i, /unclosed quotation/i,
|
|
51
|
+
/syntax error.*sql/i, /sqlite.*error/i, /pg.*error/i, /mssql/i,
|
|
52
|
+
/sqlexception/i, /jdbc/i, /odbc/i, /invalid query/i,
|
|
53
|
+
];
|
|
54
|
+
const SQLI_TIME_PAYLOADS = [
|
|
55
|
+
{ payload: "' AND SLEEP(3)--", delay: 3000, label: 'MySQL SLEEP' },
|
|
56
|
+
{ payload: "'; WAITFOR DELAY '0:0:3'--", delay: 3000, label: 'MSSQL WAITFOR' },
|
|
57
|
+
{ payload: "' AND pg_sleep(3)--", delay: 3000, label: 'PostgreSQL pg_sleep' },
|
|
58
|
+
{ payload: "' AND (SELECT * FROM (SELECT(SLEEP(3)))a)--", delay: 3000, label: 'MySQL subquery' },
|
|
59
|
+
];
|
|
60
|
+
const SSTI_PAYLOADS = [
|
|
61
|
+
{ payload: '{{7*7}}', verify: '49', label: 'Jinja2/Twig' },
|
|
62
|
+
{ payload: '${7*7}', verify: '49', label: 'Freemarker/EL' },
|
|
63
|
+
{ payload: '<%= 7*7 %>', verify: '49', label: 'Ruby ERB' },
|
|
64
|
+
{ payload: '#{7*7}', verify: '49', label: 'ERB/EJS' },
|
|
65
|
+
{ payload: '{7*7}', verify: '49', label: 'Smarty' },
|
|
66
|
+
];
|
|
67
|
+
const CMD_PAYLOADS = [
|
|
68
|
+
{ payload: `; echo ${CANARY}`, label: 'Unix semicolon' },
|
|
69
|
+
{ payload: `| echo ${CANARY}`, label: 'Unix pipe' },
|
|
70
|
+
{ payload: `\`echo ${CANARY}\``, label: 'Backtick' },
|
|
71
|
+
{ payload: `$(echo ${CANARY})`, label: 'Command substitution' },
|
|
72
|
+
];
|
|
73
|
+
const TRAVERSAL_PAYLOADS = [
|
|
74
|
+
{ payload: '../../../etc/passwd', verify: /root:.*:0:0/i },
|
|
75
|
+
{ payload: '..%2F..%2F..%2Fetc%2Fpasswd', verify: /root:.*:0:0/i },
|
|
76
|
+
{ payload: '....//....//etc/passwd', verify: /root:.*:0:0/i },
|
|
77
|
+
{ payload: '%2e%2e%2f%2e%2e%2fetc%2fpasswd', verify: /root:.*:0:0/i },
|
|
78
|
+
{ payload: '../../../windows/win.ini', verify: /\[fonts\]/i },
|
|
79
|
+
];
|
|
80
|
+
const SENSITIVE_FILES = [
|
|
81
|
+
{ path: '/.env', verify: /^[A-Z_]+=.+/m, severity: 'critical', title: 'Environment file exposed', cwe: 'CWE-538' },
|
|
82
|
+
{ path: '/.env.local', verify: /^[A-Z_]+=.+/m, severity: 'critical', title: '.env.local exposed', cwe: 'CWE-538' },
|
|
83
|
+
{ path: '/.git/config', verify: /\[core\]/i, severity: 'high', title: 'Git config exposed', cwe: 'CWE-538' },
|
|
84
|
+
{ path: '/config.php', verify: /\$db|password|host/i, severity: 'high', title: 'PHP config exposed', cwe: 'CWE-538' },
|
|
85
|
+
{ path: '/wp-config.php', verify: /DB_PASSWORD|DB_HOST/i, severity: 'critical', title: 'WordPress config exposed', cwe: 'CWE-538' },
|
|
86
|
+
{ path: '/phpinfo.php', verify: /phpinfo|PHP Version/i, severity: 'medium', title: 'phpinfo exposed', cwe: 'CWE-200' },
|
|
87
|
+
{ path: '/server-status', verify: /Apache Server Status|Server Version/i, severity: 'medium', title: 'Apache server-status exposed', cwe: 'CWE-200' },
|
|
88
|
+
{ path: '/.DS_Store', verify: /Bud1|\x00/i, severity: 'low', title: '.DS_Store exposed', cwe: 'CWE-538' },
|
|
89
|
+
{ path: '/robots.txt', verify: /disallow/i, severity: 'info', title: 'robots.txt (may reveal paths)', cwe: 'CWE-200' },
|
|
90
|
+
{ path: '/sitemap.xml', verify: /<url>|<loc>/i, severity: 'info', title: 'Sitemap exposed', cwe: 'CWE-200' },
|
|
91
|
+
{ path: '/backup.zip', verify: /PK\x03\x04/, severity: 'critical', title: 'Backup archive exposed', cwe: 'CWE-530' },
|
|
92
|
+
{ path: '/backup.sql', verify: /CREATE TABLE|INSERT INTO/i, severity: 'critical', title: 'SQL backup exposed', cwe: 'CWE-530' },
|
|
93
|
+
{ path: '/api-docs', verify: /swagger|openapi/i, severity: 'info', title: 'API docs exposed', cwe: 'CWE-200' },
|
|
94
|
+
{ path: '/swagger.json', verify: /swagger|openapi/i, severity: 'info', title: 'Swagger JSON exposed', cwe: 'CWE-200' },
|
|
95
|
+
{ path: '/openapi.json', verify: /openapi/i, severity: 'info', title: 'OpenAPI spec exposed', cwe: 'CWE-200' },
|
|
96
|
+
{ path: '/.htaccess', verify: /RewriteRule|Options|AuthType/i, severity: 'medium', title: '.htaccess exposed', cwe: 'CWE-538' },
|
|
97
|
+
{ path: '/web.config', verify: /<configuration>|<system.web>/i, severity: 'high', title: 'web.config exposed', cwe: 'CWE-538' },
|
|
98
|
+
{ path: '/package.json', verify: /"name"|"version"/i, severity: 'low', title: 'package.json exposed', cwe: 'CWE-200' },
|
|
99
|
+
{ path: '/composer.json', verify: /"name"|"require"/i, severity: 'low', title: 'composer.json exposed', cwe: 'CWE-200' },
|
|
100
|
+
{ path: '/Gemfile', verify: /gem |source /i, severity: 'low', title: 'Gemfile exposed', cwe: 'CWE-200' },
|
|
101
|
+
{ path: '/requirements.txt', verify: /==|>=|<=/, severity: 'low', title: 'requirements.txt exposed', cwe: 'CWE-200' },
|
|
102
|
+
{ path: '/docker-compose.yml', verify: /version:|services:/i, severity: 'medium', title: 'Docker Compose exposed', cwe: 'CWE-538' },
|
|
103
|
+
{ path: '/Dockerfile', verify: /FROM |RUN |CMD /i, severity: 'medium', title: 'Dockerfile exposed', cwe: 'CWE-538' },
|
|
104
|
+
{ path: '/.travis.yml', verify: /language:|script:/i, severity: 'low', title: '.travis.yml exposed', cwe: 'CWE-200' },
|
|
105
|
+
{ path: '/.github/workflows', verify: /on:|jobs:/i, severity: 'low', title: 'GitHub Actions workflow exposed', cwe: 'CWE-200' },
|
|
106
|
+
{ path: '/config/database.yml', verify: /adapter:|database:/i, severity: 'critical', title: 'Rails DB config exposed', cwe: 'CWE-538' },
|
|
107
|
+
{ path: '/application.properties', verify: /spring\.|server\.port/i, severity: 'high', title: 'Spring config exposed', cwe: 'CWE-538' },
|
|
108
|
+
{ path: '/application.yml', verify: /spring:|server:/i, severity: 'high', title: 'Spring YAML config exposed', cwe: 'CWE-538' },
|
|
109
|
+
{ path: '/appsettings.json', verify: /"ConnectionStrings"|"AppSettings"/i, severity: 'high', title: 'ASP.NET config exposed', cwe: 'CWE-538' },
|
|
110
|
+
{ path: '/crossdomain.xml', verify: /<cross-domain-policy>/i, severity: 'medium', title: 'crossdomain.xml found', cwe: 'CWE-942' },
|
|
111
|
+
{ path: '/clientaccesspolicy.xml', verify: /<access-policy>/i, severity: 'medium', title: 'clientaccesspolicy.xml found', cwe: 'CWE-942' },
|
|
112
|
+
{ path: '/admin', verify: /admin|dashboard|login/i, severity: 'info', title: 'Admin panel discovered', cwe: 'CWE-284' },
|
|
113
|
+
{ path: '/login', verify: /login|sign.?in|password/i, severity: 'info', title: 'Login page discovered', cwe: 'CWE-200' },
|
|
114
|
+
{ path: '/phpMyAdmin/', verify: /phpMyAdmin|pma_|PMA_/i, severity: 'high', title: 'phpMyAdmin exposed', cwe: 'CWE-284' },
|
|
115
|
+
];
|
|
116
|
+
const SECURITY_HEADERS = [
|
|
117
|
+
{ name: 'strict-transport-security', severity: 'medium', title: 'Missing HSTS', cwe: 'CWE-319' },
|
|
118
|
+
{ name: 'x-frame-options', severity: 'medium', title: 'Missing X-Frame-Options', cwe: 'CWE-1021' },
|
|
119
|
+
{ name: 'x-content-type-options', severity: 'low', title: 'Missing X-Content-Type-Options', cwe: 'CWE-693' },
|
|
120
|
+
{ name: 'content-security-policy', severity: 'medium', title: 'Missing Content-Security-Policy', cwe: 'CWE-1021' },
|
|
121
|
+
{ name: 'referrer-policy', severity: 'low', title: 'Missing Referrer-Policy', cwe: 'CWE-116' },
|
|
122
|
+
{ name: 'permissions-policy', severity: 'info', title: 'Missing Permissions-Policy', cwe: 'CWE-693' },
|
|
123
|
+
{ name: 'cross-origin-opener-policy', severity: 'info', title: 'Missing Cross-Origin-Opener-Policy', cwe: 'CWE-346' },
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
// ── HTTP Utilities ────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Realiza un request HTTP/HTTPS seguro con timeout, acumulador de respuesta y
|
|
130
|
+
* validación SSRF via validateUrl().
|
|
131
|
+
*
|
|
132
|
+
* @param {string} urlString
|
|
133
|
+
* @param {object} opts
|
|
134
|
+
* @returns {Promise<{success,statusCode,headers,body,timing,error?}>}
|
|
135
|
+
*/
|
|
136
|
+
function fetchWithTiming(urlString, opts = {}) {
|
|
137
|
+
return new Promise((resolve) => {
|
|
138
|
+
// Validación SSRF obligatoria antes de cualquier request
|
|
139
|
+
const validation = validateUrl(urlString);
|
|
140
|
+
if (!validation.valid) {
|
|
141
|
+
resolve({
|
|
142
|
+
success: false,
|
|
143
|
+
blocked: true,
|
|
144
|
+
blockReason: validation.reason,
|
|
145
|
+
statusCode: 0,
|
|
146
|
+
headers: {},
|
|
147
|
+
body: '',
|
|
148
|
+
timing: 0,
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const parsed = validation.url;
|
|
154
|
+
const isHttps = parsed.protocol === 'https:';
|
|
155
|
+
const lib = isHttps ? https : http;
|
|
156
|
+
const timeout = opts.timeout || DEFAULT_TIMEOUT;
|
|
157
|
+
const start = Date.now();
|
|
158
|
+
|
|
159
|
+
const reqOpts = {
|
|
160
|
+
hostname: parsed.hostname,
|
|
161
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
162
|
+
path: parsed.pathname + (parsed.search || ''),
|
|
163
|
+
method: opts.method || 'GET',
|
|
164
|
+
headers: {
|
|
165
|
+
'User-Agent': UA,
|
|
166
|
+
'Accept': 'text/html,application/json,*/*',
|
|
167
|
+
'Accept-Language': 'en-US,en;q=0.5',
|
|
168
|
+
...opts.headers,
|
|
169
|
+
},
|
|
170
|
+
rejectUnauthorized: false, // pentest scanner examina TLS por separado
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
if (opts.cookie) reqOpts.headers['Cookie'] = opts.cookie;
|
|
174
|
+
|
|
175
|
+
const body = opts.body;
|
|
176
|
+
if (body) {
|
|
177
|
+
const ct = opts.contentType || 'application/x-www-form-urlencoded';
|
|
178
|
+
reqOpts.headers['Content-Type'] = ct;
|
|
179
|
+
reqOpts.headers['Content-Length'] = Buffer.byteLength(body, 'utf8').toString();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const accumulator = createResponseAccumulator();
|
|
183
|
+
let timedOut = false;
|
|
184
|
+
|
|
185
|
+
const req = lib.request(reqOpts, (res) => {
|
|
186
|
+
res.on('data', (chunk) => accumulator.onData(chunk.toString('binary')));
|
|
187
|
+
res.on('end', () => {
|
|
188
|
+
if (timedOut) return;
|
|
189
|
+
resolve({
|
|
190
|
+
success: true,
|
|
191
|
+
statusCode: res.statusCode,
|
|
192
|
+
headers: res.headers || {},
|
|
193
|
+
body: accumulator.getBody(),
|
|
194
|
+
timing: Date.now() - start,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
res.on('error', () => {
|
|
198
|
+
if (!timedOut) resolve({ success: false, statusCode: 0, headers: {}, body: '', timing: Date.now() - start, error: 'response error' });
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const timer = setTimeout(() => {
|
|
203
|
+
timedOut = true;
|
|
204
|
+
req.destroy();
|
|
205
|
+
resolve({ success: false, statusCode: 0, headers: {}, body: '', timing: Date.now() - start, error: 'timeout' });
|
|
206
|
+
}, timeout);
|
|
207
|
+
|
|
208
|
+
req.on('error', (err) => {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
if (!timedOut) resolve({ success: false, statusCode: 0, headers: {}, body: '', timing: Date.now() - start, error: err.message });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
req.on('close', () => clearTimeout(timer));
|
|
214
|
+
|
|
215
|
+
if (body) req.write(body);
|
|
216
|
+
req.end();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Reconocimiento ────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
async function get404Baseline(baseUrl, opts) {
|
|
223
|
+
const randomPath = `/${simpleHash(SCAN_ID + Date.now())}-notfound`;
|
|
224
|
+
const res = await fetchWithTiming(baseUrl + randomPath, opts);
|
|
225
|
+
return {
|
|
226
|
+
statusCode: res.statusCode,
|
|
227
|
+
bodyLength: (res.body || '').length,
|
|
228
|
+
bodyHash: simpleHash(res.body || ''),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isSoft404(response, baseline) {
|
|
233
|
+
if (!baseline || !response.success) return false;
|
|
234
|
+
if (response.statusCode === baseline.statusCode) {
|
|
235
|
+
const lengthDiff = Math.abs((response.body || '').length - baseline.bodyLength);
|
|
236
|
+
const percentDiff = baseline.bodyLength > 0 ? lengthDiff / baseline.bodyLength : 0;
|
|
237
|
+
if (percentDiff < 0.1) return true;
|
|
238
|
+
if (simpleHash(response.body || '') === baseline.bodyHash) return true;
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function extractFromHTML(body, baseUrl) {
|
|
244
|
+
const links = new Set();
|
|
245
|
+
const forms = [];
|
|
246
|
+
const scripts = [];
|
|
247
|
+
const params = new Set();
|
|
248
|
+
|
|
249
|
+
// Extraer hrefs
|
|
250
|
+
const hrefRe = /href\s*=\s*["']([^"'#?]+(?:\?[^"']*)?)/gi;
|
|
251
|
+
let m;
|
|
252
|
+
while ((m = hrefRe.exec(body)) !== null) {
|
|
253
|
+
try {
|
|
254
|
+
const u = new URL(m[1], baseUrl);
|
|
255
|
+
if (u.origin === new URL(baseUrl).origin) {
|
|
256
|
+
links.add(u.href);
|
|
257
|
+
for (const [k] of u.searchParams) params.add(k);
|
|
258
|
+
}
|
|
259
|
+
} catch {}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Extraer scripts
|
|
263
|
+
const scriptRe = /src\s*=\s*["']([^"']+\.js[^"']*)/gi;
|
|
264
|
+
while ((m = scriptRe.exec(body)) !== null) {
|
|
265
|
+
try { scripts.push(new URL(m[1], baseUrl).href); } catch {}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Extraer forms
|
|
269
|
+
const formRe = /<form([^>]*)>([\s\S]*?)<\/form>/gi;
|
|
270
|
+
while ((m = formRe.exec(body)) !== null) {
|
|
271
|
+
const attrs = m[1];
|
|
272
|
+
const inner = m[2];
|
|
273
|
+
const actionM = /action\s*=\s*["']([^"']*)/i.exec(attrs);
|
|
274
|
+
const methodM = /method\s*=\s*["']([^"']*)/i.exec(attrs);
|
|
275
|
+
const inputs = [];
|
|
276
|
+
const inputRe = /<input[^>]+name\s*=\s*["']([^"']*)/gi;
|
|
277
|
+
let im;
|
|
278
|
+
while ((im = inputRe.exec(inner)) !== null) inputs.push(im[1]);
|
|
279
|
+
const hasCSRF = inputs.some(n => /csrf|token|_token|authenticity/i.test(n));
|
|
280
|
+
forms.push({
|
|
281
|
+
action: actionM ? actionM[1] : null,
|
|
282
|
+
method: (methodM ? methodM[1] : 'GET').toUpperCase(),
|
|
283
|
+
inputs,
|
|
284
|
+
hasCSRF,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Extraer query params desde links del body
|
|
289
|
+
const qpRe = /[?&]([a-zA-Z0-9_\-]+)=/g;
|
|
290
|
+
while ((m = qpRe.exec(body)) !== null) params.add(m[1]);
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
links: [...links].slice(0, MAX_ENDPOINTS),
|
|
294
|
+
forms,
|
|
295
|
+
scripts: [...new Set(scripts)],
|
|
296
|
+
params: [...params].slice(0, MAX_PARAMS_PER_ENDPOINT),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function detectTechStack(headers, body) {
|
|
301
|
+
const stack = [];
|
|
302
|
+
const h = JSON.stringify(headers).toLowerCase();
|
|
303
|
+
if (h.includes('php')) stack.push('PHP');
|
|
304
|
+
if (h.includes('express')) stack.push('Express.js');
|
|
305
|
+
if (h.includes('nginx')) stack.push('Nginx');
|
|
306
|
+
if (h.includes('apache')) stack.push('Apache');
|
|
307
|
+
if (h.includes('wordpress') || body.toLowerCase().includes('wp-content')) stack.push('WordPress');
|
|
308
|
+
if (h.includes('drupal') || body.toLowerCase().includes('drupal')) stack.push('Drupal');
|
|
309
|
+
if (body.toLowerCase().includes('react') || body.includes('__next')) stack.push('React/Next.js');
|
|
310
|
+
if (body.toLowerCase().includes('angular')) stack.push('Angular');
|
|
311
|
+
if (body.toLowerCase().includes('vue')) stack.push('Vue.js');
|
|
312
|
+
return stack;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Hallazgos ─────────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
let findingCounter = 0;
|
|
318
|
+
|
|
319
|
+
function createFinding(category, subcategory, severity, title, opts = {}) {
|
|
320
|
+
return {
|
|
321
|
+
id: ++findingCounter,
|
|
322
|
+
category,
|
|
323
|
+
subcategory,
|
|
324
|
+
severity,
|
|
325
|
+
title,
|
|
326
|
+
url: opts.url || null,
|
|
327
|
+
cwe: opts.cwe || null,
|
|
328
|
+
owasp: opts.owasp || null,
|
|
329
|
+
proof: opts.proof || null,
|
|
330
|
+
fix: opts.fix || null,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── Archivos sensibles ────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
async function scanSensitiveFiles(baseUrl, baseline, opts) {
|
|
337
|
+
const findings = [];
|
|
338
|
+
for (const entry of SENSITIVE_FILES) {
|
|
339
|
+
const url = baseUrl + entry.path;
|
|
340
|
+
const res = await fetchWithTiming(url, opts);
|
|
341
|
+
await sleep(RATE_LIMIT_MS);
|
|
342
|
+
if (!res.success) continue;
|
|
343
|
+
if (res.statusCode !== 200 && res.statusCode !== 206) continue;
|
|
344
|
+
if (isSoft404(res, baseline)) continue;
|
|
345
|
+
const body = res.body || '';
|
|
346
|
+
if (!entry.verify.test(body)) continue;
|
|
347
|
+
findings.push(createFinding('exposure', 'sensitive_file', entry.severity, entry.title, {
|
|
348
|
+
url,
|
|
349
|
+
cwe: entry.cwe,
|
|
350
|
+
owasp: 'A05:2021 Security Misconfiguration',
|
|
351
|
+
proof: {
|
|
352
|
+
request: `GET ${entry.path}`,
|
|
353
|
+
status: res.statusCode,
|
|
354
|
+
response_excerpt: body.slice(0, 200),
|
|
355
|
+
verification: `Pattern matched: ${entry.verify}`,
|
|
356
|
+
},
|
|
357
|
+
fix: `Restrict access to ${entry.path} via server configuration. Never expose sensitive config files.`,
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
return findings;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Cabeceras de seguridad ────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
function analyzeHeaders(response) {
|
|
366
|
+
const findings = [];
|
|
367
|
+
const headers = response.headers || {};
|
|
368
|
+
const body = response.body || '';
|
|
369
|
+
|
|
370
|
+
for (const h of SECURITY_HEADERS) {
|
|
371
|
+
if (!headers[h.name]) {
|
|
372
|
+
findings.push(createFinding('headers', h.name.replace(/-/g, '_'), h.severity, h.title, {
|
|
373
|
+
cwe: h.cwe,
|
|
374
|
+
owasp: 'A05:2021 Security Misconfiguration',
|
|
375
|
+
proof: { missing_header: h.name, verification: `Header "${h.name}" absent from response` },
|
|
376
|
+
fix: `Add the "${h.name}" header to all HTTP responses.`,
|
|
377
|
+
}));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// CSP débil
|
|
382
|
+
const csp = headers['content-security-policy'] || '';
|
|
383
|
+
if (csp && (csp.includes("'unsafe-inline'") || csp.includes("'unsafe-eval'") || csp.includes('*'))) {
|
|
384
|
+
findings.push(createFinding('headers', 'weak_csp', 'medium', 'Content-Security-Policy is weak', {
|
|
385
|
+
cwe: 'CWE-1021', owasp: 'A05:2021',
|
|
386
|
+
proof: { csp_value: csp, verification: "CSP contains 'unsafe-inline', 'unsafe-eval', or wildcard" },
|
|
387
|
+
fix: "Remove 'unsafe-inline', 'unsafe-eval', and wildcard sources from CSP.",
|
|
388
|
+
}));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Cookies sin Secure/HttpOnly
|
|
392
|
+
const cookies = [].concat(headers['set-cookie'] || []);
|
|
393
|
+
for (const cookie of cookies) {
|
|
394
|
+
const lc = cookie.toLowerCase();
|
|
395
|
+
if (!lc.includes('httponly')) {
|
|
396
|
+
findings.push(createFinding('headers', 'cookie_no_httponly', 'medium', 'Cookie missing HttpOnly flag', {
|
|
397
|
+
cwe: 'CWE-1004', owasp: 'A05:2021',
|
|
398
|
+
proof: { cookie: cookie.slice(0, 80), verification: 'HttpOnly flag absent in Set-Cookie' },
|
|
399
|
+
fix: 'Add HttpOnly flag to all session cookies.',
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
if (response.statusCode && !lc.includes('secure') && !lc.includes('samesite=none')) {
|
|
403
|
+
findings.push(createFinding('headers', 'cookie_no_secure', 'low', 'Cookie missing Secure flag', {
|
|
404
|
+
cwe: 'CWE-614', owasp: 'A05:2021',
|
|
405
|
+
proof: { cookie: cookie.slice(0, 80), verification: 'Secure flag absent in Set-Cookie' },
|
|
406
|
+
fix: 'Add Secure flag to all session cookies to prevent transmission over HTTP.',
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Server header con versión
|
|
412
|
+
const serverHeader = headers['server'] || '';
|
|
413
|
+
if (serverHeader && /\d\.\d/.test(serverHeader)) {
|
|
414
|
+
findings.push(createFinding('exposure', 'server_version', 'low', `Server version disclosed: ${serverHeader}`, {
|
|
415
|
+
cwe: 'CWE-200', owasp: 'A05:2021',
|
|
416
|
+
proof: { server_header: serverHeader, verification: 'Version number found in Server header' },
|
|
417
|
+
fix: 'Configure server to omit version from the Server header.',
|
|
418
|
+
}));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// X-Powered-By
|
|
422
|
+
const xPoweredBy = headers['x-powered-by'];
|
|
423
|
+
if (xPoweredBy) {
|
|
424
|
+
findings.push(createFinding('exposure', 'technology_disclosure', 'low', `Technology disclosed via X-Powered-By: ${xPoweredBy}`, {
|
|
425
|
+
cwe: 'CWE-200', owasp: 'A05:2021',
|
|
426
|
+
proof: { header: xPoweredBy, verification: 'X-Powered-By header reveals technology stack' },
|
|
427
|
+
fix: 'Remove or obfuscate the X-Powered-By header.',
|
|
428
|
+
}));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return findings;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── TLS ───────────────────────────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
function analyzeTLS(hostname, port) {
|
|
437
|
+
port = port || 443;
|
|
438
|
+
return new Promise((resolve) => {
|
|
439
|
+
const findings = [];
|
|
440
|
+
const socket = tls.connect({ host: hostname, port, rejectUnauthorized: false, timeout: 5000 }, () => {
|
|
441
|
+
const cert = socket.getPeerCertificate();
|
|
442
|
+
const proto = socket.getProtocol ? socket.getProtocol() : null;
|
|
443
|
+
|
|
444
|
+
if (proto && (proto === 'TLSv1' || proto === 'TLSv1.1')) {
|
|
445
|
+
findings.push(createFinding('tls', 'weak_protocol', 'high', `Deprecated TLS version in use: ${proto}`, {
|
|
446
|
+
cwe: 'CWE-326', owasp: 'A02:2021',
|
|
447
|
+
proof: { protocol: proto, verification: 'TLS 1.0/1.1 is deprecated and vulnerable to POODLE/BEAST attacks' },
|
|
448
|
+
fix: 'Disable TLS 1.0 and 1.1. Use TLS 1.2+ only.',
|
|
449
|
+
}));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (cert && cert.valid_to) {
|
|
453
|
+
const expiry = new Date(cert.valid_to);
|
|
454
|
+
const daysLeft = (expiry - Date.now()) / 86400000;
|
|
455
|
+
if (daysLeft < 30) {
|
|
456
|
+
findings.push(createFinding('tls', 'cert_expiry', daysLeft < 0 ? 'critical' : 'high',
|
|
457
|
+
daysLeft < 0 ? 'TLS certificate has expired' : `TLS certificate expires in ${Math.floor(daysLeft)} days`, {
|
|
458
|
+
cwe: 'CWE-298', owasp: 'A02:2021',
|
|
459
|
+
proof: { valid_to: cert.valid_to, days_remaining: Math.floor(daysLeft), verification: 'Certificate expiry within 30 days or already expired' },
|
|
460
|
+
fix: 'Renew the TLS certificate immediately.',
|
|
461
|
+
}));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (cert && !cert.subject) {
|
|
466
|
+
findings.push(createFinding('tls', 'self_signed', 'medium', 'Self-signed TLS certificate detected', {
|
|
467
|
+
cwe: 'CWE-297', owasp: 'A02:2021',
|
|
468
|
+
proof: { verification: 'Certificate subject missing or self-signed' },
|
|
469
|
+
fix: 'Use a certificate issued by a trusted Certificate Authority.',
|
|
470
|
+
}));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
socket.destroy();
|
|
474
|
+
resolve(findings);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
socket.on('error', () => resolve(findings));
|
|
478
|
+
socket.setTimeout(5000, () => { socket.destroy(); resolve(findings); });
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── JWT ───────────────────────────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
function analyzeJWTs(response) {
|
|
485
|
+
const findings = [];
|
|
486
|
+
const body = response.body || '';
|
|
487
|
+
const headers = response.headers || {};
|
|
488
|
+
|
|
489
|
+
const allText = body + JSON.stringify(headers);
|
|
490
|
+
const jwtRe = /eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]*/g;
|
|
491
|
+
const tokens = allText.match(jwtRe) || [];
|
|
492
|
+
|
|
493
|
+
for (const token of tokens.slice(0, 5)) {
|
|
494
|
+
try {
|
|
495
|
+
const parts = token.split('.');
|
|
496
|
+
if (parts.length < 3) continue;
|
|
497
|
+
const header = JSON.parse(Buffer.from(parts[0], 'base64').toString());
|
|
498
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
|
499
|
+
|
|
500
|
+
if (header.alg === 'none' || header.alg === 'NONE') {
|
|
501
|
+
findings.push(createFinding('auth', 'jwt_none_algorithm', 'critical', 'JWT using "none" algorithm', {
|
|
502
|
+
cwe: 'CWE-347', owasp: 'A02:2021 Cryptographic Failures',
|
|
503
|
+
proof: { alg: header.alg, token_preview: token.slice(0, 60) + '...', verification: 'JWT header declares alg:none — no signature verification' },
|
|
504
|
+
fix: 'Reject JWTs with alg:none. Whitelist only accepted algorithms (RS256, ES256).',
|
|
505
|
+
}));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (header.alg === 'HS256' || header.alg === 'HS384' || header.alg === 'HS512') {
|
|
509
|
+
findings.push(createFinding('auth', 'jwt_weak_algorithm', 'medium', `JWT uses symmetric algorithm: ${header.alg}`, {
|
|
510
|
+
cwe: 'CWE-327', owasp: 'A02:2021',
|
|
511
|
+
proof: { alg: header.alg, verification: 'HMAC algorithms are vulnerable to secret brute-force' },
|
|
512
|
+
fix: 'Prefer asymmetric algorithms (RS256, ES256) for JWTs.',
|
|
513
|
+
}));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (payload.exp) {
|
|
517
|
+
const expDate = new Date(payload.exp * 1000);
|
|
518
|
+
if (expDate < new Date()) {
|
|
519
|
+
findings.push(createFinding('auth', 'jwt_expired', 'high', 'Expired JWT accepted', {
|
|
520
|
+
cwe: 'CWE-613', owasp: 'A07:2021',
|
|
521
|
+
proof: { exp: expDate.toISOString(), verification: 'JWT expiry is in the past' },
|
|
522
|
+
fix: 'Validate JWT expiry (exp claim) on every request.',
|
|
523
|
+
}));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Datos sensibles en payload
|
|
528
|
+
const payloadStr = JSON.stringify(payload);
|
|
529
|
+
if (/password|secret|key|token|credential/i.test(payloadStr)) {
|
|
530
|
+
findings.push(createFinding('exposure', 'jwt_sensitive_data', 'high', 'JWT payload contains sensitive data', {
|
|
531
|
+
cwe: 'CWE-312', owasp: 'A02:2021',
|
|
532
|
+
proof: { fields: Object.keys(payload), verification: 'Sensitive field name detected in JWT payload' },
|
|
533
|
+
fix: 'Never store sensitive data in JWT payload. JWT payload is base64-encoded, not encrypted.',
|
|
534
|
+
}));
|
|
535
|
+
}
|
|
536
|
+
} catch {}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return findings;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ── GraphQL ───────────────────────────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
async function testGraphQL(targetUrl, opts) {
|
|
545
|
+
const findings = [];
|
|
546
|
+
const endpoints = ['/graphql', '/api/graphql', '/query', '/gql', '/v1/graphql'];
|
|
547
|
+
|
|
548
|
+
for (const ep of endpoints) {
|
|
549
|
+
const url = targetUrl + ep;
|
|
550
|
+
const introspect = '{"query":"{ __schema { types { name } } }"}';
|
|
551
|
+
const res = await fetchWithTiming(url, { ...opts, method: 'POST', body: introspect, contentType: 'application/json' });
|
|
552
|
+
await sleep(RATE_LIMIT_MS);
|
|
553
|
+
|
|
554
|
+
if (!res.success) continue;
|
|
555
|
+
if (res.statusCode !== 200) continue;
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
const json = JSON.parse(res.body || '');
|
|
559
|
+
if (json.data && json.data.__schema) {
|
|
560
|
+
findings.push(createFinding('exposure', 'graphql_introspection', 'medium', `GraphQL introspection enabled at ${ep}`, {
|
|
561
|
+
url,
|
|
562
|
+
cwe: 'CWE-200', owasp: 'A05:2021',
|
|
563
|
+
proof: {
|
|
564
|
+
endpoint: ep,
|
|
565
|
+
types_count: (json.data.__schema.types || []).length,
|
|
566
|
+
verification: 'Introspection query returned __schema data',
|
|
567
|
+
},
|
|
568
|
+
fix: 'Disable GraphQL introspection in production environments.',
|
|
569
|
+
}));
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
} catch {}
|
|
573
|
+
}
|
|
574
|
+
return findings;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ── Source Maps ───────────────────────────────────────────────────────────────
|
|
578
|
+
|
|
579
|
+
async function testSourceMaps(targetUrl, scripts, opts) {
|
|
580
|
+
const findings = [];
|
|
581
|
+
const checked = new Set();
|
|
582
|
+
|
|
583
|
+
for (const scriptUrl of scripts.slice(0, 10)) {
|
|
584
|
+
const mapUrl = scriptUrl + '.map';
|
|
585
|
+
if (checked.has(mapUrl)) continue;
|
|
586
|
+
checked.add(mapUrl);
|
|
587
|
+
|
|
588
|
+
const res = await fetchWithTiming(mapUrl, opts);
|
|
589
|
+
await sleep(RATE_LIMIT_MS);
|
|
590
|
+
if (!res.success || res.statusCode !== 200) continue;
|
|
591
|
+
|
|
592
|
+
try {
|
|
593
|
+
const json = JSON.parse(res.body || '');
|
|
594
|
+
if (json.sources || json.mappings) {
|
|
595
|
+
findings.push(createFinding('exposure', 'source_map_exposed', 'medium', `Source map exposed: ${mapUrl}`, {
|
|
596
|
+
url: mapUrl,
|
|
597
|
+
cwe: 'CWE-540', owasp: 'A05:2021',
|
|
598
|
+
proof: {
|
|
599
|
+
sources_count: (json.sources || []).length,
|
|
600
|
+
verification: 'Source map file accessible and contains valid mappings',
|
|
601
|
+
},
|
|
602
|
+
fix: 'Remove .map files from production deployments or block access via server config.',
|
|
603
|
+
}));
|
|
604
|
+
}
|
|
605
|
+
} catch {}
|
|
606
|
+
}
|
|
607
|
+
return findings;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ── Error Disclosure ──────────────────────────────────────────────────────────
|
|
611
|
+
|
|
612
|
+
async function testErrorDisclosure(targetUrl, opts) {
|
|
613
|
+
const findings = [];
|
|
614
|
+
const probes = [
|
|
615
|
+
targetUrl + "/'",
|
|
616
|
+
targetUrl + '/undefined/undefined',
|
|
617
|
+
targetUrl + '?id=../../../../etc/passwd',
|
|
618
|
+
targetUrl + '/api?debug=true&verbose=1',
|
|
619
|
+
];
|
|
620
|
+
|
|
621
|
+
for (const url of probes) {
|
|
622
|
+
const res = await fetchWithTiming(url, opts);
|
|
623
|
+
await sleep(RATE_LIMIT_MS);
|
|
624
|
+
if (!res.success) continue;
|
|
625
|
+
const body = res.body || '';
|
|
626
|
+
|
|
627
|
+
const patterns = [
|
|
628
|
+
{ re: /stack trace|stacktrace|at\s+\w+\s+\(/i, label: 'Stack trace in response' },
|
|
629
|
+
{ re: /exception in thread|unhandled exception|java\.lang\./i, label: 'Java exception disclosed' },
|
|
630
|
+
{ re: /Microsoft\.CSharp|System\.Web|ASP\.NET|\.aspx/i, label: 'ASP.NET error disclosed' },
|
|
631
|
+
{ re: /Parse error:|Fatal error:|Warning:|Notice:/i, label: 'PHP error disclosed' },
|
|
632
|
+
{ re: /OperationalError|IntegrityError|ProgrammingError/i, label: 'Python DB error' },
|
|
633
|
+
{ re: /SyntaxError:|ReferenceError:|TypeError:/i, label: 'JS error disclosed' },
|
|
634
|
+
{ re: /server error|internal server error/i, label: 'Generic server error' },
|
|
635
|
+
];
|
|
636
|
+
|
|
637
|
+
for (const { re, label } of patterns) {
|
|
638
|
+
if (re.test(body)) {
|
|
639
|
+
findings.push(createFinding('exposure', 'error_disclosure', 'medium', label, {
|
|
640
|
+
url,
|
|
641
|
+
cwe: 'CWE-209', owasp: 'A05:2021',
|
|
642
|
+
proof: {
|
|
643
|
+
request: `GET ${new URL(url).pathname}`,
|
|
644
|
+
response_excerpt: body.slice(0, 300),
|
|
645
|
+
verification: `Pattern matched: ${re}`,
|
|
646
|
+
},
|
|
647
|
+
fix: 'Configure generic error pages. Never expose stack traces in production.',
|
|
648
|
+
}));
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return findings;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ── CORS ──────────────────────────────────────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
async function testCORS(targetUrl, endpoints, opts) {
|
|
659
|
+
const findings = [];
|
|
660
|
+
const urls = [targetUrl, ...endpoints.slice(0, 5)];
|
|
661
|
+
|
|
662
|
+
for (const url of urls) {
|
|
663
|
+
const origins = [
|
|
664
|
+
'https://evil.example.com',
|
|
665
|
+
'null',
|
|
666
|
+
targetUrl.replace('://', '://evil.'),
|
|
667
|
+
];
|
|
668
|
+
|
|
669
|
+
for (const origin of origins) {
|
|
670
|
+
const res = await fetchWithTiming(url, {
|
|
671
|
+
...opts,
|
|
672
|
+
headers: { ...opts.headers, 'Origin': origin },
|
|
673
|
+
});
|
|
674
|
+
await sleep(RATE_LIMIT_MS);
|
|
675
|
+
if (!res.success) continue;
|
|
676
|
+
|
|
677
|
+
const acao = res.headers['access-control-allow-origin'] || '';
|
|
678
|
+
const acac = (res.headers['access-control-allow-credentials'] || '').toLowerCase();
|
|
679
|
+
|
|
680
|
+
if ((acao === '*' && acac === 'true') || acao === origin) {
|
|
681
|
+
const severity = (acac === 'true') ? 'high' : 'medium';
|
|
682
|
+
findings.push(createFinding('config', 'cors_misconfiguration', severity, `CORS misconfiguration — allows origin: ${origin}`, {
|
|
683
|
+
url,
|
|
684
|
+
cwe: 'CWE-942', owasp: 'A05:2021',
|
|
685
|
+
proof: {
|
|
686
|
+
origin_sent: origin,
|
|
687
|
+
acao_header: acao,
|
|
688
|
+
acac_header: acac || '(not present)',
|
|
689
|
+
verification: 'Server reflects or wildcard-allows the malicious origin',
|
|
690
|
+
},
|
|
691
|
+
fix: 'Restrict Access-Control-Allow-Origin to specific trusted origins. Never combine wildcard with credentials.',
|
|
692
|
+
}));
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return findings;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ── XSS ───────────────────────────────────────────────────────────────────────
|
|
700
|
+
|
|
701
|
+
function extractContext(body, needle, radius) {
|
|
702
|
+
radius = radius || 100;
|
|
703
|
+
const idx = body.indexOf(needle);
|
|
704
|
+
if (idx === -1) return null;
|
|
705
|
+
return body.slice(Math.max(0, idx - radius), idx + needle.length + radius);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function testXSS(targetUrl, recon, opts) {
|
|
709
|
+
const findings = [];
|
|
710
|
+
const tested = new Set();
|
|
711
|
+
const targets = collectTargets(targetUrl, recon, tested).slice(0, MAX_ENDPOINTS);
|
|
712
|
+
|
|
713
|
+
for (const target of targets) {
|
|
714
|
+
for (const xssPayload of XSS_PAYLOADS.slice(0, 3)) {
|
|
715
|
+
const u = new URL(target.url);
|
|
716
|
+
u.searchParams.set(target.param, xssPayload);
|
|
717
|
+
const res = await fetchWithTiming(u.toString(), opts);
|
|
718
|
+
await sleep(RATE_LIMIT_MS);
|
|
719
|
+
if (!res.success || !res.body) continue;
|
|
720
|
+
|
|
721
|
+
const body = res.body;
|
|
722
|
+
// Verificar reflexión del canary
|
|
723
|
+
if (body.includes(CANARY) && body.includes(xssPayload)) {
|
|
724
|
+
const context = extractContext(body, xssPayload);
|
|
725
|
+
findings.push(createFinding('injection', 'xss_reflected', 'high', `Reflected XSS on parameter "${target.param}"`, {
|
|
726
|
+
url: u.toString(),
|
|
727
|
+
cwe: 'CWE-79', owasp: 'A03:2021',
|
|
728
|
+
proof: {
|
|
729
|
+
parameter: target.param,
|
|
730
|
+
payload: xssPayload,
|
|
731
|
+
context: context ? context.slice(0, 200) : '(see response)',
|
|
732
|
+
verification: `Canary "${CANARY}" and payload reflected unescaped in response body`,
|
|
733
|
+
},
|
|
734
|
+
fix: 'Encode all user-controlled output. Use Content-Security-Policy. Avoid innerHTML with user data.',
|
|
735
|
+
}));
|
|
736
|
+
break; // un hallazgo por parámetro es suficiente
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return findings;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ── SQLi ──────────────────────────────────────────────────────────────────────
|
|
744
|
+
|
|
745
|
+
async function testSQLi(targetUrl, recon, opts) {
|
|
746
|
+
const findings = [];
|
|
747
|
+
const tested = new Set();
|
|
748
|
+
const targets = collectTargets(targetUrl, recon, tested).slice(0, MAX_ENDPOINTS);
|
|
749
|
+
|
|
750
|
+
for (const target of targets) {
|
|
751
|
+
// Prueba basada en errores
|
|
752
|
+
for (const payload of SQLI_ERROR_PAYLOADS) {
|
|
753
|
+
const u = new URL(target.url);
|
|
754
|
+
u.searchParams.set(target.param, payload);
|
|
755
|
+
const res = await fetchWithTiming(u.toString(), opts);
|
|
756
|
+
await sleep(RATE_LIMIT_MS);
|
|
757
|
+
if (!res.success || !res.body) continue;
|
|
758
|
+
|
|
759
|
+
const body = res.body;
|
|
760
|
+
const matched = SQLI_ERROR_PATTERNS.find(p => p.test(body));
|
|
761
|
+
if (matched) {
|
|
762
|
+
findings.push(createFinding('injection', 'sqli_error_based', 'critical', `SQL Injection (error-based) on "${target.param}"`, {
|
|
763
|
+
url: u.toString(),
|
|
764
|
+
cwe: 'CWE-89', owasp: 'A03:2021',
|
|
765
|
+
proof: {
|
|
766
|
+
parameter: target.param,
|
|
767
|
+
payload,
|
|
768
|
+
error_pattern: matched.toString(),
|
|
769
|
+
response_excerpt: body.slice(0, 300),
|
|
770
|
+
verification: 'Database error pattern matched in response',
|
|
771
|
+
},
|
|
772
|
+
fix: 'Use parameterized queries or prepared statements. Never concatenate user input into SQL.',
|
|
773
|
+
}));
|
|
774
|
+
break;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Prueba basada en tiempo (solo primer parámetro)
|
|
779
|
+
if (targets.indexOf(target) > 2) continue; // limitar pruebas de tiempo
|
|
780
|
+
for (const { payload, delay, label } of SQLI_TIME_PAYLOADS) {
|
|
781
|
+
const u = new URL(target.url);
|
|
782
|
+
u.searchParams.set(target.param, payload);
|
|
783
|
+
const res = await fetchWithTiming(u.toString(), { ...opts, timeout: (opts.timeout || DEFAULT_TIMEOUT) + delay });
|
|
784
|
+
await sleep(RATE_LIMIT_MS);
|
|
785
|
+
if (!res.success) continue;
|
|
786
|
+
|
|
787
|
+
if (res.timing >= delay * 0.9) {
|
|
788
|
+
findings.push(createFinding('injection', 'sqli_time_based', 'critical', `SQL Injection (time-based, ${label}) on "${target.param}"`, {
|
|
789
|
+
url: u.toString(),
|
|
790
|
+
cwe: 'CWE-89', owasp: 'A03:2021',
|
|
791
|
+
proof: {
|
|
792
|
+
parameter: target.param,
|
|
793
|
+
payload,
|
|
794
|
+
timing_ms: res.timing,
|
|
795
|
+
expected_delay_ms: delay,
|
|
796
|
+
verification: `Response delayed ${res.timing}ms (≥ ${delay * 0.9}ms threshold for ${label})`,
|
|
797
|
+
},
|
|
798
|
+
fix: 'Use parameterized queries. Disable verbose timing in DB queries.',
|
|
799
|
+
}));
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return findings;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ── SSTI ──────────────────────────────────────────────────────────────────────
|
|
808
|
+
|
|
809
|
+
async function testSSTI(targetUrl, recon, opts) {
|
|
810
|
+
const findings = [];
|
|
811
|
+
const tested = new Set();
|
|
812
|
+
const targets = collectTargets(targetUrl, recon, tested).slice(0, MAX_ENDPOINTS);
|
|
813
|
+
|
|
814
|
+
for (const target of targets) {
|
|
815
|
+
for (const { payload, verify, label } of SSTI_PAYLOADS) {
|
|
816
|
+
const u = new URL(target.url);
|
|
817
|
+
u.searchParams.set(target.param, payload);
|
|
818
|
+
const res = await fetchWithTiming(u.toString(), opts);
|
|
819
|
+
await sleep(RATE_LIMIT_MS);
|
|
820
|
+
if (!res.success || !res.body) continue;
|
|
821
|
+
|
|
822
|
+
if ((res.body || '').includes(verify)) {
|
|
823
|
+
findings.push(createFinding('injection', 'ssti', 'critical', `Server-Side Template Injection (${label}) on "${target.param}"`, {
|
|
824
|
+
url: u.toString(),
|
|
825
|
+
cwe: 'CWE-94', owasp: 'A03:2021',
|
|
826
|
+
proof: {
|
|
827
|
+
parameter: target.param,
|
|
828
|
+
payload,
|
|
829
|
+
expected_output: verify,
|
|
830
|
+
verification: `Template expression ${payload} evaluated to ${verify} in response`,
|
|
831
|
+
},
|
|
832
|
+
fix: 'Use logic-less templates. Never render user input as template code.',
|
|
833
|
+
}));
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return findings;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ── Inyección de comandos ─────────────────────────────────────────────────────
|
|
842
|
+
|
|
843
|
+
async function testCmdInjection(targetUrl, recon, opts) {
|
|
844
|
+
const findings = [];
|
|
845
|
+
const tested = new Set();
|
|
846
|
+
const targets = collectTargets(targetUrl, recon, tested).slice(0, MAX_ENDPOINTS);
|
|
847
|
+
|
|
848
|
+
for (const target of targets) {
|
|
849
|
+
for (const { payload, label } of CMD_PAYLOADS) {
|
|
850
|
+
const u = new URL(target.url);
|
|
851
|
+
u.searchParams.set(target.param, payload);
|
|
852
|
+
const res = await fetchWithTiming(u.toString(), opts);
|
|
853
|
+
await sleep(RATE_LIMIT_MS);
|
|
854
|
+
if (!res.success || !res.body) continue;
|
|
855
|
+
|
|
856
|
+
if ((res.body || '').includes(CANARY)) {
|
|
857
|
+
findings.push(createFinding('injection', 'command_injection', 'critical', `Command Injection (${label}) on "${target.param}"`, {
|
|
858
|
+
url: u.toString(),
|
|
859
|
+
cwe: 'CWE-78', owasp: 'A03:2021',
|
|
860
|
+
proof: {
|
|
861
|
+
parameter: target.param,
|
|
862
|
+
payload,
|
|
863
|
+
canary: CANARY,
|
|
864
|
+
verification: `Canary "${CANARY}" echoed back in response — command executed on server`,
|
|
865
|
+
},
|
|
866
|
+
fix: 'Never pass user input to shell commands. Use APIs instead of shell calls.',
|
|
867
|
+
}));
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return findings;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ── Path Traversal ────────────────────────────────────────────────────────────
|
|
876
|
+
|
|
877
|
+
async function testTraversal(targetUrl, recon, opts) {
|
|
878
|
+
const findings = [];
|
|
879
|
+
const tested = new Set();
|
|
880
|
+
const targets = collectTargets(targetUrl, recon, tested).slice(0, MAX_ENDPOINTS);
|
|
881
|
+
|
|
882
|
+
for (const target of targets) {
|
|
883
|
+
for (const { payload, verify } of TRAVERSAL_PAYLOADS) {
|
|
884
|
+
const u = new URL(target.url);
|
|
885
|
+
u.searchParams.set(target.param, payload);
|
|
886
|
+
const res = await fetchWithTiming(u.toString(), opts);
|
|
887
|
+
await sleep(RATE_LIMIT_MS);
|
|
888
|
+
if (!res.success || !res.body) continue;
|
|
889
|
+
|
|
890
|
+
if (verify.test(res.body || '')) {
|
|
891
|
+
findings.push(createFinding('injection', 'path_traversal', 'high', `Path Traversal on "${target.param}"`, {
|
|
892
|
+
url: u.toString(),
|
|
893
|
+
cwe: 'CWE-22', owasp: 'A01:2021',
|
|
894
|
+
proof: {
|
|
895
|
+
parameter: target.param,
|
|
896
|
+
payload,
|
|
897
|
+
verification: `Pattern ${verify} matched in response (likely /etc/passwd or win.ini content)`,
|
|
898
|
+
},
|
|
899
|
+
fix: 'Canonicalize file paths. Use allowlists for file access. Never pass user input directly to file operations.',
|
|
900
|
+
}));
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return findings;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// ── Open Redirect ─────────────────────────────────────────────────────────────
|
|
909
|
+
|
|
910
|
+
async function testOpenRedirect(targetUrl, recon, opts) {
|
|
911
|
+
const findings = [];
|
|
912
|
+
const tested = new Set();
|
|
913
|
+
const targets = collectTargets(targetUrl, recon, tested).slice(0, MAX_ENDPOINTS);
|
|
914
|
+
const redirectPayloads = ['https://evil.example.com', '//evil.example.com', 'https://evil.example.com%2F@' + new URL(targetUrl).hostname];
|
|
915
|
+
|
|
916
|
+
for (const target of targets) {
|
|
917
|
+
const paramLower = target.param.toLowerCase();
|
|
918
|
+
if (!/url|redirect|next|return|to|location|target|goto/i.test(paramLower)) continue;
|
|
919
|
+
|
|
920
|
+
for (const payload of redirectPayloads) {
|
|
921
|
+
const u = new URL(target.url);
|
|
922
|
+
u.searchParams.set(target.param, payload);
|
|
923
|
+
const res = await fetchWithTiming(u.toString(), opts);
|
|
924
|
+
await sleep(RATE_LIMIT_MS);
|
|
925
|
+
if (!res.success) continue;
|
|
926
|
+
|
|
927
|
+
const loc = res.headers['location'] || '';
|
|
928
|
+
if ((res.statusCode >= 300 && res.statusCode < 400) && loc.includes('evil.example.com')) {
|
|
929
|
+
findings.push(createFinding('logic', 'open_redirect', 'medium', `Open Redirect on "${target.param}"`, {
|
|
930
|
+
url: u.toString(),
|
|
931
|
+
cwe: 'CWE-601', owasp: 'A01:2021',
|
|
932
|
+
proof: {
|
|
933
|
+
parameter: target.param,
|
|
934
|
+
payload,
|
|
935
|
+
redirect_to: loc,
|
|
936
|
+
status_code: res.statusCode,
|
|
937
|
+
verification: 'Server redirected to attacker-controlled domain',
|
|
938
|
+
},
|
|
939
|
+
fix: 'Validate redirect targets against an allowlist. Never use user input as redirect URLs.',
|
|
940
|
+
}));
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return findings;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// ── Host Header Injection ─────────────────────────────────────────────────────
|
|
949
|
+
|
|
950
|
+
async function testHostHeader(targetUrl, opts) {
|
|
951
|
+
const findings = [];
|
|
952
|
+
const poisonedHost = 'evil.example.com';
|
|
953
|
+
const res = await fetchWithTiming(targetUrl, {
|
|
954
|
+
...opts,
|
|
955
|
+
headers: { ...opts.headers, 'Host': poisonedHost },
|
|
956
|
+
});
|
|
957
|
+
await sleep(RATE_LIMIT_MS);
|
|
958
|
+
|
|
959
|
+
if (!res.success || !res.body) return findings;
|
|
960
|
+
if ((res.body || '').includes(poisonedHost) || (res.headers['location'] || '').includes(poisonedHost)) {
|
|
961
|
+
findings.push(createFinding('config', 'host_header_injection', 'medium', 'Host Header Injection', {
|
|
962
|
+
url: targetUrl,
|
|
963
|
+
cwe: 'CWE-113', owasp: 'A03:2021',
|
|
964
|
+
proof: {
|
|
965
|
+
poisoned_host: poisonedHost,
|
|
966
|
+
verification: 'Server reflected poisoned Host header in response body or Location header',
|
|
967
|
+
},
|
|
968
|
+
fix: 'Validate the Host header against a whitelist of allowed hosts. Use absolute URLs in redirects.',
|
|
969
|
+
}));
|
|
970
|
+
}
|
|
971
|
+
return findings;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// ── Method Tampering ──────────────────────────────────────────────────────────
|
|
975
|
+
|
|
976
|
+
async function testMethodTampering(targetUrl, endpoints, opts) {
|
|
977
|
+
const findings = [];
|
|
978
|
+
const urls = [targetUrl, ...endpoints.slice(0, 5)];
|
|
979
|
+
const methods = ['PUT', 'DELETE', 'PATCH', 'TRACE', 'OPTIONS', 'CONNECT'];
|
|
980
|
+
|
|
981
|
+
for (const url of urls) {
|
|
982
|
+
for (const method of methods) {
|
|
983
|
+
const res = await fetchWithTiming(url, { ...opts, method });
|
|
984
|
+
await sleep(RATE_LIMIT_MS);
|
|
985
|
+
if (!res.success) continue;
|
|
986
|
+
|
|
987
|
+
if (res.statusCode === 200 && method !== 'OPTIONS') {
|
|
988
|
+
findings.push(createFinding('config', 'method_tampering', 'medium', `Unexpected HTTP method allowed: ${method}`, {
|
|
989
|
+
url,
|
|
990
|
+
cwe: 'CWE-650', owasp: 'A05:2021',
|
|
991
|
+
proof: {
|
|
992
|
+
method,
|
|
993
|
+
status_code: res.statusCode,
|
|
994
|
+
verification: `Server returned 200 for ${method} request`,
|
|
995
|
+
},
|
|
996
|
+
fix: `Restrict allowed HTTP methods. Only expose GET/POST (and others only where explicitly required).`,
|
|
997
|
+
}));
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
if (method === 'TRACE' && res.statusCode === 200) {
|
|
1001
|
+
findings.push(createFinding('config', 'trace_enabled', 'low', 'HTTP TRACE method enabled (XST risk)', {
|
|
1002
|
+
url,
|
|
1003
|
+
cwe: 'CWE-16', owasp: 'A05:2021',
|
|
1004
|
+
proof: { method: 'TRACE', status_code: 200, verification: 'TRACE method returns 200 — XST attack vector' },
|
|
1005
|
+
fix: 'Disable TRACE method on the web server.',
|
|
1006
|
+
}));
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
return findings;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// ── Race Condition ────────────────────────────────────────────────────────────
|
|
1014
|
+
|
|
1015
|
+
async function testRaceCondition(targetUrl, recon, opts) {
|
|
1016
|
+
const findings = [];
|
|
1017
|
+
const endpoint = recon.forms.find(f => f.method === 'POST') ? targetUrl : null;
|
|
1018
|
+
if (!endpoint) return findings;
|
|
1019
|
+
|
|
1020
|
+
const concurrency = 20;
|
|
1021
|
+
const start = Date.now();
|
|
1022
|
+
const results = await Promise.all(
|
|
1023
|
+
Array.from({ length: concurrency }, () =>
|
|
1024
|
+
fetchWithTiming(endpoint, { ...opts, method: 'POST', body: 'amount=1', contentType: 'application/x-www-form-urlencoded' })
|
|
1025
|
+
)
|
|
1026
|
+
);
|
|
1027
|
+
const elapsed = Date.now() - start;
|
|
1028
|
+
|
|
1029
|
+
const successes = results.filter(r => r.success && r.statusCode === 200);
|
|
1030
|
+
if (successes.length > 1 && elapsed < 2000) {
|
|
1031
|
+
findings.push(createFinding('logic', 'race_condition', 'high', `Potential Race Condition on POST endpoint`, {
|
|
1032
|
+
url: endpoint,
|
|
1033
|
+
cwe: 'CWE-362', owasp: 'A04:2021',
|
|
1034
|
+
proof: {
|
|
1035
|
+
concurrent_requests: concurrency,
|
|
1036
|
+
successful_responses: successes.length,
|
|
1037
|
+
elapsed_ms: elapsed,
|
|
1038
|
+
verification: `${successes.length} concurrent POST requests succeeded within ${elapsed}ms`,
|
|
1039
|
+
},
|
|
1040
|
+
fix: 'Use database-level locks or atomic operations for state-changing requests.',
|
|
1041
|
+
}));
|
|
1042
|
+
}
|
|
1043
|
+
return findings;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// ── Prototype Pollution ───────────────────────────────────────────────────────
|
|
1047
|
+
|
|
1048
|
+
async function testPrototypePollution(targetUrl, recon, opts) {
|
|
1049
|
+
const findings = [];
|
|
1050
|
+
const tested = new Set();
|
|
1051
|
+
const targets = collectTargets(targetUrl, recon, tested).slice(0, MAX_ENDPOINTS);
|
|
1052
|
+
|
|
1053
|
+
for (const target of targets) {
|
|
1054
|
+
const pollutionParams = ['__proto__[polluted]', 'constructor[prototype][polluted]'];
|
|
1055
|
+
for (const pp of pollutionParams) {
|
|
1056
|
+
const u = new URL(target.url);
|
|
1057
|
+
u.searchParams.set(pp, 'true');
|
|
1058
|
+
const url = u.toString();
|
|
1059
|
+
const res = await fetchWithTiming(url, opts);
|
|
1060
|
+
await sleep(RATE_LIMIT_MS);
|
|
1061
|
+
if (!res.success || !res.body) continue;
|
|
1062
|
+
try {
|
|
1063
|
+
const json = JSON.parse(res.body);
|
|
1064
|
+
if (json.polluted === 'true' || json.polluted === true) {
|
|
1065
|
+
findings.push(createFinding('injection', 'prototype_pollution', 'critical', `Prototype Pollution via query parameter on ${target.url}`, {
|
|
1066
|
+
url,
|
|
1067
|
+
cwe: 'CWE-1321', owasp: 'A03:2021',
|
|
1068
|
+
proof: {
|
|
1069
|
+
request: `GET ${url}`,
|
|
1070
|
+
response_excerpt: JSON.stringify(json).slice(0, 200),
|
|
1071
|
+
verification: 'Polluted property appeared in response JSON object',
|
|
1072
|
+
},
|
|
1073
|
+
fix: 'Sanitize object keys. Use Object.create(null) for user-controlled objects. Block __proto__ and constructor keys.',
|
|
1074
|
+
}));
|
|
1075
|
+
return findings;
|
|
1076
|
+
}
|
|
1077
|
+
} catch {}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Body pollution
|
|
1081
|
+
const jsonPayloads = [
|
|
1082
|
+
'{"__proto__":{"polluted":true}}',
|
|
1083
|
+
'{"constructor":{"prototype":{"polluted":true}}}',
|
|
1084
|
+
];
|
|
1085
|
+
for (const body of jsonPayloads) {
|
|
1086
|
+
const res = await fetchWithTiming(target.url, { ...opts, method: 'POST', body, contentType: 'application/json' });
|
|
1087
|
+
await sleep(RATE_LIMIT_MS);
|
|
1088
|
+
if (!res.success || !res.body) continue;
|
|
1089
|
+
try {
|
|
1090
|
+
const json = JSON.parse(res.body);
|
|
1091
|
+
if (json.polluted === true) {
|
|
1092
|
+
findings.push(createFinding('injection', 'prototype_pollution', 'critical', `Prototype Pollution via JSON body on ${target.url}`, {
|
|
1093
|
+
url: target.url,
|
|
1094
|
+
cwe: 'CWE-1321', owasp: 'A03:2021',
|
|
1095
|
+
proof: {
|
|
1096
|
+
request: `POST ${target.url} with ${body}`,
|
|
1097
|
+
verification: 'Polluted property appeared in response after __proto__ injection',
|
|
1098
|
+
},
|
|
1099
|
+
fix: 'Use a JSON schema validator. Strip __proto__ and constructor from parsed objects.',
|
|
1100
|
+
}));
|
|
1101
|
+
return findings;
|
|
1102
|
+
}
|
|
1103
|
+
} catch {}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return findings;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// ── HTTP Request Smuggling ────────────────────────────────────────────────────
|
|
1110
|
+
|
|
1111
|
+
async function testRequestSmuggling(targetUrl, opts) {
|
|
1112
|
+
const findings = [];
|
|
1113
|
+
const res = await fetchWithTiming(targetUrl, {
|
|
1114
|
+
...opts, method: 'POST',
|
|
1115
|
+
headers: { ...opts.headers, 'Transfer-Encoding': 'chunked' },
|
|
1116
|
+
body: '0\r\n\r\n',
|
|
1117
|
+
});
|
|
1118
|
+
if (!res.success) return findings;
|
|
1119
|
+
|
|
1120
|
+
const smuggleRes = await fetchWithTiming(targetUrl, {
|
|
1121
|
+
...opts, method: 'POST', timeout: 15000,
|
|
1122
|
+
headers: {
|
|
1123
|
+
...opts.headers,
|
|
1124
|
+
'Content-Length': '4',
|
|
1125
|
+
'Transfer-Encoding': 'chunked',
|
|
1126
|
+
},
|
|
1127
|
+
body: '1\r\nZ\r\nQ\r\n\r\n',
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
if (smuggleRes.success && res.success && smuggleRes.statusCode === 400 && res.statusCode < 400) {
|
|
1131
|
+
findings.push(createFinding('network', 'request_smuggling_potential', 'medium', 'Server may be vulnerable to HTTP Request Smuggling (CL/TE desync)', {
|
|
1132
|
+
url: targetUrl,
|
|
1133
|
+
cwe: 'CWE-444', owasp: 'A05:2021',
|
|
1134
|
+
proof: {
|
|
1135
|
+
normal_status: res.statusCode,
|
|
1136
|
+
smuggle_status: smuggleRes.statusCode,
|
|
1137
|
+
verification: 'Server returned different status for conflicting CL/TE headers',
|
|
1138
|
+
},
|
|
1139
|
+
fix: 'Normalize CL/TE handling. Use HTTP/2 where possible.',
|
|
1140
|
+
}));
|
|
1141
|
+
}
|
|
1142
|
+
return findings;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// ── CSRF ──────────────────────────────────────────────────────────────────────
|
|
1146
|
+
|
|
1147
|
+
function testCSRF(recon) {
|
|
1148
|
+
const findings = [];
|
|
1149
|
+
for (const form of recon.forms) {
|
|
1150
|
+
if (form.method !== 'POST') continue;
|
|
1151
|
+
if (!form.hasCSRF) {
|
|
1152
|
+
findings.push(createFinding('session', 'csrf_missing', 'medium', `POST form at "${form.action || '/'}" has no CSRF token`, {
|
|
1153
|
+
cwe: 'CWE-352', owasp: 'A01:2021',
|
|
1154
|
+
proof: {
|
|
1155
|
+
form_action: form.action || '/',
|
|
1156
|
+
form_method: form.method,
|
|
1157
|
+
input_fields: form.inputs,
|
|
1158
|
+
verification: 'No field matching csrf/token/_token/authenticity_token found in form',
|
|
1159
|
+
},
|
|
1160
|
+
fix: 'Add a CSRF token to all state-changing forms. Verify the token server-side on submission.',
|
|
1161
|
+
}));
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
return findings;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// ── Parameter Pollution ───────────────────────────────────────────────────────
|
|
1168
|
+
|
|
1169
|
+
async function testParamPollution(targetUrl, recon, opts) {
|
|
1170
|
+
const findings = [];
|
|
1171
|
+
const tested = new Set();
|
|
1172
|
+
const targets = collectTargets(targetUrl, recon, tested).slice(0, 5);
|
|
1173
|
+
|
|
1174
|
+
for (const target of targets) {
|
|
1175
|
+
const u = new URL(target.url);
|
|
1176
|
+
u.searchParams.append(target.param, 'val1');
|
|
1177
|
+
u.searchParams.append(target.param, 'val2');
|
|
1178
|
+
const res = await fetchWithTiming(u.toString(), opts);
|
|
1179
|
+
await sleep(RATE_LIMIT_MS);
|
|
1180
|
+
if (!res.success || !res.body) continue;
|
|
1181
|
+
|
|
1182
|
+
if (res.body.includes('val1') && res.body.includes('val2')) {
|
|
1183
|
+
findings.push(createFinding('logic', 'param_pollution', 'low', `HTTP Parameter Pollution on "${target.param}"`, {
|
|
1184
|
+
url: u.toString(),
|
|
1185
|
+
cwe: 'CWE-235',
|
|
1186
|
+
proof: {
|
|
1187
|
+
request: `GET ${u.pathname}?${target.param}=val1&${target.param}=val2`,
|
|
1188
|
+
verification: 'Both parameter values reflected in response',
|
|
1189
|
+
},
|
|
1190
|
+
fix: 'Reject or deduplicate duplicate query parameters.',
|
|
1191
|
+
}));
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
return findings;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// ── Utilidad ──────────────────────────────────────────────────────────────────
|
|
1198
|
+
|
|
1199
|
+
function collectTargets(targetUrl, recon, tested) {
|
|
1200
|
+
const targets = [];
|
|
1201
|
+
for (const link of recon.links) {
|
|
1202
|
+
try {
|
|
1203
|
+
const u = new URL(link, targetUrl);
|
|
1204
|
+
for (const [key] of u.searchParams) {
|
|
1205
|
+
const sig = `${u.pathname}:${key}`;
|
|
1206
|
+
if (tested.has(sig)) continue;
|
|
1207
|
+
tested.add(sig);
|
|
1208
|
+
targets.push({ url: `${u.origin}${u.pathname}`, param: key });
|
|
1209
|
+
}
|
|
1210
|
+
} catch {}
|
|
1211
|
+
}
|
|
1212
|
+
for (const param of recon.params) {
|
|
1213
|
+
const sig = `/:${param}`;
|
|
1214
|
+
if (!tested.has(sig)) { tested.add(sig); targets.push({ url: targetUrl, param }); }
|
|
1215
|
+
}
|
|
1216
|
+
return targets;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
1220
|
+
|
|
1221
|
+
async function main() {
|
|
1222
|
+
const args = process.argv.slice(2);
|
|
1223
|
+
const positional = args.filter(a => !a.startsWith('--'));
|
|
1224
|
+
const flags = args.filter(a => a.startsWith('--'));
|
|
1225
|
+
const deep = flags.includes('--deep');
|
|
1226
|
+
|
|
1227
|
+
let cookie = null, customHeaders = {}, scope = null, timeout = DEFAULT_TIMEOUT;
|
|
1228
|
+
for (let i = 0; i < args.length; i++) {
|
|
1229
|
+
if (args[i] === '--cookie' && args[i + 1]) { cookie = args[++i]; }
|
|
1230
|
+
if (args[i] === '--header' && args[i + 1]) {
|
|
1231
|
+
const h = args[++i]; const idx = h.indexOf(':');
|
|
1232
|
+
if (idx > 0) customHeaders[h.slice(0, idx).trim().toLowerCase()] = h.slice(idx + 1).trim();
|
|
1233
|
+
}
|
|
1234
|
+
if (args[i] === '--scope' && args[i + 1]) { scope = args[++i]; }
|
|
1235
|
+
if (args[i] === '--timeout' && args[i + 1]) { timeout = parseInt(args[++i]) || DEFAULT_TIMEOUT; }
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
if (positional.length < 1) {
|
|
1239
|
+
output({ error: 'Usage: node pentest-scanner.js <target-url> [--deep] [--cookie "name=value"] [--header "Key: Value"] [--scope /path]', success: false });
|
|
1240
|
+
process.exit(0);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
let targetUrl = positional[0];
|
|
1244
|
+
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = 'https://' + targetUrl;
|
|
1245
|
+
targetUrl = targetUrl.replace(/\/+$/, '');
|
|
1246
|
+
|
|
1247
|
+
// Validación SSRF del target principal antes de iniciar
|
|
1248
|
+
const targetValidation = validateUrl(targetUrl);
|
|
1249
|
+
if (!targetValidation.valid) {
|
|
1250
|
+
output({
|
|
1251
|
+
success: false,
|
|
1252
|
+
target: targetUrl,
|
|
1253
|
+
scanned_at: new Date().toISOString(),
|
|
1254
|
+
findings: [{ type: 'url_bloqueada', razon: targetValidation.reason }],
|
|
1255
|
+
summary: { high: 0, medium: 0, low: 0, false_positives_filtered: 0 },
|
|
1256
|
+
});
|
|
1257
|
+
process.exit(0);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const fetchOpts = { timeout, cookie, headers: customHeaders };
|
|
1261
|
+
const startTime = Date.now();
|
|
1262
|
+
const allFindings = [];
|
|
1263
|
+
|
|
1264
|
+
// Fase 1: Reconocimiento
|
|
1265
|
+
const [mainPage, baseline] = await Promise.all([
|
|
1266
|
+
fetchWithTiming(targetUrl, { ...fetchOpts, followRedirects: true }),
|
|
1267
|
+
get404Baseline(targetUrl, fetchOpts),
|
|
1268
|
+
]);
|
|
1269
|
+
|
|
1270
|
+
if (!mainPage.success) {
|
|
1271
|
+
output({
|
|
1272
|
+
success: false,
|
|
1273
|
+
target: targetUrl,
|
|
1274
|
+
scanned_at: new Date().toISOString(),
|
|
1275
|
+
error: `No se pudo alcanzar el objetivo: ${mainPage.error || mainPage.blockReason || 'desconocido'}`,
|
|
1276
|
+
findings: [],
|
|
1277
|
+
summary: { high: 0, medium: 0, low: 0, false_positives_filtered: 0 },
|
|
1278
|
+
});
|
|
1279
|
+
process.exit(0);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const recon = extractFromHTML(mainPage.body || '', targetUrl);
|
|
1283
|
+
const techStack = detectTechStack(mainPage.headers || {}, mainPage.body || '');
|
|
1284
|
+
|
|
1285
|
+
if (scope) recon.links = recon.links.filter(l => l.startsWith(scope));
|
|
1286
|
+
|
|
1287
|
+
// Fase 2: Análisis pasivo
|
|
1288
|
+
const [headerFindings, tlsFindings, jwtFindings, csrfFindings] = await Promise.all([
|
|
1289
|
+
Promise.resolve(analyzeHeaders(mainPage)),
|
|
1290
|
+
targetUrl.startsWith('https://') ? analyzeTLS(new URL(targetUrl).hostname) : Promise.resolve([]),
|
|
1291
|
+
Promise.resolve(analyzeJWTs(mainPage)),
|
|
1292
|
+
Promise.resolve(testCSRF(recon)),
|
|
1293
|
+
]);
|
|
1294
|
+
allFindings.push(...headerFindings, ...tlsFindings, ...jwtFindings, ...csrfFindings);
|
|
1295
|
+
|
|
1296
|
+
// Fase 3: Escaneo activo en paralelo
|
|
1297
|
+
const [sensitiveFileFindings, corsFindings, errorFindings, hostHeaderFindings, graphqlFindings, sourceMapFindings] = await Promise.all([
|
|
1298
|
+
scanSensitiveFiles(targetUrl, baseline, fetchOpts),
|
|
1299
|
+
testCORS(targetUrl, recon.links, fetchOpts),
|
|
1300
|
+
testErrorDisclosure(targetUrl, fetchOpts),
|
|
1301
|
+
testHostHeader(targetUrl, fetchOpts),
|
|
1302
|
+
testGraphQL(targetUrl, fetchOpts),
|
|
1303
|
+
testSourceMaps(targetUrl, recon.scripts, fetchOpts),
|
|
1304
|
+
]);
|
|
1305
|
+
allFindings.push(...sensitiveFileFindings, ...corsFindings, ...errorFindings, ...hostHeaderFindings, ...graphqlFindings, ...sourceMapFindings);
|
|
1306
|
+
|
|
1307
|
+
// Pruebas de inyección secuenciales
|
|
1308
|
+
allFindings.push(...await testXSS(targetUrl, recon, fetchOpts));
|
|
1309
|
+
allFindings.push(...await testSQLi(targetUrl, recon, fetchOpts));
|
|
1310
|
+
allFindings.push(...await testSSTI(targetUrl, recon, fetchOpts));
|
|
1311
|
+
allFindings.push(...await testCmdInjection(targetUrl, recon, fetchOpts));
|
|
1312
|
+
allFindings.push(...await testTraversal(targetUrl, recon, fetchOpts));
|
|
1313
|
+
allFindings.push(...await testOpenRedirect(targetUrl, recon, fetchOpts));
|
|
1314
|
+
allFindings.push(...await testMethodTampering(targetUrl, recon.links, fetchOpts));
|
|
1315
|
+
|
|
1316
|
+
// Fase 4: Modo deep
|
|
1317
|
+
if (deep) {
|
|
1318
|
+
allFindings.push(...await testRaceCondition(targetUrl, recon, fetchOpts));
|
|
1319
|
+
allFindings.push(...await testPrototypePollution(targetUrl, recon, fetchOpts));
|
|
1320
|
+
allFindings.push(...await testParamPollution(targetUrl, recon, fetchOpts));
|
|
1321
|
+
allFindings.push(...await testRequestSmuggling(targetUrl, fetchOpts));
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Compilar resultados
|
|
1325
|
+
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
1326
|
+
allFindings.sort((a, b) => (severityOrder[a.severity] || 5) - (severityOrder[b.severity] || 5));
|
|
1327
|
+
|
|
1328
|
+
const summary = { critical: 0, high: 0, medium: 0, low: 0, info: 0, false_positives_filtered: 0, total: allFindings.length };
|
|
1329
|
+
for (const f of allFindings) {
|
|
1330
|
+
if (f.severity) summary[f.severity] = (summary[f.severity] || 0) + 1;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
output({
|
|
1334
|
+
success: true,
|
|
1335
|
+
target: targetUrl,
|
|
1336
|
+
scanned_at: new Date().toISOString(),
|
|
1337
|
+
scan_mode: deep ? 'deep' : 'standard',
|
|
1338
|
+
scan_duration_ms: Date.now() - startTime,
|
|
1339
|
+
tech_stack: techStack,
|
|
1340
|
+
recon: {
|
|
1341
|
+
endpoints_discovered: recon.links.length,
|
|
1342
|
+
parameters_found: recon.params.length,
|
|
1343
|
+
forms_found: recon.forms.length,
|
|
1344
|
+
scripts_found: recon.scripts.length,
|
|
1345
|
+
},
|
|
1346
|
+
summary,
|
|
1347
|
+
findings: allFindings,
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// Exportar funciones para tests
|
|
1352
|
+
module.exports = {
|
|
1353
|
+
fetchWithTiming,
|
|
1354
|
+
validateUrl,
|
|
1355
|
+
extractFromHTML,
|
|
1356
|
+
collectTargets,
|
|
1357
|
+
analyzeHeaders,
|
|
1358
|
+
analyzeJWTs,
|
|
1359
|
+
testCSRF,
|
|
1360
|
+
testCORS,
|
|
1361
|
+
testXSS,
|
|
1362
|
+
testSQLi,
|
|
1363
|
+
testSSTI,
|
|
1364
|
+
testCmdInjection,
|
|
1365
|
+
testTraversal,
|
|
1366
|
+
testOpenRedirect,
|
|
1367
|
+
testHostHeader,
|
|
1368
|
+
testMethodTampering,
|
|
1369
|
+
testRaceCondition,
|
|
1370
|
+
testPrototypePollution,
|
|
1371
|
+
testParamPollution,
|
|
1372
|
+
testRequestSmuggling,
|
|
1373
|
+
scanSensitiveFiles,
|
|
1374
|
+
testGraphQL,
|
|
1375
|
+
testSourceMaps,
|
|
1376
|
+
testErrorDisclosure,
|
|
1377
|
+
analyzeTLS,
|
|
1378
|
+
get404Baseline,
|
|
1379
|
+
isSoft404,
|
|
1380
|
+
detectTechStack,
|
|
1381
|
+
generateReportCard: () => '', // placeholder — solo CLI
|
|
1382
|
+
CANARY,
|
|
1383
|
+
SCAN_ID,
|
|
1384
|
+
RATE_LIMIT_MS,
|
|
1385
|
+
MAX_ENDPOINTS,
|
|
1386
|
+
// internal para acceso en tests
|
|
1387
|
+
_internal: { createFinding },
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
// Generar report card ASCII (accesible externamente)
|
|
1391
|
+
function generateReportCard(target, summary, duration, topFindings) {
|
|
1392
|
+
const w = 64;
|
|
1393
|
+
const pad = (s, n) => (s + ' '.repeat(n)).slice(0, n);
|
|
1394
|
+
const ctr = (s, n) => { const p = Math.max(0, n - s.length); const l = Math.floor(p / 2); return ' '.repeat(l) + s + ' '.repeat(p - l); };
|
|
1395
|
+
let score = 100;
|
|
1396
|
+
score -= (summary.critical || 0) * 20;
|
|
1397
|
+
score -= (summary.high || 0) * 10;
|
|
1398
|
+
score -= (summary.medium || 0) * 5;
|
|
1399
|
+
score -= (summary.low || 0) * 2;
|
|
1400
|
+
score = Math.max(0, Math.min(100, score));
|
|
1401
|
+
const rating = score >= 90 ? 'SECURE' : score >= 70 ? 'MODERATE RISK' : score >= 40 ? 'HIGH RISK' : 'CRITICAL RISK';
|
|
1402
|
+
const barLen = Math.round(score / 100 * 20);
|
|
1403
|
+
const bar = '#'.repeat(barLen) + '-'.repeat(20 - barLen);
|
|
1404
|
+
const lines = [];
|
|
1405
|
+
lines.push('+' + '='.repeat(w) + '+');
|
|
1406
|
+
lines.push('|' + ctr('ULTRASHIP PENETRATION TEST REPORT', w) + '|');
|
|
1407
|
+
lines.push('+' + '='.repeat(w) + '+');
|
|
1408
|
+
lines.push('|' + pad(` Target: ${new URL(target).host}`, w) + '|');
|
|
1409
|
+
lines.push('|' + pad(` Duration: ${(duration / 1000).toFixed(1)}s | Findings: ${summary.total}`, w) + '|');
|
|
1410
|
+
lines.push('+' + '='.repeat(w) + '+');
|
|
1411
|
+
lines.push('|' + pad(` ${bar} SCORE: ${score}/100 (${rating})`, w) + '|');
|
|
1412
|
+
lines.push('+' + '='.repeat(w) + '+');
|
|
1413
|
+
lines.push('|' + pad(` [!!] CRITICAL: ${summary.critical} [!] HIGH: ${summary.high} [~] MEDIUM: ${summary.medium} [-] LOW: ${summary.low}`, w) + '|');
|
|
1414
|
+
lines.push('+' + '-'.repeat(w) + '+');
|
|
1415
|
+
if (topFindings && topFindings.length > 0) {
|
|
1416
|
+
lines.push('|' + pad(' TOP FINDINGS:', w) + '|');
|
|
1417
|
+
for (const f of topFindings.slice(0, 8)) {
|
|
1418
|
+
const sev = f.severity === 'critical' ? '[!!]' : f.severity === 'high' ? ' [!]' : f.severity === 'medium' ? ' [~]' : ' [-]';
|
|
1419
|
+
lines.push('|' + pad(` ${sev} ${f.title}`.slice(0, w), w) + '|');
|
|
1420
|
+
}
|
|
1421
|
+
} else {
|
|
1422
|
+
lines.push('|' + ctr('No vulnerabilities found', w) + '|');
|
|
1423
|
+
}
|
|
1424
|
+
lines.push('+' + '='.repeat(w) + '+');
|
|
1425
|
+
return lines.join('\n');
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// Actualizar export con la versión real
|
|
1429
|
+
module.exports.generateReportCard = generateReportCard;
|
|
1430
|
+
|
|
1431
|
+
if (require.main === module) {
|
|
1432
|
+
main().catch((err) => {
|
|
1433
|
+
process.stderr.write('[pentest-scanner] Error fatal: ' + (err.message || String(err)) + '\n');
|
|
1434
|
+
process.exit(0); // exit 0 — nunca crash Claude Code
|
|
1435
|
+
});
|
|
1436
|
+
}
|