security-pentest-planner 1.0.2__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.
- security_pentest_planner/__init__.py +48 -0
- security_pentest_planner/__main__.py +8 -0
- security_pentest_planner/cli.py +65 -0
- security_pentest_planner/contract_analyzer.py +219 -0
- security_pentest_planner/errors.py +14 -0
- security_pentest_planner/models.py +111 -0
- security_pentest_planner/openapi_parser.py +203 -0
- security_pentest_planner/plan_renderer.py +270 -0
- security_pentest_planner/planner.py +84 -0
- security_pentest_planner/vector_catalog.py +438 -0
- security_pentest_planner/version.py +4 -0
- security_pentest_planner-1.0.2.dist-info/METADATA +164 -0
- security_pentest_planner-1.0.2.dist-info/RECORD +17 -0
- security_pentest_planner-1.0.2.dist-info/WHEEL +5 -0
- security_pentest_planner-1.0.2.dist-info/entry_points.txt +2 -0
- security_pentest_planner-1.0.2.dist-info/licenses/LICENSE +21 -0
- security_pentest_planner-1.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""security_pentest_planner package.
|
|
2
|
+
|
|
3
|
+
Gera Planos de Ação de Pentest a partir de especificações OpenAPI/Swagger.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .contract_analyzer import ContractAnalyzer
|
|
11
|
+
from .errors import Error, InputError, ParseError
|
|
12
|
+
from .models import Category, Endpoint, Parameter, RedFlag, Vector
|
|
13
|
+
from .openapi_parser import OpenAPIParser
|
|
14
|
+
from .plan_renderer import PlanRenderer
|
|
15
|
+
from .planner import Planner
|
|
16
|
+
from .vector_catalog import VectorCatalog
|
|
17
|
+
from .version import __version__
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate(input_path: str, **options: Any) -> str:
|
|
21
|
+
"""Generate a pentest action plan from an OpenAPI specification file."""
|
|
22
|
+
return Planner(input_path=input_path, **options).call()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def generate_from_spec(spec: dict[str, Any], **options: Any) -> str:
|
|
26
|
+
"""Generate a pentest action plan from an in-memory OpenAPI specification dict."""
|
|
27
|
+
return Planner(spec=spec, **options).call()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"__version__",
|
|
32
|
+
"generate",
|
|
33
|
+
"generate_from_spec",
|
|
34
|
+
"Planner",
|
|
35
|
+
"OpenAPIParser",
|
|
36
|
+
"ContractAnalyzer",
|
|
37
|
+
"VectorCatalog",
|
|
38
|
+
"PlanRenderer",
|
|
39
|
+
"Endpoint",
|
|
40
|
+
"Parameter",
|
|
41
|
+
"RedFlag",
|
|
42
|
+
"Vector",
|
|
43
|
+
"Category",
|
|
44
|
+
"Error",
|
|
45
|
+
"ParseError",
|
|
46
|
+
"InputError",
|
|
47
|
+
]
|
|
48
|
+
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""CLI entry point for security-pentest-planner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Sequence
|
|
9
|
+
|
|
10
|
+
from .errors import Error
|
|
11
|
+
from .planner import Planner
|
|
12
|
+
from .version import __version__
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
"""Builds the argument parser for the CLI."""
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="security-pentest-planner",
|
|
19
|
+
description="Gera um Plano de Ação de Pentest a partir de especificação OpenAPI.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("input", nargs="?", help="Caminho do arquivo OpenAPI (.yaml, .yml, .json)")
|
|
22
|
+
parser.add_argument("-t", "--team", help="Nome do time no título do documento")
|
|
23
|
+
parser.add_argument("-s", "--scope", help="Limitar escopo a um path (ex: /api/v1/metrics)")
|
|
24
|
+
parser.add_argument("-d", "--datalake", action="store_true", help="Incluir vetores de Data Lake no plano")
|
|
25
|
+
parser.add_argument("--api-layer", default="API", help="Nome da camada de API no documento (default: API)")
|
|
26
|
+
parser.add_argument("--data-layer", default="Data Lake", help="Nome da camada de Data Lake no documento (default: Data Lake)")
|
|
27
|
+
parser.add_argument("-o", "--output", help="Salvar plano em arquivo (default: stdout)")
|
|
28
|
+
parser.add_argument("-v", "--version", action="version", version=f"security_pentest_planner {__version__}")
|
|
29
|
+
return parser
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
33
|
+
"""Main CLI execution method."""
|
|
34
|
+
parser = build_parser()
|
|
35
|
+
args = parser.parse_args(argv)
|
|
36
|
+
|
|
37
|
+
if not args.input:
|
|
38
|
+
parser.print_help(sys.stderr)
|
|
39
|
+
return 1
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
planner = Planner(
|
|
43
|
+
input_path=args.input,
|
|
44
|
+
team=args.team,
|
|
45
|
+
scope=args.scope,
|
|
46
|
+
include_datalake=args.datalake,
|
|
47
|
+
api_layer=args.api_layer,
|
|
48
|
+
data_layer=args.data_layer,
|
|
49
|
+
)
|
|
50
|
+
plan = planner.call()
|
|
51
|
+
|
|
52
|
+
if args.output:
|
|
53
|
+
Path(args.output).write_text(plan, encoding="utf-8")
|
|
54
|
+
print(f"Plano de Ação gerado em {args.output}", file=sys.stderr)
|
|
55
|
+
else:
|
|
56
|
+
print(plan)
|
|
57
|
+
return 0
|
|
58
|
+
except Error as e:
|
|
59
|
+
print(f"Erro: {e}", file=sys.stderr)
|
|
60
|
+
return 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
sys.exit(main())
|
|
65
|
+
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Contract analyzer for detecting security signals and red flags."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Sequence
|
|
7
|
+
|
|
8
|
+
from .models import Endpoint, Parameter, RedFlag
|
|
9
|
+
from .openapi_parser import OpenAPIParser
|
|
10
|
+
|
|
11
|
+
ANALYTICS_PATH_PATTERN = re.compile(r"metric|analytic|report|channel|insight|dashboard|aggregate", re.IGNORECASE)
|
|
12
|
+
ANALYTICS_SUMMARY_PATTERN = re.compile(r"metric|analytic|report|aggregate", re.IGNORECASE)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ContractAnalyzer:
|
|
16
|
+
"""Analyzes an OpenAPI specification for pentest triggers and security red flags."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
parser: OpenAPIParser,
|
|
21
|
+
scope_paths: str | Sequence[str] | None = None,
|
|
22
|
+
include_datalake: bool = False,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.parser: OpenAPIParser = parser
|
|
25
|
+
if isinstance(scope_paths, str):
|
|
26
|
+
self.scope_paths: list[str] = [scope_paths]
|
|
27
|
+
elif scope_paths is not None:
|
|
28
|
+
self.scope_paths = list(scope_paths)
|
|
29
|
+
else:
|
|
30
|
+
self.scope_paths = []
|
|
31
|
+
self.include_datalake: bool = include_datalake
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def scoped_endpoints(self) -> list[Endpoint]:
|
|
35
|
+
"""Endpoints matching the configured scope paths (or all if scope is empty)."""
|
|
36
|
+
endpoints = self.parser.endpoints
|
|
37
|
+
if not self.scope_paths:
|
|
38
|
+
return endpoints
|
|
39
|
+
|
|
40
|
+
return [
|
|
41
|
+
ep
|
|
42
|
+
for ep in endpoints
|
|
43
|
+
if any(ep.path == p or ep.path.startswith(p) for p in self.scope_paths)
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
def parameters_for(self, endpoint: Endpoint) -> list[Parameter]:
|
|
47
|
+
"""Returns parameters for a given endpoint."""
|
|
48
|
+
return self.parser.parameters_for(endpoint)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def tenant_headers(self) -> list[Parameter]:
|
|
52
|
+
"""Unique tenant headers detected across scoped endpoints."""
|
|
53
|
+
seen: set[str] = set()
|
|
54
|
+
result: list[Parameter] = []
|
|
55
|
+
for ep in self.scoped_endpoints:
|
|
56
|
+
for p in self.parameters_for(ep):
|
|
57
|
+
if p.is_tenant_header and p.name not in seen:
|
|
58
|
+
seen.add(p.name)
|
|
59
|
+
result.append(p)
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def date_params(self) -> list[Parameter]:
|
|
64
|
+
"""Unique date/time parameters detected across scoped endpoints."""
|
|
65
|
+
seen: set[str] = set()
|
|
66
|
+
result: list[Parameter] = []
|
|
67
|
+
for ep in self.scoped_endpoints:
|
|
68
|
+
for p in self.parameters_for(ep):
|
|
69
|
+
if p.is_date_param and p.name not in seen:
|
|
70
|
+
seen.add(p.name)
|
|
71
|
+
result.append(p)
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def enum_params(self) -> list[Parameter]:
|
|
76
|
+
"""Unique enum parameters detected across scoped endpoints."""
|
|
77
|
+
seen: set[str] = set()
|
|
78
|
+
result: list[Parameter] = []
|
|
79
|
+
for ep in self.scoped_endpoints:
|
|
80
|
+
for p in self.parameters_for(ep):
|
|
81
|
+
if p.is_enum_param and p.name not in seen:
|
|
82
|
+
seen.add(p.name)
|
|
83
|
+
result.append(p)
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def timezone_headers(self) -> list[Parameter]:
|
|
88
|
+
"""Unique timezone headers detected across scoped endpoints."""
|
|
89
|
+
seen: set[str] = set()
|
|
90
|
+
result: list[Parameter] = []
|
|
91
|
+
for ep in self.scoped_endpoints:
|
|
92
|
+
for p in self.parameters_for(ep):
|
|
93
|
+
if p.is_timezone_header and p.name not in seen:
|
|
94
|
+
seen.add(p.name)
|
|
95
|
+
result.append(p)
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def has_tenant_scoping(self) -> bool:
|
|
100
|
+
"""True if tenant headers are present."""
|
|
101
|
+
return bool(self.tenant_headers)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def has_date_range(self) -> bool:
|
|
105
|
+
"""True if date query parameters are present."""
|
|
106
|
+
return bool(self.date_params)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def has_enum_params(self) -> bool:
|
|
110
|
+
"""True if enum parameters are present."""
|
|
111
|
+
return bool(self.enum_params)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def has_free_text_headers(self) -> bool:
|
|
115
|
+
"""True if timezone header or free-form tenant headers are present."""
|
|
116
|
+
return bool(self.timezone_headers) or any(not h.enum_values for h in self.tenant_headers)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def is_analytics_endpoint(self) -> bool:
|
|
120
|
+
"""True if any scoped endpoint matches analytical patterns."""
|
|
121
|
+
for ep in self.scoped_endpoints:
|
|
122
|
+
if ANALYTICS_PATH_PATTERN.search(ep.path):
|
|
123
|
+
return True
|
|
124
|
+
if ep.summary and ANALYTICS_SUMMARY_PATTERN.search(ep.summary):
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def red_flags(self) -> list[RedFlag]:
|
|
130
|
+
"""Generates list of identified contract security red flags."""
|
|
131
|
+
flags: list[RedFlag] = []
|
|
132
|
+
|
|
133
|
+
if not self.parser.security_schemes and self.scoped_endpoints:
|
|
134
|
+
flags.append(
|
|
135
|
+
RedFlag(
|
|
136
|
+
message="Ausência de `securitySchemes` documentado no contrato OpenAPI",
|
|
137
|
+
severity="high",
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
for header in self.tenant_headers:
|
|
142
|
+
if not header.required:
|
|
143
|
+
flags.append(
|
|
144
|
+
RedFlag(
|
|
145
|
+
message=f"Header `{header.name}` documentado como opcional (`required: false`)",
|
|
146
|
+
severity="high",
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
tenant_names = {h.name for h in self.tenant_headers}
|
|
151
|
+
if len(tenant_names) > 1:
|
|
152
|
+
joined = " vs ".join(sorted(tenant_names))
|
|
153
|
+
flags.append(
|
|
154
|
+
RedFlag(
|
|
155
|
+
message=f"Divergência de nomes de header de tenant: {joined}",
|
|
156
|
+
severity="medium",
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
for tz in self.timezone_headers:
|
|
161
|
+
if not tz.enum_values:
|
|
162
|
+
flags.append(
|
|
163
|
+
RedFlag(
|
|
164
|
+
message=f"Header `{tz.name}` como string livre (sem enum/whitelist IANA)",
|
|
165
|
+
severity="medium",
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if self.is_analytics_endpoint:
|
|
170
|
+
flags.append(
|
|
171
|
+
RedFlag(
|
|
172
|
+
message="Nenhum rate limiting documentado em endpoint analítico",
|
|
173
|
+
severity="medium",
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return flags
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def signals(self) -> dict[str, bool]:
|
|
181
|
+
"""Dictionary of detected architectural security signals."""
|
|
182
|
+
return {
|
|
183
|
+
"tenant_scoping": self.has_tenant_scoping,
|
|
184
|
+
"date_range": self.has_date_range,
|
|
185
|
+
"enum_params": self.has_enum_params,
|
|
186
|
+
"free_text_headers": self.has_free_text_headers,
|
|
187
|
+
"analytics": self.is_analytics_endpoint,
|
|
188
|
+
"datalake": self.include_datalake,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def primary_endpoint(self) -> Endpoint | None:
|
|
193
|
+
"""First scoped endpoint or None."""
|
|
194
|
+
endpoints = self.scoped_endpoints
|
|
195
|
+
return endpoints[0] if endpoints else None
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def host(self) -> str:
|
|
199
|
+
"""Target API host or default."""
|
|
200
|
+
servers = self.parser.servers
|
|
201
|
+
return servers[0] if servers else "api.example.com"
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def tenant_header_name(self) -> str:
|
|
205
|
+
"""First detected tenant header name or default."""
|
|
206
|
+
headers = self.tenant_headers
|
|
207
|
+
return headers[0].name if headers else "X-Tenant-ID"
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def date_param_names(self) -> list[str]:
|
|
211
|
+
"""List of detected date parameter names."""
|
|
212
|
+
return [p.name for p in self.date_params]
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def enum_param(self) -> Parameter | None:
|
|
216
|
+
"""First detected enum parameter or None."""
|
|
217
|
+
params = self.enum_params
|
|
218
|
+
return params[0] if params else None
|
|
219
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Exceptions for security_pentest_planner."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Error(Exception):
|
|
5
|
+
"""Base exception for security_pentest_planner."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ParseError(Error):
|
|
9
|
+
"""Raised when an OpenAPI document cannot be parsed."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InputError(Error):
|
|
13
|
+
"""Raised when input parameters or scope paths are invalid."""
|
|
14
|
+
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Data models for security_pentest_planner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
TENANT_HEADER_PATTERN = re.compile(r"tenant", re.IGNORECASE)
|
|
10
|
+
DATE_PARAM_PATTERN = re.compile(r"date|start|end|from|to|period|range", re.IGNORECASE)
|
|
11
|
+
TIMEZONE_HEADER_PATTERN = re.compile(r"timezone|time_zone|tz", re.IGNORECASE)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Parameter:
|
|
16
|
+
"""Represents an OpenAPI operation parameter."""
|
|
17
|
+
|
|
18
|
+
name: str
|
|
19
|
+
location: str
|
|
20
|
+
required: bool = False
|
|
21
|
+
schema: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
description: str | None = None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def enum_values(self) -> list[Any]:
|
|
26
|
+
"""Returns enum values from the schema or its items."""
|
|
27
|
+
if not isinstance(self.schema, dict):
|
|
28
|
+
return []
|
|
29
|
+
enum = self.schema.get("enum")
|
|
30
|
+
if enum:
|
|
31
|
+
return list(enum)
|
|
32
|
+
items = self.schema.get("items")
|
|
33
|
+
if isinstance(items, dict) and items.get("enum"):
|
|
34
|
+
return list(items["enum"])
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def type(self) -> str | None:
|
|
39
|
+
"""Returns the schema type if defined."""
|
|
40
|
+
if isinstance(self.schema, dict):
|
|
41
|
+
return self.schema.get("type")
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def is_tenant_header(self) -> bool:
|
|
46
|
+
"""Checks if parameter is a tenant header."""
|
|
47
|
+
return self.location == "header" and bool(TENANT_HEADER_PATTERN.search(self.name))
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_date_param(self) -> bool:
|
|
51
|
+
"""Checks if parameter is a date/time range query parameter."""
|
|
52
|
+
return self.location == "query" and bool(DATE_PARAM_PATTERN.search(self.name))
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def is_timezone_header(self) -> bool:
|
|
56
|
+
"""Checks if parameter is a timezone header."""
|
|
57
|
+
return self.location == "header" and bool(TIMEZONE_HEADER_PATTERN.search(self.name))
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_enum_param(self) -> bool:
|
|
61
|
+
"""Checks if parameter restricts values using an enum."""
|
|
62
|
+
return bool(self.enum_values)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Endpoint:
|
|
67
|
+
"""Represents an API endpoint operation."""
|
|
68
|
+
|
|
69
|
+
path: str
|
|
70
|
+
method: str
|
|
71
|
+
operation_id: str | None = None
|
|
72
|
+
summary: str | None = None
|
|
73
|
+
parameters: list[Parameter] = field(default_factory=list)
|
|
74
|
+
security: list[Any] | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class RedFlag:
|
|
79
|
+
"""Represents a security design smell or missing control in the OpenAPI contract."""
|
|
80
|
+
|
|
81
|
+
message: str
|
|
82
|
+
severity: str # "high", "medium", "low"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class Category:
|
|
87
|
+
"""Represents a pentest category."""
|
|
88
|
+
|
|
89
|
+
slug: str
|
|
90
|
+
title: str
|
|
91
|
+
layer: str # "api" or "datalake"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class Vector:
|
|
96
|
+
"""Represents a specific offensive security test vector."""
|
|
97
|
+
|
|
98
|
+
id: str
|
|
99
|
+
name: str
|
|
100
|
+
category_slug: str
|
|
101
|
+
category_title: str
|
|
102
|
+
layer: str # "api" or "datalake"
|
|
103
|
+
triggers: list[str]
|
|
104
|
+
objective: str
|
|
105
|
+
procedure: list[str]
|
|
106
|
+
payload: str
|
|
107
|
+
payload_type: str
|
|
108
|
+
expected_validation: str
|
|
109
|
+
failure_criteria: str
|
|
110
|
+
risk: str
|
|
111
|
+
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""OpenAPI contract parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from .errors import ParseError
|
|
12
|
+
from .models import Endpoint, Parameter
|
|
13
|
+
|
|
14
|
+
VALID_HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options", "trace"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OpenAPIParser:
|
|
18
|
+
"""Parses OpenAPI 3.x specifications in YAML or JSON."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, content: dict[str, Any], source_path: str | None = None) -> None:
|
|
21
|
+
self.raw: dict[str, Any] = content or {}
|
|
22
|
+
self.source_path: str | None = source_path
|
|
23
|
+
self._endpoints: list[Endpoint] | None = None
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def parse_file(cls, path_str: str) -> OpenAPIParser:
|
|
27
|
+
"""Parses an OpenAPI spec from a file path."""
|
|
28
|
+
path = Path(path_str)
|
|
29
|
+
if not path.is_file():
|
|
30
|
+
raise ParseError(f"Arquivo não encontrado: {path_str}")
|
|
31
|
+
|
|
32
|
+
content = path.read_text(encoding="utf-8")
|
|
33
|
+
ext = path.suffix.lower()
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
if ext in {".yaml", ".yml"}:
|
|
37
|
+
parsed = yaml.safe_load(content)
|
|
38
|
+
elif ext == ".json":
|
|
39
|
+
parsed = json.loads(content)
|
|
40
|
+
else:
|
|
41
|
+
raise ParseError(f"Unsupported file extension: {path_str}. Use .yaml, .yml or .json")
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
if isinstance(exc, ParseError):
|
|
44
|
+
raise
|
|
45
|
+
raise ParseError(f"Erro ao processar contrato OpenAPI em {path_str}: {exc}") from exc
|
|
46
|
+
|
|
47
|
+
if not isinstance(parsed, dict):
|
|
48
|
+
raise ParseError(f"Conteúdo OpenAPI inválido em {path_str} (esperado objeto/dicionário).")
|
|
49
|
+
|
|
50
|
+
return cls(parsed, source_path=path_str)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def info(self) -> dict[str, Any]:
|
|
54
|
+
"""Returns the info section of the OpenAPI spec."""
|
|
55
|
+
info_data = self.raw.get("info")
|
|
56
|
+
return info_data if isinstance(info_data, dict) else {}
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def title(self) -> str | None:
|
|
60
|
+
"""API Title."""
|
|
61
|
+
return self.info.get("title")
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def version(self) -> str | None:
|
|
65
|
+
"""API Version."""
|
|
66
|
+
return self.info.get("version")
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def servers(self) -> list[str]:
|
|
70
|
+
"""List of server URLs."""
|
|
71
|
+
servers_data = self.raw.get("servers")
|
|
72
|
+
if not isinstance(servers_data, list):
|
|
73
|
+
return []
|
|
74
|
+
urls = []
|
|
75
|
+
for s in servers_data:
|
|
76
|
+
if isinstance(s, dict) and "url" in s and s["url"]:
|
|
77
|
+
urls.append(s["url"])
|
|
78
|
+
return urls
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def security_schemes(self) -> dict[str, Any]:
|
|
82
|
+
"""Returns security schemes defined in components."""
|
|
83
|
+
components = self.raw.get("components")
|
|
84
|
+
if isinstance(components, dict):
|
|
85
|
+
schemes = components.get("securitySchemes")
|
|
86
|
+
if isinstance(schemes, dict):
|
|
87
|
+
return schemes
|
|
88
|
+
return {}
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def endpoints(self) -> list[Endpoint]:
|
|
92
|
+
"""Returns all parsed endpoints/operations."""
|
|
93
|
+
if self._endpoints is None:
|
|
94
|
+
self._endpoints = self._build_endpoints()
|
|
95
|
+
return self._endpoints
|
|
96
|
+
|
|
97
|
+
def parameters_for(self, endpoint: Endpoint) -> list[Parameter]:
|
|
98
|
+
"""Merges global path-level parameters with endpoint-level parameters."""
|
|
99
|
+
paths = self.raw.get("paths")
|
|
100
|
+
global_params: list[Any] = []
|
|
101
|
+
if isinstance(paths, dict):
|
|
102
|
+
path_item = paths.get(endpoint.path)
|
|
103
|
+
if isinstance(path_item, dict):
|
|
104
|
+
gp = path_item.get("parameters")
|
|
105
|
+
if isinstance(gp, list):
|
|
106
|
+
global_params = gp
|
|
107
|
+
|
|
108
|
+
all_params = global_params + list(endpoint.parameters)
|
|
109
|
+
return self._merge_parameters(all_params)
|
|
110
|
+
|
|
111
|
+
def _build_endpoints(self) -> list[Endpoint]:
|
|
112
|
+
paths = self.raw.get("paths")
|
|
113
|
+
if not isinstance(paths, dict):
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
endpoints: list[Endpoint] = []
|
|
117
|
+
for path_str, methods in paths.items():
|
|
118
|
+
if not isinstance(methods, dict):
|
|
119
|
+
continue
|
|
120
|
+
for method, operation in methods.items():
|
|
121
|
+
if method.lower() not in VALID_HTTP_METHODS or not isinstance(operation, dict):
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
raw_params = operation.get("parameters")
|
|
125
|
+
param_objs: list[Parameter] = []
|
|
126
|
+
if isinstance(raw_params, list):
|
|
127
|
+
for p in raw_params:
|
|
128
|
+
built = self._build_parameter(p)
|
|
129
|
+
if built:
|
|
130
|
+
param_objs.append(built)
|
|
131
|
+
|
|
132
|
+
summary = operation.get("summary") or operation.get("description")
|
|
133
|
+
endpoints.append(
|
|
134
|
+
Endpoint(
|
|
135
|
+
path=path_str,
|
|
136
|
+
method=method.upper(),
|
|
137
|
+
operation_id=operation.get("operationId"),
|
|
138
|
+
summary=summary,
|
|
139
|
+
parameters=param_objs,
|
|
140
|
+
security=operation.get("security"),
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return endpoints
|
|
145
|
+
|
|
146
|
+
def _build_parameter(self, param: Any) -> Parameter | None:
|
|
147
|
+
if isinstance(param, Parameter):
|
|
148
|
+
return param
|
|
149
|
+
resolved = self._resolve_ref(param)
|
|
150
|
+
if not isinstance(resolved, dict) or "name" not in resolved:
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
schema = resolved.get("schema")
|
|
154
|
+
if not isinstance(schema, dict):
|
|
155
|
+
schema = {}
|
|
156
|
+
|
|
157
|
+
return Parameter(
|
|
158
|
+
name=resolved["name"],
|
|
159
|
+
location=resolved.get("in", ""),
|
|
160
|
+
required=resolved.get("required") is True,
|
|
161
|
+
schema=schema,
|
|
162
|
+
description=resolved.get("description"),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def _resolve_ref(self, obj: Any) -> Any:
|
|
166
|
+
if not isinstance(obj, dict) or "$ref" not in obj:
|
|
167
|
+
return obj
|
|
168
|
+
|
|
169
|
+
ref = obj["$ref"]
|
|
170
|
+
if not isinstance(ref, str):
|
|
171
|
+
return obj
|
|
172
|
+
|
|
173
|
+
if ref.startswith("#/"):
|
|
174
|
+
clean_ref = ref[2:]
|
|
175
|
+
elif ref.startswith("#"):
|
|
176
|
+
clean_ref = ref[1:]
|
|
177
|
+
else:
|
|
178
|
+
clean_ref = ref
|
|
179
|
+
|
|
180
|
+
parts = [p.replace("~1", "/").replace("~0", "~") for p in clean_ref.split("/") if p]
|
|
181
|
+
node: Any = self.raw
|
|
182
|
+
for part in parts:
|
|
183
|
+
if isinstance(node, dict) and part in node:
|
|
184
|
+
node = node[part]
|
|
185
|
+
else:
|
|
186
|
+
return obj
|
|
187
|
+
return node
|
|
188
|
+
|
|
189
|
+
def _merge_parameters(self, params: list[Any]) -> list[Parameter]:
|
|
190
|
+
merged: list[Parameter] = []
|
|
191
|
+
seen: set[tuple[str, str]] = set()
|
|
192
|
+
|
|
193
|
+
for p in params:
|
|
194
|
+
built = self._build_parameter(p)
|
|
195
|
+
if not built:
|
|
196
|
+
continue
|
|
197
|
+
key = (built.location, built.name)
|
|
198
|
+
if key not in seen:
|
|
199
|
+
seen.add(key)
|
|
200
|
+
merged.append(built)
|
|
201
|
+
|
|
202
|
+
return merged
|
|
203
|
+
|