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