secscan 0.1.0
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +519 -0
- data/examples/poc/custom-rules.json +13 -0
- data/examples/poc/src/app.js +5 -0
- data/exe/secscan +7 -0
- data/lib/secscan/cli.rb +94 -0
- data/lib/secscan/errors.rb +7 -0
- data/lib/secscan/report.rb +249 -0
- data/lib/secscan/rules.rb +205 -0
- data/lib/secscan/scanner.rb +438 -0
- data/lib/secscan/version.rb +5 -0
- data/lib/secscan.rb +30 -0
- metadata +90 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Secscan
|
|
6
|
+
module Report
|
|
7
|
+
SARIF_SCHEMA = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def to_h(report, reveal_secrets: false)
|
|
12
|
+
{
|
|
13
|
+
"scanner" => report.scanner,
|
|
14
|
+
"version" => report.version,
|
|
15
|
+
"timestamp" => report.timestamp,
|
|
16
|
+
"target" => report.target,
|
|
17
|
+
"totalFiles" => report.total_files,
|
|
18
|
+
"scannedFilesCount" => report.scanned_files_count,
|
|
19
|
+
"ignoredFilesCount" => report.ignored_files_count,
|
|
20
|
+
"findings" => report.findings.map { |finding| finding_hash(finding, reveal_secrets) },
|
|
21
|
+
"apiEndpoints" => report.api_endpoints.map { |endpoint| endpoint_hash(endpoint) },
|
|
22
|
+
"metrics" => metrics_hash(report.metrics),
|
|
23
|
+
"durationMs" => report.duration_ms
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_json(report, reveal_secrets: false)
|
|
28
|
+
JSON.pretty_generate(to_h(report, reveal_secrets: reveal_secrets))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_csv(report, reveal_secrets: false)
|
|
32
|
+
rows = [["ID", "Severity", "Rule Name", "Category", "File", "Line", "Column", "Entropy", "Masked Secret", "Remediation"]]
|
|
33
|
+
report.findings.each do |finding|
|
|
34
|
+
shown = reveal_secrets ? finding.matched_secret : finding.masked_secret
|
|
35
|
+
rows << [
|
|
36
|
+
finding.id, finding.severity, finding.rule_name, finding.category, finding.file,
|
|
37
|
+
finding.line, finding.column, finding.entropy, shown, finding.remediation
|
|
38
|
+
]
|
|
39
|
+
end
|
|
40
|
+
rows.map { |row| row.map { |cell| csv_cell(cell) }.join(",") }.join("\n") + "\n"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def csv_cell(value)
|
|
44
|
+
text = value.to_s
|
|
45
|
+
return text unless text.match?(/[",\n]/)
|
|
46
|
+
|
|
47
|
+
"\"#{text.gsub('"', '""')}\""
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_sarif(report, reveal_secrets: false)
|
|
51
|
+
rules = []
|
|
52
|
+
seen = {}
|
|
53
|
+
report.findings.each do |finding|
|
|
54
|
+
next if seen[finding.rule_id]
|
|
55
|
+
|
|
56
|
+
seen[finding.rule_id] = true
|
|
57
|
+
level = %w[CRITICAL HIGH].include?(finding.severity) ? "error" : "warning"
|
|
58
|
+
rules << {
|
|
59
|
+
"id" => finding.rule_id,
|
|
60
|
+
"name" => finding.rule_name,
|
|
61
|
+
"shortDescription" => { "text" => finding.description },
|
|
62
|
+
"help" => { "text" => finding.remediation },
|
|
63
|
+
"defaultConfiguration" => { "level" => level }
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
results = report.findings.map do |finding|
|
|
68
|
+
level = %w[CRITICAL HIGH].include?(finding.severity) ? "error" : "warning"
|
|
69
|
+
shown = reveal_secrets ? finding.matched_secret : finding.masked_secret
|
|
70
|
+
{
|
|
71
|
+
"ruleId" => finding.rule_id,
|
|
72
|
+
"level" => level,
|
|
73
|
+
"message" => { "text" => "#{finding.rule_name}. Value: #{shown}" },
|
|
74
|
+
"locations" => [
|
|
75
|
+
{
|
|
76
|
+
"physicalLocation" => {
|
|
77
|
+
"artifactLocation" => { "uri" => finding.file },
|
|
78
|
+
"region" => {
|
|
79
|
+
"startLine" => finding.line,
|
|
80
|
+
"startColumn" => finding.column,
|
|
81
|
+
"snippet" => { "text" => finding.public_snippet(reveal_secrets) }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
JSON.pretty_generate(
|
|
90
|
+
"$schema" => SARIF_SCHEMA,
|
|
91
|
+
"version" => "2.1.0",
|
|
92
|
+
"runs" => [
|
|
93
|
+
{
|
|
94
|
+
"tool" => {
|
|
95
|
+
"driver" => {
|
|
96
|
+
"name" => "SecScan",
|
|
97
|
+
"semanticVersion" => report.version,
|
|
98
|
+
"informationUri" => "https://github.com/saulofilho/secscan",
|
|
99
|
+
"rules" => rules
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"results" => results
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def to_markdown(report, reveal_secrets: false)
|
|
109
|
+
metrics = report.metrics
|
|
110
|
+
findings_md = if report.findings.empty?
|
|
111
|
+
"_No secrets or sensitive routes found._"
|
|
112
|
+
else
|
|
113
|
+
report.findings.map { |finding| finding_markdown(finding, reveal_secrets) }.join("\n\n")
|
|
114
|
+
end
|
|
115
|
+
routes = if report.api_endpoints.empty?
|
|
116
|
+
"_No endpoints mapped._"
|
|
117
|
+
else
|
|
118
|
+
report.api_endpoints.map do |item|
|
|
119
|
+
suffix = item.is_internal_or_admin ? " **(sensitive)**" : ""
|
|
120
|
+
"- `#{item.method}` `#{item.path}` in `#{item.file}:#{item.line}`#{suffix}"
|
|
121
|
+
end.join("\n")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
<<~MARKDOWN
|
|
125
|
+
# SecScan report
|
|
126
|
+
|
|
127
|
+
- **Target:** `#{report.target}`
|
|
128
|
+
- **When:** #{report.timestamp}
|
|
129
|
+
- **Files scanned:** #{report.scanned_files_count} (ignored: #{report.ignored_files_count})
|
|
130
|
+
- **Findings:** #{report.findings.length}
|
|
131
|
+
- **Security score:** #{metrics.security_score}/100
|
|
132
|
+
- **Impact score:** #{metrics.security_impact_score}/100 (#{metrics.impact_level})
|
|
133
|
+
|
|
134
|
+
## Severity
|
|
135
|
+
|
|
136
|
+
| Severity | Count |
|
|
137
|
+
|---|---|
|
|
138
|
+
| CRITICAL | #{metrics.critical_count} |
|
|
139
|
+
| HIGH | #{metrics.high_count} |
|
|
140
|
+
| MEDIUM | #{metrics.medium_count} |
|
|
141
|
+
| LOW | #{metrics.low_count} |
|
|
142
|
+
| INFO | #{metrics.info_count} |
|
|
143
|
+
|
|
144
|
+
## Findings
|
|
145
|
+
|
|
146
|
+
#{findings_md}
|
|
147
|
+
|
|
148
|
+
## Endpoints (#{report.api_endpoints.length})
|
|
149
|
+
|
|
150
|
+
#{routes}
|
|
151
|
+
MARKDOWN
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def to_table(report, reveal_secrets: false)
|
|
155
|
+
metrics = report.metrics
|
|
156
|
+
lines = [
|
|
157
|
+
"SecScan Static Security Analysis",
|
|
158
|
+
"Target: #{report.target} | Files: #{report.scanned_files_count} | Findings: #{report.findings.length} | #{report.duration_ms}ms",
|
|
159
|
+
"Impact: #{metrics.security_impact_score}/100 (#{metrics.impact_level}) | Security score: #{metrics.security_score}/100",
|
|
160
|
+
"-" * 72
|
|
161
|
+
]
|
|
162
|
+
lines << "No exposed secrets or sensitive API paths detected." if report.findings.empty?
|
|
163
|
+
report.findings.each do |finding|
|
|
164
|
+
shown = reveal_secrets ? finding.matched_secret : finding.masked_secret
|
|
165
|
+
lines << format("%-8s [%s]", finding.severity, finding.rule_name)
|
|
166
|
+
lines << " File: #{finding.file}:#{finding.line}"
|
|
167
|
+
lines << " Secret: #{shown} (entropy #{finding.entropy})"
|
|
168
|
+
lines << " Snippet: #{finding.public_snippet(reveal_secrets)}"
|
|
169
|
+
lines << " Info: #{finding.description}"
|
|
170
|
+
lines << ""
|
|
171
|
+
end
|
|
172
|
+
"#{lines.join("\n")}\n"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def render(report, format, reveal_secrets: false)
|
|
176
|
+
case format.to_s.downcase
|
|
177
|
+
when "json" then "#{to_json(report, reveal_secrets: reveal_secrets)}\n"
|
|
178
|
+
when "csv" then to_csv(report, reveal_secrets: reveal_secrets)
|
|
179
|
+
when "sarif" then "#{to_sarif(report, reveal_secrets: reveal_secrets)}\n"
|
|
180
|
+
when "markdown" then to_markdown(report, reveal_secrets: reveal_secrets)
|
|
181
|
+
when "table" then to_table(report, reveal_secrets: reveal_secrets)
|
|
182
|
+
else
|
|
183
|
+
raise InputError, "unknown format: #{format}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def finding_hash(finding, reveal_secrets)
|
|
188
|
+
item = {
|
|
189
|
+
"id" => finding.id,
|
|
190
|
+
"ruleId" => finding.rule_id,
|
|
191
|
+
"ruleName" => finding.rule_name,
|
|
192
|
+
"category" => finding.category,
|
|
193
|
+
"severity" => finding.severity,
|
|
194
|
+
"file" => finding.file,
|
|
195
|
+
"line" => finding.line,
|
|
196
|
+
"column" => finding.column,
|
|
197
|
+
"snippet" => finding.public_snippet(reveal_secrets),
|
|
198
|
+
"maskedSecret" => finding.masked_secret,
|
|
199
|
+
"entropy" => finding.entropy,
|
|
200
|
+
"description" => finding.description,
|
|
201
|
+
"remediation" => finding.remediation,
|
|
202
|
+
"fileCriticality" => finding.file_criticality,
|
|
203
|
+
"fileCriticalityWeight" => finding.file_criticality_weight,
|
|
204
|
+
"weightedScore" => finding.weighted_score
|
|
205
|
+
}
|
|
206
|
+
item["matchedSecret"] = finding.matched_secret if reveal_secrets
|
|
207
|
+
item
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def endpoint_hash(endpoint)
|
|
211
|
+
{
|
|
212
|
+
"id" => endpoint.id,
|
|
213
|
+
"file" => endpoint.file,
|
|
214
|
+
"line" => endpoint.line,
|
|
215
|
+
"method" => endpoint.method,
|
|
216
|
+
"path" => endpoint.path,
|
|
217
|
+
"isInternalOrAdmin" => endpoint.is_internal_or_admin,
|
|
218
|
+
"snippet" => endpoint.snippet
|
|
219
|
+
}
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def metrics_hash(metrics)
|
|
223
|
+
{
|
|
224
|
+
"criticalCount" => metrics.critical_count,
|
|
225
|
+
"highCount" => metrics.high_count,
|
|
226
|
+
"mediumCount" => metrics.medium_count,
|
|
227
|
+
"lowCount" => metrics.low_count,
|
|
228
|
+
"infoCount" => metrics.info_count,
|
|
229
|
+
"securityScore" => metrics.security_score,
|
|
230
|
+
"securityImpactScore" => metrics.security_impact_score,
|
|
231
|
+
"impactLevel" => metrics.impact_level,
|
|
232
|
+
"totalWeightedRisk" => metrics.total_weighted_risk,
|
|
233
|
+
"averageEntropy" => metrics.average_entropy
|
|
234
|
+
}
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def finding_markdown(finding, reveal_secrets)
|
|
238
|
+
shown = reveal_secrets ? finding.matched_secret : finding.masked_secret
|
|
239
|
+
<<~BLOCK.chomp
|
|
240
|
+
### [#{finding.severity}] #{finding.rule_name}
|
|
241
|
+
- **File:** `#{finding.file}` (line #{finding.line})
|
|
242
|
+
- **Value:** `#{shown}`
|
|
243
|
+
- **Entropy:** #{finding.entropy}
|
|
244
|
+
- **Description:** #{finding.description}
|
|
245
|
+
- **Remediation:** #{finding.remediation}
|
|
246
|
+
BLOCK
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Secscan
|
|
4
|
+
SEVERITY_RANK = {
|
|
5
|
+
"INFO" => 0,
|
|
6
|
+
"LOW" => 1,
|
|
7
|
+
"MEDIUM" => 2,
|
|
8
|
+
"HIGH" => 3,
|
|
9
|
+
"CRITICAL" => 4
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
SEVERITY_BASE_WEIGHTS = {
|
|
13
|
+
"CRITICAL" => 25,
|
|
14
|
+
"HIGH" => 14,
|
|
15
|
+
"MEDIUM" => 7,
|
|
16
|
+
"LOW" => 3,
|
|
17
|
+
"INFO" => 1
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
FILE_CRITICALITY_MULTIPLIERS = {
|
|
21
|
+
"CRITICAL" => 2.0,
|
|
22
|
+
"HIGH" => 1.5,
|
|
23
|
+
"MEDIUM" => 1.0,
|
|
24
|
+
"LOW" => 0.5
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
Rule = Struct.new(
|
|
28
|
+
:id, :name, :pattern, :severity, :category, :description,
|
|
29
|
+
:remediation, :flags, :min_entropy, :enabled,
|
|
30
|
+
keyword_init: true
|
|
31
|
+
) do
|
|
32
|
+
def initialize(**kwargs)
|
|
33
|
+
kwargs[:severity] = kwargs.fetch(:severity, "MEDIUM").to_s.upcase
|
|
34
|
+
kwargs[:category] ||= "CUSTOM"
|
|
35
|
+
kwargs[:description] ||= ""
|
|
36
|
+
kwargs[:remediation] ||= ""
|
|
37
|
+
kwargs[:flags] ||= "g"
|
|
38
|
+
kwargs[:enabled] = true if kwargs[:enabled].nil?
|
|
39
|
+
super
|
|
40
|
+
raise ArgumentError, "invalid severity #{severity} on rule #{id}" unless SEVERITY_RANK.key?(severity)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
module DefaultRules
|
|
45
|
+
def self.build(id:, name:, pattern:, severity:, category:, description:, remediation:, flags: "g", min_entropy: nil)
|
|
46
|
+
Rule.new(
|
|
47
|
+
id: id,
|
|
48
|
+
name: name,
|
|
49
|
+
pattern: pattern,
|
|
50
|
+
severity: severity,
|
|
51
|
+
category: category,
|
|
52
|
+
description: description,
|
|
53
|
+
remediation: remediation,
|
|
54
|
+
flags: flags,
|
|
55
|
+
min_entropy: min_entropy
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
ALL = [
|
|
60
|
+
build(
|
|
61
|
+
id: "sec-aws-akid",
|
|
62
|
+
name: "AWS Access Key ID",
|
|
63
|
+
pattern: "\\b(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}\\b",
|
|
64
|
+
severity: "CRITICAL",
|
|
65
|
+
category: "CLOUD_CREDENTIAL",
|
|
66
|
+
description: "Chave de acesso pública da AWS encontrada hardcoded no código.",
|
|
67
|
+
remediation: "Utilize AWS IAM Roles, variáveis de ambiente ou AWS Secrets Manager.",
|
|
68
|
+
min_entropy: 3.5
|
|
69
|
+
),
|
|
70
|
+
build(
|
|
71
|
+
id: "sec-aws-secret",
|
|
72
|
+
name: "AWS Secret Access Key",
|
|
73
|
+
pattern: "(?:aws_secret_access_key|aws_secret|aws_key)\\s*[:=]\\s*['\"][A-Za-z0-9/+=]{40}['\"]",
|
|
74
|
+
flags: "gi",
|
|
75
|
+
severity: "CRITICAL",
|
|
76
|
+
category: "CLOUD_CREDENTIAL",
|
|
77
|
+
description: "Segredo de autenticação da AWS exposto diretamente.",
|
|
78
|
+
remediation: "Rotacione a chave no console da AWS e migre para credenciais temporárias.",
|
|
79
|
+
min_entropy: 4.2
|
|
80
|
+
),
|
|
81
|
+
build(
|
|
82
|
+
id: "sec-google-api",
|
|
83
|
+
name: "Google Cloud / Gemini API Key",
|
|
84
|
+
pattern: "\\bAIza[-0-9A-Za-z_]{35}\\b",
|
|
85
|
+
severity: "HIGH",
|
|
86
|
+
category: "API_KEY",
|
|
87
|
+
description: "Chave de API do Google Cloud ou Gemini exposta no código.",
|
|
88
|
+
remediation: "Restrinja a chave no Google Cloud Console ou mova a chamada para o servidor.",
|
|
89
|
+
min_entropy: 4.0
|
|
90
|
+
),
|
|
91
|
+
build(
|
|
92
|
+
id: "sec-github-pat",
|
|
93
|
+
name: "GitHub Personal Access Token",
|
|
94
|
+
pattern: "\\b(?:ghp_[0-9a-zA-Z]{36}|gho_[0-9a-zA-Z]{36}|github_pat_[0-9a-zA-Z_]{82})\\b",
|
|
95
|
+
severity: "CRITICAL",
|
|
96
|
+
category: "AUTH_TOKEN",
|
|
97
|
+
description: "Token de acesso pessoal do GitHub exposto no repositório.",
|
|
98
|
+
remediation: "Revogue o token e armazene o valor como secret do CI.",
|
|
99
|
+
min_entropy: 4.1
|
|
100
|
+
),
|
|
101
|
+
build(
|
|
102
|
+
id: "sec-stripe-secret",
|
|
103
|
+
name: "Stripe Secret API Key",
|
|
104
|
+
pattern: "\\b(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{24,99}\\b",
|
|
105
|
+
severity: "CRITICAL",
|
|
106
|
+
category: "API_KEY",
|
|
107
|
+
description: "Chave secreta ou restrita do Stripe exposta.",
|
|
108
|
+
remediation: "Rotacione a chave no painel do Stripe. Não envie chaves secretas ao frontend.",
|
|
109
|
+
min_entropy: 4.0
|
|
110
|
+
),
|
|
111
|
+
build(
|
|
112
|
+
id: "sec-jwt-token",
|
|
113
|
+
name: "JSON Web Token (JWT)",
|
|
114
|
+
pattern: "\\beyJ[-_A-Za-z0-9=]+\\.[-_A-Za-z0-9=]+\\.[-+/=_A-Za-z0-9.]*\\b",
|
|
115
|
+
severity: "HIGH",
|
|
116
|
+
category: "AUTH_TOKEN",
|
|
117
|
+
description: "Token JWT estático embutido no código-fonte.",
|
|
118
|
+
remediation: "Substitua tokens estáticos por um fluxo de autenticação.",
|
|
119
|
+
min_entropy: 4.3
|
|
120
|
+
),
|
|
121
|
+
build(
|
|
122
|
+
id: "sec-slack-webhook",
|
|
123
|
+
name: "Slack Incoming Webhook / Bot Token",
|
|
124
|
+
pattern: "https://hooks\\.slack\\.com/services/T[a-zA-Z0-9_]{8}/B[a-zA-Z0-9_]{8,12}/[a-zA-Z0-9_]{24}|xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*",
|
|
125
|
+
severity: "HIGH",
|
|
126
|
+
category: "AUTH_TOKEN",
|
|
127
|
+
description: "URL de webhook do Slack ou token de bot hardcoded.",
|
|
128
|
+
remediation: "Mova a URL ou o token para uma variável de ambiente.",
|
|
129
|
+
min_entropy: 3.8
|
|
130
|
+
),
|
|
131
|
+
build(
|
|
132
|
+
id: "sec-private-key",
|
|
133
|
+
name: "RSA / OpenSSH Private Key",
|
|
134
|
+
pattern: "-----BEGIN (?:RSA|EC|OPENSSH|DSA|PGP|PRIVATE) KEY-----[\\s\\S]*?-----END (?:RSA|EC|OPENSSH|DSA|PGP|PRIVATE) KEY-----",
|
|
135
|
+
severity: "CRITICAL",
|
|
136
|
+
category: "PRIVATE_KEY",
|
|
137
|
+
description: "Chave criptográfica privada encontrada embutida no arquivo.",
|
|
138
|
+
remediation: "Remova a chave do controle de versão e carregue-a por um cofre ou agent."
|
|
139
|
+
),
|
|
140
|
+
build(
|
|
141
|
+
id: "sec-db-uri",
|
|
142
|
+
name: "Database Connection URI with Credentials",
|
|
143
|
+
pattern: "(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis)://[^\\s:\"']+:[^\\s:\"']+@[^\\s:\"']+",
|
|
144
|
+
flags: "gi",
|
|
145
|
+
severity: "CRITICAL",
|
|
146
|
+
category: "DATABASE_URI",
|
|
147
|
+
description: "String de conexão com usuário e senha em texto claro.",
|
|
148
|
+
remediation: "Extraia as credenciais para uma variável de ambiente."
|
|
149
|
+
),
|
|
150
|
+
build(
|
|
151
|
+
id: "sec-hardcoded-pass",
|
|
152
|
+
name: "Hardcoded Password Assignment",
|
|
153
|
+
pattern: "(?:password|passwd|pwd|db_pass|secret_key|client_secret)\\s*[:=]\\s*['\"][^'\"]{8,64}['\"]",
|
|
154
|
+
flags: "gi",
|
|
155
|
+
severity: "HIGH",
|
|
156
|
+
category: "PASSWORD",
|
|
157
|
+
description: "Atribuição de senha ou chave secreta fixa no código.",
|
|
158
|
+
remediation: "Injete o valor em tempo de execução por variável de ambiente.",
|
|
159
|
+
min_entropy: 3.2
|
|
160
|
+
),
|
|
161
|
+
build(
|
|
162
|
+
id: "sec-openai-key",
|
|
163
|
+
name: "OpenAI API Secret Key",
|
|
164
|
+
pattern: "\\bsk-(?:proj-|live-)?[-_a-zA-Z0-9]{32,80}\\b",
|
|
165
|
+
severity: "CRITICAL",
|
|
166
|
+
category: "API_KEY",
|
|
167
|
+
description: "Chave secreta de API da OpenAI encontrada no código.",
|
|
168
|
+
remediation: "Revogue a chave e mantenha-a apenas no servidor.",
|
|
169
|
+
min_entropy: 4.1
|
|
170
|
+
),
|
|
171
|
+
build(
|
|
172
|
+
id: "sec-sendgrid-key",
|
|
173
|
+
name: "SendGrid API Key",
|
|
174
|
+
pattern: "\\bSG\\.[-0-9A-Za-z_]{22}\\.[-0-9A-Za-z_]{43}\\b",
|
|
175
|
+
severity: "HIGH",
|
|
176
|
+
category: "API_KEY",
|
|
177
|
+
description: "Chave de envio de e-mails do SendGrid exposta.",
|
|
178
|
+
remediation: "Rotacione a chave e mova o valor para o ambiente do backend.",
|
|
179
|
+
min_entropy: 4.0
|
|
180
|
+
),
|
|
181
|
+
build(
|
|
182
|
+
id: "sec-sensitive-api-path",
|
|
183
|
+
name: "Sensitive or Admin API Route",
|
|
184
|
+
pattern: "['\"](?:/api/v[0-9]+)?/(?:admin|internal|superadmin|debug|actuator|metrics|management|auth/token|users/export|billing/charge)[^\\s'\"]*['\"]",
|
|
185
|
+
flags: "gi",
|
|
186
|
+
severity: "MEDIUM",
|
|
187
|
+
category: "API_PATH",
|
|
188
|
+
description: "Rota de API administrativa ou sensível mapeada no código.",
|
|
189
|
+
remediation: "Exija autorização na rota e evite publicá-la em bundles sem controle de acesso."
|
|
190
|
+
),
|
|
191
|
+
build(
|
|
192
|
+
id: "sec-api-endpoint",
|
|
193
|
+
name: "Hardcoded API Path / Endpoint",
|
|
194
|
+
pattern: "['\"](?:https?://[-.a-zA-Z0-9]+)?/(?:api|v1|v2|graphql|rest|webhook)/[-./_a-zA-Z0-9]+['\"]",
|
|
195
|
+
flags: "gi",
|
|
196
|
+
severity: "LOW",
|
|
197
|
+
category: "API_PATH",
|
|
198
|
+
description: "Endpoint ou caminho de API fixo no código.",
|
|
199
|
+
remediation: "Use uma URL base configurável por ambiente."
|
|
200
|
+
)
|
|
201
|
+
].freeze
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
DEFAULT_RULES = DefaultRules::ALL
|
|
205
|
+
end
|