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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bcc45ece1c0bd69559144e7fe445ccf7d6b5ebef75ead44a5c154a466bdc9a96
|
|
4
|
+
data.tar.gz: 2e697b751eba0431ed172b985700e1d38289f5755212dbc4db240536cf4a805a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9508039a75d5287b49f5c9f9c73d2136e848d8e03d2b3cb41fb698c43474eecc67ff796dc901be0695291346f949ad209dd41b67e79eec3e19434c144dc09dda
|
|
7
|
+
data.tar.gz: 7dfadfcb457ce8177642f56bf48222b6d3f92f9ff859ce0f2d7f5c1735747e7afa87564b8cca2f390588a603b8a00646149717aec45ef83e9e37c0997dcc1e5e
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [1.0.1] - 2026-07-07
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- YAML parser no longer references unresolved `Date`/`Time` constants inside `OpenAPIParser`, fixing CI on Ruby 3.1–3.3
|
|
8
|
+
|
|
9
|
+
## [1.0.0] - 2026-06-26
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Parser OpenAPI 3.x (YAML/JSON)
|
|
14
|
+
- Analisador de contrato com detecção de red flags
|
|
15
|
+
- Catálogo de vetores OWASP API Security e Data Lake
|
|
16
|
+
- Renderizador de Plano de Ação em Markdown
|
|
17
|
+
- CLI `security-pentest-planner`
|
|
18
|
+
- API programática `SecurityPentestPlanner.generate`
|
|
19
|
+
|
|
20
|
+
## [Unreleased]
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Repositório movido para projeto pessoal open source
|
|
25
|
+
- Removidas referências corporativas e exemplos específicos de produto interno
|
|
26
|
+
- Defaults genéricos para camadas API (`API` em vez de nome de produto)
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Saulo Filho
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# security_pentest_planner
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/security_pentest_planner)
|
|
4
|
+
|
|
5
|
+
**Site:** [saulofilho.github.io/security-pentest-planner](https://saulofilho.github.io/security-pentest-planner/)
|
|
6
|
+
|
|
7
|
+
Gera **Planos de Ação de Pentest** a partir de especificações OpenAPI/Swagger.
|
|
8
|
+
|
|
9
|
+
Ferramenta open source para times de engenharia e AppSec que precisam estruturar testes ofensivos **antes** da execução — com vetores OWASP, procedimentos, payloads e critérios de validação.
|
|
10
|
+
|
|
11
|
+
## O que ela faz
|
|
12
|
+
|
|
13
|
+
- Analisa contratos **OpenAPI 3.x** (YAML ou JSON)
|
|
14
|
+
- Detecta sinais de risco: tenant headers, parâmetros de data, enums, timezone livre
|
|
15
|
+
- Seleciona vetores OWASP API Security aplicáveis via taxonomia
|
|
16
|
+
- Gera um **design doc** em Markdown com:
|
|
17
|
+
- Procedimentos passo a passo por vetor
|
|
18
|
+
- Payloads e exemplos HTTP/SQL
|
|
19
|
+
- Tabela resumo (Acesso · Entrada · Disponibilidade · Informação)
|
|
20
|
+
- Recomendações imediatas de remediação
|
|
21
|
+
- Red flags do contrato
|
|
22
|
+
|
|
23
|
+
## O que ela NÃO faz
|
|
24
|
+
|
|
25
|
+
- Scripts executáveis (Faraday, Ruby, SQL runnable)
|
|
26
|
+
- Resultados de teste (APROVADO/REPROVADO)
|
|
27
|
+
- RFCs de implementação
|
|
28
|
+
|
|
29
|
+
Esses artefatos pertencem à **fase de execução** do pentest.
|
|
30
|
+
|
|
31
|
+
## Instalação
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
gem install security_pentest_planner
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Ou no `Gemfile`:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
gem "security_pentest_planner"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Uso via CLI
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Gerar plano no stdout
|
|
47
|
+
security-pentest-planner swagger/v2/swagger.json
|
|
48
|
+
|
|
49
|
+
# Salvar em arquivo
|
|
50
|
+
security-pentest-planner openapi.yaml -o plano-de-acao-pentest.md
|
|
51
|
+
|
|
52
|
+
# Com escopo e Data Lake
|
|
53
|
+
security-pentest-planner openapi.yaml \
|
|
54
|
+
--team "Platform Team" \
|
|
55
|
+
--scope /api/v1/metrics \
|
|
56
|
+
--datalake \
|
|
57
|
+
-o plano.md
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Opções
|
|
61
|
+
|
|
62
|
+
| Flag | Descrição |
|
|
63
|
+
|------|-----------|
|
|
64
|
+
| `-t, --team TEAM` | Nome do time no título |
|
|
65
|
+
| `-s, --scope PATH` | Limitar a um endpoint |
|
|
66
|
+
| `-d, --datalake` | Incluir vetores de Data Lake |
|
|
67
|
+
| `--api-layer NAME` | Nome da camada API (default: API) |
|
|
68
|
+
| `--data-layer NAME` | Nome da camada Data Lake (default: Data Lake) |
|
|
69
|
+
| `-o, --output FILE` | Salvar em arquivo |
|
|
70
|
+
|
|
71
|
+
## Uso programático
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
require "security_pentest_planner"
|
|
75
|
+
|
|
76
|
+
plan = SecurityPentestPlanner.generate(
|
|
77
|
+
input_path: "openapi.yaml",
|
|
78
|
+
team: "Platform Team",
|
|
79
|
+
scope: "/api/v1/metrics",
|
|
80
|
+
include_datalake: true
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
File.write("plano-de-acao-pentest.md", plan)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Publicar no RubyGems.org
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
bundle install
|
|
90
|
+
bundle exec rspec
|
|
91
|
+
gem build security_pentest_planner.gemspec
|
|
92
|
+
gem push security_pentest_planner-1.0.0.gem
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
> Requer conta no [RubyGems.org](https://rubygems.org) e MFA habilitado.
|
|
96
|
+
|
|
97
|
+
## Desenvolvimento
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
git clone https://github.com/saulofilho/security-pentest-planner.git
|
|
101
|
+
cd security-pentest-planner
|
|
102
|
+
bundle install
|
|
103
|
+
bundle exec rspec
|
|
104
|
+
bundle exec rake
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Fluxo recomendado
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
OpenAPI spec → security_pentest_planner → Plano de Ação (Markdown)
|
|
111
|
+
↓
|
|
112
|
+
Execução manual / CI
|
|
113
|
+
↓
|
|
114
|
+
Resultados + RFCs
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
A gem cobre a fase de **planejamento** de forma determinística — ideal para CI/CD, pipelines e integração em projetos Ruby/Rails.
|
|
118
|
+
|
|
119
|
+
## Licença
|
|
120
|
+
|
|
121
|
+
MIT — veja [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "../security_pentest_planner"
|
|
6
|
+
|
|
7
|
+
module SecurityPentestPlanner
|
|
8
|
+
class CLI
|
|
9
|
+
def self.run(argv = ARGV)
|
|
10
|
+
new.run(argv)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run(argv)
|
|
14
|
+
options = default_options
|
|
15
|
+
parser = build_option_parser(options)
|
|
16
|
+
parser.parse!(argv)
|
|
17
|
+
|
|
18
|
+
input = argv.first
|
|
19
|
+
abort parser.help if input.nil?
|
|
20
|
+
|
|
21
|
+
plan = SecurityPentestPlanner.generate(
|
|
22
|
+
input_path: input,
|
|
23
|
+
**options
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if options[:output]
|
|
27
|
+
File.write(options[:output], plan)
|
|
28
|
+
warn "Plano de Ação gerado em #{options[:output]}"
|
|
29
|
+
else
|
|
30
|
+
puts plan
|
|
31
|
+
end
|
|
32
|
+
rescue Error => e
|
|
33
|
+
warn "Erro: #{e.message}"
|
|
34
|
+
exit 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def default_options
|
|
40
|
+
{
|
|
41
|
+
team: nil,
|
|
42
|
+
scope: nil,
|
|
43
|
+
include_datalake: false,
|
|
44
|
+
api_layer: "API",
|
|
45
|
+
data_layer: "Data Lake",
|
|
46
|
+
output: nil
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build_option_parser(options)
|
|
51
|
+
OptionParser.new do |opts|
|
|
52
|
+
opts.banner = <<~BANNER
|
|
53
|
+
Usage: security-pentest-planner [options] <openapi.yaml>
|
|
54
|
+
|
|
55
|
+
Gera um Plano de Ação de Pentest a partir de especificação OpenAPI.
|
|
56
|
+
|
|
57
|
+
BANNER
|
|
58
|
+
|
|
59
|
+
opts.on("-t", "--team TEAM", "Nome do time no título do documento") do |team|
|
|
60
|
+
options[:team] = team
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
opts.on("-s", "--scope PATH", "Limitar escopo a um path (ex: /api/v1/metrics)") do |scope|
|
|
64
|
+
options[:scope] = scope
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
opts.on("-d", "--datalake", "Incluir vetores de Data Lake no plano") do
|
|
68
|
+
options[:include_datalake] = true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
opts.on("--api-layer NAME", "Nome da camada de API no documento") do |name|
|
|
72
|
+
options[:api_layer] = name
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
opts.on("--data-layer NAME", "Nome da camada de Data Lake no documento") do |name|
|
|
76
|
+
options[:data_layer] = name
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
opts.on("-o", "--output FILE", "Salvar plano em arquivo (default: stdout)") do |file|
|
|
80
|
+
options[:output] = file
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
opts.on("-v", "--version", "Exibe a versão") do
|
|
84
|
+
puts "security_pentest_planner #{SecurityPentestPlanner::VERSION}"
|
|
85
|
+
exit
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
opts.on("-h", "--help", "Exibe esta ajuda") do
|
|
89
|
+
puts opts
|
|
90
|
+
exit
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
SecurityPentestPlanner::CLI.run if $PROGRAM_NAME == __FILE__
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecurityPentestPlanner
|
|
4
|
+
class ContractAnalyzer
|
|
5
|
+
RedFlag = Struct.new(:message, :severity, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
attr_reader :parser, :scope_paths, :include_datalake
|
|
8
|
+
|
|
9
|
+
def initialize(parser, scope_paths: nil, include_datalake: false)
|
|
10
|
+
@parser = parser
|
|
11
|
+
@scope_paths = scope_paths
|
|
12
|
+
@include_datalake = include_datalake
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def scoped_endpoints
|
|
16
|
+
endpoints = parser.endpoints
|
|
17
|
+
return endpoints if scope_paths.nil? || scope_paths.empty?
|
|
18
|
+
|
|
19
|
+
paths = Array(scope_paths)
|
|
20
|
+
endpoints.select { |ep| paths.any? { |p| ep.path == p || ep.path.start_with?(p) } }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def tenant_headers
|
|
24
|
+
scoped_endpoints.flat_map { |ep| parameters_for(ep).select(&:tenant_header?) }
|
|
25
|
+
.uniq { |p| p.name }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def date_params
|
|
29
|
+
scoped_endpoints.flat_map { |ep| parameters_for(ep).select(&:date_param?) }
|
|
30
|
+
.uniq { |p| p.name }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def enum_params
|
|
34
|
+
scoped_endpoints.flat_map { |ep| parameters_for(ep).select(&:enum_param?) }
|
|
35
|
+
.uniq { |p| p.name }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def timezone_headers
|
|
39
|
+
scoped_endpoints.flat_map { |ep| parameters_for(ep).select(&:timezone_header?) }
|
|
40
|
+
.uniq { |p| p.name }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def has_tenant_scoping?
|
|
44
|
+
tenant_headers.any?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def has_date_range?
|
|
48
|
+
date_params.any?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def has_enum_params?
|
|
52
|
+
enum_params.any?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def has_free_text_headers?
|
|
56
|
+
timezone_headers.any? || tenant_headers.any? { |h| !h.enum_values.any? }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def analytics_endpoint?
|
|
60
|
+
scoped_endpoints.any? do |ep|
|
|
61
|
+
ep.path.match?(/metric|analytic|report|channel|insight|dashboard|aggregate/i) ||
|
|
62
|
+
ep.summary.to_s.match?(/metric|analytic|report|aggregate/i)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def red_flags
|
|
67
|
+
flags = []
|
|
68
|
+
|
|
69
|
+
if parser.security_schemes.empty? && scoped_endpoints.any?
|
|
70
|
+
flags << RedFlag.new(
|
|
71
|
+
message: "Ausência de `securitySchemes` documentado no contrato OpenAPI",
|
|
72
|
+
severity: :high
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
tenant_headers.each do |header|
|
|
77
|
+
next if header.required
|
|
78
|
+
|
|
79
|
+
flags << RedFlag.new(
|
|
80
|
+
message: "Header `#{header.name}` documentado como opcional (`required: false`)",
|
|
81
|
+
severity: :high
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if tenant_headers.map(&:name).uniq.size > 1
|
|
86
|
+
flags << RedFlag.new(
|
|
87
|
+
message: "Divergência de nomes de header de tenant: #{tenant_headers.map(&:name).uniq.join(' vs ')}",
|
|
88
|
+
severity: :medium
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
timezone_headers.each do |header|
|
|
93
|
+
next if header.enum_values.any?
|
|
94
|
+
|
|
95
|
+
flags << RedFlag.new(
|
|
96
|
+
message: "Header `#{header.name}` como string livre (sem enum/whitelist IANA)",
|
|
97
|
+
severity: :medium
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
if analytics_endpoint?
|
|
102
|
+
flags << RedFlag.new(
|
|
103
|
+
message: "Nenhum rate limiting documentado em endpoint analítico",
|
|
104
|
+
severity: :medium
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
flags
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def signals
|
|
112
|
+
{
|
|
113
|
+
tenant_scoping: has_tenant_scoping?,
|
|
114
|
+
date_range: has_date_range?,
|
|
115
|
+
enum_params: has_enum_params?,
|
|
116
|
+
free_text_headers: has_free_text_headers?,
|
|
117
|
+
analytics: analytics_endpoint?,
|
|
118
|
+
datalake: include_datalake
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def primary_endpoint
|
|
123
|
+
scoped_endpoints.first
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def host
|
|
127
|
+
parser.servers.first || "api.example.com"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def tenant_header_name
|
|
131
|
+
tenant_headers.first&.name || "X-Tenant-ID"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def date_param_names
|
|
135
|
+
date_params.map(&:name)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def enum_param
|
|
139
|
+
enum_params.first
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def parameters_for(endpoint)
|
|
145
|
+
parser.parameters_for(endpoint)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module SecurityPentestPlanner
|
|
7
|
+
class OpenAPIParser
|
|
8
|
+
TENANT_HEADER_PATTERN = /tenant/i
|
|
9
|
+
DATE_PARAM_PATTERN = /date|start|end|from|to|period|range/i
|
|
10
|
+
TIMEZONE_HEADER_PATTERN = /timezone|time_zone|tz/i
|
|
11
|
+
|
|
12
|
+
Endpoint = Struct.new(
|
|
13
|
+
:path,
|
|
14
|
+
:method,
|
|
15
|
+
:operation_id,
|
|
16
|
+
:summary,
|
|
17
|
+
:parameters,
|
|
18
|
+
:security,
|
|
19
|
+
keyword_init: true
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
Parameter = Struct.new(
|
|
23
|
+
:name,
|
|
24
|
+
:location,
|
|
25
|
+
:required,
|
|
26
|
+
:schema,
|
|
27
|
+
:description,
|
|
28
|
+
keyword_init: true
|
|
29
|
+
) do
|
|
30
|
+
def enum_values
|
|
31
|
+
schema&.dig("enum") || schema&.dig("items", "enum") || []
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def type
|
|
35
|
+
schema&.dig("type")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def tenant_header?
|
|
39
|
+
location == "header" && name.match?(TENANT_HEADER_PATTERN)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def date_param?
|
|
43
|
+
location == "query" && name.match?(DATE_PARAM_PATTERN)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def timezone_header?
|
|
47
|
+
location == "header" && name.match?(TIMEZONE_HEADER_PATTERN)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def enum_param?
|
|
51
|
+
enum_values.any? || schema&.dig("items", "enum")&.any?
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
attr_reader :raw, :source_path
|
|
56
|
+
|
|
57
|
+
def initialize(content, source_path: nil)
|
|
58
|
+
@raw = content
|
|
59
|
+
@source_path = source_path
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.parse_file(path)
|
|
63
|
+
content = File.read(path)
|
|
64
|
+
parsed = case File.extname(path).downcase
|
|
65
|
+
when ".yaml", ".yml" then YAML.safe_load(content, aliases: true)
|
|
66
|
+
when ".json" then JSON.parse(content)
|
|
67
|
+
else
|
|
68
|
+
raise ParseError, "Unsupported file extension: #{path}. Use .yaml, .yml or .json"
|
|
69
|
+
end
|
|
70
|
+
new(parsed, source_path: path)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def title
|
|
74
|
+
info["title"]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def version
|
|
78
|
+
info["version"]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def servers
|
|
82
|
+
Array(raw["servers"]).map { |s| s["url"] }.compact
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def security_schemes
|
|
86
|
+
raw.dig("components", "securitySchemes") || {}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def endpoints
|
|
90
|
+
@endpoints ||= build_endpoints
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def parameters_for(endpoint)
|
|
94
|
+
global = Array(raw.dig("paths", endpoint.path, "parameters"))
|
|
95
|
+
local = Array(endpoint.parameters)
|
|
96
|
+
merge_parameters(global + local)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def info
|
|
102
|
+
raw["info"] || {}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def build_endpoints
|
|
106
|
+
paths = raw["paths"] || {}
|
|
107
|
+
paths.flat_map do |path, methods|
|
|
108
|
+
methods.filter_map do |method, operation|
|
|
109
|
+
next unless operation.is_a?(Hash)
|
|
110
|
+
next unless %w[get post put patch delete head options].include?(method)
|
|
111
|
+
|
|
112
|
+
Endpoint.new(
|
|
113
|
+
path: path,
|
|
114
|
+
method: method.upcase,
|
|
115
|
+
operation_id: operation["operationId"],
|
|
116
|
+
summary: operation["summary"] || operation["description"],
|
|
117
|
+
parameters: Array(operation["parameters"]).map { |p| build_parameter(p) },
|
|
118
|
+
security: operation["security"]
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def build_parameter(param)
|
|
125
|
+
resolved = resolve_ref(param)
|
|
126
|
+
Parameter.new(
|
|
127
|
+
name: resolved["name"],
|
|
128
|
+
location: resolved["in"],
|
|
129
|
+
required: resolved["required"] == true,
|
|
130
|
+
schema: resolved["schema"] || {},
|
|
131
|
+
description: resolved["description"]
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def resolve_ref(obj)
|
|
136
|
+
return obj unless obj.is_a?(Hash) && obj["$ref"]
|
|
137
|
+
|
|
138
|
+
ref = obj["$ref"]
|
|
139
|
+
parts = ref.split("/")
|
|
140
|
+
parts.reduce(raw) { |node, key| node[key] }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def merge_parameters(params)
|
|
144
|
+
params
|
|
145
|
+
.map { |p| p.is_a?(Parameter) ? p : build_parameter(p) }
|
|
146
|
+
.uniq { |p| [p.location, p.name] }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|