security_pentest_planner 1.0.1
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE.txt +21 -0
- data/README.md +121 -0
- data/exe/security-pentest-planner +7 -0
- data/lib/security_pentest_planner/cli.rb +97 -0
- data/lib/security_pentest_planner/contract_analyzer.rb +148 -0
- data/lib/security_pentest_planner/errors.rb +7 -0
- data/lib/security_pentest_planner/openapi_parser.rb +149 -0
- data/lib/security_pentest_planner/plan_renderer.rb +240 -0
- data/lib/security_pentest_planner/planner.rb +54 -0
- data/lib/security_pentest_planner/vector_catalog.rb +439 -0
- data/lib/security_pentest_planner/version.rb +5 -0
- data/lib/security_pentest_planner.rb +22 -0
- metadata +88 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecurityPentestPlanner
|
|
4
|
+
class PlanRenderer
|
|
5
|
+
SUMMARY_CATEGORIES = {
|
|
6
|
+
"Acesso" => %w[tenant-traversal header-injection privilege-escalation sa-overprivilege metadata-leak],
|
|
7
|
+
"Entrada" => %w[sqli-basic enum-injection timezone-manipulation bypass-tenant union-exploitation type-juggling],
|
|
8
|
+
"Disponibilidade" => %w[data-range-exhaustion rate-limit-absence cartesian-product full-scan],
|
|
9
|
+
"Informação" => %w[verbosity-check metadata-leak]
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(analyzer:, vectors:, team: nil, api_layer: "API", data_layer: "Data Lake")
|
|
13
|
+
@analyzer = analyzer
|
|
14
|
+
@vectors = vectors
|
|
15
|
+
@team = team || "TIME"
|
|
16
|
+
@api_layer = api_layer
|
|
17
|
+
@data_layer = data_layer
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def render
|
|
21
|
+
sections = [
|
|
22
|
+
header,
|
|
23
|
+
introduction,
|
|
24
|
+
api_section,
|
|
25
|
+
datalake_section,
|
|
26
|
+
summary_table,
|
|
27
|
+
recommendations,
|
|
28
|
+
datalake_sql_reference,
|
|
29
|
+
assumptions
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
sections.compact.join("\n\n")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :analyzer, :vectors, :team, :api_layer, :data_layer
|
|
38
|
+
|
|
39
|
+
def header
|
|
40
|
+
"# #{team} — Plano de Ação de Pentest"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def introduction
|
|
44
|
+
endpoint = analyzer.primary_endpoint
|
|
45
|
+
scope_table = analyzer.scoped_endpoints.map do |ep|
|
|
46
|
+
tenant = analyzer.tenant_headers.find { |h| analyzer.parser.parameters_for(ep).include?(h) }
|
|
47
|
+
required = tenant&.required ? "true" : "**false**"
|
|
48
|
+
"| #{ep.method} | `#{ep.path}` | `#{tenant&.name || '—'}` (required: #{required}) |"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
red_flags = analyzer.red_flags.map { |f| "- #{f.message}" }.join("\n")
|
|
52
|
+
|
|
53
|
+
<<~MD.strip
|
|
54
|
+
## Introdução do Projeto
|
|
55
|
+
|
|
56
|
+
#{intro_text}
|
|
57
|
+
|
|
58
|
+
**Camadas em escopo:**
|
|
59
|
+
|
|
60
|
+
- **#{api_layer}:** Endpoints REST mapeados no contrato OpenAPI
|
|
61
|
+
#{datalake_in_scope? ? "- **#{data_layer}:** Queries analíticas, IAM e persistência downstream" : ""}
|
|
62
|
+
|
|
63
|
+
**Fonte analisada:** `#{analyzer.parser.source_path || "OpenAPI spec"}`
|
|
64
|
+
|
|
65
|
+
**Endpoint(s) / componente(s):**
|
|
66
|
+
|
|
67
|
+
| Método | Path | Header de tenant |
|
|
68
|
+
|--------|------|------------------|
|
|
69
|
+
#{scope_table.join("\n")}
|
|
70
|
+
|
|
71
|
+
**Red flags do contrato:**
|
|
72
|
+
#{red_flags.empty? ? "- Nenhum red flag automático detectado — revisão manual recomendada." : red_flags}
|
|
73
|
+
MD
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def intro_text
|
|
77
|
+
title = analyzer.parser.title || "API"
|
|
78
|
+
has_tenant = analyzer.has_tenant_scoping?
|
|
79
|
+
|
|
80
|
+
text = "API **#{title}**"
|
|
81
|
+
text += " com escopo multi-tenant via header HTTP" if has_tenant
|
|
82
|
+
text += ", com filtragem por período e agregação analítica" if analyzer.has_date_range?
|
|
83
|
+
text += "."
|
|
84
|
+
|
|
85
|
+
text + "\n\nModelo **Defense in Depth** relevante porque parâmetros e headers são manipuláveis pelo cliente" \
|
|
86
|
+
+ (analyzer.analytics_endpoint? ? " e endpoints disparam agregações pesadas downstream." : ".")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def api_section
|
|
90
|
+
api_vectors = vectors.select { |v| v.layer == :api }
|
|
91
|
+
return nil if api_vectors.empty?
|
|
92
|
+
|
|
93
|
+
grouped = api_vectors.group_by(&:category_title)
|
|
94
|
+
categories = grouped.map.with_index(1) do |(title, items), index|
|
|
95
|
+
"### #{index}. #{title}\n\n#{render_vectors(items)}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
"## PENTEST — #{api_layer}\n\n> Incluir apenas categorias aplicáveis ao input.\n\n#{categories.join("\n\n---\n\n")}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def datalake_section
|
|
102
|
+
return nil unless datalake_in_scope?
|
|
103
|
+
|
|
104
|
+
dl_vectors = vectors.select { |v| v.layer == :datalake }
|
|
105
|
+
return nil if dl_vectors.empty?
|
|
106
|
+
|
|
107
|
+
grouped = dl_vectors.group_by(&:category_title)
|
|
108
|
+
categories = grouped.map.with_index(1) do |(title, items), index|
|
|
109
|
+
"### #{index}. #{title}\n\n#{render_vectors(items)}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
"## PENTEST — #{data_layer}\n\n> Incluir somente quando queries, IAM ou persistência estiverem no escopo.\n\n#{categories.join("\n\n---\n\n")}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def render_vectors(items)
|
|
116
|
+
items.map do |vector|
|
|
117
|
+
procedure = vector.procedure.map.with_index(1) { |step, i| "#{i}. #{step}" }.join("\n")
|
|
118
|
+
<<~VECTOR.strip
|
|
119
|
+
#### #{vector.name}
|
|
120
|
+
|
|
121
|
+
- **Objetivo:** #{vector.objective}
|
|
122
|
+
- **Procedimento:**
|
|
123
|
+
#{procedure.gsub(/^/, " ")}
|
|
124
|
+
- **Payload / exemplo:**
|
|
125
|
+
```#{vector.payload_type}
|
|
126
|
+
#{vector.payload}
|
|
127
|
+
```
|
|
128
|
+
- **Validação esperada:** #{vector.expected_validation}
|
|
129
|
+
- **Critério de falha:** #{vector.failure_criteria}
|
|
130
|
+
- **Risco:** #{vector.risk}
|
|
131
|
+
VECTOR
|
|
132
|
+
end.join("\n\n")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def summary_table
|
|
136
|
+
rows = SUMMARY_CATEGORIES.filter_map do |category, vector_ids|
|
|
137
|
+
match = vectors.find { |v| vector_ids.include?(v.id) }
|
|
138
|
+
next unless match
|
|
139
|
+
|
|
140
|
+
"| #{category} | #{match.name} | #{truncate(match.objective, 80)} |"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
return nil if rows.empty?
|
|
144
|
+
|
|
145
|
+
<<~MD.strip
|
|
146
|
+
## Tabela de Resumo para o Time
|
|
147
|
+
|
|
148
|
+
| Categoria | Vetor de Teste | Objetivo |
|
|
149
|
+
|-----------|----------------|----------|
|
|
150
|
+
#{rows.join("\n")}
|
|
151
|
+
MD
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def recommendations
|
|
155
|
+
api_recs = []
|
|
156
|
+
dl_recs = []
|
|
157
|
+
|
|
158
|
+
if analyzer.tenant_headers.any? { |header| !header.required }
|
|
159
|
+
api_recs << "Tornar `#{analyzer.tenant_header_name}` obrigatório no OpenAPI (`required: true`) e aplicar early return na Controller."
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
if analyzer.timezone_headers.any?
|
|
163
|
+
api_recs << "Implementar whitelist IANA para header `#{analyzer.timezone_headers.first.name}`."
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
if analyzer.analytics_endpoint?
|
|
167
|
+
api_recs << "Implementar rate limiting (`rack-attack` ou equivalente) em endpoints analíticos."
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
if analyzer.has_date_range?
|
|
171
|
+
api_recs << "Limitar intervalo máximo de consulta (ex: 90 dias) para prevenir DoS lógico."
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
if analyzer.parser.security_schemes.empty?
|
|
175
|
+
api_recs << "Documentar `securitySchemes` no contrato OpenAPI e alinhar com autenticação em produção."
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
if datalake_in_scope?
|
|
179
|
+
dl_recs << 'Banir interpolação `#{variable}` em heredocs SQL; usar parametrização nativa GCP (`@tenant_id`).'
|
|
180
|
+
dl_recs << "Aplicar IAM mínimo — revogar acesso a `INFORMATION_SCHEMA` quando não necessário."
|
|
181
|
+
dl_recs << "Segregar Service Accounts por finalidade (API read-only vs. pipelines de escrita)."
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
return nil if api_recs.empty? && dl_recs.empty?
|
|
185
|
+
|
|
186
|
+
sections = ["## Recomendações Imediatas"]
|
|
187
|
+
|
|
188
|
+
unless api_recs.empty?
|
|
189
|
+
sections << "### #{api_layer}\n\n#{api_recs.map.with_index(1) { |r, i| "#{i}. #{r}" }.join("\n")}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
unless dl_recs.empty?
|
|
193
|
+
sections << "### #{data_layer}\n\n#{dl_recs.map.with_index(1) { |r, i| "#{i}. #{r}" }.join("\n")}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
sections.join("\n\n")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def datalake_sql_reference
|
|
200
|
+
return nil unless datalake_in_scope?
|
|
201
|
+
|
|
202
|
+
<<~MD.strip
|
|
203
|
+
## Referência SQL
|
|
204
|
+
|
|
205
|
+
| Tipo de Teste | Exemplo | Objetivo |
|
|
206
|
+
|---------------|---------|----------|
|
|
207
|
+
| Exploração de Esquema | `SELECT * FROM information_schema.columns WHERE table_name = '...'` | Mapear metadados |
|
|
208
|
+
| Bypass de Tenant | `SELECT count(*) FROM ... WHERE tenant_id = 'X' OR '1'='1'` | Cross-tenant leak |
|
|
209
|
+
| Verificar Usuário | `SELECT current_user()` | Privilégios da SA |
|
|
210
|
+
| Ataque de Custo | `SELECT a.*, b.* FROM big_table a CROSS JOIN big_table b` | DoS financeiro |
|
|
211
|
+
MD
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def assumptions
|
|
215
|
+
<<~MD.strip
|
|
216
|
+
## Suposições
|
|
217
|
+
|
|
218
|
+
- Plano gerado automaticamente a partir do contrato OpenAPI; revisão humana de AppSec é obrigatória antes da execução.
|
|
219
|
+
- Payloads usam nomes extraídos do contrato; ajustar host, credenciais e IDs de tenant conforme ambiente de teste.
|
|
220
|
+
- Este documento **não** inclui scripts executáveis, resultados de teste nem RFCs de implementação.
|
|
221
|
+
|
|
222
|
+
## Fora do escopo
|
|
223
|
+
|
|
224
|
+
- Execução dos testes e coleta de evidências
|
|
225
|
+
- Scripts Faraday/Ruby/SQL runnable
|
|
226
|
+
- RFCs de remediação pós-execução
|
|
227
|
+
MD
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def datalake_in_scope?
|
|
231
|
+
analyzer.include_datalake || vectors.any? { |v| v.layer == :datalake }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def truncate(text, max_length, omission: "...")
|
|
235
|
+
return text if text.length <= max_length
|
|
236
|
+
|
|
237
|
+
"#{text[0, max_length - omission.length]}#{omission}"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecurityPentestPlanner
|
|
4
|
+
class Planner
|
|
5
|
+
DEFAULT_OPTIONS = {
|
|
6
|
+
team: nil,
|
|
7
|
+
scope: nil,
|
|
8
|
+
include_datalake: false,
|
|
9
|
+
api_layer: "API",
|
|
10
|
+
data_layer: "Data Lake"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(input_path: nil, spec: nil, **options)
|
|
14
|
+
@input_path = input_path
|
|
15
|
+
@spec = spec
|
|
16
|
+
@options = DEFAULT_OPTIONS.merge(options)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
parser = build_parser
|
|
21
|
+
analyzer = ContractAnalyzer.new(
|
|
22
|
+
parser,
|
|
23
|
+
scope_paths: options[:scope],
|
|
24
|
+
include_datalake: options[:include_datalake]
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
raise InputError, "Nenhum endpoint encontrado no escopo informado." if analyzer.scoped_endpoints.empty?
|
|
28
|
+
|
|
29
|
+
vectors = VectorCatalog.vectors_for(analyzer)
|
|
30
|
+
raise InputError, "Nenhum vetor aplicável encontrado para os sinais detectados." if vectors.empty?
|
|
31
|
+
|
|
32
|
+
PlanRenderer.new(
|
|
33
|
+
analyzer: analyzer,
|
|
34
|
+
vectors: vectors,
|
|
35
|
+
team: options[:team],
|
|
36
|
+
api_layer: options[:api_layer],
|
|
37
|
+
data_layer: options[:data_layer]
|
|
38
|
+
).render
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
attr_reader :input_path, :spec, :options
|
|
44
|
+
|
|
45
|
+
def build_parser
|
|
46
|
+
return OpenAPIParser.new(spec, source_path: input_path) if spec
|
|
47
|
+
|
|
48
|
+
raise InputError, "input_path é obrigatório quando spec não é informado." unless input_path
|
|
49
|
+
raise InputError, "Arquivo não encontrado: #{input_path}" unless File.exist?(input_path)
|
|
50
|
+
|
|
51
|
+
OpenAPIParser.parse_file(input_path)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|