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.
@@ -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