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.
- {pandoraspec-0.2.0 → pandoraspec-0.2.2}/PKG-INFO +8 -1
- {pandoraspec-0.2.0 → pandoraspec-0.2.2}/README.md +6 -0
- pandoraspec-0.2.2/pandoraspec/checks/__init__.py +0 -0
- pandoraspec-0.2.2/pandoraspec/checks/drift.py +145 -0
- pandoraspec-0.2.2/pandoraspec/checks/resilience.py +78 -0
- pandoraspec-0.2.2/pandoraspec/checks/security.py +51 -0
- {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pandoraspec/cli.py +3 -2
- pandoraspec-0.2.2/pandoraspec/constants.py +5 -0
- pandoraspec-0.2.2/pandoraspec/core.py +83 -0
- pandoraspec-0.2.2/pandoraspec/seed.py +144 -0
- {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pandoraspec.egg-info/PKG-INFO +8 -1
- pandoraspec-0.2.2/pandoraspec.egg-info/SOURCES.txt +23 -0
- {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pandoraspec.egg-info/requires.txt +1 -0
- {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pyproject.toml +3 -2
- pandoraspec-0.2.2/tests/test_checks_drift.py +53 -0
- pandoraspec-0.2.2/tests/test_checks_resilience.py +59 -0
- pandoraspec-0.2.2/tests/test_checks_security.py +51 -0
- pandoraspec-0.2.2/tests/test_core.py +35 -0
- pandoraspec-0.2.2/tests/test_seed.py +69 -0
- pandoraspec-0.2.0/pandoraspec/core.py +0 -470
- pandoraspec-0.2.0/pandoraspec.egg-info/SOURCES.txt +0 -12
- {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pandoraspec/__init__.py +0 -0
- {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pandoraspec/reporting.py +0 -0
- {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pandoraspec.egg-info/dependency_links.txt +0 -0
- {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pandoraspec.egg-info/entry_points.txt +0 -0
- {pandoraspec-0.2.0 → pandoraspec-0.2.2}/pandoraspec.egg-info/top_level.txt +0 -0
- {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.
|
|
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,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.
|
|
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,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pandoraspec"
|
|
7
|
-
version = "0.2.
|
|
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]
|