pandoraspec 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {pandoraspec-0.2.0 → pandoraspec-0.2.2}/PKG-INFO +8 -1
  2. {pandoraspec-0.2.0 → pandoraspec-0.2.2}/README.md +6 -0
  3. pandoraspec-0.2.2/pandoraspec/checks/__init__.py +0 -0
  4. pandoraspec-0.2.2/pandoraspec/checks/drift.py +145 -0
  5. pandoraspec-0.2.2/pandoraspec/checks/resilience.py +78 -0
  6. pandoraspec-0.2.2/pandoraspec/checks/security.py +51 -0
  7. {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pandoraspec/cli.py +3 -2
  8. pandoraspec-0.2.2/pandoraspec/constants.py +5 -0
  9. pandoraspec-0.2.2/pandoraspec/core.py +83 -0
  10. pandoraspec-0.2.2/pandoraspec/seed.py +144 -0
  11. {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pandoraspec.egg-info/PKG-INFO +8 -1
  12. pandoraspec-0.2.2/pandoraspec.egg-info/SOURCES.txt +23 -0
  13. {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pandoraspec.egg-info/requires.txt +1 -0
  14. {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pyproject.toml +3 -2
  15. pandoraspec-0.2.2/tests/test_checks_drift.py +53 -0
  16. pandoraspec-0.2.2/tests/test_checks_resilience.py +59 -0
  17. pandoraspec-0.2.2/tests/test_checks_security.py +51 -0
  18. pandoraspec-0.2.2/tests/test_core.py +35 -0
  19. pandoraspec-0.2.2/tests/test_seed.py +69 -0
  20. pandoraspec-0.2.0/pandoraspec/core.py +0 -470
  21. pandoraspec-0.2.0/pandoraspec.egg-info/SOURCES.txt +0 -12
  22. {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pandoraspec/__init__.py +0 -0
  23. {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pandoraspec/reporting.py +0 -0
  24. {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pandoraspec.egg-info/dependency_links.txt +0 -0
  25. {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pandoraspec.egg-info/entry_points.txt +0 -0
  26. {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pandoraspec.egg-info/top_level.txt +0 -0
  27. {pandoraspec-0.2.0 → pandoraspec-0.2.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pandoraspec
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: DORA Compliance Auditor for OpenAPI Specs
5
5
  Author-email: Ulises Merlan <ulimerlan@gmail.com>
6
6
  License: MIT
@@ -12,6 +12,7 @@ Requires-Dist: rich
12
12
  Requires-Dist: weasyprint
13
13
  Requires-Dist: jinja2
14
14
  Requires-Dist: requests
15
+ Requires-Dist: pytest
15
16
 
16
17
  # PanDoraSpec
17
18
 
@@ -82,6 +83,12 @@ pandoraspec https://api.example.com/spec.json --vendor "Stripe" --key "sk_live_.
82
83
  pandoraspec ./openapi.yaml
83
84
  ```
84
85
 
86
+ ### Override Base URL
87
+ If your OpenAPI spec uses variables (e.g. `https://{env}.api.com`) or you want to audit a specific target:
88
+ ```bash
89
+ pandoraspec https://api.example.com/spec.json --base-url https://staging.api.example.com
90
+ ```
91
+
85
92
  ---
86
93
 
87
94
  ## 🏎️ Zero-Config Testing (DORA Compliance)
@@ -67,6 +67,12 @@ pandoraspec https://api.example.com/spec.json --vendor "Stripe" --key "sk_live_.
67
67
  pandoraspec ./openapi.yaml
68
68
  ```
69
69
 
70
+ ### Override Base URL
71
+ If your OpenAPI spec uses variables (e.g. `https://{env}.api.com`) or you want to audit a specific target:
72
+ ```bash
73
+ pandoraspec https://api.example.com/spec.json --base-url https://staging.api.example.com
74
+ ```
75
+
70
76
  ---
71
77
 
72
78
  ## 🏎️ Zero-Config Testing (DORA Compliance)
File without changes
@@ -0,0 +1,145 @@
1
+ import html
2
+ from typing import List, Dict, Any
3
+ from schemathesis import checks
4
+ from schemathesis.specs.openapi import checks as oai_checks
5
+ from schemathesis.checks import CheckContext, ChecksConfig
6
+ from ..seed import SeedManager
7
+
8
+ def run_drift_check(schema, base_url: str, api_key: str, seed_manager: SeedManager) -> List[Dict]:
9
+ """
10
+ Module A: The 'Docs vs. Code' Drift Check (The Integrity Test)
11
+ Uses schemathesis to verify if the API implementation matches the spec.
12
+ """
13
+ results = []
14
+ # Mapping check names to actual functions
15
+ check_map = {
16
+ "not_a_server_error": checks.not_a_server_error,
17
+ "status_code_conformance": oai_checks.status_code_conformance,
18
+ "response_schema_conformance": oai_checks.response_schema_conformance
19
+ }
20
+ check_names = list(check_map.keys())
21
+
22
+ # Schemathesis 4.x checks require a context object
23
+ checks_config = ChecksConfig()
24
+ check_ctx = CheckContext(
25
+ override=None,
26
+ auth=None,
27
+ headers=None,
28
+ config=checks_config,
29
+ transport_kwargs=None,
30
+ )
31
+
32
+ for op in schema.get_all_operations():
33
+ # Handle Result type (Ok/Err) wrapping if present
34
+ operation = op.ok() if hasattr(op, "ok") else op
35
+
36
+ try:
37
+ # Generate test case
38
+ try:
39
+ case = operation.as_strategy().example()
40
+ except (AttributeError, Exception):
41
+ try:
42
+ cases = list(operation.make_case())
43
+ case = cases[0] if cases else None
44
+ except (AttributeError, Exception):
45
+ case = None
46
+
47
+ if not case:
48
+ continue
49
+
50
+ seed_manager.apply_seed_data(case)
51
+
52
+ formatted_path = operation.path
53
+ if case.path_parameters:
54
+ for key, value in case.path_parameters.items():
55
+ formatted_path = formatted_path.replace(f"{{{key}}}", f"{{{key}:{value}}}")
56
+
57
+ print(f"AUDIT LOG: Testing endpoint {operation.method.upper()} {formatted_path}")
58
+
59
+ headers = {}
60
+ if api_key:
61
+ auth_header = api_key if api_key.lower().startswith("bearer ") else f"Bearer {api_key}"
62
+ headers["Authorization"] = auth_header
63
+
64
+ # Call the API
65
+ target_url = f"{base_url.rstrip('/')}/{formatted_path.lstrip('/')}"
66
+ print(f"AUDIT LOG: Calling {operation.method.upper()} {target_url}")
67
+
68
+ response = case.call(base_url=base_url, headers=headers)
69
+ print(f"AUDIT LOG: Response Status Code: {response.status_code}")
70
+
71
+ # We manually call the check function to ensure arguments are passed correctly.
72
+ for check_name in check_names:
73
+ check_func = check_map[check_name]
74
+ try:
75
+ # Direct call: check_func(ctx, response, case)
76
+ check_func(check_ctx, response, case)
77
+
78
+ # If we get here, the check passed
79
+ results.append({
80
+ "module": "A",
81
+ "endpoint": f"{operation.method.upper()} {operation.path}",
82
+ "issue": f"{check_name} - Passed",
83
+ "status": "PASS",
84
+ "severity": "INFO",
85
+ "details": f"Status: {response.status_code}"
86
+ })
87
+
88
+ except AssertionError as e:
89
+ # This catches actual drift (e.g., Schema validation failed)
90
+ # Capture and format detailed error info
91
+ validation_errors = []
92
+
93
+ # Safely get causes if they exist and are iterable
94
+ causes = getattr(e, "causes", None)
95
+ if causes:
96
+ for cause in causes:
97
+ if hasattr(cause, "message"):
98
+ validation_errors.append(cause.message)
99
+ else:
100
+ validation_errors.append(str(cause))
101
+
102
+ if not validation_errors:
103
+ validation_errors.append(str(e) or "Validation failed")
104
+
105
+ err_msg = "<br>".join(validation_errors)
106
+ safe_err = html.escape(err_msg)
107
+
108
+ # Add helpful context (Status & Body Preview)
109
+ context_msg = f"Status: {response.status_code}"
110
+ try:
111
+ if response.content:
112
+ preview = response.text[:500]
113
+ safe_preview = html.escape(preview)
114
+ context_msg += f"<br>Response: {safe_preview}"
115
+ except Exception:
116
+ pass
117
+
118
+ full_details = f"<strong>Error:</strong> {safe_err}<br><br><strong>Context:</strong><br>{context_msg}"
119
+
120
+ print(f"AUDIT LOG: Validation {check_name} failed: {err_msg}")
121
+ results.append({
122
+ "module": "A",
123
+ "endpoint": f"{operation.method.upper()} {operation.path}",
124
+ "issue": f"Schema Drift Detected ({check_name})",
125
+ "status": "FAIL",
126
+ "details": full_details,
127
+ "severity": "HIGH"
128
+ })
129
+ except Exception as e:
130
+ # This catches unexpected coding errors
131
+ print(f"AUDIT LOG: Error executing check {check_name}: {str(e)}")
132
+ results.append({
133
+ "module": "A",
134
+ "endpoint": f"{operation.method.upper()} {operation.path}",
135
+ "issue": f"Check Execution Error ({check_name})",
136
+ "status": "FAIL",
137
+ "details": str(e),
138
+ "severity": "HIGH"
139
+ })
140
+
141
+ except Exception as e:
142
+ print(f"AUDIT LOG: Critical Error during endpoint test: {str(e)}")
143
+ continue
144
+
145
+ return results
@@ -0,0 +1,78 @@
1
+ from typing import List, Dict
2
+ from ..seed import SeedManager
3
+ from ..constants import FLOOD_REQUEST_COUNT
4
+
5
+ def run_resilience_tests(schema, base_url: str, api_key: str, seed_manager: SeedManager) -> List[Dict]:
6
+ """
7
+ Module B: The 'Resilience' Stress Test (Art. 24 & 25)
8
+ Checks for Rate Limiting and Timeout gracefully handling.
9
+ """
10
+ results = []
11
+ ops = list(schema.get_all_operations())
12
+ if not ops:
13
+ return []
14
+
15
+ print("AUDIT LOG: Starting Module B: Resilience Stress Test (flooding requests)...")
16
+
17
+ operation = ops[0].ok() if hasattr(ops[0], "ok") else ops[0]
18
+
19
+ # Simulate flooding
20
+ responses = []
21
+ for _ in range(FLOOD_REQUEST_COUNT):
22
+ try:
23
+ case = operation.as_strategy().example()
24
+ except (AttributeError, Exception):
25
+ try:
26
+ cases = list(operation.make_case())
27
+ case = cases[0] if cases else None
28
+ except (AttributeError, Exception):
29
+ case = None
30
+
31
+ if case:
32
+ seed_manager.apply_seed_data(case)
33
+
34
+ headers = {}
35
+ if api_key:
36
+ auth_header = api_key if api_key.lower().startswith("bearer ") else f"Bearer {api_key}"
37
+ headers["Authorization"] = auth_header
38
+
39
+ responses.append(case.call(base_url=base_url, headers=headers))
40
+
41
+ has_429 = any(r.status_code == 429 for r in responses)
42
+ has_500 = any(r.status_code == 500 for r in responses)
43
+
44
+ if not has_429 and has_500:
45
+ results.append({
46
+ "module": "B",
47
+ "issue": "Poor Resilience: 500 Error during flood",
48
+ "status": "FAIL",
49
+ "details": "The API returned 500 Internal Server Error instead of 429 Too Many Requests when flooded.",
50
+ "severity": "CRITICAL"
51
+ })
52
+ elif not has_429:
53
+ results.append({
54
+ "module": "B",
55
+ "issue": "No Rate Limiting Enforced",
56
+ "status": "FAIL",
57
+ "details": "The API did not return 429 Too Many Requests during high volume testing.",
58
+ "severity": "MEDIUM"
59
+ })
60
+ else:
61
+ results.append({
62
+ "module": "B",
63
+ "issue": "Rate Limiting Functional",
64
+ "status": "PASS",
65
+ "details": "The API correctly returned 429 Too Many Requests when flooded.",
66
+ "severity": "INFO"
67
+ })
68
+
69
+ if not has_500:
70
+ results.append({
71
+ "module": "B",
72
+ "issue": "Stress Handling",
73
+ "status": "PASS",
74
+ "details": "No 500 Internal Server Errors were observed during stress testing.",
75
+ "severity": "INFO"
76
+ })
77
+
78
+ return results
@@ -0,0 +1,51 @@
1
+ from typing import List, Dict
2
+ from ..constants import SENSITIVE_PATH_KEYWORDS
3
+
4
+ def run_security_hygiene(schema, base_url: str) -> List[Dict]:
5
+ """
6
+ Module C: Security Hygiene Check
7
+ Checks for TLS and Auth leakage in URL.
8
+ """
9
+ results = []
10
+ print(f"AUDIT LOG: Checking Security Hygiene for base URL: {base_url}")
11
+ if base_url and not base_url.startswith("https"):
12
+ results.append({
13
+ "module": "C",
14
+ "issue": "Insecure Connection (No TLS)",
15
+ "status": "FAIL",
16
+ "details": "The API base URL does not use HTTPS.",
17
+ "severity": "CRITICAL"
18
+ })
19
+ else:
20
+ results.append({
21
+ "module": "C",
22
+ "issue": "Secure Connection (TLS)",
23
+ "status": "PASS",
24
+ "details": "The API uses HTTPS.",
25
+ "severity": "INFO"
26
+ })
27
+
28
+ auth_leakage_found = False
29
+ for op in schema.get_all_operations():
30
+ operation = op.ok() if hasattr(op, "ok") else op
31
+ endpoint = operation.path
32
+ if any(keyword in endpoint.lower() for keyword in SENSITIVE_PATH_KEYWORDS):
33
+ auth_leakage_found = True
34
+ results.append({
35
+ "module": "C",
36
+ "issue": "Auth Leakage Risk",
37
+ "status": "FAIL",
38
+ "details": f"Endpoint '{endpoint}' indicates auth tokens might be passed in the URL.",
39
+ "severity": "HIGH"
40
+ })
41
+
42
+ if not auth_leakage_found:
43
+ results.append({
44
+ "module": "C",
45
+ "issue": "No Auth Leakage in URLs",
46
+ "status": "PASS",
47
+ "details": "No endpoints found with 'key' or 'token' in the path, suggesting safe header-based auth.",
48
+ "severity": "INFO"
49
+ })
50
+
51
+ return results
@@ -20,7 +20,8 @@ def run_audit(
20
20
  target: str = typer.Argument(..., help="URL or path to OpenAPI schema"),
21
21
  api_key: str = typer.Option(None, "--key", "-k", help="API Key for authenticated endpoints"),
22
22
  vendor: str = typer.Option("Vendor", "--vendor", "-v", help="Vendor name for the report"),
23
- config: str = typer.Option(None, "--config", "-c", help="Path to .yaml configuration file")
23
+ config: str = typer.Option(None, "--config", "-c", help="Path to .yaml configuration file"),
24
+ base_url: str = typer.Option(None, "--base-url", "-b", help="Override API Base URL")
24
25
  ):
25
26
  """
26
27
  Run a DORA audit against an OpenAPI schema.
@@ -39,7 +40,7 @@ def run_audit(
39
40
 
40
41
  try:
41
42
  # 2. Pass seed_data to Engine
42
- engine = AuditEngine(target=target, api_key=api_key, seed_data=seed_data)
43
+ engine = AuditEngine(target=target, api_key=api_key, seed_data=seed_data, base_url=base_url)
43
44
 
44
45
  results = engine.run_full_audit()
45
46
 
@@ -0,0 +1,5 @@
1
+ # Resilience/Stress Testing
2
+ FLOOD_REQUEST_COUNT = 20
3
+
4
+ # Security Hygiene
5
+ SENSITIVE_PATH_KEYWORDS = ["key", "token"]
@@ -0,0 +1,83 @@
1
+ import schemathesis
2
+ from typing import Dict, Any
3
+ import os
4
+ from .seed import SeedManager
5
+ from .checks.drift import run_drift_check
6
+ from .checks.resilience import run_resilience_tests
7
+ from .checks.security import run_security_hygiene
8
+
9
+ class AuditEngine:
10
+ def __init__(self, target: str, api_key: str = None, seed_data: Dict[str, Any] = None, base_url: str = None):
11
+ self.target = target
12
+ self.api_key = api_key
13
+ self.seed_data = seed_data or {}
14
+ self.base_url = base_url
15
+ self.dynamic_cache = {} # Cache for dynamic seed values
16
+ self.schema = None
17
+
18
+ try:
19
+ if os.path.exists(target) and os.path.isfile(target):
20
+ print(f"DEBUG: Loading schema from local file: {target}")
21
+ self.schema = schemathesis.openapi.from_path(target)
22
+ else:
23
+ self.schema = schemathesis.openapi.from_url(target)
24
+
25
+ # If base_url was manually provided, we skip dynamic resolution
26
+ if self.base_url:
27
+ print(f"DEBUG: Using manual override base_url: {self.base_url}")
28
+ resolved_url = self.base_url
29
+ else:
30
+ # Priority 1: Extract from the 'servers' field in the spec
31
+ resolved_url = None
32
+ if hasattr(self.schema, "raw_schema"):
33
+ servers = self.schema.raw_schema.get("servers", [])
34
+ if servers and isinstance(servers, list) and len(servers) > 0:
35
+ spec_server_url = servers[0].get("url")
36
+ if spec_server_url:
37
+ resolved_url = spec_server_url
38
+ print(f"DEBUG: Found server URL in specification: {resolved_url}")
39
+
40
+ # Priority 2: Use whatever schemathesis resolved automatically (fallback)
41
+ if not resolved_url:
42
+ resolved_url = getattr(self.schema, "base_url", None)
43
+ print(f"DEBUG: Falling back to Schemathesis resolved base_url: {resolved_url}")
44
+
45
+ if not resolved_url and self.target and not os.path.exists(self.target):
46
+ # Fallback: Derive from target URL (e.g., remove swagger.json)
47
+ try:
48
+ from urllib.parse import urlparse, urlunparse
49
+ parsed = urlparse(self.target)
50
+ path_parts = parsed.path.split('/')
51
+ # Simple heuristic: remove the last segment (e.g. swagger.json) to get base
52
+ if '.' in path_parts[-1]:
53
+ path_parts.pop()
54
+ new_path = '/'.join(path_parts)
55
+ resolved_url = urlunparse(parsed._replace(path=new_path))
56
+ print(f"DEBUG: Derived base_url from schema_url: {resolved_url}")
57
+ except Exception as e:
58
+ print(f"DEBUG: Failed to derive base_url from schema_url: {e}")
59
+
60
+ print(f"DEBUG: Final resolved base_url for engine: {resolved_url}")
61
+ self.base_url = resolved_url
62
+ if resolved_url:
63
+ try:
64
+ self.schema.base_url = resolved_url
65
+ except Exception:
66
+ pass
67
+ except Exception as e:
68
+ # Handle invalid URL or schema loading error gracefully
69
+ print(f"Error loading schema: {e}")
70
+ if target and (target.startswith("http") or os.path.exists(target)):
71
+ pass # Allow to continue if it's just a warning, but schemathesis might fail later
72
+ else:
73
+ raise ValueError(f"Failed to load OpenAPI schema from {target}. Error: {str(e)}")
74
+
75
+ # Initialize Seed Manager
76
+ self.seed_manager = SeedManager(self.seed_data, self.base_url, self.api_key)
77
+
78
+ def run_full_audit(self) -> Dict:
79
+ return {
80
+ "drift_check": run_drift_check(self.schema, self.base_url, self.api_key, self.seed_manager),
81
+ "resilience": run_resilience_tests(self.schema, self.base_url, self.api_key, self.seed_manager),
82
+ "security": run_security_hygiene(self.schema, self.base_url)
83
+ }
@@ -0,0 +1,144 @@
1
+ import re
2
+ import requests
3
+ from typing import Dict, Any, Optional
4
+
5
+ class SeedManager:
6
+ def __init__(self, seed_data: Dict[str, Any], base_url: Optional[str] = None, api_key: Optional[str] = None):
7
+ self.seed_data = seed_data
8
+ self.base_url = base_url
9
+ self.api_key = api_key
10
+ self.dynamic_cache = {}
11
+
12
+ def _resolve_dynamic_value(self, config_value: Any) -> Any:
13
+ """Resolves dynamic seed values like `from_endpoint`"""
14
+ if not isinstance(config_value, dict) or "from_endpoint" not in config_value:
15
+ return config_value
16
+
17
+ endpoint_def = config_value["from_endpoint"]
18
+ if endpoint_def in self.dynamic_cache:
19
+ return self.dynamic_cache[endpoint_def]
20
+
21
+ try:
22
+ method, path = endpoint_def.split(" ", 1)
23
+
24
+ # Interpolate path parameters (e.g., /user/{id}) from general seeds
25
+ if '{' in path:
26
+ general_seeds = self.seed_data.get('general', {})
27
+
28
+ def replace_param(match):
29
+ param_name = match.group(1)
30
+ if param_name in general_seeds:
31
+ return str(general_seeds[param_name])
32
+ print(f"WARNING: Missing seed value for {{{param_name}}} in dynamic endpoint {endpoint_def}")
33
+ return match.group(0) # Leave as is
34
+
35
+ path = re.sub(r"\{([a-zA-Z0-9_]+)\}", replace_param, path)
36
+
37
+ if not self.base_url:
38
+ print("WARNING: Cannot resolve dynamic seed, base_url is not set.")
39
+ return None
40
+
41
+ url = f"{self.base_url.rstrip('/')}/{path.lstrip('/')}"
42
+
43
+ headers = {}
44
+ if self.api_key:
45
+ auth_header = self.api_key if self.api_key.lower().startswith("bearer ") else f"Bearer {self.api_key}"
46
+ headers["Authorization"] = auth_header
47
+
48
+ print(f"AUDIT LOG: Resolving dynamic seed from {method} {path}")
49
+ response = requests.request(method, url, headers=headers)
50
+
51
+ if response.status_code >= 400:
52
+ print(f"WARNING: Dynamic seed request failed with {response.status_code}")
53
+ return None
54
+
55
+ result = None
56
+ extract_key = config_value.get("extract")
57
+ regex_pattern = config_value.get("regex")
58
+
59
+ # JSON Extraction
60
+ if extract_key:
61
+ try:
62
+ json_data = response.json()
63
+ # Simple key traversal for now (e.g. 'data.id')
64
+ keys = extract_key.split('.')
65
+ val = json_data
66
+ for k in keys:
67
+ if isinstance(val, dict):
68
+ val = val.get(k)
69
+ else:
70
+ val = None
71
+ break
72
+ result = val
73
+ except Exception:
74
+ print("WARNING: Failed to parse JSON or extract key")
75
+ else:
76
+ # Default to text body
77
+ result = response.text
78
+
79
+ # Regex Extraction
80
+ if regex_pattern and result is not None:
81
+ match = re.search(regex_pattern, str(result))
82
+ if match:
83
+ # Return first group if exists, else the whole match
84
+ result = match.group(1) if match.groups() else match.group(0)
85
+
86
+ self.dynamic_cache[endpoint_def] = result
87
+ return result
88
+
89
+ except Exception as e:
90
+ print(f"ERROR: Failed to resolve dynamic seed: {e}")
91
+ return None
92
+
93
+ def apply_seed_data(self, case):
94
+ """Helper to inject seed data into test cases with hierarchy: General < Verbs < Endpoints"""
95
+ if not self.seed_data:
96
+ return
97
+
98
+ # Determine if using hierarchical structure
99
+ is_hierarchical = any(k in self.seed_data for k in ['general', 'verbs', 'endpoints'])
100
+
101
+ if is_hierarchical:
102
+ # 1. Start with General
103
+ merged_data = self.seed_data.get('general', {}).copy()
104
+
105
+ # 2. Apply Verb-specific
106
+ if hasattr(case, 'operation'):
107
+ method = case.operation.method.upper()
108
+ path = case.operation.path
109
+
110
+ verb_data = self.seed_data.get('verbs', {}).get(method, {})
111
+ merged_data.update(verb_data)
112
+
113
+ # 3. Apply Endpoint-specific
114
+ # precise match on path template
115
+ endpoint_data = self.seed_data.get('endpoints', {}).get(path, {}).get(method, {})
116
+ merged_data.update(endpoint_data)
117
+ else:
118
+ # Legacy flat structure
119
+ merged_data = self.seed_data.copy() # Copy to avoid mutating original config
120
+
121
+ # Resolve dynamic values for the final merged dataset
122
+ resolved_data = {}
123
+ for k, v in merged_data.items():
124
+ resolved_val = self._resolve_dynamic_value(v)
125
+ if resolved_val is not None:
126
+ resolved_data[k] = resolved_val
127
+
128
+ # Inject into Path Parameters (e.g., /users/{userId})
129
+ if hasattr(case, 'path_parameters') and case.path_parameters:
130
+ for key in case.path_parameters:
131
+ if key in resolved_data:
132
+ case.path_parameters[key] = resolved_data[key]
133
+
134
+ # Inject into Query Parameters (e.g., ?status=active)
135
+ if hasattr(case, 'query') and case.query:
136
+ for key in case.query:
137
+ if key in resolved_data:
138
+ case.query[key] = resolved_data[key]
139
+
140
+ # Inject into Headers (e.g., X-Tenant-ID)
141
+ if hasattr(case, 'headers') and case.headers:
142
+ for key in case.headers:
143
+ if key in resolved_data:
144
+ case.headers[key] = str(resolved_data[key])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pandoraspec
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: DORA Compliance Auditor for OpenAPI Specs
5
5
  Author-email: Ulises Merlan <ulimerlan@gmail.com>
6
6
  License: MIT
@@ -12,6 +12,7 @@ Requires-Dist: rich
12
12
  Requires-Dist: weasyprint
13
13
  Requires-Dist: jinja2
14
14
  Requires-Dist: requests
15
+ Requires-Dist: pytest
15
16
 
16
17
  # PanDoraSpec
17
18
 
@@ -82,6 +83,12 @@ pandoraspec https://api.example.com/spec.json --vendor "Stripe" --key "sk_live_.
82
83
  pandoraspec ./openapi.yaml
83
84
  ```
84
85
 
86
+ ### Override Base URL
87
+ If your OpenAPI spec uses variables (e.g. `https://{env}.api.com`) or you want to audit a specific target:
88
+ ```bash
89
+ pandoraspec https://api.example.com/spec.json --base-url https://staging.api.example.com
90
+ ```
91
+
85
92
  ---
86
93
 
87
94
  ## 🏎️ Zero-Config Testing (DORA Compliance)
@@ -0,0 +1,23 @@
1
+ README.md
2
+ pyproject.toml
3
+ pandoraspec/__init__.py
4
+ pandoraspec/cli.py
5
+ pandoraspec/constants.py
6
+ pandoraspec/core.py
7
+ pandoraspec/reporting.py
8
+ pandoraspec/seed.py
9
+ pandoraspec.egg-info/PKG-INFO
10
+ pandoraspec.egg-info/SOURCES.txt
11
+ pandoraspec.egg-info/dependency_links.txt
12
+ pandoraspec.egg-info/entry_points.txt
13
+ pandoraspec.egg-info/requires.txt
14
+ pandoraspec.egg-info/top_level.txt
15
+ pandoraspec/checks/__init__.py
16
+ pandoraspec/checks/drift.py
17
+ pandoraspec/checks/resilience.py
18
+ pandoraspec/checks/security.py
19
+ tests/test_checks_drift.py
20
+ tests/test_checks_resilience.py
21
+ tests/test_checks_security.py
22
+ tests/test_core.py
23
+ tests/test_seed.py
@@ -4,3 +4,4 @@ rich
4
4
  weasyprint
5
5
  jinja2
6
6
  requests
7
+ pytest
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pandoraspec"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "DORA Compliance Auditor for OpenAPI Specs"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Ulises Merlan", email = "ulimerlan@gmail.com" }]
@@ -16,7 +16,8 @@ dependencies = [
16
16
  "rich",
17
17
  "weasyprint",
18
18
  "jinja2",
19
- "requests"
19
+ "requests",
20
+ "pytest"
20
21
  ]
21
22
 
22
23
  [project.scripts]