jaku.sh 1.0.0
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/LICENSE +52 -0
- package/README.md +636 -0
- package/action.yml +264 -0
- package/bin/jaku +2 -0
- package/package.json +62 -0
- package/src/agents/ai-agent.js +175 -0
- package/src/agents/api-agent.js +95 -0
- package/src/agents/base-agent.js +158 -0
- package/src/agents/crawl-agent.js +175 -0
- package/src/agents/event-bus.js +59 -0
- package/src/agents/findings-ledger.js +410 -0
- package/src/agents/logic-agent.js +144 -0
- package/src/agents/orchestrator.js +323 -0
- package/src/agents/qa-agent.js +149 -0
- package/src/agents/security-agent.js +211 -0
- package/src/cli.js +423 -0
- package/src/core/accessibility-checker.js +171 -0
- package/src/core/ai/ai-endpoint-detector.js +227 -0
- package/src/core/ai/guardrail-prober.js +362 -0
- package/src/core/ai/indirect-injector.js +106 -0
- package/src/core/ai/jailbreak-tester.js +212 -0
- package/src/core/ai/model-dos-tester.js +174 -0
- package/src/core/ai/model-fingerprinter.js +246 -0
- package/src/core/ai/multi-turn-attacker.js +297 -0
- package/src/core/ai/output-analyzer.js +182 -0
- package/src/core/ai/prompt-injector.js +543 -0
- package/src/core/ai/system-prompt-extractor.js +244 -0
- package/src/core/api/api-key-auditor.js +266 -0
- package/src/core/api/auth-flow-tester.js +430 -0
- package/src/core/api/cors-ws-tester.js +263 -0
- package/src/core/api/graphql-tester.js +287 -0
- package/src/core/api/oauth-prober.js +343 -0
- package/src/core/auth-manager.js +902 -0
- package/src/core/broken-flow-detector.js +207 -0
- package/src/core/browser-manager.js +119 -0
- package/src/core/console-monitor.js +111 -0
- package/src/core/crawler.js +430 -0
- package/src/core/csr-waiter.js +410 -0
- package/src/core/form-validator.js +240 -0
- package/src/core/logic/abuse-pattern-scanner.js +291 -0
- package/src/core/logic/access-boundary-tester.js +448 -0
- package/src/core/logic/business-rule-inferrer.js +196 -0
- package/src/core/logic/graphql-auditor.js +298 -0
- package/src/core/logic/parameter-polluter.js +212 -0
- package/src/core/logic/pricing-exploiter.js +299 -0
- package/src/core/logic/race-condition-detector.js +222 -0
- package/src/core/logic/workflow-enforcer.js +284 -0
- package/src/core/performance-checker.js +204 -0
- package/src/core/responsive-checker.js +228 -0
- package/src/core/security/cors-prober.js +150 -0
- package/src/core/security/csrf-prober.js +217 -0
- package/src/core/security/dependency-auditor.js +182 -0
- package/src/core/security/file-upload-tester.js +340 -0
- package/src/core/security/header-analyzer.js +324 -0
- package/src/core/security/infra-scanner.js +391 -0
- package/src/core/security/path-traversal.js +112 -0
- package/src/core/security/prototype-pollution.js +147 -0
- package/src/core/security/secret-detector.js +517 -0
- package/src/core/security/sqli-prober.js +257 -0
- package/src/core/security/tls-checker.js +223 -0
- package/src/core/security/xss-scanner.js +225 -0
- package/src/core/test-generator.js +339 -0
- package/src/core/test-runner.js +398 -0
- package/src/reporting/diff-reporter.js +172 -0
- package/src/reporting/report-generator.js +408 -0
- package/src/reporting/sarif-generator.js +190 -0
- package/src/utils/config.js +57 -0
- package/src/utils/finding.js +67 -0
- package/src/utils/logger.js +50 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import { createFinding } from '../../utils/finding.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SQLi Prober — Tests query-bearing inputs for SQL/NoSQL injection vulnerabilities.
|
|
6
|
+
* SAFETY: Simulation only — no destructive payloads (DROP, DELETE, etc.) are ever sent.
|
|
7
|
+
*/
|
|
8
|
+
export class SQLiProber {
|
|
9
|
+
constructor(logger) {
|
|
10
|
+
this.logger = logger;
|
|
11
|
+
this.findings = [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// SQL injection test payloads — detection-only, non-destructive
|
|
15
|
+
static SQL_PAYLOADS = [
|
|
16
|
+
{ name: 'Single quote', payload: "'", errorPatterns: ['sql', 'syntax', 'mysql', 'postgresql', 'sqlite', 'ora-', 'odbc'] },
|
|
17
|
+
{ name: 'Classic OR', payload: "' OR '1'='1", errorPatterns: ['sql', 'syntax', 'query'] },
|
|
18
|
+
{ name: 'Double dash comment', payload: "' -- ", errorPatterns: ['sql', 'syntax'] },
|
|
19
|
+
{ name: 'UNION probe', payload: "' UNION SELECT NULL--", errorPatterns: ['union', 'select', 'column'] },
|
|
20
|
+
{ name: 'Boolean blind true', payload: "' AND '1'='1", errorPatterns: [] },
|
|
21
|
+
{ name: 'Boolean blind false', payload: "' AND '1'='2", errorPatterns: [] },
|
|
22
|
+
{ name: 'Numeric injection', payload: '1 OR 1=1', errorPatterns: ['sql', 'syntax'] },
|
|
23
|
+
{ name: 'Stacked query probe', payload: "'; SELECT 1--", errorPatterns: ['syntax', 'multiple'] },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// NoSQL injection payloads for JSON bodies
|
|
27
|
+
static NOSQL_PAYLOADS = [
|
|
28
|
+
{ name: 'NoSQL $gt operator', payload: { '$gt': '' }, desc: 'MongoDB greater-than operator' },
|
|
29
|
+
{ name: 'NoSQL $ne null', payload: { '$ne': null }, desc: 'MongoDB not-equal null' },
|
|
30
|
+
{ name: 'NoSQL $regex', payload: { '$regex': '.*' }, desc: 'MongoDB regex wildcard' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// Common SQL error signatures in responses
|
|
34
|
+
static ERROR_SIGNATURES = [
|
|
35
|
+
/SQL syntax.*MySQL/i,
|
|
36
|
+
/Warning.*mysql_/i,
|
|
37
|
+
/PostgreSQL.*ERROR/i,
|
|
38
|
+
/ERROR:\s*syntax error at or near/i,
|
|
39
|
+
/pg_query\(\)/i,
|
|
40
|
+
/Microsoft OLE DB Provider/i,
|
|
41
|
+
/\[Microsoft\]\[ODBC/i,
|
|
42
|
+
/ORA-\d{5}/i,
|
|
43
|
+
/SQLite\/JDBCDriver/i,
|
|
44
|
+
/SQLite\.Exception/i,
|
|
45
|
+
/SQLITE_ERROR/i,
|
|
46
|
+
/org\.hibernate\.QueryException/i,
|
|
47
|
+
/Unclosed quotation mark/i,
|
|
48
|
+
/quoted string not properly terminated/i,
|
|
49
|
+
/com\.mysql\.jdbc/i,
|
|
50
|
+
/Syntax error in string in query expression/i,
|
|
51
|
+
/SQLSTATE\[\d{5}\]/i,
|
|
52
|
+
/you have an error in your sql syntax/i,
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run SQL injection probing on all discovered surfaces.
|
|
57
|
+
*/
|
|
58
|
+
async probe(surfaceInventory) {
|
|
59
|
+
// Test URL parameters
|
|
60
|
+
await this._testURLParams(surfaceInventory);
|
|
61
|
+
|
|
62
|
+
// Test form inputs
|
|
63
|
+
await this._testFormInputs(surfaceInventory);
|
|
64
|
+
|
|
65
|
+
// Test API endpoints with JSON bodies
|
|
66
|
+
await this._testAPIEndpoints(surfaceInventory);
|
|
67
|
+
|
|
68
|
+
this.logger?.info?.(`SQLi prober found ${this.findings.length} issues`);
|
|
69
|
+
return this.findings;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Test URL query parameters for SQL injection.
|
|
74
|
+
*/
|
|
75
|
+
async _testURLParams(inventory) {
|
|
76
|
+
for (const page of inventory.pages) {
|
|
77
|
+
if (typeof page.status !== 'number') continue;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const parsedUrl = new URL(page.url);
|
|
81
|
+
const params = [...parsedUrl.searchParams.keys()];
|
|
82
|
+
if (params.length === 0) continue;
|
|
83
|
+
|
|
84
|
+
for (const param of params) {
|
|
85
|
+
for (const { name, payload } of SQLiProber.SQL_PAYLOADS.slice(0, 4)) {
|
|
86
|
+
const testUrl = new URL(page.url);
|
|
87
|
+
testUrl.searchParams.set(param, payload);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const resp = await fetch(testUrl.toString(), {
|
|
91
|
+
signal: AbortSignal.timeout(10000),
|
|
92
|
+
redirect: 'follow',
|
|
93
|
+
});
|
|
94
|
+
const body = await resp.text();
|
|
95
|
+
|
|
96
|
+
const errorMatch = this._detectSQLError(body);
|
|
97
|
+
if (errorMatch) {
|
|
98
|
+
this.findings.push(createFinding({
|
|
99
|
+
module: 'security',
|
|
100
|
+
title: `SQL Injection: ${param} parameter (${name})`,
|
|
101
|
+
severity: 'critical',
|
|
102
|
+
affected_surface: page.url,
|
|
103
|
+
description: `The URL parameter "${param}" appears vulnerable to SQL injection. The "${name}" payload triggered a database error in the response, indicating unsanitized input is being passed directly to SQL queries.\n\nError signature: ${errorMatch}`,
|
|
104
|
+
reproduction: [
|
|
105
|
+
`1. Navigate to: ${testUrl.toString()}`,
|
|
106
|
+
`2. Observe database error message in the response`,
|
|
107
|
+
`3. Error signature: ${errorMatch}`,
|
|
108
|
+
],
|
|
109
|
+
evidence: JSON.stringify({ param, payload: name, errorSignature: errorMatch, responseSnippet: body.substring(0, 300) }),
|
|
110
|
+
remediation: 'Use parameterized queries (prepared statements) for all database operations. Never concatenate user input into SQL strings. Implement input validation and WAF rules.',
|
|
111
|
+
references: ['https://owasp.org/www-community/attacks/SQL_Injection', 'CWE-89'],
|
|
112
|
+
}));
|
|
113
|
+
break; // One finding per param
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// Request failed
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// URL parsing failed
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Test form inputs for SQL injection.
|
|
128
|
+
*/
|
|
129
|
+
async _testFormInputs(inventory) {
|
|
130
|
+
const browser = await chromium.launch({ headless: true });
|
|
131
|
+
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
|
132
|
+
|
|
133
|
+
for (const form of inventory.forms) {
|
|
134
|
+
const page = await context.newPage();
|
|
135
|
+
try {
|
|
136
|
+
await page.goto(form.page, { waitUntil: 'networkidle', timeout: 15000 });
|
|
137
|
+
|
|
138
|
+
for (const field of form.fields) {
|
|
139
|
+
if (['hidden', 'submit', 'button', 'checkbox', 'radio', 'file'].includes(field.type)) continue;
|
|
140
|
+
|
|
141
|
+
// Test with the single quote payload (most universal SQL diagnostic)
|
|
142
|
+
const testPayload = SQLiProber.SQL_PAYLOADS[0];
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const input = await page.$(`[name="${field.name}"]`) || await page.$(`#${field.name}`);
|
|
146
|
+
if (!input) continue;
|
|
147
|
+
|
|
148
|
+
await input.fill(testPayload.payload);
|
|
149
|
+
|
|
150
|
+
// Submit
|
|
151
|
+
const submitBtn = await page.$('button[type="submit"], input[type="submit"]');
|
|
152
|
+
if (submitBtn) {
|
|
153
|
+
// Intercept response
|
|
154
|
+
const [response] = await Promise.all([
|
|
155
|
+
page.waitForNavigation({ timeout: 10000 }).catch(() => null),
|
|
156
|
+
submitBtn.click(),
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
await page.waitForTimeout(1000);
|
|
160
|
+
const content = await page.content();
|
|
161
|
+
|
|
162
|
+
const errorMatch = this._detectSQLError(content);
|
|
163
|
+
if (errorMatch) {
|
|
164
|
+
this.findings.push(createFinding({
|
|
165
|
+
module: 'security',
|
|
166
|
+
title: `SQL Injection: Form Field "${field.name}" in ${form.id}`,
|
|
167
|
+
severity: 'critical',
|
|
168
|
+
affected_surface: form.page,
|
|
169
|
+
description: `The form field "${field.name}" in form "${form.id}" appears vulnerable to SQL injection. A single quote payload triggered a database error, indicating raw input is passed to SQL queries.\n\nError: ${errorMatch}`,
|
|
170
|
+
reproduction: [
|
|
171
|
+
`1. Navigate to ${form.page}`,
|
|
172
|
+
`2. Enter a single quote (') in the "${field.name}" field`,
|
|
173
|
+
`3. Submit the form`,
|
|
174
|
+
`4. Observe SQL error in the response`,
|
|
175
|
+
],
|
|
176
|
+
evidence: JSON.stringify({ form: form.id, field: field.name, errorSignature: errorMatch }),
|
|
177
|
+
remediation: 'Use parameterized queries. Never pass raw form input to SQL queries.',
|
|
178
|
+
references: ['https://owasp.org/www-community/attacks/SQL_Injection', 'CWE-89'],
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Go back for next field
|
|
183
|
+
await page.goto(form.page, { waitUntil: 'networkidle', timeout: 10000 });
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// Field test failed
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} catch (err) {
|
|
190
|
+
this.logger?.debug?.(`SQLi form test failed for ${form.page}: ${err.message}`);
|
|
191
|
+
} finally {
|
|
192
|
+
await page.close();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await browser.close();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Test API endpoints for NoSQL injection via JSON bodies.
|
|
201
|
+
*/
|
|
202
|
+
async _testAPIEndpoints(inventory) {
|
|
203
|
+
for (const endpoint of inventory.apiEndpoints || []) {
|
|
204
|
+
if (endpoint.method !== 'POST' && endpoint.method !== 'PUT') continue;
|
|
205
|
+
|
|
206
|
+
for (const { name, payload, desc } of SQLiProber.NOSQL_PAYLOADS) {
|
|
207
|
+
try {
|
|
208
|
+
// Send a JSON body with NoSQL operator
|
|
209
|
+
const resp = await fetch(endpoint.url, {
|
|
210
|
+
method: endpoint.method,
|
|
211
|
+
headers: { 'Content-Type': 'application/json' },
|
|
212
|
+
body: JSON.stringify({ username: payload, password: payload }),
|
|
213
|
+
signal: AbortSignal.timeout(10000),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const body = await resp.text();
|
|
217
|
+
|
|
218
|
+
// Check for unexpected success (auth bypass) or detailed errors
|
|
219
|
+
if (resp.ok && body.includes('token')) {
|
|
220
|
+
this.findings.push(createFinding({
|
|
221
|
+
module: 'security',
|
|
222
|
+
title: `NoSQL Injection: ${name} at ${new URL(endpoint.url).pathname}`,
|
|
223
|
+
severity: 'critical',
|
|
224
|
+
affected_surface: endpoint.url,
|
|
225
|
+
description: `The API endpoint may be vulnerable to NoSQL injection. A ${desc} payload in the JSON body returned what appears to be a successful authentication response, suggesting the database query was manipulated.`,
|
|
226
|
+
reproduction: [
|
|
227
|
+
`1. Send ${endpoint.method} to ${endpoint.url}`,
|
|
228
|
+
`2. Body: ${JSON.stringify({ username: payload, password: payload })}`,
|
|
229
|
+
`3. Response contains authentication token`,
|
|
230
|
+
],
|
|
231
|
+
evidence: JSON.stringify({ endpoint: endpoint.url, method: endpoint.method, payload: name }),
|
|
232
|
+
remediation: 'Validate and sanitize all input before database queries. Use explicit type checking to reject objects where strings are expected. Use mongoose schema validation or equivalent.',
|
|
233
|
+
references: ['https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/05.6-Testing_for_NoSQL_Injection'],
|
|
234
|
+
}));
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// Request failed
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Check response body for SQL error signatures.
|
|
246
|
+
*/
|
|
247
|
+
_detectSQLError(body) {
|
|
248
|
+
if (!body) return null;
|
|
249
|
+
for (const pattern of SQLiProber.ERROR_SIGNATURES) {
|
|
250
|
+
const match = body.match(pattern);
|
|
251
|
+
if (match) return match[0];
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export default SQLiProber;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { createFinding } from '../../utils/finding.js';
|
|
2
|
+
import https from 'https';
|
|
3
|
+
import http from 'http';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* TLS Checker — Reviews TLS/SSL configuration.
|
|
7
|
+
* Checks certificate validity, protocol version, HTTPS enforcement, and mixed content.
|
|
8
|
+
*/
|
|
9
|
+
export class TLSChecker {
|
|
10
|
+
constructor(logger) {
|
|
11
|
+
this.logger = logger;
|
|
12
|
+
this.findings = [];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Run TLS checks on the target.
|
|
17
|
+
*/
|
|
18
|
+
async check(surfaceInventory) {
|
|
19
|
+
const baseUrl = surfaceInventory.baseUrl;
|
|
20
|
+
|
|
21
|
+
// Check HTTPS/TLS
|
|
22
|
+
await this._checkCertificate(baseUrl);
|
|
23
|
+
await this._checkHTTPSEnforcement(baseUrl);
|
|
24
|
+
await this._checkMixedContent(surfaceInventory);
|
|
25
|
+
|
|
26
|
+
this.logger?.info?.(`TLS checker found ${this.findings.length} issues`);
|
|
27
|
+
return this.findings;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check TLS certificate details.
|
|
32
|
+
*/
|
|
33
|
+
async _checkCertificate(baseUrl) {
|
|
34
|
+
try {
|
|
35
|
+
const url = new URL(baseUrl);
|
|
36
|
+
if (url.protocol !== 'https:') {
|
|
37
|
+
this.findings.push(createFinding({
|
|
38
|
+
module: 'security',
|
|
39
|
+
title: 'Site Not Using HTTPS',
|
|
40
|
+
severity: 'high',
|
|
41
|
+
affected_surface: baseUrl,
|
|
42
|
+
description: `The target site is served over HTTP (${baseUrl}). All data including credentials, session tokens, and personal information is transmitted in plaintext, making it vulnerable to eavesdropping and man-in-the-middle attacks.`,
|
|
43
|
+
reproduction: [
|
|
44
|
+
`1. Navigate to ${baseUrl}`,
|
|
45
|
+
'2. Note the URL uses http:// not https://',
|
|
46
|
+
],
|
|
47
|
+
remediation: 'Enable HTTPS with a valid TLS certificate. Use services like Let\'s Encrypt for free certificates. Redirect all HTTP traffic to HTTPS.',
|
|
48
|
+
references: ['https://letsencrypt.org/'],
|
|
49
|
+
}));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check certificate details
|
|
54
|
+
await new Promise((resolve) => {
|
|
55
|
+
const req = https.get(baseUrl, { rejectUnauthorized: false, timeout: 10000 }, (res) => {
|
|
56
|
+
const cert = res.socket.getPeerCertificate();
|
|
57
|
+
|
|
58
|
+
if (!cert || Object.keys(cert).length === 0) {
|
|
59
|
+
this.findings.push(createFinding({
|
|
60
|
+
module: 'security',
|
|
61
|
+
title: 'TLS Certificate Not Available',
|
|
62
|
+
severity: 'high',
|
|
63
|
+
affected_surface: baseUrl,
|
|
64
|
+
description: 'Could not retrieve the TLS certificate for this site.',
|
|
65
|
+
reproduction: [`1. Attempt to inspect TLS certificate for ${baseUrl}`],
|
|
66
|
+
remediation: 'Ensure a valid TLS certificate is installed and configured correctly.',
|
|
67
|
+
}));
|
|
68
|
+
resolve();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check expiration
|
|
73
|
+
if (cert.valid_to) {
|
|
74
|
+
const expiryDate = new Date(cert.valid_to);
|
|
75
|
+
const now = new Date();
|
|
76
|
+
const daysUntilExpiry = Math.floor((expiryDate - now) / (1000 * 86400));
|
|
77
|
+
|
|
78
|
+
if (daysUntilExpiry < 0) {
|
|
79
|
+
this.findings.push(createFinding({
|
|
80
|
+
module: 'security',
|
|
81
|
+
title: 'TLS Certificate Expired',
|
|
82
|
+
severity: 'critical',
|
|
83
|
+
affected_surface: baseUrl,
|
|
84
|
+
description: `The TLS certificate expired on ${cert.valid_to} (${Math.abs(daysUntilExpiry)} days ago). Browsers will show security warnings and users cannot trust the connection.`,
|
|
85
|
+
reproduction: [
|
|
86
|
+
`1. Navigate to ${baseUrl}`,
|
|
87
|
+
`2. Browser shows certificate expired warning`,
|
|
88
|
+
`3. Certificate expired: ${cert.valid_to}`,
|
|
89
|
+
],
|
|
90
|
+
remediation: 'Immediately renew the TLS certificate. Set up auto-renewal to prevent future expirations.',
|
|
91
|
+
}));
|
|
92
|
+
} else if (daysUntilExpiry < 30) {
|
|
93
|
+
this.findings.push(createFinding({
|
|
94
|
+
module: 'security',
|
|
95
|
+
title: `TLS Certificate Expiring Soon (${daysUntilExpiry} days)`,
|
|
96
|
+
severity: 'medium',
|
|
97
|
+
affected_surface: baseUrl,
|
|
98
|
+
description: `The TLS certificate expires on ${cert.valid_to} (${daysUntilExpiry} days from now). Renew it before expiration to avoid service disruption.`,
|
|
99
|
+
reproduction: [
|
|
100
|
+
`1. Inspect certificate for ${baseUrl}`,
|
|
101
|
+
`2. Expiry: ${cert.valid_to} (${daysUntilExpiry} days remaining)`,
|
|
102
|
+
],
|
|
103
|
+
remediation: 'Renew the TLS certificate before it expires. Set up auto-renewal with your certificate provider.',
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check if self-signed
|
|
109
|
+
if (cert.issuer && cert.subject &&
|
|
110
|
+
JSON.stringify(cert.issuer) === JSON.stringify(cert.subject)) {
|
|
111
|
+
this.findings.push(createFinding({
|
|
112
|
+
module: 'security',
|
|
113
|
+
title: 'Self-Signed TLS Certificate',
|
|
114
|
+
severity: 'medium',
|
|
115
|
+
affected_surface: baseUrl,
|
|
116
|
+
description: `The TLS certificate appears to be self-signed (issuer matches subject). Browsers will show security warnings and users won't trust the connection.\n\nSubject: ${cert.subject?.CN || 'unknown'}\nIssuer: ${cert.issuer?.CN || 'unknown'}`,
|
|
117
|
+
reproduction: [
|
|
118
|
+
`1. Navigate to ${baseUrl}`,
|
|
119
|
+
'2. Browser shows "Not Secure" or certificate warning',
|
|
120
|
+
],
|
|
121
|
+
remediation: 'Replace with a certificate from a trusted Certificate Authority. Use Let\'s Encrypt for free certificates.',
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
resolve();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
req.on('error', () => resolve());
|
|
129
|
+
req.setTimeout(10000, () => { req.destroy(); resolve(); });
|
|
130
|
+
});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
this.logger?.debug?.(`TLS certificate check failed: ${err.message}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check if HTTP redirects to HTTPS.
|
|
138
|
+
*/
|
|
139
|
+
async _checkHTTPSEnforcement(baseUrl) {
|
|
140
|
+
try {
|
|
141
|
+
const url = new URL(baseUrl);
|
|
142
|
+
if (url.protocol !== 'https:') return; // Already flagged
|
|
143
|
+
|
|
144
|
+
// Try HTTP version
|
|
145
|
+
const httpUrl = baseUrl.replace('https://', 'http://');
|
|
146
|
+
|
|
147
|
+
await new Promise((resolve) => {
|
|
148
|
+
const req = http.get(httpUrl, { timeout: 10000 }, (res) => {
|
|
149
|
+
if (res.statusCode >= 300 && res.statusCode < 400) {
|
|
150
|
+
const location = res.headers.location || '';
|
|
151
|
+
if (location.startsWith('https://')) {
|
|
152
|
+
// Good — redirects to HTTPS
|
|
153
|
+
resolve();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// HTTP served without redirect to HTTPS
|
|
159
|
+
if (res.statusCode === 200) {
|
|
160
|
+
this.findings.push(createFinding({
|
|
161
|
+
module: 'security',
|
|
162
|
+
title: 'HTTP Not Redirecting to HTTPS',
|
|
163
|
+
severity: 'medium',
|
|
164
|
+
affected_surface: httpUrl,
|
|
165
|
+
description: `The HTTP version of the site (${httpUrl}) serves content instead of redirecting to HTTPS. Users who navigate to the HTTP version are not automatically upgraded to a secure connection.`,
|
|
166
|
+
reproduction: [
|
|
167
|
+
`1. Navigate to ${httpUrl} (note: http not https)`,
|
|
168
|
+
`2. Page loads over HTTP with status ${res.statusCode}`,
|
|
169
|
+
'3. No redirect to HTTPS occurs',
|
|
170
|
+
],
|
|
171
|
+
remediation: 'Configure your web server to redirect all HTTP requests to HTTPS (301 permanent redirect). Add HSTS header to prevent future HTTP access.',
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
resolve();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
req.on('error', () => resolve()); // HTTP may not be listening — that's fine
|
|
179
|
+
req.setTimeout(10000, () => { req.destroy(); resolve(); });
|
|
180
|
+
});
|
|
181
|
+
} catch (err) {
|
|
182
|
+
this.logger?.debug?.(`HTTPS enforcement check failed: ${err.message}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Check for mixed content (HTTP resources on HTTPS pages).
|
|
188
|
+
*/
|
|
189
|
+
async _checkMixedContent(surfaceInventory) {
|
|
190
|
+
const baseUrl = surfaceInventory.baseUrl;
|
|
191
|
+
if (!baseUrl.startsWith('https://')) return;
|
|
192
|
+
|
|
193
|
+
// Check crawled page data for HTTP resources
|
|
194
|
+
for (const page of surfaceInventory.pages) {
|
|
195
|
+
if (typeof page.status !== 'number') continue;
|
|
196
|
+
|
|
197
|
+
// Look for HTTP URLs in links that should be HTTPS
|
|
198
|
+
for (const link of (page.links || [])) {
|
|
199
|
+
if (link.startsWith('http://') && !link.includes('localhost')) {
|
|
200
|
+
// Only flag if the link is to a resource (js, css, img) not navigation
|
|
201
|
+
const ext = link.split('.').pop()?.toLowerCase();
|
|
202
|
+
if (['js', 'css', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'woff', 'woff2'].includes(ext)) {
|
|
203
|
+
this.findings.push(createFinding({
|
|
204
|
+
module: 'security',
|
|
205
|
+
title: `Mixed Content: HTTP Resource on HTTPS Page`,
|
|
206
|
+
severity: 'medium',
|
|
207
|
+
affected_surface: page.url,
|
|
208
|
+
description: `An HTTPS page loads a resource over HTTP: ${link}. Mixed content can be intercepted and modified by attackers, potentially injecting malicious code.`,
|
|
209
|
+
reproduction: [
|
|
210
|
+
`1. Navigate to ${page.url} (HTTPS)`,
|
|
211
|
+
`2. Resource loaded over HTTP: ${link}`,
|
|
212
|
+
],
|
|
213
|
+
remediation: 'Change all resource URLs to use HTTPS or protocol-relative URLs (//).',
|
|
214
|
+
}));
|
|
215
|
+
break; // One finding per page
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export default TLSChecker;
|