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