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.
@@ -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,8 @@
1
+ """Main entry point when invoked as python -m security_pentest_planner."""
2
+
3
+ import sys
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
8
+
@@ -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
+