pandoraspec 0.1.1__py3-none-any.whl → 0.2.7__py3-none-any.whl
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/cli.py +28 -20
- pandoraspec/config.py +23 -0
- pandoraspec/constants.py +17 -0
- pandoraspec/core.py +52 -329
- pandoraspec/modules/__init__.py +0 -0
- pandoraspec/modules/drift.py +180 -0
- pandoraspec/modules/resilience.py +174 -0
- pandoraspec/modules/security.py +234 -0
- pandoraspec/orchestrator.py +69 -0
- pandoraspec/reporting/__init__.py +0 -0
- pandoraspec/reporting/generator.py +111 -0
- pandoraspec/{reporting.py → reporting/templates.py} +10 -88
- pandoraspec/seed.py +181 -0
- pandoraspec/utils/__init__.py +0 -0
- pandoraspec/utils/logger.py +21 -0
- pandoraspec/utils/parsing.py +35 -0
- pandoraspec/utils/url.py +23 -0
- pandoraspec-0.2.7.dist-info/METADATA +200 -0
- pandoraspec-0.2.7.dist-info/RECORD +23 -0
- pandoraspec-0.2.7.dist-info/entry_points.txt +2 -0
- pandoraspec-0.1.1.dist-info/METADATA +0 -72
- pandoraspec-0.1.1.dist-info/RECORD +0 -9
- pandoraspec-0.1.1.dist-info/entry_points.txt +0 -2
- {pandoraspec-0.1.1.dist-info → pandoraspec-0.2.7.dist-info}/WHEEL +0 -0
- {pandoraspec-0.1.1.dist-info → pandoraspec-0.2.7.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import html
|
|
2
|
+
from schemathesis import checks
|
|
3
|
+
from schemathesis.specs.openapi import checks as oai_checks
|
|
4
|
+
from schemathesis.checks import CheckContext, ChecksConfig
|
|
5
|
+
from urllib.parse import unquote
|
|
6
|
+
from ..seed import SeedManager
|
|
7
|
+
from ..utils.logger import logger
|
|
8
|
+
|
|
9
|
+
def run_drift_check(schema, base_url: str, api_key: str, seed_manager: SeedManager) -> list[dict]:
|
|
10
|
+
"""
|
|
11
|
+
Module A: The 'Docs vs. Code' Drift Check (The Integrity Test)
|
|
12
|
+
Uses schemathesis to verify if the API implementation matches the spec.
|
|
13
|
+
"""
|
|
14
|
+
results = []
|
|
15
|
+
# Mapping check names to actual functions
|
|
16
|
+
check_map = {
|
|
17
|
+
"not_a_server_error": checks.not_a_server_error,
|
|
18
|
+
"status_code_conformance": oai_checks.status_code_conformance,
|
|
19
|
+
"response_schema_conformance": oai_checks.response_schema_conformance
|
|
20
|
+
}
|
|
21
|
+
check_names = list(check_map.keys())
|
|
22
|
+
|
|
23
|
+
# Schemathesis 4.x checks require a context object
|
|
24
|
+
checks_config = ChecksConfig()
|
|
25
|
+
check_ctx = CheckContext(
|
|
26
|
+
override=None,
|
|
27
|
+
auth=None,
|
|
28
|
+
headers=None,
|
|
29
|
+
config=checks_config,
|
|
30
|
+
transport_kwargs=None,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
for op in schema.get_all_operations():
|
|
34
|
+
# Handle Result type (Ok/Err) wrapping if present
|
|
35
|
+
operation = op.ok() if hasattr(op, "ok") else op
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
# Generate test case
|
|
39
|
+
try:
|
|
40
|
+
case = operation.as_strategy().example()
|
|
41
|
+
except (AttributeError, Exception):
|
|
42
|
+
try:
|
|
43
|
+
cases = list(operation.make_case())
|
|
44
|
+
case = cases[0] if cases else None
|
|
45
|
+
except (AttributeError, Exception):
|
|
46
|
+
case = None
|
|
47
|
+
|
|
48
|
+
if not case:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
seeded_keys = seed_manager.apply_seed_data(case) or set()
|
|
52
|
+
|
|
53
|
+
formatted_path = operation.path
|
|
54
|
+
if case.path_parameters:
|
|
55
|
+
for key, value in case.path_parameters.items():
|
|
56
|
+
if key in seeded_keys:
|
|
57
|
+
display_value = unquote(str(value))
|
|
58
|
+
else:
|
|
59
|
+
display_value = "random"
|
|
60
|
+
|
|
61
|
+
formatted_path = formatted_path.replace(f"{{{key}}}", f"{{{key}:{display_value}}}")
|
|
62
|
+
|
|
63
|
+
logger.info(f"AUDIT LOG: Testing endpoint {operation.method.upper()} {formatted_path}")
|
|
64
|
+
|
|
65
|
+
headers = {}
|
|
66
|
+
if api_key:
|
|
67
|
+
auth_header = api_key if api_key.lower().startswith("bearer ") else f"Bearer {api_key}"
|
|
68
|
+
headers["Authorization"] = auth_header
|
|
69
|
+
|
|
70
|
+
# Call the API
|
|
71
|
+
target_url = f"{base_url.rstrip('/')}/{formatted_path.lstrip('/')}"
|
|
72
|
+
logger.debug(f"AUDIT LOG: Calling {operation.method.upper()} {target_url}")
|
|
73
|
+
|
|
74
|
+
response = case.call(base_url=base_url, headers=headers)
|
|
75
|
+
logger.debug(f"AUDIT LOG: Response Status Code: {response.status_code}")
|
|
76
|
+
|
|
77
|
+
# We manually call the check function to ensure arguments are passed correctly.
|
|
78
|
+
for check_name in check_names:
|
|
79
|
+
check_func = check_map[check_name]
|
|
80
|
+
try:
|
|
81
|
+
# Direct call: check_func(ctx, response, case)
|
|
82
|
+
check_func(check_ctx, response, case)
|
|
83
|
+
|
|
84
|
+
# If we get here, the check passed
|
|
85
|
+
results.append({
|
|
86
|
+
"module": "A",
|
|
87
|
+
"endpoint": f"{operation.method.upper()} {operation.path}",
|
|
88
|
+
"issue": f"{check_name} - Passed",
|
|
89
|
+
"status": "PASS",
|
|
90
|
+
"severity": "INFO",
|
|
91
|
+
"details": f"Status: {response.status_code}"
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
except AssertionError as e:
|
|
95
|
+
# This catches actual drift (e.g., Schema validation failed)
|
|
96
|
+
# Capture and format detailed error info
|
|
97
|
+
validation_errors = []
|
|
98
|
+
|
|
99
|
+
# Safely get causes if they exist and are iterable
|
|
100
|
+
causes = getattr(e, "causes", None)
|
|
101
|
+
if causes:
|
|
102
|
+
for cause in causes:
|
|
103
|
+
msg = cause.message if hasattr(cause, "message") else str(cause)
|
|
104
|
+
|
|
105
|
+
# START: Loose DateTime Check
|
|
106
|
+
# If strict validation fails on date-time, try to be forgiving
|
|
107
|
+
if "is not a 'date-time'" in msg:
|
|
108
|
+
try:
|
|
109
|
+
# Extract value from message: "'2023-10-25 12:00:00' is not a 'date-time'"
|
|
110
|
+
val_str = msg.split("'")[1]
|
|
111
|
+
from datetime import datetime
|
|
112
|
+
# specific check for the common "Space instead of T" issue
|
|
113
|
+
normalized = val_str.replace(" ", "T")
|
|
114
|
+
# check for likely valid formats that jsonschema hates
|
|
115
|
+
datetime.fromisoformat(normalized)
|
|
116
|
+
# If we parsed it, it's a False Positive for our purposes (drift is minor)
|
|
117
|
+
logger.info(f"AUDIT LOG: Ignoring strict date-time failure for plausible value: {val_str}")
|
|
118
|
+
continue
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
# END: Loose DateTime Check
|
|
122
|
+
|
|
123
|
+
validation_errors.append(msg)
|
|
124
|
+
|
|
125
|
+
if not validation_errors:
|
|
126
|
+
# If we filtered everything out, consider it a PASS
|
|
127
|
+
if causes:
|
|
128
|
+
results.append({
|
|
129
|
+
"module": "A",
|
|
130
|
+
"endpoint": f"{operation.method.upper()} {operation.path}",
|
|
131
|
+
"issue": f"{check_name} - Passed (Loose Validation)",
|
|
132
|
+
"status": "PASS",
|
|
133
|
+
"severity": "INFO",
|
|
134
|
+
"details": f"Status: {response.status_code}. Ignored minor format mismatches."
|
|
135
|
+
})
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
validation_errors.append(str(e) or "Validation failed")
|
|
139
|
+
|
|
140
|
+
err_msg = "<br>".join(validation_errors)
|
|
141
|
+
safe_err = html.escape(err_msg)
|
|
142
|
+
|
|
143
|
+
# Add helpful context (Status & Body Preview)
|
|
144
|
+
context_msg = f"Status: {response.status_code}"
|
|
145
|
+
try:
|
|
146
|
+
if response.content:
|
|
147
|
+
preview = response.text[:500]
|
|
148
|
+
safe_preview = html.escape(preview)
|
|
149
|
+
context_msg += f"<br>Response: {safe_preview}"
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
full_details = f"<strong>Error:</strong> {safe_err}<br><br><strong>Context:</strong><br>{context_msg}"
|
|
154
|
+
|
|
155
|
+
logger.warning(f"AUDIT LOG: Validation {check_name} failed: {err_msg}")
|
|
156
|
+
results.append({
|
|
157
|
+
"module": "A",
|
|
158
|
+
"endpoint": f"{operation.method.upper()} {operation.path}",
|
|
159
|
+
"issue": f"Schema Drift Detected ({check_name})",
|
|
160
|
+
"status": "FAIL",
|
|
161
|
+
"details": full_details,
|
|
162
|
+
"severity": "HIGH"
|
|
163
|
+
})
|
|
164
|
+
except Exception as e:
|
|
165
|
+
# This catches unexpected coding errors
|
|
166
|
+
logger.error(f"AUDIT LOG: Error executing check {check_name}: {str(e)}")
|
|
167
|
+
results.append({
|
|
168
|
+
"module": "A",
|
|
169
|
+
"endpoint": f"{operation.method.upper()} {operation.path}",
|
|
170
|
+
"issue": f"Check Execution Error ({check_name})",
|
|
171
|
+
"status": "FAIL",
|
|
172
|
+
"details": str(e),
|
|
173
|
+
"severity": "HIGH"
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.critical(f"AUDIT LOG: Critical Error during endpoint test: {str(e)}")
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
return results
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from ..seed import SeedManager
|
|
3
|
+
from ..constants import (
|
|
4
|
+
FLOOD_REQUEST_COUNT,
|
|
5
|
+
LATENCY_THRESHOLD_WARN,
|
|
6
|
+
RECOVERY_WAIT_TIME,
|
|
7
|
+
HTTP_429_TOO_MANY_REQUESTS,
|
|
8
|
+
HTTP_500_INTERNAL_SERVER_ERROR
|
|
9
|
+
)
|
|
10
|
+
from ..utils.logger import logger
|
|
11
|
+
|
|
12
|
+
def run_resilience_tests(schema, base_url: str, api_key: str, seed_manager: SeedManager) -> list[dict]:
|
|
13
|
+
"""
|
|
14
|
+
Module B: The 'Resilience' Stress Test (Art. 24 & 25)
|
|
15
|
+
Checks for Rate Limiting, Latency degradation, and Recovery.
|
|
16
|
+
"""
|
|
17
|
+
results = []
|
|
18
|
+
ops = list(schema.get_all_operations())
|
|
19
|
+
if not ops:
|
|
20
|
+
return []
|
|
21
|
+
|
|
22
|
+
logger.info("AUDIT LOG: Starting Module B: Resilience Stress Test (flooding requests)...")
|
|
23
|
+
|
|
24
|
+
operation = ops[0].ok() if hasattr(ops[0], "ok") else ops[0]
|
|
25
|
+
|
|
26
|
+
# Simulate flooding
|
|
27
|
+
responses = []
|
|
28
|
+
latencies = []
|
|
29
|
+
|
|
30
|
+
for _ in range(FLOOD_REQUEST_COUNT):
|
|
31
|
+
try:
|
|
32
|
+
case = operation.as_strategy().example()
|
|
33
|
+
except (AttributeError, Exception):
|
|
34
|
+
try:
|
|
35
|
+
cases = list(operation.make_case())
|
|
36
|
+
case = cases[0] if cases else None
|
|
37
|
+
except (AttributeError, Exception):
|
|
38
|
+
case = None
|
|
39
|
+
|
|
40
|
+
if case:
|
|
41
|
+
seed_manager.apply_seed_data(case)
|
|
42
|
+
|
|
43
|
+
headers = {}
|
|
44
|
+
if api_key:
|
|
45
|
+
auth_header = api_key if api_key.lower().startswith("bearer ") else f"Bearer {api_key}"
|
|
46
|
+
headers["Authorization"] = auth_header
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
resp = case.call(base_url=base_url, headers=headers)
|
|
50
|
+
responses.append(resp)
|
|
51
|
+
# Capture latency if available
|
|
52
|
+
if hasattr(resp, 'elapsed'):
|
|
53
|
+
if hasattr(resp.elapsed, 'total_seconds'):
|
|
54
|
+
latencies.append(resp.elapsed.total_seconds())
|
|
55
|
+
elif isinstance(resp.elapsed, (int, float)):
|
|
56
|
+
latencies.append(float(resp.elapsed))
|
|
57
|
+
else:
|
|
58
|
+
latencies.append(0.0)
|
|
59
|
+
else:
|
|
60
|
+
latencies.append(0.0)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
logger.warning(f"Request failed during flood: {e}")
|
|
63
|
+
|
|
64
|
+
has_429 = any(r.status_code == HTTP_429_TOO_MANY_REQUESTS for r in responses)
|
|
65
|
+
has_500 = any(r.status_code == HTTP_500_INTERNAL_SERVER_ERROR for r in responses)
|
|
66
|
+
|
|
67
|
+
avg_latency = sum(latencies) / len(latencies) if latencies else 0.0
|
|
68
|
+
|
|
69
|
+
# Recovery Check (Circuit Breaker)
|
|
70
|
+
logger.info(f"Waiting {RECOVERY_WAIT_TIME}s for circuit breaker recovery check...")
|
|
71
|
+
time.sleep(RECOVERY_WAIT_TIME)
|
|
72
|
+
|
|
73
|
+
recovery_failed = False
|
|
74
|
+
try:
|
|
75
|
+
# Attempt one probe request to see if API is back to normal
|
|
76
|
+
# We regenerate a case to be safe
|
|
77
|
+
try:
|
|
78
|
+
recovery_case = operation.as_strategy().example()
|
|
79
|
+
except:
|
|
80
|
+
cases = list(operation.make_case())
|
|
81
|
+
recovery_case = cases[0] if cases else None
|
|
82
|
+
|
|
83
|
+
if recovery_case:
|
|
84
|
+
seed_manager.apply_seed_data(recovery_case)
|
|
85
|
+
rec_headers = {}
|
|
86
|
+
if api_key:
|
|
87
|
+
rec_headers["Authorization"] = api_key if api_key.lower().startswith("bearer ") else f"Bearer {api_key}"
|
|
88
|
+
|
|
89
|
+
recovery_resp = recovery_case.call(base_url=base_url, headers=rec_headers)
|
|
90
|
+
|
|
91
|
+
# If it returns 500, it's definitely NOT recovered
|
|
92
|
+
if recovery_resp.status_code == HTTP_500_INTERNAL_SERVER_ERROR:
|
|
93
|
+
recovery_failed = True
|
|
94
|
+
# Note: We count 429 as "still waiting" but not a crash, so technically "recovered" from error state?
|
|
95
|
+
# Ideally it should be 200, but ensuring no 500 is the critical resilience check here.
|
|
96
|
+
except Exception:
|
|
97
|
+
# Connection error means it's down
|
|
98
|
+
recovery_failed = True
|
|
99
|
+
|
|
100
|
+
# Helper to create consistent result objects
|
|
101
|
+
def _create_result(issue, status, details, severity):
|
|
102
|
+
return {
|
|
103
|
+
"module": "B",
|
|
104
|
+
"issue": issue,
|
|
105
|
+
"status": status,
|
|
106
|
+
"details": details,
|
|
107
|
+
"severity": severity
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# 1. Rate Limiting Check
|
|
111
|
+
if has_429:
|
|
112
|
+
results.append(_create_result(
|
|
113
|
+
"Rate Limiting Functional",
|
|
114
|
+
"PASS",
|
|
115
|
+
f"The API correctly returned {HTTP_429_TOO_MANY_REQUESTS} Too Many Requests when flooded.",
|
|
116
|
+
"INFO"
|
|
117
|
+
))
|
|
118
|
+
else:
|
|
119
|
+
results.append(_create_result(
|
|
120
|
+
"No Rate Limiting Enforced",
|
|
121
|
+
"FAIL",
|
|
122
|
+
f"The API did not return {HTTP_429_TOO_MANY_REQUESTS} Too Many Requests during high volume testing.",
|
|
123
|
+
"MEDIUM"
|
|
124
|
+
))
|
|
125
|
+
|
|
126
|
+
# 2. Stress Handling Check (500 Errors)
|
|
127
|
+
if has_500:
|
|
128
|
+
results.append(_create_result(
|
|
129
|
+
"Poor Resilience: 500 Error during flood",
|
|
130
|
+
"FAIL",
|
|
131
|
+
f"The API returned {HTTP_500_INTERNAL_SERVER_ERROR} Internal Server Error instead of {HTTP_429_TOO_MANY_REQUESTS} Too Many Requests when flooded.",
|
|
132
|
+
"CRITICAL"
|
|
133
|
+
))
|
|
134
|
+
else:
|
|
135
|
+
results.append(_create_result(
|
|
136
|
+
"Stress Handling",
|
|
137
|
+
"PASS",
|
|
138
|
+
f"No {HTTP_500_INTERNAL_SERVER_ERROR} Internal Server Errors were observed during stress testing.",
|
|
139
|
+
"INFO"
|
|
140
|
+
))
|
|
141
|
+
|
|
142
|
+
# 3. Latency Check
|
|
143
|
+
if avg_latency > LATENCY_THRESHOLD_WARN:
|
|
144
|
+
results.append(_create_result(
|
|
145
|
+
"Performance Degradation",
|
|
146
|
+
"FAIL",
|
|
147
|
+
f"Average latency during stress was {avg_latency:.2f}s (Threshold: {LATENCY_THRESHOLD_WARN}s).",
|
|
148
|
+
"WARNING"
|
|
149
|
+
))
|
|
150
|
+
else:
|
|
151
|
+
results.append(_create_result(
|
|
152
|
+
"Performance Stability",
|
|
153
|
+
"PASS",
|
|
154
|
+
f"Average latency {avg_latency:.2f}s remained within acceptable limits.",
|
|
155
|
+
"INFO"
|
|
156
|
+
))
|
|
157
|
+
|
|
158
|
+
# 4. Recovery Check
|
|
159
|
+
if recovery_failed:
|
|
160
|
+
results.append(_create_result(
|
|
161
|
+
"Recovery Failure",
|
|
162
|
+
"FAIL",
|
|
163
|
+
f"API failed to recover (returned {HTTP_500_INTERNAL_SERVER_ERROR} or crash) after {RECOVERY_WAIT_TIME}s cooldown.",
|
|
164
|
+
"HIGH"
|
|
165
|
+
))
|
|
166
|
+
else:
|
|
167
|
+
results.append(_create_result(
|
|
168
|
+
"Self-Healing / Recovery",
|
|
169
|
+
"PASS",
|
|
170
|
+
f"API successfully handled legitimate requests after {RECOVERY_WAIT_TIME}s cooldown.",
|
|
171
|
+
"INFO"
|
|
172
|
+
))
|
|
173
|
+
|
|
174
|
+
return results
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from ..constants import SENSITIVE_PATH_KEYWORDS, SECURITY_SCAN_LIMIT, HTTP_200_OK, HTTP_500_INTERNAL_SERVER_ERROR
|
|
4
|
+
from ..utils.logger import logger
|
|
5
|
+
|
|
6
|
+
def _check_headers(base_url: str) -> list[dict]:
|
|
7
|
+
"""Check for security headers on the base URL."""
|
|
8
|
+
results = []
|
|
9
|
+
try:
|
|
10
|
+
response = requests.get(base_url, timeout=5)
|
|
11
|
+
headers = response.headers
|
|
12
|
+
|
|
13
|
+
missing_headers = []
|
|
14
|
+
full_headers = {
|
|
15
|
+
"Strict-Transport-Security": "HSTS",
|
|
16
|
+
"Content-Security-Policy": "CSP",
|
|
17
|
+
"X-Content-Type-Options": "No-Sniff",
|
|
18
|
+
"X-Frame-Options": "Clickjacking Protection"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for header, name in full_headers.items():
|
|
22
|
+
if header not in headers:
|
|
23
|
+
missing_headers.append(name)
|
|
24
|
+
|
|
25
|
+
if missing_headers:
|
|
26
|
+
results.append({
|
|
27
|
+
"module": "C",
|
|
28
|
+
"issue": "Missing Security Headers",
|
|
29
|
+
"status": "FAIL",
|
|
30
|
+
"details": f"Missing recommended headers: {', '.join(missing_headers)}",
|
|
31
|
+
"severity": "MEDIUM"
|
|
32
|
+
})
|
|
33
|
+
else:
|
|
34
|
+
results.append({
|
|
35
|
+
"module": "C",
|
|
36
|
+
"issue": "Security Headers",
|
|
37
|
+
"status": "PASS",
|
|
38
|
+
"details": "All core security headers are present.",
|
|
39
|
+
"severity": "INFO"
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
except Exception as e:
|
|
43
|
+
logger.warning(f"Failed to check headers: {e}")
|
|
44
|
+
|
|
45
|
+
return results
|
|
46
|
+
|
|
47
|
+
def _check_auth_enforcement(ops, base_url: str) -> list[dict]:
|
|
48
|
+
"""
|
|
49
|
+
Check if endpoints are protected by default.
|
|
50
|
+
Tries to access up to 3 static GET endpoints without credentials.
|
|
51
|
+
Fails if 200 OK is returned.
|
|
52
|
+
"""
|
|
53
|
+
results = []
|
|
54
|
+
# Filter for GET operations without path parameters (simple access)
|
|
55
|
+
simple_gets = [
|
|
56
|
+
op for op in ops
|
|
57
|
+
if op.method.upper() == "GET" and "{" not in op.path
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
# Take top N
|
|
61
|
+
targets = simple_gets[:SECURITY_SCAN_LIMIT]
|
|
62
|
+
if not targets:
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
failures = []
|
|
66
|
+
for op in targets:
|
|
67
|
+
url = f"{base_url.rstrip('/')}{op.path}"
|
|
68
|
+
try:
|
|
69
|
+
# Request without any Auth headers
|
|
70
|
+
resp = requests.get(url, timeout=5)
|
|
71
|
+
# If we get 200 OK on what should likely be a protected API (heuristic)
|
|
72
|
+
# Note: This is aggressive. Some endpoints like /health might be public.
|
|
73
|
+
# We filter out obvious public paths?
|
|
74
|
+
if resp.status_code == HTTP_200_OK:
|
|
75
|
+
# Filter out obvious public endpoints that SHOULD be accessible
|
|
76
|
+
public_keywords = ["health", "status", "ping", "login", "auth", "token", "sign", "doc", "openapi", "well-known"]
|
|
77
|
+
if not any(k in op.path.lower() for k in public_keywords):
|
|
78
|
+
failures.append(op.path)
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
if failures:
|
|
83
|
+
results.append({
|
|
84
|
+
"module": "C",
|
|
85
|
+
"issue": "Auth Enforcement Failed",
|
|
86
|
+
"status": "FAIL",
|
|
87
|
+
"details": f"Endpoints accessible without auth: {', '.join(failures)}",
|
|
88
|
+
"severity": "CRITICAL"
|
|
89
|
+
})
|
|
90
|
+
else:
|
|
91
|
+
results.append({
|
|
92
|
+
"module": "C",
|
|
93
|
+
"issue": "Auth Enforcement",
|
|
94
|
+
"status": "PASS",
|
|
95
|
+
"details": f"Checked {len(targets)} endpoints; none returned {HTTP_200_OK} OK without info.",
|
|
96
|
+
"severity": "INFO"
|
|
97
|
+
})
|
|
98
|
+
return results
|
|
99
|
+
|
|
100
|
+
def _check_injection(ops, base_url: str, api_key: str = None) -> list[dict]:
|
|
101
|
+
"""
|
|
102
|
+
Basic probe for SQLi/XSS in query parameters.
|
|
103
|
+
"""
|
|
104
|
+
results = []
|
|
105
|
+
# Find operations with query parameters
|
|
106
|
+
candidates = []
|
|
107
|
+
for op in ops:
|
|
108
|
+
# Check definitions for query params (heuristic via schemathesis structure)
|
|
109
|
+
# schemathesis op has 'query' in parameters
|
|
110
|
+
# For simplicity in this structure, we might just try appending ?id=' OR 1=1
|
|
111
|
+
if op.method.upper() == "GET":
|
|
112
|
+
candidates.append(op)
|
|
113
|
+
|
|
114
|
+
targets = candidates[:SECURITY_SCAN_LIMIT] # Limit scan
|
|
115
|
+
if not targets:
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
injection_failures = []
|
|
119
|
+
|
|
120
|
+
headers = {}
|
|
121
|
+
if api_key:
|
|
122
|
+
# Use key if available to penetrate deeper
|
|
123
|
+
headers["Authorization"] = api_key if "Bearer" in api_key else f"Bearer {api_key}"
|
|
124
|
+
|
|
125
|
+
payloads = ["' OR '1'='1", "<script>alert(1)</script>"]
|
|
126
|
+
|
|
127
|
+
for op in targets:
|
|
128
|
+
# Construct URL with path params blindly replaced if any (to avoid 404 if possible)
|
|
129
|
+
# But for injection, simplistic probing on paths without params is safer
|
|
130
|
+
if "{" in op.path:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
url = f"{base_url.rstrip('/')}{op.path}"
|
|
134
|
+
|
|
135
|
+
for payload in payloads:
|
|
136
|
+
try:
|
|
137
|
+
# Add as arbitrary query param 'q' and 'id' - common vectors
|
|
138
|
+
params = {"q": payload, "id": payload, "search": payload}
|
|
139
|
+
resp = requests.get(url, headers=headers, params=params, timeout=5)
|
|
140
|
+
|
|
141
|
+
if resp.status_code == HTTP_500_INTERNAL_SERVER_ERROR:
|
|
142
|
+
injection_failures.append(f"{op.path} (500 Error on injection)")
|
|
143
|
+
if payload in resp.text:
|
|
144
|
+
injection_failures.append(f"{op.path} (Reflected XSS: payload found in response)")
|
|
145
|
+
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
if injection_failures:
|
|
150
|
+
results.append({
|
|
151
|
+
"module": "C",
|
|
152
|
+
"issue": "Injection Vulnerabilities",
|
|
153
|
+
"status": "FAIL",
|
|
154
|
+
"details": f"Potential issues found: {', '.join(list(set(injection_failures)))}",
|
|
155
|
+
"severity": "HIGH"
|
|
156
|
+
})
|
|
157
|
+
else:
|
|
158
|
+
results.append({
|
|
159
|
+
"module": "C",
|
|
160
|
+
"issue": "Basic Injection Check",
|
|
161
|
+
"status": "PASS",
|
|
162
|
+
"details": f"No {HTTP_500_INTERNAL_SERVER_ERROR} errors or reflected payloads detected during basic probing.",
|
|
163
|
+
"severity": "INFO"
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
return results
|
|
167
|
+
|
|
168
|
+
def run_security_hygiene(schema, base_url: str, api_key: str = None) -> list[dict]:
|
|
169
|
+
"""
|
|
170
|
+
Module C: Security Hygiene Check
|
|
171
|
+
Checks for TLS, Auth leakage, Headers, and Basic Vulnerabilities.
|
|
172
|
+
"""
|
|
173
|
+
results = []
|
|
174
|
+
logger.info(f"AUDIT LOG: Checking Security Hygiene for base URL: {base_url}")
|
|
175
|
+
|
|
176
|
+
# 0. TLS Check
|
|
177
|
+
if base_url and not base_url.startswith("https"):
|
|
178
|
+
results.append({
|
|
179
|
+
"module": "C",
|
|
180
|
+
"issue": "Insecure Connection (No TLS)",
|
|
181
|
+
"status": "FAIL",
|
|
182
|
+
"details": "The API base URL does not use HTTPS.",
|
|
183
|
+
"severity": "CRITICAL"
|
|
184
|
+
})
|
|
185
|
+
else:
|
|
186
|
+
results.append({
|
|
187
|
+
"module": "C",
|
|
188
|
+
"issue": "Secure Connection (TLS)",
|
|
189
|
+
"status": "PASS",
|
|
190
|
+
"details": "The API uses HTTPS.",
|
|
191
|
+
"severity": "INFO"
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
# Collect operations
|
|
195
|
+
try:
|
|
196
|
+
all_ops = list(schema.get_all_operations())
|
|
197
|
+
ops = [op.ok() if hasattr(op, "ok") else op for op in all_ops]
|
|
198
|
+
except Exception:
|
|
199
|
+
ops = []
|
|
200
|
+
|
|
201
|
+
# 1. Auth Leakage in URL
|
|
202
|
+
auth_leakage_found = False
|
|
203
|
+
for operation in ops:
|
|
204
|
+
endpoint = operation.path
|
|
205
|
+
if any(keyword in endpoint.lower() for keyword in SENSITIVE_PATH_KEYWORDS):
|
|
206
|
+
auth_leakage_found = True
|
|
207
|
+
results.append({
|
|
208
|
+
"module": "C",
|
|
209
|
+
"issue": "Auth Leakage Risk",
|
|
210
|
+
"status": "FAIL",
|
|
211
|
+
"details": f"Endpoint '{endpoint}' indicates auth tokens might be passed in the URL.",
|
|
212
|
+
"severity": "HIGH"
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
if not auth_leakage_found:
|
|
216
|
+
results.append({
|
|
217
|
+
"module": "C",
|
|
218
|
+
"issue": "No Auth Leakage in URLs",
|
|
219
|
+
"status": "PASS",
|
|
220
|
+
"details": "No endpoints found with 'key' or 'token' in the path, suggesting safe header-based auth.",
|
|
221
|
+
"severity": "INFO"
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
# 2. Check Headers
|
|
225
|
+
if base_url:
|
|
226
|
+
results.extend(_check_headers(base_url))
|
|
227
|
+
|
|
228
|
+
# 3. Check Auth Enforcement
|
|
229
|
+
results.extend(_check_auth_enforcement(ops, base_url))
|
|
230
|
+
|
|
231
|
+
# 4. Check Injection
|
|
232
|
+
results.extend(_check_injection(ops, base_url, api_key))
|
|
233
|
+
|
|
234
|
+
return results
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
import os
|
|
3
|
+
from typing import Optional, Dict, Any
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from .core import AuditEngine
|
|
6
|
+
from .reporting.generator import generate_report, generate_json_report
|
|
7
|
+
from .utils.logger import logger
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class AuditRunResult:
|
|
11
|
+
results: Dict[str, Any]
|
|
12
|
+
report_path: str
|
|
13
|
+
seed_count: int
|
|
14
|
+
|
|
15
|
+
from .config import validate_config, PandoraConfig
|
|
16
|
+
|
|
17
|
+
def load_config(config_path: str) -> PandoraConfig:
|
|
18
|
+
if config_path and os.path.exists(config_path):
|
|
19
|
+
try:
|
|
20
|
+
with open(config_path, "r") as f:
|
|
21
|
+
raw_data = yaml.safe_load(f) or {}
|
|
22
|
+
return validate_config(raw_data)
|
|
23
|
+
except Exception as e:
|
|
24
|
+
logger.error(f"Failed to load or validate config from {config_path}: {e}")
|
|
25
|
+
return PandoraConfig()
|
|
26
|
+
return PandoraConfig()
|
|
27
|
+
|
|
28
|
+
def run_dora_audit_logic(
|
|
29
|
+
target: str,
|
|
30
|
+
vendor: str,
|
|
31
|
+
api_key: Optional[str] = None,
|
|
32
|
+
config_path: Optional[str] = None,
|
|
33
|
+
base_url: Optional[str] = None,
|
|
34
|
+
output_format: str = "pdf",
|
|
35
|
+
output_path: Optional[str] = None
|
|
36
|
+
) -> AuditRunResult:
|
|
37
|
+
"""
|
|
38
|
+
Orchestrates the DORA audit: loads config, runs engine, generates report.
|
|
39
|
+
Decoupled from CLI/Printing.
|
|
40
|
+
"""
|
|
41
|
+
# 1. Load Config
|
|
42
|
+
seed_data = {}
|
|
43
|
+
if config_path:
|
|
44
|
+
config_data = load_config(config_path)
|
|
45
|
+
seed_data = config_data.seed_data
|
|
46
|
+
|
|
47
|
+
# 2. Initialize Engine
|
|
48
|
+
engine = AuditEngine(
|
|
49
|
+
target=target,
|
|
50
|
+
api_key=api_key,
|
|
51
|
+
seed_data=seed_data,
|
|
52
|
+
base_url=base_url
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# 3. Run Audit
|
|
56
|
+
logger.info(f"Starting audit for {target}")
|
|
57
|
+
results = engine.run_full_audit()
|
|
58
|
+
|
|
59
|
+
# 4. Generate Report
|
|
60
|
+
if output_format.lower() == "json":
|
|
61
|
+
report_path = generate_json_report(vendor, results, output_path=output_path)
|
|
62
|
+
else:
|
|
63
|
+
report_path = generate_report(vendor, results, output_path=output_path)
|
|
64
|
+
|
|
65
|
+
return AuditRunResult(
|
|
66
|
+
results=results,
|
|
67
|
+
report_path=report_path,
|
|
68
|
+
seed_count=len(seed_data)
|
|
69
|
+
)
|
|
File without changes
|