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,182 @@
|
|
|
1
|
+
import { createFinding } from '../../utils/finding.js';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Dependency Auditor — Audits project dependencies for known CVEs.
|
|
8
|
+
* Runs npm audit and parses vulnerability advisories into JAKU findings.
|
|
9
|
+
*/
|
|
10
|
+
export class DependencyAuditor {
|
|
11
|
+
constructor(config, logger) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.logger = logger;
|
|
14
|
+
this.findings = [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Run dependency audit on the project.
|
|
19
|
+
*/
|
|
20
|
+
async audit() {
|
|
21
|
+
const projectRoot = process.cwd();
|
|
22
|
+
|
|
23
|
+
// Try npm audit first
|
|
24
|
+
if (fs.existsSync(path.join(projectRoot, 'package-lock.json')) ||
|
|
25
|
+
fs.existsSync(path.join(projectRoot, 'package.json'))) {
|
|
26
|
+
await this._runNpmAudit(projectRoot);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check for known vulnerable patterns in package.json
|
|
30
|
+
await this._checkPackageJson(projectRoot);
|
|
31
|
+
|
|
32
|
+
this.logger?.info?.(`Dependency auditor found ${this.findings.length} issues`);
|
|
33
|
+
return this.findings;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Run npm audit and parse results.
|
|
38
|
+
*/
|
|
39
|
+
async _runNpmAudit(projectRoot) {
|
|
40
|
+
try {
|
|
41
|
+
const lockPath = path.join(projectRoot, 'package-lock.json');
|
|
42
|
+
if (!fs.existsSync(lockPath)) {
|
|
43
|
+
this.logger?.debug?.('No package-lock.json found, skipping npm audit');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let auditOutput;
|
|
48
|
+
try {
|
|
49
|
+
auditOutput = execSync('npm audit --json 2>/dev/null', {
|
|
50
|
+
cwd: projectRoot,
|
|
51
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
52
|
+
timeout: 30000,
|
|
53
|
+
encoding: 'utf-8',
|
|
54
|
+
});
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// npm audit exits with non-zero if vulnerabilities found — that's expected
|
|
57
|
+
auditOutput = err.stdout || err.output?.[1] || '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!auditOutput) return;
|
|
61
|
+
|
|
62
|
+
let auditData;
|
|
63
|
+
try {
|
|
64
|
+
auditData = JSON.parse(auditOutput);
|
|
65
|
+
} catch {
|
|
66
|
+
this.logger?.debug?.('Failed to parse npm audit JSON output');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Parse npm audit v2+ format
|
|
71
|
+
if (auditData.vulnerabilities) {
|
|
72
|
+
for (const [pkgName, vuln] of Object.entries(auditData.vulnerabilities)) {
|
|
73
|
+
const severity = this._mapSeverity(vuln.severity);
|
|
74
|
+
|
|
75
|
+
this.findings.push(createFinding({
|
|
76
|
+
module: 'security',
|
|
77
|
+
title: `Vulnerable Dependency: ${pkgName} (${vuln.severity})`,
|
|
78
|
+
severity,
|
|
79
|
+
affected_surface: `npm:${pkgName}`,
|
|
80
|
+
description: `The package "${pkgName}" (${vuln.range || 'unknown version'}) has known security vulnerabilities.\n\n${vuln.via?.map?.(v => typeof v === 'string' ? v : `- ${v.title || v.name}: ${v.url || ''}`).join('\\n') || 'See npm advisory for details.'}\n\nVulnerable range: ${vuln.range || 'unknown'}\nSeverity: ${vuln.severity}\nDirect: ${vuln.isDirect ? 'Yes' : 'No (transitive)'}`,
|
|
81
|
+
reproduction: [
|
|
82
|
+
'1. Run `npm audit` in the project root',
|
|
83
|
+
`2. Observe vulnerability in ${pkgName}`,
|
|
84
|
+
],
|
|
85
|
+
evidence: JSON.stringify({
|
|
86
|
+
package: pkgName,
|
|
87
|
+
severity: vuln.severity,
|
|
88
|
+
range: vuln.range,
|
|
89
|
+
fixAvailable: vuln.fixAvailable,
|
|
90
|
+
isDirect: vuln.isDirect,
|
|
91
|
+
}, null, 2),
|
|
92
|
+
remediation: vuln.fixAvailable
|
|
93
|
+
? `Run \`npm audit fix\` to automatically resolve. If fix is not compatible, manually update ${pkgName} to a patched version.`
|
|
94
|
+
: `No automatic fix available. Check if a newer major version of ${pkgName} resolves the vulnerability, or look for alternative packages.`,
|
|
95
|
+
references: vuln.via?.filter?.(v => typeof v === 'object' && v.url)?.map?.(v => v.url) || [],
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Summary
|
|
101
|
+
if (auditData.metadata?.vulnerabilities) {
|
|
102
|
+
const meta = auditData.metadata.vulnerabilities;
|
|
103
|
+
const total = meta.total || Object.values(meta).reduce((a, b) => a + (typeof b === 'number' ? b : 0), 0);
|
|
104
|
+
this.logger?.info?.(`npm audit: ${total} vulnerabilities found (${meta.critical || 0} critical, ${meta.high || 0} high, ${meta.moderate || 0} moderate, ${meta.low || 0} low)`);
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
this.logger?.debug?.(`npm audit failed: ${err.message}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check package.json for known risky patterns.
|
|
113
|
+
*/
|
|
114
|
+
async _checkPackageJson(projectRoot) {
|
|
115
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
116
|
+
if (!fs.existsSync(pkgPath)) return;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
120
|
+
const allDeps = {
|
|
121
|
+
...(pkg.dependencies || {}),
|
|
122
|
+
...(pkg.devDependencies || {}),
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Check for wildcard versions
|
|
126
|
+
for (const [name, version] of Object.entries(allDeps)) {
|
|
127
|
+
if (version === '*' || version === 'latest') {
|
|
128
|
+
this.findings.push(createFinding({
|
|
129
|
+
module: 'security',
|
|
130
|
+
title: `Unpinned Dependency: ${name} (${version})`,
|
|
131
|
+
severity: 'medium',
|
|
132
|
+
affected_surface: `npm:${name}`,
|
|
133
|
+
description: `The dependency "${name}" uses version "${version}". Unpinned dependencies can introduce breaking changes or supply chain attacks when a compromised version is published.`,
|
|
134
|
+
reproduction: [
|
|
135
|
+
`1. Check package.json`,
|
|
136
|
+
`2. "${name}": "${version}" — no version pinned`,
|
|
137
|
+
],
|
|
138
|
+
remediation: `Pin ${name} to a specific version range. Use \`npm install ${name}@<version>\` to install a specific version.`,
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check for known risky scripts
|
|
144
|
+
const scripts = pkg.scripts || {};
|
|
145
|
+
const riskyPatterns = ['curl', 'wget', 'eval', 'exec', 'child_process'];
|
|
146
|
+
for (const [scriptName, scriptCmd] of Object.entries(scripts)) {
|
|
147
|
+
for (const pattern of riskyPatterns) {
|
|
148
|
+
if (scriptCmd.toLowerCase().includes(pattern)) {
|
|
149
|
+
this.findings.push(createFinding({
|
|
150
|
+
module: 'security',
|
|
151
|
+
title: `Risky Script Pattern: "${pattern}" in "${scriptName}"`,
|
|
152
|
+
severity: 'medium',
|
|
153
|
+
affected_surface: 'package.json scripts',
|
|
154
|
+
description: `The npm script "${scriptName}" contains the pattern "${pattern}": \`${scriptCmd}\`. This could indicate a supply chain risk or unsafe operation.`,
|
|
155
|
+
reproduction: [
|
|
156
|
+
`1. Inspect package.json scripts.${scriptName}`,
|
|
157
|
+
`2. Script contains: ${scriptCmd}`,
|
|
158
|
+
],
|
|
159
|
+
remediation: `Review the script for safety. Ensure "${pattern}" usage is intentional and secure.`,
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
this.logger?.debug?.(`package.json check failed: ${err.message}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
_mapSeverity(npmSeverity) {
|
|
170
|
+
const map = {
|
|
171
|
+
'critical': 'critical',
|
|
172
|
+
'high': 'high',
|
|
173
|
+
'moderate': 'medium',
|
|
174
|
+
'medium': 'medium',
|
|
175
|
+
'low': 'low',
|
|
176
|
+
'info': 'info',
|
|
177
|
+
};
|
|
178
|
+
return map[npmSeverity?.toLowerCase()] || 'medium';
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export default DependencyAuditor;
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { createFinding } from '../../utils/finding.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FileUploadTester — Tests file upload endpoints for abuse vectors.
|
|
5
|
+
*
|
|
6
|
+
* Probes:
|
|
7
|
+
* - MIME type spoofing (image extension with script content)
|
|
8
|
+
* - Path traversal in filenames (../../etc/passwd)
|
|
9
|
+
* - Oversized payload upload
|
|
10
|
+
* - Dangerous file type acceptance (.php, .jsp, .aspx, .sh)
|
|
11
|
+
* - Double extension bypass (file.jpg.php)
|
|
12
|
+
* - Null byte injection (file.php%00.jpg)
|
|
13
|
+
* - Content-Type header manipulation
|
|
14
|
+
*/
|
|
15
|
+
export class FileUploadTester {
|
|
16
|
+
constructor(logger) {
|
|
17
|
+
this.logger = logger;
|
|
18
|
+
|
|
19
|
+
this.UPLOAD_INDICATORS = [
|
|
20
|
+
'type="file"', 'input[type=file]', 'enctype="multipart/form-data"',
|
|
21
|
+
'dropzone', 'file-upload', 'fileUpload', 'upload',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
this.UPLOAD_PATHS = [
|
|
25
|
+
'/upload', '/api/upload', '/api/files', '/api/images',
|
|
26
|
+
'/api/media', '/api/attachments', '/api/v1/upload',
|
|
27
|
+
'/api/v1/files', '/file/upload', '/media/upload',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
this.DANGEROUS_EXTENSIONS = [
|
|
31
|
+
'php', 'php5', 'phtml', 'phar',
|
|
32
|
+
'jsp', 'jspx', 'jsw',
|
|
33
|
+
'asp', 'aspx', 'ashx',
|
|
34
|
+
'sh', 'bash', 'cgi', 'pl',
|
|
35
|
+
'py', 'rb', 'exe', 'bat', 'cmd',
|
|
36
|
+
'svg', 'html', 'htm', 'xml',
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Test file upload security.
|
|
42
|
+
*/
|
|
43
|
+
async test(surfaceInventory) {
|
|
44
|
+
const findings = [];
|
|
45
|
+
const baseUrl = this._getBaseUrl(surfaceInventory);
|
|
46
|
+
if (!baseUrl) return findings;
|
|
47
|
+
|
|
48
|
+
this.logger?.info?.('File Upload Tester: starting tests');
|
|
49
|
+
|
|
50
|
+
// Discover upload endpoints
|
|
51
|
+
const uploadEndpoints = await this._discoverUploads(baseUrl, surfaceInventory);
|
|
52
|
+
|
|
53
|
+
if (uploadEndpoints.length === 0) {
|
|
54
|
+
this.logger?.info?.('File Upload Tester: no upload endpoints found');
|
|
55
|
+
return findings;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.logger?.info?.(`File Upload Tester: found ${uploadEndpoints.length} upload endpoints`);
|
|
59
|
+
|
|
60
|
+
for (const endpoint of uploadEndpoints) {
|
|
61
|
+
// 1. Test MIME type spoofing
|
|
62
|
+
const mimeFindings = await this._testMIMESpoofing(endpoint);
|
|
63
|
+
findings.push(...mimeFindings);
|
|
64
|
+
|
|
65
|
+
// 2. Test path traversal in filename
|
|
66
|
+
const pathFindings = await this._testPathTraversal(endpoint);
|
|
67
|
+
findings.push(...pathFindings);
|
|
68
|
+
|
|
69
|
+
// 3. Test dangerous file types
|
|
70
|
+
const typeFindings = await this._testDangerousTypes(endpoint);
|
|
71
|
+
findings.push(...typeFindings);
|
|
72
|
+
|
|
73
|
+
// 4. Test oversized uploads
|
|
74
|
+
const sizeFindings = await this._testOversizedUpload(endpoint);
|
|
75
|
+
findings.push(...sizeFindings);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.logger?.info?.(`File Upload Tester: found ${findings.length} issues`);
|
|
79
|
+
return findings;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async _discoverUploads(baseUrl, surfaceInventory) {
|
|
83
|
+
const endpoints = [];
|
|
84
|
+
|
|
85
|
+
// Check forms for file inputs
|
|
86
|
+
const forms = surfaceInventory.forms || [];
|
|
87
|
+
for (const form of forms) {
|
|
88
|
+
const html = JSON.stringify(form);
|
|
89
|
+
if (this.UPLOAD_INDICATORS.some(ind => html.toLowerCase().includes(ind.toLowerCase()))) {
|
|
90
|
+
endpoints.push({
|
|
91
|
+
url: form.action ? new URL(form.action, baseUrl).href : baseUrl,
|
|
92
|
+
source: 'form',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Probe common upload paths
|
|
98
|
+
for (const path of this.UPLOAD_PATHS) {
|
|
99
|
+
try {
|
|
100
|
+
const url = new URL(path, baseUrl).href;
|
|
101
|
+
const response = await fetch(url, {
|
|
102
|
+
method: 'OPTIONS',
|
|
103
|
+
signal: AbortSignal.timeout(3000),
|
|
104
|
+
});
|
|
105
|
+
// Accept any non-404 as potential upload endpoint
|
|
106
|
+
if (response.status !== 404 && response.status !== 403) {
|
|
107
|
+
endpoints.push({ url, source: 'probe' });
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return endpoints;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async _testMIMESpoofing(endpoint) {
|
|
118
|
+
const findings = [];
|
|
119
|
+
|
|
120
|
+
// Create a "JPEG" that's actually a PHP script
|
|
121
|
+
const spoofedContent = '<?php echo "JAKU_MIME_SPOOF_TEST"; ?>';
|
|
122
|
+
const boundary = '----JAKUBoundary' + Date.now();
|
|
123
|
+
|
|
124
|
+
const body = [
|
|
125
|
+
`--${boundary}`,
|
|
126
|
+
`Content-Disposition: form-data; name="file"; filename="innocent.jpg"`,
|
|
127
|
+
`Content-Type: image/jpeg`,
|
|
128
|
+
'',
|
|
129
|
+
spoofedContent,
|
|
130
|
+
`--${boundary}--`,
|
|
131
|
+
].join('\r\n');
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const response = await fetch(endpoint.url, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },
|
|
137
|
+
body,
|
|
138
|
+
signal: AbortSignal.timeout(5000),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (response.ok) {
|
|
142
|
+
const text = await response.text();
|
|
143
|
+
if (/success|upload|created|saved|file/i.test(text) &&
|
|
144
|
+
!/invalid|rejected|not allowed|unsupported|error/i.test(text)) {
|
|
145
|
+
findings.push(createFinding({
|
|
146
|
+
module: 'security',
|
|
147
|
+
title: 'File Upload: MIME Type Spoofing Accepted',
|
|
148
|
+
severity: 'high',
|
|
149
|
+
affected_surface: endpoint.url,
|
|
150
|
+
description: `Upload endpoint accepted a file with mismatched Content-Type (image/jpeg) and actual content (PHP script). The server validates by Content-Type header rather than file content, allowing server-side script execution.`,
|
|
151
|
+
reproduction: [
|
|
152
|
+
`1. Upload file named "innocent.jpg" with Content-Type: image/jpeg`,
|
|
153
|
+
`2. File content: <?php echo "test"; ?>`,
|
|
154
|
+
`3. Server accepts the upload`,
|
|
155
|
+
],
|
|
156
|
+
evidence: `MIME type: image/jpeg, Content: PHP script`,
|
|
157
|
+
remediation: 'Validate files by magic bytes (file signature), not Content-Type header or extension. Use a media type allowlist. Store uploads outside the webroot. Serve with Content-Disposition: attachment.',
|
|
158
|
+
references: ['CWE-434'],
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
// Not reachable
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return findings;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async _testPathTraversal(endpoint) {
|
|
170
|
+
const findings = [];
|
|
171
|
+
|
|
172
|
+
const traversalNames = [
|
|
173
|
+
'../../../etc/passwd',
|
|
174
|
+
'..\\..\\..\\windows\\system32\\config\\sam',
|
|
175
|
+
'....//....//....//etc/passwd',
|
|
176
|
+
'file.txt%00.jpg',
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
const boundary = '----JAKUBoundary' + Date.now();
|
|
180
|
+
|
|
181
|
+
for (const filename of traversalNames) {
|
|
182
|
+
const body = [
|
|
183
|
+
`--${boundary}`,
|
|
184
|
+
`Content-Disposition: form-data; name="file"; filename="${filename}"`,
|
|
185
|
+
`Content-Type: text/plain`,
|
|
186
|
+
'',
|
|
187
|
+
'JAKU_PATH_TRAVERSAL_TEST',
|
|
188
|
+
`--${boundary}--`,
|
|
189
|
+
].join('\r\n');
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const response = await fetch(endpoint.url, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },
|
|
195
|
+
body,
|
|
196
|
+
signal: AbortSignal.timeout(5000),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (response.ok) {
|
|
200
|
+
const text = await response.text();
|
|
201
|
+
if (/success|upload|created|saved/i.test(text) &&
|
|
202
|
+
!/invalid|rejected|sanitized|error|\.\..*not allowed/i.test(text)) {
|
|
203
|
+
findings.push(createFinding({
|
|
204
|
+
module: 'security',
|
|
205
|
+
title: 'File Upload: Path Traversal in Filename',
|
|
206
|
+
severity: 'critical',
|
|
207
|
+
affected_surface: endpoint.url,
|
|
208
|
+
description: `Upload endpoint accepted a filename containing path traversal characters ("${filename}"). An attacker can overwrite arbitrary server files or place web shells in accessible directories.`,
|
|
209
|
+
reproduction: [
|
|
210
|
+
`1. Upload file with filename: "${filename}"`,
|
|
211
|
+
`2. Server does not sanitize the filename`,
|
|
212
|
+
],
|
|
213
|
+
evidence: `Filename: ${filename}`,
|
|
214
|
+
remediation: 'Strip all path components from uploaded filenames. Use a generated UUID as the stored filename. Never use user-supplied filenames for disk storage.',
|
|
215
|
+
references: ['CWE-22'],
|
|
216
|
+
}));
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return findings;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async _testDangerousTypes(endpoint) {
|
|
229
|
+
const findings = [];
|
|
230
|
+
const boundary = '----JAKUBoundary' + Date.now();
|
|
231
|
+
const acceptedTypes = [];
|
|
232
|
+
|
|
233
|
+
for (const ext of this.DANGEROUS_EXTENSIONS.slice(0, 6)) {
|
|
234
|
+
const body = [
|
|
235
|
+
`--${boundary}`,
|
|
236
|
+
`Content-Disposition: form-data; name="file"; filename="test.${ext}"`,
|
|
237
|
+
`Content-Type: application/octet-stream`,
|
|
238
|
+
'',
|
|
239
|
+
'// JAKU dangerous type test',
|
|
240
|
+
`--${boundary}--`,
|
|
241
|
+
].join('\r\n');
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
const response = await fetch(endpoint.url, {
|
|
245
|
+
method: 'POST',
|
|
246
|
+
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },
|
|
247
|
+
body,
|
|
248
|
+
signal: AbortSignal.timeout(5000),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
if (response.ok) {
|
|
252
|
+
const text = await response.text();
|
|
253
|
+
if (/success|upload|created|saved/i.test(text) &&
|
|
254
|
+
!/invalid|rejected|not allowed|forbidden|error/i.test(text)) {
|
|
255
|
+
acceptedTypes.push(ext);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (acceptedTypes.length > 0) {
|
|
264
|
+
findings.push(createFinding({
|
|
265
|
+
module: 'security',
|
|
266
|
+
title: 'File Upload: Dangerous File Types Accepted',
|
|
267
|
+
severity: 'critical',
|
|
268
|
+
affected_surface: endpoint.url,
|
|
269
|
+
description: `Upload endpoint accepts dangerous file extensions: ${acceptedTypes.map(t => `.${t}`).join(', ')}. If these files are served by the web server, an attacker can achieve remote code execution.`,
|
|
270
|
+
reproduction: [
|
|
271
|
+
`1. Upload files with extensions: ${acceptedTypes.join(', ')}`,
|
|
272
|
+
`2. Server accepts the uploads`,
|
|
273
|
+
],
|
|
274
|
+
evidence: `Accepted dangerous types: ${acceptedTypes.join(', ')}`,
|
|
275
|
+
remediation: 'Implement a strict file extension allowlist (e.g., only .jpg, .png, .pdf, .doc). Reject all other extensions. Store files outside the web root. Configure the web server to never execute uploaded files.',
|
|
276
|
+
references: ['CWE-434'],
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return findings;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async _testOversizedUpload(endpoint) {
|
|
284
|
+
const findings = [];
|
|
285
|
+
const boundary = '----JAKUBoundary' + Date.now();
|
|
286
|
+
|
|
287
|
+
// Generate a ~5MB payload
|
|
288
|
+
const largeContent = 'A'.repeat(5 * 1024 * 1024);
|
|
289
|
+
|
|
290
|
+
const body = [
|
|
291
|
+
`--${boundary}`,
|
|
292
|
+
`Content-Disposition: form-data; name="file"; filename="large_test.txt"`,
|
|
293
|
+
`Content-Type: text/plain`,
|
|
294
|
+
'',
|
|
295
|
+
largeContent,
|
|
296
|
+
`--${boundary}--`,
|
|
297
|
+
].join('\r\n');
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
const response = await fetch(endpoint.url, {
|
|
301
|
+
method: 'POST',
|
|
302
|
+
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },
|
|
303
|
+
body,
|
|
304
|
+
signal: AbortSignal.timeout(15000),
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
if (response.ok) {
|
|
308
|
+
const text = await response.text();
|
|
309
|
+
if (/success|upload|created|saved/i.test(text) &&
|
|
310
|
+
!/too large|size limit|maximum|exceeded|413|error/i.test(text)) {
|
|
311
|
+
findings.push(createFinding({
|
|
312
|
+
module: 'security',
|
|
313
|
+
title: 'File Upload: No Size Limit Enforced',
|
|
314
|
+
severity: 'medium',
|
|
315
|
+
affected_surface: endpoint.url,
|
|
316
|
+
description: `Upload endpoint accepted a 5MB file without size restrictions. An attacker can exhaust disk space or memory with repeated large uploads (denial of service).`,
|
|
317
|
+
evidence: `5MB payload accepted`,
|
|
318
|
+
remediation: 'Enforce server-side file size limits (e.g., 2MB for images). Return 413 Payload Too Large for oversized files. Implement per-user/IP upload rate limiting.',
|
|
319
|
+
references: ['CWE-400'],
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
} catch {
|
|
324
|
+
// Timeout or rejection — that's fine
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return findings;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
_getBaseUrl(surfaceInventory) {
|
|
331
|
+
const pages = surfaceInventory.pages || [];
|
|
332
|
+
if (pages.length === 0) return null;
|
|
333
|
+
try {
|
|
334
|
+
const parsed = new URL(pages[0].url || pages[0]);
|
|
335
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
336
|
+
} catch { return null; }
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export default FileUploadTester;
|